mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 08:02:12 -06:00
fix(test): serve deep-link assets like the Gateway in the Control UI e2e preview server
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user