feat: auto-reload qa lab fast refresh

This commit is contained in:
Peter Steinberger
2026-04-07 09:54:24 +01:00
parent 45663f2879
commit 124cd5e307
5 changed files with 97 additions and 2 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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("<title>");
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</title></head><body><div id='app'></div></body></html>",
"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 () => {

View File

@@ -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;

View File

@@ -18,6 +18,14 @@ async function getJson<T>(path: string): Promise<T> {
return (await response.json()) as T;
}
async function getJsonNoStore<T>(path: string): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
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);
}