diff --git a/qa/scenarios/ui/control-ui-service-worker-update.yaml b/qa/scenarios/ui/control-ui-service-worker-update.yaml new file mode 100644 index 000000000000..d970e391cd24 --- /dev/null +++ b/qa/scenarios/ui/control-ui-service-worker-update.yaml @@ -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. diff --git a/ui/src/e2e/service-worker-update.e2e.test.ts b/ui/src/e2e/service-worker-update.e2e.test.ts new file mode 100644 index 000000000000..d98999581010 --- /dev/null +++ b/ui/src/e2e/service-worker-update.e2e.test.ts @@ -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 { + 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 { + 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((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); +}); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 551edaa1c1a7..3efca30b29b8 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -447,15 +447,13 @@ function controlUiE2ePreviewConfigPlugin(): Plugin { }; } -export async function startBundledControlUiE2eServer(outDir: string): Promise { - 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 { + // 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 { + 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 { + 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 { + 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 { + await buildProductionControlUiE2e(outDir, buildId); + return startBuiltControlUiE2eServer(outDir); +} + async function resolveAvailableLoopbackPort(): Promise { 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 "); + } + await runProductionControlUiBuild(outDir); +} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */