fix: preserve live Control UI timeout diagnostics (#156659)

This commit is contained in:
Peter Steinberger 2026-09-23 10:35:51 -07:00 • committed by GitHub
parent 7dbfab8c2c
commit a4fbf649cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 162 additions and 20 deletions

View file

@ -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

View file

@ -195,11 +195,16 @@ export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): Cont
chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const openBrowserContexts = new Map<BrowserContext, AbortController | undefined>();
const contextClosures = new WeakMap<BrowserContext, Promise<void>>();
const contextDiagnostics = new WeakMap<
BrowserContext,
{ test: TestContext | undefined; capture?: Promise<void> }
>();
const contextAcquisitions = new Map<Promise<BrowserContext>, AbortController | undefined>();
const acquisitionFailures: Array<{ owner: AbortController | undefined; error: unknown }> = [];
const scenarios = new Set<Promise<unknown>>();
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<void> => {
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<void> => {
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<BrowserContext> => {
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<T>();
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;
}
},

View file

@ -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<void>)
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);