diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index cf16048b3c6..0c4d7173d19 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -49,7 +49,8 @@ pnpm qa:lab:watch `qa:lab:up:fast` keeps the Docker services on a prebuilt image and bind-mounts `extensions/qa-lab/web/dist` into the `qa-lab` container. `qa:lab:watch` -rebuilds that bundle on change; refresh the browser after each rebuild. +rebuilds that bundle on change, and the browser auto-reloads when the QA Lab +asset hash changes. ## Repo-backed seeds diff --git a/extensions/qa-lab/src/docker-harness.ts b/extensions/qa-lab/src/docker-harness.ts index feb1c36b8b0..c07fddd5273 100644 --- a/extensions/qa-lab/src/docker-harness.ts +++ b/extensions/qa-lab/src/docker-harness.ts @@ -209,7 +209,7 @@ Fast UI refresh: - \`pnpm qa:lab:up --use-prebuilt-image --bind-ui-dist --skip-ui-build\` - In another shell, rebuild the QA Lab bundle on change: - \`pnpm qa:lab:watch\` -- Refresh the browser after each rebuild to pick up the new bundle. +- The browser auto-reloads when the QA Lab asset hash changes. Gateway: diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 35bd8c55c73..7a226d1e59b 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -206,6 +206,23 @@ describe("qa-lab server", () => { const html = await rootResponse.text(); expect(html).not.toContain("QA Lab UI not built"); expect(html).toContain(""); + + const version1 = (await (await fetch(`${lab.baseUrl}/api/ui-version`)).json()) as { + version: string | null; + }; + expect(version1.version).toMatch(/^[0-9a-f]{12}$/); + + await writeFile( + path.join(uiDistDir, "index.html"), + "<!doctype html><html><head><title>QA Lab Updated
", + "utf8", + ); + + const version2 = (await (await fetch(`${lab.baseUrl}/api/ui-version`)).json()) as { + version: string | null; + }; + expect(version2.version).toMatch(/^[0-9a-f]{12}$/); + expect(version2.version).not.toBe(version1.version); }); it("can disable the embedded echo gateway for real-suite runs", async () => { diff --git a/extensions/qa-lab/src/lab-server.ts b/extensions/qa-lab/src/lab-server.ts index 3851490ccc3..5759ae3b30d 100644 --- a/extensions/qa-lab/src/lab-server.ts +++ b/extensions/qa-lab/src/lab-server.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs"; import { createServer, @@ -180,6 +181,45 @@ function resolveUiDistDir(overrideDir?: string | null) { ); } +function listUiAssetFiles(rootDir: string, currentDir = rootDir): string[] { + const entries = fs + .readdirSync(currentDir, { withFileTypes: true }) + .toSorted((left, right) => left.name.localeCompare(right.name)); + const files: string[] = []; + for (const entry of entries) { + const resolved = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + files.push(...listUiAssetFiles(rootDir, resolved)); + continue; + } + if (!entry.isFile()) { + continue; + } + files.push(path.relative(rootDir, resolved)); + } + return files; +} + +function resolveUiAssetVersion(overrideDir?: string | null): string | null { + try { + const distDir = resolveUiDistDir(overrideDir); + const indexPath = path.join(distDir, "index.html"); + if (!fs.existsSync(indexPath) || !fs.statSync(indexPath).isFile()) { + return null; + } + const hash = createHash("sha1"); + for (const relativeFile of listUiAssetFiles(distDir)) { + hash.update(relativeFile); + hash.update("\0"); + hash.update(fs.readFileSync(path.join(distDir, relativeFile))); + hash.update("\0"); + } + return hash.digest("hex").slice(0, 12); + } catch { + return null; + } +} + function resolveAdvertisedBaseUrl(params: { bindHost?: string; bindPort: number; @@ -536,6 +576,14 @@ export async function startQaLabServer(params?: { writeJson(res, 200, { report: latestReport }); return; } + if (req.method === "GET" && url.pathname === "/api/ui-version") { + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + res.end(JSON.stringify({ version: resolveUiAssetVersion(params?.uiDistDir) })); + return; + } if (req.method === "GET" && url.pathname === "/api/outcomes") { writeJson(res, 200, { run: latestScenarioRun }); return; diff --git a/extensions/qa-lab/web/src/app.ts b/extensions/qa-lab/web/src/app.ts index 40e4d057b2d..141fbadb8e1 100644 --- a/extensions/qa-lab/web/src/app.ts +++ b/extensions/qa-lab/web/src/app.ts @@ -18,6 +18,14 @@ async function getJson(path: string): Promise { return (await response.json()) as T; } +async function getJsonNoStore(path: string): Promise { + const response = await fetch(path, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return (await response.json()) as T; +} + async function postJson(path: string, body: unknown): Promise { const response = await fetch(path, { method: "POST", @@ -91,6 +99,7 @@ export async function createQaLabApp(root: HTMLDivElement) { let lastFingerprint = ""; let renderDeferred = false; let previousRunnerStatus: string | null = null; + let currentUiVersion: string | null = null; function stateFingerprint(): string { const msgs = state.snapshot?.messages; @@ -182,6 +191,24 @@ export async function createQaLabApp(root: HTMLDivElement) { } } + async function pollUiVersion() { + if (document.visibilityState === "hidden") { + return; + } + try { + const payload = await getJsonNoStore<{ version: string | null }>("/api/ui-version"); + if (!currentUiVersion) { + currentUiVersion = payload.version; + return; + } + if (payload.version && payload.version !== currentUiVersion) { + window.location.reload(); + } + } catch { + // Ignore transient rebuild windows while the dist dir is being rewritten. + } + } + /* ---------- Draft mutations ---------- */ function updateRunnerDraft(mutator: (draft: RunnerSelection) => RunnerSelection) { @@ -590,5 +617,7 @@ export async function createQaLabApp(root: HTMLDivElement) { render(); await refresh(); + void pollUiVersion(); setInterval(() => void refresh(), 1_000); + setInterval(() => void pollUiVersion(), 1_000); }