mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
fix: preserve live Control UI timeout diagnostics (#156659)
This commit is contained in:
parent
7dbfab8c2c
commit
a4fbf649cd
3 changed files with 162 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue