Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions src/automation/BrowserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ interface BrowserCreationResult {
fingerprint: BrowserFingerprintWithHeaders
}

type BrowserChannel = 'chrome' | 'msedge'

class BrowserManager {
private readonly bot: MicrosoftRewardsBot
private static readonly BROWSER_CLOSE_TIMEOUT_MS = 10_000
private readonly activeBrowsers = new Set<rebrowser.Browser>()
private static readonly BROWSER_ARGS = [
'--no-sandbox',
Expand Down Expand Up @@ -75,14 +74,35 @@ class BrowserManager {
}
}

private async closeBrowserWithTimeout(browser: rebrowser.Browser): Promise<void> {
let timeout: NodeJS.Timeout | undefined
try {
await Promise.race([
browser.close(),
new Promise<void>((_, reject) => {
timeout = setTimeout(
() =>
reject(
new Error(
`Browser close timed out after ${BrowserManager.BROWSER_CLOSE_TIMEOUT_MS}ms`
)
),
BrowserManager.BROWSER_CLOSE_TIMEOUT_MS
)
})
])
} finally {
if (timeout) clearTimeout(timeout)
}
}

async createBrowser(account: Account): Promise<BrowserCreationResult> {
let browser: rebrowser.Browser
let channel: BrowserChannel | undefined
try {
this.bot.logger.info(
this.bot.isMobile,
'BROWSER',
'Initializing browser — detecting available channel (Chromium › Chrome › Edge)...'
'Initializing browser — verifying Patchright bundled Chromium...'
)

const proxyConfig = account.proxy.url
Expand Down Expand Up @@ -124,8 +144,7 @@ class BrowserManager {
)

const fingerprint =
sessionData.fingerprint ??
(await this.generateFingerprint(this.bot.isMobile, channel === 'msedge' ? 'edge' : 'chrome'))
sessionData.fingerprint ?? (await this.generateFingerprint(this.bot.isMobile, 'chrome'))

const context = await newInjectedContext(browser as any, {
fingerprint,
Expand All @@ -134,9 +153,11 @@ class BrowserManager {
screen: DESKTOP_BROWSER_VIEWPORT
}
})
context.once('close', () => {
context.once('close', async () => {
this.activeBrowsers.delete(browser)
void browser.close().catch(() => {})
if (browser.isConnected()) {
await this.closeBrowserWithTimeout(browser).catch(() => {})
}
})

await context.addInitScript(() => {
Expand Down Expand Up @@ -205,15 +226,15 @@ class BrowserManager {
return { context: context as unknown as BrowserContext, fingerprint }
} catch (error) {
this.activeBrowsers.delete(browser)
await browser.close().catch(() => {})
await this.closeBrowserWithTimeout(browser).catch(() => {})
throw error
}
}

async closeAll(): Promise<void> {
const browsers = [...this.activeBrowsers]
this.activeBrowsers.clear()
await Promise.allSettled(browsers.map(browser => browser.close()))
await Promise.allSettled(browsers.map(browser => this.closeBrowserWithTimeout(browser)))
}

private formatProxyServer(proxy: AccountProxy): string {
Expand Down
52 changes: 43 additions & 9 deletions src/automation/PageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { URLS } from './DashboardSelectors'

export default class PageController {
private bot: MicrosoftRewardsBot
private static readonly BROWSER_CLOSE_TIMEOUT_MS = 10_000

/**
* Once the legacy JSON dashboard API (`/api/getuserinfo`) fails, Microsoft has
Expand Down Expand Up @@ -75,6 +76,17 @@ export default class PageController {
}
}

try {
const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage
await page
.goto(this.bot.config.baseURL, { waitUntil: 'domcontentloaded', timeout: 30_000 })
.catch(() => {})
const html = await page.content()
return this.parseDashboardHtml(html)
} catch (fallbackError) {
this.bot.logger.error(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Failed to get dashboard data')
throw fallbackError
}
return this.getDashboardDataFromHtml()
}

Expand Down Expand Up @@ -122,7 +134,11 @@ export default class PageController {
private parseDashboardHtml(html: string): DashboardData {
const legacyMatch = html.match(/var\s+dashboard\s*=\s*({.*?});/s)
if (legacyMatch?.[1]) {
this.bot.logger.debug(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Extracted dashboard data from legacy HTML embed')
this.bot.logger.debug(
this.bot.isMobile,
'GET-DASHBOARD-DATA',
'Extracted dashboard data from legacy HTML embed'
)
return JSON.parse(legacyMatch[1]) as DashboardData
}

Expand Down Expand Up @@ -199,9 +215,7 @@ export default class PageController {
const parsed = JSON.parse(callArgs) as unknown
if (!Array.isArray(parsed)) return null

return parsed
.filter((part): part is string => typeof part === 'string')
.join('\n')
return parsed.filter((part): part is string => typeof part === 'string').join('\n')
} catch {
const stringValues: string[] = []
const stringPattern = /"((?:\\.|[^"\\])*)"/gs
Expand Down Expand Up @@ -783,9 +797,9 @@ export default class PageController {
}
}

async closeBrowser(browser: BrowserContext, email: string) {
async closeBrowser(context: BrowserContext, email: string) {
try {
const cookies = await browser.cookies()
const cookies = await context.cookies()

// Save cookies
this.bot.logger.debug(
Expand All @@ -798,7 +812,7 @@ export default class PageController {
// Save localStorage from all open pages (rewards.bing.com, bing.com)
try {
const storageOrigins: StorageOrigin[] = []
const pages = browser.pages()
const pages = context.pages()
const seenOrigins = new Set<string>()

for (const page of pages) {
Expand Down Expand Up @@ -847,10 +861,10 @@ export default class PageController {

await this.bot.utils.wait(2000)

// Close browser
await browser.close()
await this.closeWithTimeout(() => context.close(), 'context')
this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!')
} catch (error) {
await this.closeWithTimeout(() => context.close(), 'context').catch(() => {})
this.bot.logger.error(
this.bot.isMobile,
'CLOSE-BROWSER',
Expand All @@ -860,6 +874,26 @@ export default class PageController {
}
}

private async closeWithTimeout(close: () => Promise<void>, label: string): Promise<void> {
let timeout: NodeJS.Timeout | undefined
try {
await Promise.race([
close(),
new Promise<void>((_, reject) => {
timeout = setTimeout(
() =>
reject(
new Error(`${label} close timed out after ${PageController.BROWSER_CLOSE_TIMEOUT_MS}ms`)
),
PageController.BROWSER_CLOSE_TIMEOUT_MS
)
})
])
} finally {
if (timeout) clearTimeout(timeout)
}
}

/**
* Report an activity completion via `fetch()` executed inside the browser page.
*
Expand Down
4 changes: 4 additions & 0 deletions tests/browser-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ test('browser runtime requires Patchright Chromium instead of silently using sys

test('browser processes are tracked and closed during shutdown paths', () => {
assert.match(browserManager, /activeBrowsers = new Set/)
assert.match(browserManager, /BROWSER_CLOSE_TIMEOUT_MS = 10_000/)
assert.match(browserManager, /closeBrowserWithTimeout\(browser\)/)
assert.match(browserManager, /context\.once\('close', async \(\) =>/)
assert.match(browserManager, /async closeAll\(\)/)
assert.match(browserManager, /browsers\.map\(browser => this\.closeBrowserWithTimeout\(browser\)\)/)
assert.match(main, /await rewardsBot\.closeAllBrowsers\(\)/)
})