diff --git a/docs/reference/test/lanes.md b/docs/reference/test/lanes.md index 8b2c2930f283..c8f5d34f15d7 100644 --- a/docs/reference/test/lanes.md +++ b/docs/reference/test/lanes.md @@ -125,6 +125,10 @@ match only `failure-*/failure.public.json`; raw reports and screenshots remain private. Older frozen targets without the public summary produce no matching upload and never fall back to raw captures. +The shared suite captures native test timeouts before draining routes and closing +the owned browser context. Cleanup and the pending test body join the same capture, +so a later closed-page error does not replace the original timeout evidence. + The shared failure collector gives renderer evaluation and screenshot capture one five-second budget. If the renderer stalls, it records incomplete diagnostics and returns so the caller can rethrow the original failure. A late browser response diff --git a/ui/src/e2e/control-ui-e2e-suite.test-support.ts b/ui/src/e2e/control-ui-e2e-suite.test-support.ts index 0c12b9580d15..14d2fa69987c 100644 --- a/ui/src/e2e/control-ui-e2e-suite.test-support.ts +++ b/ui/src/e2e/control-ui-e2e-suite.test-support.ts @@ -195,11 +195,16 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont chromiumAvailable || !allowMissingChromium ? describe : describe.skip; const openBrowserContexts = new Map(); const contextClosures = new WeakMap>(); + const contextDiagnostics = new WeakMap< + BrowserContext, + { test: TestContext | undefined; capture?: Promise } + >(); const contextAcquisitions = new Map, AbortController | undefined>(); const acquisitionFailures: Array<{ owner: AbortController | undefined; error: unknown }> = []; const scenarios = new Set>(); const resourceLifetime = new AbortController(); - let activeScenario: AbortController | undefined; + let activeTest: TestContext | undefined; + let activeScenario: { controller: AbortController; test: TestContext } | undefined; let unsafeCleanup: { error: unknown; retainedState: () => string | undefined } | undefined; let browser: Browser | undefined; let server: ControlUiE2eServer | undefined; @@ -242,6 +247,33 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont } }; + const captureContextFailure = ( + context: BrowserContext, + failure: unknown, + originalPage?: Page, + ): Promise => { + const diagnostic = contextDiagnostics.get(context)!; + if (!diagnostic.capture) { + const serialized = asNullableRecord(failure); + const error = + failure instanceof Error + ? failure + : Object.assign( + new Error( + typeof serialized?.message === "string" ? serialized.message : String(failure), + ), + typeof serialized?.name === "string" ? { name: serialized.name } : {}, + typeof serialized?.stack === "string" ? { stack: serialized.stack } : {}, + ); + diagnostic.capture = Promise.resolve().then(async () => { + for (const page of new Set([...(originalPage ? [originalPage] : []), ...context.pages()])) { + await captureControlUiE2eFailureDiagnostics(page, { error, label: options.name }); + } + }); + } + return diagnostic.capture; + }; + const closeBrowserContext = (context: BrowserContext): Promise => { let closing = contextClosures.get(context); if (!closing) { @@ -250,13 +282,27 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont // Playwright's second close can return while the first is still finalizing. closing = Promise.resolve().then(async () => { const registrations = [...held.pages.values()].flat(); - for (const registration of registrations) { - registration.release(); - } - // Release all gates, then join registration and active page/context callbacks. - // A first request does not settle later fetches; wait mode preserves their errors. await runQaGatewayFixture( - () => settleControlUiCleanup(registrations.map(({ installed }) => installed)), + async () => { + const diagnostic = contextDiagnostics.get(context); + const test = diagnostic?.test; + const failure = test?.signal.aborted + ? test.signal.reason + : test?.task.result?.errors?.[0]; + // Native timeout rejects the wrapper before pending page operations settle. + if (failure !== undefined) { + await captureContextFailure(context, failure); + } else { + await diagnostic?.capture; + } + }, + async () => { + for (const registration of registrations) { + registration.release(); + } + // Release all gates before joining active page/context callbacks. + await settleControlUiCleanup(registrations.map(({ installed }) => installed)); + }, () => settleControlUiCleanup([ ...[...held.pages.keys()].map((page) => page.unrouteAll({ behavior: "wait" })), @@ -304,7 +350,8 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont ): Promise => { assertControlUiForkActive(); const currentBrowser = browser; - const owner = activeScenario; + const owner = activeScenario?.controller; + const test = activeScenario?.test ?? activeTest; if (!currentBrowser) { return Promise.reject(new Error("Control UI E2E browser accessed before suite setup")); } @@ -316,6 +363,7 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont const acquisition = Promise.resolve().then(async () => { const context = await currentBrowser.newContext(contextOptions); openBrowserContexts.set(context, owner); + contextDiagnostics.set(context, { test }); if (stopping || owner?.signal.aborted) { await closeBrowserContext(context); throw new ControlUiE2eAcquisitionClosedError( @@ -370,7 +418,7 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont const operation = createDeferredCore(); const retainedState = scenario.retainedState ?? resources?.retainedState ?? (() => undefined); let cleanupComplete = false; - activeScenario = owner; + activeScenario = { controller: owner, test: context }; scenarios.add(operation.promise); const abort = () => { owner.abort(context.signal.reason); @@ -430,8 +478,9 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont }, define(defineTests) { describeControlUiE2e(options.name, () => { - beforeEach(() => { + beforeEach((context) => { assertControlUiForkActive(); + activeTest = context; artifactDir = undefined; }); beforeAll(() => { @@ -472,7 +521,7 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont assertControlUiForkActive(); stopping = true; resourceLifetime.abort(); - activeScenario?.abort(); + activeScenario?.controller.abort(); const closingContexts = closeOpenBrowserContexts(); const contexts = Promise.allSettled([closingContexts]); const teardown = (async () => { @@ -529,13 +578,7 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont try { return await run(fixture); } catch (error) { - // Keep closed-page diagnostics and capture other live documents before teardown. - for (const diagnosticPage of new Set([page, ...context.pages()])) { - await captureControlUiE2eFailureDiagnostics(diagnosticPage, { - error: error instanceof Error ? error : new Error(String(error)), - label: options.name, - }); - } + await captureContextFailure(context, error, page); throw error; } }, diff --git a/ui/src/test-helpers/control-ui-e2e-suite.test.ts b/ui/src/test-helpers/control-ui-e2e-suite.test.ts index 48828074914b..b29b2fb39126 100644 --- a/ui/src/test-helpers/control-ui-e2e-suite.test.ts +++ b/ui/src/test-helpers/control-ui-e2e-suite.test.ts @@ -14,6 +14,8 @@ const repoRoot = path.resolve(import.meta.dirname, "../../.."); const helperPath = path.join(repoRoot, "ui/src/e2e/control-ui-e2e-suite.test-support.ts"); type FixtureMode = + | "diagnostic-tracked-timeout" + | "diagnostic-scenario-timeout" | "tracked-close-success" | "tracked-close-failure" | "concurrent-close" @@ -75,6 +77,7 @@ const state = vi.hoisted(() => { requestDisposed: deferred(), disposalFault: new Error("synthetic request context disposed"), firstCleanupEvent: "", pendingCloseCalls: -1, fulfilledBeforeFetchRelease: [], heldBodyErrorRetained: false, contextCleanupEvent: "", callbackOutcomes: [], + pendingPageReject: undefined, closeFault: new Error("synthetic context close failure") }; }); vi.mock("playwright", () => ({ chromium: { launch: async () => { @@ -91,6 +94,37 @@ vi.mock("playwright", () => ({ chromium: { launch: async () => { isClosed: () => true, url: () => "about:blank", }); + if (${JSON.stringify(mode)}.startsWith("diagnostic-")) { + let pageClosed = false; + let context; + const page = Object.assign(new EventEmitter(), { + ...closedPage, + isClosed: () => pageClosed, + context: () => context, + frames: () => [], + evaluate: async () => { + state.events.push(pageClosed ? "capture closed page" : "capture live page"); + record(); + if (pageClosed) throw new Error("synthetic page closed"); + return { failureSummary: { available: true } }; + }, + }); + context = { + setDefaultTimeout() {}, + pages: () => pageClosed ? [] : [page], + newPage: async () => page, + browser: () => ({ isConnected: () => true }), + unrouteAll: async () => { state.events.push("drain"); record(); }, + close: async () => { + pageClosed = true; + state.closeCalls++; + state.events.push("close"); + state.pendingPageReject?.(new Error("synthetic page closed")); + record(); + }, + }; + return context; + } if (${JSON.stringify(mode)} === "held-route-drain") { const handlers = []; const pending = new Set(); @@ -204,7 +238,7 @@ fs.writeFileSync(${JSON.stringify(path.join(root, "worker.pid"))}, String(proces record(); let sharedFixture; const suite = createControlUiE2eSuite({ name: "owned context fixture", - trackBrowserContexts: ${mode.startsWith("tracked-")}, + trackBrowserContexts: ${mode.startsWith("tracked-") || mode === "diagnostic-tracked-timeout"}, ...(${JSON.stringify(mode)}.startsWith("resources-") ? { resources: { retainedState: () => sharedFixture?.root, @@ -238,7 +272,22 @@ const suite = createControlUiE2eSuite({ name: "owned context fixture", }, }); suite.define(() => { - if (${JSON.stringify(mode)}.startsWith("resources-")) { + if (${JSON.stringify(mode)}.startsWith("diagnostic-")) { + it("retains the native page timeout", async (context) => { + const run = () => suite.withPage({}, async () => { + await new Promise((resolve, reject) => { state.pendingPageReject = reject; }); + }); + if (${JSON.stringify(mode)} === "diagnostic-scenario-timeout") { + await suite.runScenario(context, { run }); + } else { + await run(); + } + }, 50); + it("starts the successor after timeout cleanup", () => { + state.events.push("successor"); record(); + fs.writeFileSync(${JSON.stringify(path.join(root, "successor.txt"))}, "started"); + }); + } else if (${JSON.stringify(mode)}.startsWith("resources-")) { it.for(["first", "second"])("uses shared resources: %s", async (name, context) => { await suite.runScenario(context, { run: async () => { expect(process.env.OPENCLAW_STATE_DIR).toBe(sharedFixture.stateDir); @@ -441,6 +490,7 @@ export default defineConfig({ OPENCLAW_HOME: path.join(root, "home"), OPENCLAW_STATE_DIR: path.join(root, "home/.openclaw"), OPENCLAW_CONFIG_PATH: path.join(root, "home/.openclaw/openclaw.json"), + OPENCLAW_UI_E2E_DIAGNOSTIC_DIR: path.join(root, "diagnostics"), TMPDIR: path.join(root, "tmp"), TMP: path.join(root, "tmp"), TEMP: path.join(root, "tmp"), @@ -482,7 +532,27 @@ export default defineConfig({ () => false, ); } + const captures: Array<{ + public: { hostBeforeRead: { pageClosed: boolean }; rendererRead: string }; + private: { failure: { message: string } }; + }> = []; + if (mode.startsWith("diagnostic-")) { + for (const entry of await fs.readdir(path.join(root, "diagnostics"))) { + captures.push({ + public: JSON.parse( + await fs.readFile(path.join(root, "diagnostics", entry, "failure.public.json"), "utf8"), + ), + private: JSON.parse( + await fs.readFile( + path.join(root, "diagnostics", entry, "failure.private.json"), + "utf8", + ), + ), + }); + } + } return { + captures, code: child.exitCode, output: `${output.stdout}\n${output.stderr}`, report: nativeReport, @@ -514,6 +584,31 @@ function runJoinedShutdownTest(context: TestContext, body: () => Promise) return run; } +it.for(["diagnostic-tracked-timeout", "diagnostic-scenario-timeout"] as const)( + "captures a native timeout before its context closes: %s", + (mode, context) => + runJoinedShutdownTest(context, async () => { + const result = await runFixture(mode, context.signal); + expect(result.code, result.output).toBe(1); + expect(result.report.numFailedTests, result.output).toBe(1); + expect(result.report.numPassedTests, result.output).toBe(1); + expect(result.output).toContain("Test timed out in 50ms"); + expect(result.successorStarted).toBe(true); + expect(result.journal.events).toEqual(["capture live page", "drain", "close", "successor"]); + expect(result.captures).toHaveLength(1); + expect(result.captures[0]?.public).toMatchObject({ + hostBeforeRead: { pageClosed: false }, + rendererRead: "completed", + }); + expect(result.captures[0]?.private.failure.message).toContain("Test timed out in 50ms"); + expect(result.journal).toMatchObject({ + closeCalls: 1, + browserClosed: true, + serverClosed: true, + }); + }), +); + it("drains held-module callbacks before closing the context after a body failure", (context) => runJoinedShutdownTest(context, async () => { const result = await runFixture("held-route-drain", context.signal);