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"),
+ "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);
}