From 331e54e740752db7aa43aaefff7e6a7059921389 Mon Sep 17 00:00:00 2001 From: lixiang <1014027506@qq.com> Date: Mon, 7 Sep 2026 17:06:26 +0800 Subject: [PATCH] fix(e2e): relaunch when the renderer comes up without a frame pipeline About one launch in four on a self-hosted Windows runner comes up with no frame production at all: the DOM reads perfectly healthy under page.evaluate -- correct geometry, nothing covering the element -- while requestAnimationFrame never fires. Playwright's actionability check compares an element's bounding box across two rAF callbacks, so the first click of every spec hung for 30s on "waiting for element to be visible, enabled and stable", with no "intercepts pointer events" in the trace to explain it. The state never recovers. Forcing show/focus/moveTop moves isFocused but leaves the window at 0 fps, and window visibility, minimised state and focus read identically on stalled and healthy windows, so there is nothing to wait for and nothing to poke. Relaunching is the only remedy. Gate the fixture on a real animation frame and relaunch when one does not arrive, up to five times -- stalls are independent between launches, so five attempts put an all-stalled run near 0.1%, and a relaunch reuses the warm HOME rather than repeating the runtime download. Also move the first-run dismissal from the page to the context, where it lands before the main window's first load. That drops the reload round trip the page fixture needed to apply it. app and page now read from one internal fixture so both refer to the same proven launch. Measured over 19 launches on the affected box: 5 stalls, all recovered, 14/14 specs passing, zero click timeouts, zero reloads. --- packages/launcher/e2e/fixtures.ts | 173 +++++++++++++++++++++++++----- 1 file changed, 144 insertions(+), 29 deletions(-) diff --git a/packages/launcher/e2e/fixtures.ts b/packages/launcher/e2e/fixtures.ts index 0ac5fa126..5e51fc3a6 100644 --- a/packages/launcher/e2e/fixtures.ts +++ b/packages/launcher/e2e/fixtures.ts @@ -9,6 +9,7 @@ import { test as base, _electron as electron, + type BrowserContext, type ElectronApplication, type Page, } from "@playwright/test" @@ -19,6 +20,11 @@ import path from "node:path" interface LauncherFixtures { /** Isolated HOME for this test — `~/.openagents` (daemon log/config) lives here. */ homeDir: string + /** + * Internal: a launcher proven to be rendering. `app` and `page` read from it + * so both refer to the same attempt — specs should depend on those, not this. + */ + launcher: { app: ElectronApplication; page: Page } app: ElectronApplication page: Page } @@ -30,6 +36,61 @@ function mainEntry(): string { : path.resolve(process.cwd(), "out/main/index.js") } +/** + * How many times to relaunch a launcher that comes up without frame production. + * + * Stalls run at roughly one launch in four and are independent between + * launches, so five attempts put an all-stalled run near 0.1%. A relaunch + * reuses the warm HOME, so it costs seconds rather than another runtime + * download — the slowest recovery observed added ~2 min to a spec budgeted 15. + */ +const MAX_LAUNCH_ATTEMPTS = 5 + +/** Flags that keep the first-run onboarding wizard and guided tour closed. */ +function dismissFirstRun(): void { + try { + localStorage.setItem("onboarding_completed", "true") + localStorage.setItem("guided_tour_completed", "true") + } catch { + /* ignore */ + } +} + +/** + * Whether the renderer is actually producing frames. + * + * A window occasionally ends up with no frame production at all. The DOM stays + * perfectly healthy under `page.evaluate` — correct geometry, nothing covering + * the element — while `requestAnimationFrame` never fires. Playwright's + * actionability check compares an element's bounding box across two rAF + * callbacks, so every click then hangs on "waiting for element to be visible, + * enabled and stable" until it times out, with no `intercepts pointer events` + * to explain it. + * + * Measured on a self-hosted Windows box at roughly one launch in four (5 of 19), + * at random and independent between launches. It is a property of the launch + * itself, not of anything the fixture does afterwards. The state never recovers: + * forcing show/focus/moveTop moves `isFocused` yet leaves the window at 0 fps, + * and window visibility, minimised state and focus read identically on stalled + * and healthy windows. There is nothing to wait for and nothing to poke, so the + * only remedy is to relaunch — which is what the `launcher` fixture does. + */ +async function producesFrames(page: Page, timeoutMs = 10_000): Promise { + return page + .evaluate( + (ms) => + new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ms) + requestAnimationFrame(() => { + clearTimeout(timer) + resolve(true) + }) + }), + timeoutMs, + ) + .catch(() => false) +} + // On first launch the app shows a `data:text/html` SPLASH window while it // bootstraps (downloads the portable Node runtime + core lib, minutes), then // creates the real mainWindow at `index.html` and destroys the splash. So the @@ -46,51 +107,105 @@ async function mainAppPage(app: ElectronApplication): Promise { throw new Error("main app window (index.html) never appeared within 6 min") } +/** Starts the launcher and returns its main window, ready for a spec to drive. */ +async function launchOnce(env: Record): Promise<{ + app: ElectronApplication + page: Page +}> { + const app = await electron.launch({ args: [mainEntry()], env }) + + // Register on the CONTEXT, not the page: the main window does not exist yet + // (the splash comes first), so the flags are in place for its very first load. + // Registering on the page instead would mean loading the app and reloading it + // to apply them — an extra round trip that buys nothing. + await (app.context() as BrowserContext).addInitScript(dismissFirstRun) + + const page = await mainAppPage(app) + await page.waitForLoadState("domcontentloaded") + + // Warm profiles could in principle create the main window before the init + // script lands. Not observed in 14 runs, but reload rather than let a spec + // fight the onboarding overlay. + const dismissed = await page + .evaluate(() => { + try { + return localStorage.getItem("onboarding_completed") === "true" + } catch { + return false + } + }) + .catch(() => false) + if (!dismissed) { + console.warn("[fixtures] init script lost the race to the main window; reloading") + await page.evaluate(dismissFirstRun).catch(() => {}) + await page.reload() + await page.waitForLoadState("domcontentloaded") + } + + return { app, page } +} + export const test = base.extend({ homeDir: async ({}, use) => { const home = mkdtempSync(path.join(tmpdir(), "oa-e2e-")) await use(home) }, - app: async ({ homeDir }, use) => { + launcher: async ({ homeDir }, use) => { // Windows keys userData off APPDATA; give it a home-scoped location too so // profiles never leak between runs on self-hosted-style reuse. const appData = path.join(homeDir, "AppData", "Roaming") const localAppData = path.join(homeDir, "AppData", "Local") mkdirSync(appData, { recursive: true }) mkdirSync(localAppData, { recursive: true }) + const env = { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + APPDATA: appData, + LOCALAPPDATA: localAppData, + } as Record - const app = await electron.launch({ - args: [mainEntry()], - env: { - ...process.env, - HOME: homeDir, - USERPROFILE: homeDir, - APPDATA: appData, - LOCALAPPDATA: localAppData, - }, - }) - await use(app) - await app.close().catch(() => {}) - }, - - page: async ({ app }, use) => { - const page = await mainAppPage(app) - await page.waitForLoadState("domcontentloaded") - // Pre-dismiss first-run overlays (onboarding wizard + spotlight tour) so - // they don't intercept clicks, then reload so the flags are read before the - // app's onboarding effect runs. - await page.addInitScript(() => { + // Hand a spec nothing but a window that is proven to be rendering. The check + // costs seconds next to a 15-minute install, and a stalled window costs the + // whole spec. A relaunch reuses the warm HOME, so it does not repeat the + // runtime download. + let ready: { app: ElectronApplication; page: Page } | undefined + for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt++) { + let candidate: { app: ElectronApplication; page: Page } | undefined try { - localStorage.setItem("onboarding_completed", "true") - localStorage.setItem("guided_tour_completed", "true") + candidate = await launchOnce(env) + if (await producesFrames(candidate.page)) { + ready = candidate + break + } } catch { - /* ignore */ + /* fall through to the relaunch below */ } - }) - await page.reload() - await page.waitForLoadState("domcontentloaded") - await use(page) + console.warn( + `[fixtures] launch ${attempt}/${MAX_LAUNCH_ATTEMPTS} produced no frames; relaunching`, + ) + // Always close on the way out — an abandoned Electron process holds the + // isolated HOME open and poisons the next attempt. + await candidate?.app.close().catch(() => {}) + } + if (!ready) { + throw new Error( + `launcher produced no frames across ${MAX_LAUNCH_ATTEMPTS} launches — ` + + `the renderer never established a frame pipeline`, + ) + } + + await use(ready) + await ready.app.close().catch(() => {}) + }, + + app: async ({ launcher }, use) => { + await use(launcher.app) + }, + + page: async ({ launcher }, use) => { + await use(launcher.page) }, })