mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-10 17:16:55 +00:00
test(ui): add service-worker update primary QA proof (#118805)
* test(ui): prove service-worker production updates * test(qa): register control ui service-worker proof * fix(ui): type production build environment * fix(ui): avoid service-worker test shadowing * test(ui): gate service-worker proof capture * test(ui): isolate production e2e build output * test(ui): assert claimed service worker version
This commit is contained in:
31
qa/scenarios/ui/control-ui-service-worker-update.yaml
Normal file
31
qa/scenarios/ui/control-ui-service-worker-update.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
title: Control UI production service-worker update
|
||||
|
||||
scenario:
|
||||
id: control-ui-service-worker-update
|
||||
surface: control-ui
|
||||
coverage:
|
||||
primary:
|
||||
- control-ui.service-worker-updates
|
||||
objective: >-
|
||||
Prove a production Control UI worker updates in place on one browser origin
|
||||
and serves the newly built application assets.
|
||||
successCriteria:
|
||||
- Chromium loads a production Control UI build and becomes controlled by its activated worker.
|
||||
- A second production build is published at the same origin.
|
||||
- The installed registration discovers and activates the updated worker.
|
||||
- The updated worker claims the page and the production update message reloads it.
|
||||
- A build-specific asset fetched while controlled matches the second build, not stale output.
|
||||
docsRefs:
|
||||
- docs/web/control-ui.md
|
||||
codeRefs:
|
||||
- ui/src/main.ts
|
||||
- ui/public/sw.js
|
||||
- ui/src/e2e/service-worker-update.e2e.test.ts
|
||||
- ui/src/test-helpers/control-ui-e2e.ts
|
||||
execution:
|
||||
kind: playwright
|
||||
path: ui/src/e2e/service-worker-update.e2e.test.ts
|
||||
testNamePattern: activates the next production worker and serves its refreshed build asset
|
||||
summary: >-
|
||||
Real Chromium proof across two production Control UI builds, native worker
|
||||
update and activation, page takeover, reload, and refreshed hashed assets.
|
||||
243
ui/src/e2e/service-worker-update.e2e.test.ts
Normal file
243
ui/src/e2e/service-worker-update.e2e.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildProductionControlUiE2e,
|
||||
canRunPlaywrightChromium,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startProductionControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const artifactDir = path.resolve(".artifacts/control-ui-e2e/service-worker-update");
|
||||
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const workerUpdateVersionsStorageKey = "openclaw.control-ui-e2e.worker-update-versions";
|
||||
|
||||
const buildA = "service-worker-build-a";
|
||||
const buildB = "service-worker-build-b";
|
||||
|
||||
type BuildAsset = {
|
||||
path: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
let browser: Browser;
|
||||
let outDir: string;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
async function findBuildAsset(buildId: string): Promise<BuildAsset> {
|
||||
const assetsDir = path.join(outDir, "assets");
|
||||
for (const fileName of await readdir(assetsDir)) {
|
||||
if (!fileName.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const source = await readFile(path.join(assetsDir, fileName));
|
||||
if (source.includes(Buffer.from(buildId))) {
|
||||
return {
|
||||
path: `assets/${fileName}`,
|
||||
sha256: createHash("sha256").update(source).digest("hex"),
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`Production Control UI output did not contain build id ${buildId}`);
|
||||
}
|
||||
|
||||
async function ensureControlledPage(page: Page, pageErrors: string[], expectedBuildId: string) {
|
||||
const registration = await page.evaluate(async (workerBuildId) => {
|
||||
const ready = navigator.serviceWorker.ready.then((value) => ({
|
||||
activeState: value.active?.state ?? null,
|
||||
controlled: navigator.serviceWorker.controller !== null,
|
||||
error: null,
|
||||
}));
|
||||
const timeout = new Promise<{
|
||||
activeState: null;
|
||||
controlled: boolean;
|
||||
error: string;
|
||||
}>((resolve) => {
|
||||
window.setTimeout(() => {
|
||||
void (async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
const response = await fetch(`/sw.js?v=${workerBuildId}`);
|
||||
resolve({
|
||||
activeState: null,
|
||||
controlled: navigator.serviceWorker.controller !== null,
|
||||
error: JSON.stringify({
|
||||
isSecureContext,
|
||||
location: window.location.href,
|
||||
registrations: registrations.map((value) => ({
|
||||
active: value.active?.state ?? null,
|
||||
activeScriptUrl: value.active?.scriptURL ?? null,
|
||||
installing: value.installing?.state ?? null,
|
||||
scope: value.scope,
|
||||
waiting: value.waiting?.state ?? null,
|
||||
})),
|
||||
serviceWorker: {
|
||||
contentType: response.headers.get("content-type"),
|
||||
status: response.status,
|
||||
},
|
||||
}),
|
||||
});
|
||||
})();
|
||||
}, 10_000);
|
||||
});
|
||||
return Promise.race([ready, timeout]);
|
||||
}, expectedBuildId);
|
||||
if (registration.error) {
|
||||
throw new Error(
|
||||
`Service worker did not become ready: ${registration.error}; page errors: ${JSON.stringify(pageErrors)}`,
|
||||
);
|
||||
}
|
||||
await page.waitForFunction(async () => {
|
||||
const value = await navigator.serviceWorker.ready;
|
||||
return value.active?.state === "activated";
|
||||
});
|
||||
if (!registration.controlled) {
|
||||
await page.reload();
|
||||
}
|
||||
await page.waitForFunction(() => navigator.serviceWorker?.controller?.state === "activated");
|
||||
}
|
||||
|
||||
async function readWorkerUpdateVersions(page: Page): Promise<string[]> {
|
||||
return page.evaluate((storageKey) => {
|
||||
const stored = JSON.parse(sessionStorage.getItem(storageKey) ?? "[]") as unknown;
|
||||
return Array.isArray(stored)
|
||||
? stored.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
}, workerUpdateVersionsStorageKey);
|
||||
}
|
||||
|
||||
async function fetchControlledAsset(
|
||||
page: Page,
|
||||
assetPath: string,
|
||||
): Promise<{ controllerState: string | null; sha256: string }> {
|
||||
return page.evaluate(async (relativePath) => {
|
||||
const controller = navigator.serviceWorker.controller;
|
||||
if (controller && controller.state !== "activated") {
|
||||
await new Promise<void>((resolve) => {
|
||||
controller.addEventListener(
|
||||
"statechange",
|
||||
() => {
|
||||
if (controller.state === "activated") {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
const response = await fetch(new URL(relativePath, window.location.href));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Build asset request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const digest = await crypto.subtle.digest("SHA-256", await response.arrayBuffer());
|
||||
return {
|
||||
controllerState: controller?.state ?? null,
|
||||
sha256: [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join(""),
|
||||
};
|
||||
}, assetPath);
|
||||
}
|
||||
|
||||
describe("Control UI service-worker production update E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!canRunPlaywrightChromium(chromiumExecutablePath)) {
|
||||
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
|
||||
}
|
||||
outDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-service-worker-update-"));
|
||||
server = await startProductionControlUiE2eServer(outDir, buildA);
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
if (outDir) {
|
||||
await rm(outDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("activates the next production worker and serves its refreshed build asset", async () => {
|
||||
if (captureUiProof) {
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
}
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "allow",
|
||||
viewport: { height: 720, width: 1280 },
|
||||
...(captureUiProof
|
||||
? { recordVideo: { dir: artifactDir, size: { height: 720, width: 1280 } } }
|
||||
: {}),
|
||||
});
|
||||
// An update keeps the incumbent script URL while installing changed bytes.
|
||||
// The worker emits its embedded version only after clients.claim() resolves.
|
||||
await context.addInitScript((storageKey) => {
|
||||
navigator.serviceWorker.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "sw-updated" || typeof event.data.version !== "string") {
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(sessionStorage.getItem(storageKey) ?? "[]") as unknown;
|
||||
const versions = Array.isArray(stored)
|
||||
? stored.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
versions.push(event.data.version);
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(versions));
|
||||
});
|
||||
}, workerUpdateVersionsStorageKey);
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(`${error.name}:${error.message}`));
|
||||
|
||||
try {
|
||||
expect((await page.goto(`${server.baseUrl}chat`))?.status()).toBe(200);
|
||||
await ensureControlledPage(page, pageErrors, buildA);
|
||||
await expect.poll(() => readWorkerUpdateVersions(page)).toContain(buildA);
|
||||
|
||||
const assetA = await findBuildAsset(buildA);
|
||||
const initialAsset = await fetchControlledAsset(page, assetA.path);
|
||||
expect(initialAsset).toEqual({
|
||||
controllerState: "activated",
|
||||
sha256: assetA.sha256,
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => caches.keys()))
|
||||
.toContain(`openclaw-control-${buildA}`);
|
||||
|
||||
await buildProductionControlUiE2e(outDir, buildB);
|
||||
const assetB = await findBuildAsset(buildB);
|
||||
expect(assetB.path).not.toBe(assetA.path);
|
||||
expect(assetB.sha256).not.toBe(assetA.sha256);
|
||||
|
||||
const reloaded = page.waitForEvent("domcontentloaded");
|
||||
await page.evaluate(() => {
|
||||
void navigator.serviceWorker.ready.then((registration) => registration.update());
|
||||
});
|
||||
await reloaded;
|
||||
await ensureControlledPage(page, pageErrors, buildB);
|
||||
await expect.poll(() => readWorkerUpdateVersions(page)).toContain(buildB);
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => caches.keys()))
|
||||
.toContain(`openclaw-control-${buildB}`);
|
||||
const refreshedAsset = await fetchControlledAsset(page, assetB.path);
|
||||
expect(refreshedAsset).toEqual({
|
||||
controllerState: "activated",
|
||||
sha256: assetB.sha256,
|
||||
});
|
||||
expect(refreshedAsset.sha256).not.toBe(initialAsset.sha256);
|
||||
|
||||
if (captureUiProof) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(artifactDir, "updated-worker-controlled-page.png"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -447,15 +447,13 @@ function controlUiE2ePreviewConfigPlugin(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
export async function startBundledControlUiE2eServer(outDir: string): Promise<ControlUiE2eServer> {
|
||||
const [{ build, preview }, { default: controlUiViteConfig }] = await Promise.all([
|
||||
import("vite"),
|
||||
import("../../vite.config.ts"),
|
||||
]);
|
||||
const port = await resolveAvailableLoopbackPort();
|
||||
function createBundledControlUiE2eConfig(
|
||||
controlUiViteConfig: (options: { outDir?: string }) => InlineConfig,
|
||||
outDir: string,
|
||||
): InlineConfig {
|
||||
const config = controlUiViteConfig({ outDir });
|
||||
const uiRoot = path.join(resolveRepoRoot(), "ui");
|
||||
const sharedConfig: InlineConfig = {
|
||||
return {
|
||||
...config,
|
||||
base: "/",
|
||||
configFile: false,
|
||||
@@ -468,7 +466,60 @@ export async function startBundledControlUiE2eServer(outDir: string): Promise<Co
|
||||
logLevel: "error" as const,
|
||||
root: uiRoot,
|
||||
};
|
||||
await build(sharedConfig);
|
||||
}
|
||||
|
||||
export async function buildProductionControlUiE2e(outDir: string, buildId: string): Promise<void> {
|
||||
// Keep the production config outside Vitest, but write directly to the
|
||||
// caller-owned output so concurrent E2E builds cannot replace its worker.
|
||||
const repoRoot = resolveRepoRoot();
|
||||
const uiRoot = path.join(repoRoot, "ui");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
OPENCLAW_CONTROL_UI_BUILD_ID: buildId,
|
||||
};
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("VITEST")) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", fileURLToPath(import.meta.url), "--production-build", outDir],
|
||||
{
|
||||
cwd: uiRoot,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Production Control UI build failed (exit ${result.status ?? "unknown"}):\n${result.stderr || result.stdout}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runProductionControlUiBuild(outDir: string): Promise<void> {
|
||||
const [{ build }, { default: controlUiViteConfig }] = await Promise.all([
|
||||
import("vite"),
|
||||
import("../../vite.config.ts"),
|
||||
]);
|
||||
await build({
|
||||
...controlUiViteConfig({ outDir }),
|
||||
configFile: false,
|
||||
logLevel: "error",
|
||||
root: path.join(resolveRepoRoot(), "ui"),
|
||||
});
|
||||
}
|
||||
|
||||
async function startBuiltControlUiE2eServer(outDir: string): Promise<ControlUiE2eServer> {
|
||||
const [{ preview }, { default: controlUiViteConfig }] = await Promise.all([
|
||||
import("vite"),
|
||||
import("../../vite.config.ts"),
|
||||
]);
|
||||
const port = await resolveAvailableLoopbackPort();
|
||||
const sharedConfig = createBundledControlUiE2eConfig(controlUiViteConfig, outDir);
|
||||
const server = await preview({
|
||||
...sharedConfig,
|
||||
plugins: [...(sharedConfig.plugins ?? []), controlUiE2ePreviewConfigPlugin()],
|
||||
@@ -489,6 +540,23 @@ export async function startBundledControlUiE2eServer(outDir: string): Promise<Co
|
||||
}
|
||||
}
|
||||
|
||||
export async function startBundledControlUiE2eServer(outDir: string): Promise<ControlUiE2eServer> {
|
||||
const [{ build }, { default: controlUiViteConfig }] = await Promise.all([
|
||||
import("vite"),
|
||||
import("../../vite.config.ts"),
|
||||
]);
|
||||
await build(createBundledControlUiE2eConfig(controlUiViteConfig, outDir));
|
||||
return startBuiltControlUiE2eServer(outDir);
|
||||
}
|
||||
|
||||
export async function startProductionControlUiE2eServer(
|
||||
outDir: string,
|
||||
buildId: string,
|
||||
): Promise<ControlUiE2eServer> {
|
||||
await buildProductionControlUiE2e(outDir, buildId);
|
||||
return startBuiltControlUiE2eServer(outDir);
|
||||
}
|
||||
|
||||
async function resolveAvailableLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = createNetServer();
|
||||
@@ -2053,4 +2121,12 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const [command, outDir] = process.argv.slice(2);
|
||||
if (command !== "--production-build" || !outDir) {
|
||||
throw new Error("Usage: control-ui-e2e.ts --production-build <out-dir>");
|
||||
}
|
||||
await runProductionControlUiBuild(outDir);
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
Reference in New Issue
Block a user