diff --git a/bun.lock b/bun.lock index a596e250..f6087608 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "marked": "^17.0.5", "oxlint": "^1.51.0", "oxlint-tsgolint": "^0.15.0", + "playwright-core": "^1.59.1", "tsgolint": "^0.0.1", "typescript": "6.0.2", }, @@ -77,6 +78,8 @@ "oxlint-tsgolint": ["oxlint-tsgolint@0.15.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.15.0", "@oxlint-tsgolint/darwin-x64": "0.15.0", "@oxlint-tsgolint/linux-arm64": "0.15.0", "@oxlint-tsgolint/linux-x64": "0.15.0", "@oxlint-tsgolint/win32-arm64": "0.15.0", "@oxlint-tsgolint/win32-x64": "0.15.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw=="], + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "tsgolint": ["tsgolint@0.0.1", "", {}, "sha512-cSh6jgqMsVrzaRipcTBDcfiUo3iTK92gukInY0eeFP14ICe1pZjBC+yL1rVfQSBR72ZaBizmwsqEI4g1eqx1Eg=="], "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], diff --git a/package.json b/package.json index c2f42e22..33b6a493 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "marked": "^17.0.5", "oxlint": "^1.51.0", "oxlint-tsgolint": "^0.15.0", + "playwright-core": "^1.59.1", "tsgolint": "^0.0.1", "typescript": "6.0.2" } diff --git a/scripts/browser-automation.ts b/scripts/browser-automation.ts index 32464382..4a610f06 100644 --- a/scripts/browser-automation.ts +++ b/scripts/browser-automation.ts @@ -3,6 +3,7 @@ import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writ import { createConnection, createServer as createNetServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { Browser as PlaywrightBrowser, BrowserContext as PlaywrightBrowserContext, Page as PlaywrightPage } from 'playwright-core' import { readNavigationPhaseState, readNavigationReportText, type NavigationPhase } from '../shared/navigation-state.ts' export type BrowserKind = 'chrome' | 'safari' | 'firefox' @@ -29,6 +30,20 @@ export type BrowserAutomationLock = { release: () => void } +type ChromeAutomationDriver = 'apple-script' | 'playwright' + +type PlaywrightChromeSessionState = { + browser: PlaywrightBrowser + context: PlaywrightBrowserContext + page: PlaywrightPage +} + +const PLAYWRIGHT_CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const PLAYWRIGHT_CHROME_VIEWPORT = { width: 1512, height: 762 } as const +const PLAYWRIGHT_CHROME_SCREEN = { width: 1512, height: 982 } as const +const PLAYWRIGHT_CHROME_DEVICE_SCALE_FACTOR = 2 +const PLAYWRIGHT_CHROME_SCREEN_INFO = '--screen-info={3024x1964 devicePixelRatio=2}' + function runAppleScript(lines: string[]): string { return execFileSync( 'osascript', @@ -67,6 +82,23 @@ function runBackgroundAppleScript(lines: string[]): string { } } +function getChromeAutomationDriver(options: BrowserSessionOptions): ChromeAutomationDriver { + const raw = (process.env['CHROME_AUTOMATION_DRIVER'] ?? 'apple-script').trim().toLowerCase() + + if (raw === '' || raw === 'apple-script') { + return 'apple-script' + } + + if (raw === 'playwright') { + if (options.foreground === true) { + throw new Error('CHROME_AUTOMATION_DRIVER=playwright does not support foreground runs; use AppleScript for benchmarks') + } + return 'playwright' + } + + throw new Error(`Unsupported CHROME_AUTOMATION_DRIVER=${raw}; expected apple-script or playwright`) +} + export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } @@ -410,6 +442,125 @@ async function initializeFirefoxSession(): Promise { } } +async function loadPlaywrightChromium(): Promise<{ + launch: (options: { + headless: boolean + executablePath: string + args: string[] + }) => Promise +}> { + try { + const module = await import('playwright-core') + return module.chromium + } catch (error) { + throw new Error( + `CHROME_AUTOMATION_DRIVER=playwright requires playwright-core to be installed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } +} + +type PlaywrightChromeEnvironment = { + innerWidth: number + innerHeight: number + screenWidth: number + screenHeight: number + dpr: number +} + +async function readPlaywrightChromeEnvironment(page: PlaywrightPage): Promise { + return await page.evaluate(() => ({ + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + screenWidth: window.screen.width, + screenHeight: window.screen.height, + dpr: window.devicePixelRatio, + })) +} + +function formatPlaywrightChromeEnvironment(env: PlaywrightChromeEnvironment): string { + return `${env.innerWidth}x${env.innerHeight} screen=${env.screenWidth}x${env.screenHeight} dpr=${env.dpr}` +} + +async function assertPlaywrightChromeEnvironment(page: PlaywrightPage): Promise { + const env = await readPlaywrightChromeEnvironment(page) + const matches = + env.innerWidth === PLAYWRIGHT_CHROME_VIEWPORT.width && + env.innerHeight === PLAYWRIGHT_CHROME_VIEWPORT.height && + env.screenWidth === PLAYWRIGHT_CHROME_SCREEN.width && + env.screenHeight === PLAYWRIGHT_CHROME_SCREEN.height && + env.dpr === PLAYWRIGHT_CHROME_DEVICE_SCALE_FACTOR + + if (matches) return + + throw new Error( + `Pinned Playwright Chrome environment mismatch: expected ${PLAYWRIGHT_CHROME_VIEWPORT.width}x${PLAYWRIGHT_CHROME_VIEWPORT.height} screen=${PLAYWRIGHT_CHROME_SCREEN.width}x${PLAYWRIGHT_CHROME_SCREEN.height} dpr=${PLAYWRIGHT_CHROME_DEVICE_SCALE_FACTOR}, got ${formatPlaywrightChromeEnvironment(env)}`, + ) +} + +async function initializePlaywrightChromeSession(): Promise { + const chromium = await loadPlaywrightChromium() + const browser = await chromium.launch({ + headless: true, + executablePath: PLAYWRIGHT_CHROME_EXECUTABLE, + args: [PLAYWRIGHT_CHROME_SCREEN_INFO], + }) + + try { + const context = await browser.newContext({ + viewport: PLAYWRIGHT_CHROME_VIEWPORT, + screen: PLAYWRIGHT_CHROME_SCREEN, + deviceScaleFactor: PLAYWRIGHT_CHROME_DEVICE_SCALE_FACTOR, + }) + const page = await context.newPage() + return { browser, context, page } + } catch (error) { + await browser.close().catch(() => {}) + throw error + } +} + +function closePlaywrightChromeSessionState(state: PlaywrightChromeSessionState): void { + void state.context.close().catch(() => {}) + void state.browser.close().catch(() => {}) +} + +function createPlaywrightChromeSession(): BrowserSession { + let statePromise: Promise | null = null + let closed = false + + function ensureState(): Promise { + if (closed) { + return Promise.reject(new Error('Playwright Chrome automation session already closed')) + } + statePromise ??= initializePlaywrightChromeSession() + return statePromise + } + + return { + async navigate(url) { + const state = await ensureState() + await state.page.goto(url, { waitUntil: 'load' }) + await assertPlaywrightChromeEnvironment(state.page) + }, + async readLocationUrl() { + try { + const state = await ensureState() + return state.page.url() + } catch { + return '' + } + }, + close() { + if (closed) return + closed = true + if (statePromise === null) return + void statePromise.then(closePlaywrightChromeSessionState, () => {}) + }, + } +} + function createSafariSession(options: BrowserSessionOptions): BrowserSession { const scriptLines = ['tell application "Safari"'] @@ -476,6 +627,10 @@ function createSafariSession(options: BrowserSessionOptions): BrowserSession { } function createChromeSession(options: BrowserSessionOptions): BrowserSession { + if (getChromeAutomationDriver(options) === 'playwright') { + return createPlaywrightChromeSession() + } + const scriptLines = [ 'tell application "Google Chrome"', 'if (count of windows) = 0 then make new window',