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:
Vincent Koc
2026-08-04 06:56:47 +08:00
committed by GitHub
parent 06b5f0b2ff
commit a2ccd6ea61
3 changed files with 358 additions and 8 deletions

View 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.

View 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);
});

View File

@@ -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. */