From c59ef0b40cfadc90ed7b09ae596d38cb2608da7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 15 Aug 2026 00:10:40 -0700 Subject: [PATCH] fix(test): serve deep-link assets like the Gateway in the Control UI e2e preview server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the checks-ui-e2e boot-stall flake (run 31867505277): the built index.html references modules relatively (./assets/*), and the real Gateway resolves /assets/ at any path depth (src/gateway/control-ui.ts), but the e2e preview server let Vite's SPA fallback answer /chat/assets/index-*.js with index.html as text/html. The browser rejects that module silently (console-only, no pageerror), so a document reloaded on a deep link like /chat/research never boots. Under CPU load the harness's replaceState("/")+reload pair raced the router's redirect and reloaded on the deep link, producing the all-null 30s poll. - Preview server now mirrors the Gateway's depth-insensitive /assets/ resolution; the racy replaceState canonicalization hack is deleted. - Deterministic regression test boots a document directly on /chat/research; mutation-proven (fails with the middleware disabled). - Failure diagnostics generalized: boot waits capture screenshot, script/ resource evidence, pageErrors, and a navigation-traced event ring. Proof: Testbox tbx_01m022feapn38ypws3mb2b9jhv saturated loops — pre-fix 2/10 failed with the exact CI signature; post-fix 0/14 at the same pace. --- ui/src/e2e/service-worker-update.e2e.test.ts | 55 +++++++- ui/src/test-helpers/control-ui-e2e.ts | 135 +++++++++++++++---- 2 files changed, 161 insertions(+), 29 deletions(-) diff --git a/ui/src/e2e/service-worker-update.e2e.test.ts b/ui/src/e2e/service-worker-update.e2e.test.ts index ad7d8aa87a92..33d01ab5e8f4 100644 --- a/ui/src/e2e/service-worker-update.e2e.test.ts +++ b/ui/src/e2e/service-worker-update.e2e.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildProductionControlUiE2e, canRunPlaywrightChromium, + captureControlUiE2eFailureDiagnostics, controlUiE2eWaitTimeoutMs, installMockGateway, resolvePlaywrightChromiumExecutablePath, @@ -163,10 +164,11 @@ async function ensureControlledPage(page: Page, pageErrors: string[], expectedBu return value.active?.state === "activated"; }); if (!registration.controlled) { - // The production preview serves static assets directly. Canonicalize the - // first controlled reload so Vite's portable relative asset URLs do not - // resolve beneath a client-side deep link such as /chat/research. - await page.evaluate(() => window.history.replaceState(window.history.state, "", "/")); + // Reload once so the freshly activated worker controls the page. The + // reload may land on a router deep link like /chat/research; the preview + // server mirrors the Gateway's depth-insensitive /assets/ resolution, so + // that boots correctly. (A racy replaceState("/") used to canonicalize + // the URL here and could interleave with the router's own redirect.) await page.reload(); } await page.waitForFunction(() => navigator.serviceWorker?.controller?.state === "activated"); @@ -220,6 +222,40 @@ async function fetchControlledAsset( } describe("Control UI service-worker production update E2E", () => { + it("boots a document loaded on a deep link (Gateway asset-path contract)", async () => { + // The built index.html references ./assets/* relatively, so a document at + // /chat/research requests /chat/assets/*. The Gateway resolves /assets/ + // at any depth (src/gateway/control-ui.ts); the preview server must honor + // the same contract or reloads on deep links serve HTML as the module and + // the app silently never boots. + const context = await browser.newContext({ serviceWorkers: "block" }); + const page = await context.newPage(); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(`${error.name}:${error.message}`)); + await installMockGateway(page, { + assistantAgentId: "research", + defaultAgentId: "research", + serverBuildId: buildA, + }); + try { + expect((await page.goto(`${server.baseUrl}chat/research`))?.status()).toBe(200); + await page.waitForFunction(() => Boolean(customElements.get("openclaw-app")), undefined, { + timeout: controlUiE2eWaitTimeoutMs, + }); + } catch (error) { + if (error instanceof Error) { + await captureControlUiE2eFailureDiagnostics(page, { + error, + label: "deep-link-boot", + pageErrors, + }); + } + throw error; + } finally { + await context.close(); + } + }, 60_000); + beforeAll(async () => { if (!canRunPlaywrightChromium(chromiumExecutablePath)) { throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`); @@ -454,6 +490,17 @@ describe("Control UI service-worker production update E2E", () => { path: path.join(artifactDir, "updated-worker-controlled-page.png"), }); } + } catch (error) { + // Boot/readiness stalls otherwise fail as all-null poll snapshots with + // no CI evidence; capture page state before the context closes. + if (error instanceof Error) { + await captureControlUiE2eFailureDiagnostics(page, { + error, + label: "service-worker-update-reconnect", + pageErrors, + }); + } + throw error; } finally { await installGate?.close(); await context.close(); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index a7c9851116a7..6ea8a6a4bbac 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -6,7 +6,7 @@ import { createServer as createNetServer } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildControlUiSessionPath } from "@openclaw/session-url-contract"; -import type { ConsoleMessage, Locator, Page, Request } from "playwright"; +import type { ConsoleMessage, Frame, Locator, Page, Request } from "playwright"; import type { InlineConfig, Plugin, PreviewServer, ViteDevServer } from "vite"; import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/version.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../src/gateway/control-ui-contract.js"; @@ -355,7 +355,7 @@ let controlUiE2eDiagnosticSequence = 0; type ControlUiE2eDiagnosticEvent = { at: string; details: Record; - source: "console" | "pageerror" | "requestfailed"; + source: "console" | "framenavigated" | "pageerror" | "requestfailed"; }; function installControlUiE2ePageDiagnosticRing(page: Page): ControlUiE2eDiagnosticEvent[] { @@ -400,11 +400,25 @@ function installControlUiE2ePageDiagnosticRing(page: Page): ControlUiE2eDiagnost source: "requestfailed", }); }; + const onFrameNavigated = (frame: Frame) => { + // Main-frame navigations order boot/reload sequences in failure reports; + // subframes are noise. + if (frame !== page.mainFrame()) { + return; + } + push({ + at: new Date().toISOString(), + details: { url: frame.url() }, + source: "framenavigated", + }); + }; page.on("console", onConsole); + page.on("framenavigated", onFrameNavigated); page.on("pageerror", onPageError); page.on("requestfailed", onRequestFailed); page.once("close", () => { page.off("console", onConsole); + page.off("framenavigated", onFrameNavigated); page.off("pageerror", onPageError); page.off("requestfailed", onRequestFailed); controlUiE2ePageDiagnostics.delete(page); @@ -608,6 +622,28 @@ export async function startControlUiE2eServer( }; } +// Mirror the Gateway's depth-insensitive asset resolution +// (src/gateway/control-ui.ts): any "/assets/" segment serves the bundled +// asset. The built index.html uses portable relative asset URLs, so a +// document reloaded on a deep link like /chat/research requests +// /chat/assets/*.js; without this contract Vite's SPA fallback answers with +// index.html and the module never executes, bricking the page. +function controlUiE2eGatewayAssetPathPlugin(): Plugin { + return { + name: "control-ui-e2e-gateway-asset-paths", + configurePreviewServer(server) { + server.middlewares.use((req, _res, next) => { + const url = req.url ?? ""; + const assetsIndex = url.indexOf("/assets/"); + if (assetsIndex > 0) { + req.url = url.slice(assetsIndex); + } + next(); + }); + }, + }; +} + function controlUiE2ePreviewConfigPlugin( bootstrapConfig: Record = { basePath: "/", @@ -704,7 +740,11 @@ async function startBuiltControlUiE2eServer( const sharedConfig = createBundledControlUiE2eConfig(controlUiViteConfig, outDir); const server = await preview({ ...sharedConfig, - plugins: [...(sharedConfig.plugins ?? []), controlUiE2ePreviewConfigPlugin(bootstrapConfig)], + plugins: [ + ...(sharedConfig.plugins ?? []), + controlUiE2eGatewayAssetPathPlugin(), + controlUiE2ePreviewConfigPlugin(bootstrapConfig), + ], preview: { host: "127.0.0.1", port, @@ -2511,14 +2551,11 @@ function createMockGatewayControls( continue; } if (error instanceof Error && error.name === "TimeoutError") { - try { - await captureControlUiE2eRequestTimeout(page, method, error, diagnosticEvents); - } catch (captureError) { - console.error("[control-ui-e2e] failed to capture request-timeout diagnostics", { - captureError, - method, - }); - } + await captureControlUiE2eFailureDiagnostics(page, { + error, + label: method, + pageEvents: diagnosticEvents, + }); } throw error; } @@ -2528,18 +2565,53 @@ function createMockGatewayControls( }; } -async function captureControlUiE2eRequestTimeout( +/** + * Capture a screenshot plus a browser/app-state report for a failed E2E wait. + * Wired into mock-Gateway request timeouts automatically; boot/readiness waits + * in individual tests should call this from their failure path so CI artifacts + * explain stalls instead of surfacing all-null poll snapshots. + */ +export async function captureControlUiE2eFailureDiagnostics( page: Page, - method: string, - timeoutError: Error, - diagnosticEvents: ControlUiE2eDiagnosticEvent[], + options: { + error: Error; + label: string; + pageErrors?: string[]; + pageEvents?: ControlUiE2eDiagnosticEvent[]; + }, +): Promise { + try { + await captureControlUiE2eFailureDiagnosticsUnsafe(page, options); + } catch (captureError) { + console.error("[control-ui-e2e] failed to capture failure diagnostics", { + captureError, + label: options.label, + }); + } +} + +async function captureControlUiE2eFailureDiagnosticsUnsafe( + page: Page, + { + error, + label, + pageErrors = [], + // The mock-Gateway installer keeps a per-page diagnostic ring; default to + // it so ad-hoc test callers get console/navigation history for free. + pageEvents = controlUiE2ePageDiagnostics.get(page) ?? [], + }: { + error: Error; + label: string; + pageErrors?: string[]; + pageEvents?: ControlUiE2eDiagnosticEvent[]; + }, ): Promise { const configuredDir = process.env.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR?.trim(); const artifactDir = path.resolve( configuredDir || path.join(resolveRepoRoot(), ".artifacts", "control-ui-e2e-timeouts", "local"), ); mkdirSync(artifactDir, { recursive: true }); - const safeMethod = method.replaceAll(/[^a-zA-Z0-9_.-]+/gu, "-"); + const safeMethod = label.replaceAll(/[^a-zA-Z0-9_.-]+/gu, "-"); const captureId = `${new Date().toISOString().replaceAll(/[:.]/gu, "-")}-${String(++controlUiE2eDiagnosticSequence).padStart(2, "0")}-${safeMethod}`; const screenshotName = `${captureId}.png`; const screenshotPath = path.join(artifactDir, screenshotName); @@ -2645,6 +2717,18 @@ async function captureControlUiE2eRequestTimeout( hasApp: Boolean(app), hasShell: Boolean(shell), readyState: document.readyState, + // A stalled or failed bundle fetch shows as a script src with no + // matching completed resource entry (resource timing only records + // finished requests). + completedResources: performance + .getEntriesByType("resource") + .filter((entry) => /\.(?:js|css)(?:\?|$)/u.test(entry.name)) + .map((entry) => ({ + duration: Math.round(entry.duration), + name: entry.name, + })), + scripts: [...document.scripts].map((script) => script.src || "(inline)"), + serviceWorkerController: navigator.serviceWorker?.controller?.state ?? null, title: document.title, url: window.location.href, }, @@ -2670,8 +2754,8 @@ async function captureControlUiE2eRequestTimeout( captureErrors.push(`page.screenshot: ${String(error)}`); } const report = { - schemaVersion: 1, - awaitedMethod: method, + schemaVersion: 2, + label, browserState, captureErrors, capturedAt: new Date().toISOString(), @@ -2682,22 +2766,23 @@ async function captureControlUiE2eRequestTimeout( shardIndex: process.env.SHARD_INDEX ?? null, vitestShardCount: process.env.VITEST_SHARD_COUNT ?? null, }, - pageEvents: [...diagnosticEvents], + pageEvents: [...pageEvents], + pageErrors: [...pageErrors], page: { closed: page.isClosed(), url: page.url(), }, screenshot: screenshotWritten ? screenshotName : null, - timeout: { - message: timeoutError.message, - name: timeoutError.name, - stack: timeoutError.stack ?? null, + failure: { + message: error.message, + name: error.name, + stack: error.stack ?? null, }, }; writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); - console.error(`[control-ui-e2e] request timeout diagnostics: ${reportPath}`); + console.error(`[control-ui-e2e] failure diagnostics: ${reportPath}`); if (screenshotWritten) { - console.error(`[control-ui-e2e] request timeout screenshot: ${screenshotPath}`); + console.error(`[control-ui-e2e] failure screenshot: ${screenshotPath}`); } }