From 54a884865e83c65227e330a178d5cc7239bd4472 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 09:44:21 +0100 Subject: [PATCH] feat: add fast qa lab ui refresh mode --- docs/concepts/qa-e2e-automation.md | 14 +++++++++++ extensions/qa-lab/src/cli.runtime.ts | 6 +++++ extensions/qa-lab/src/cli.ts | 17 ++++++++++++++ extensions/qa-lab/src/docker-harness.test.ts | 5 ++++ extensions/qa-lab/src/docker-harness.ts | 23 +++++++++++++++++-- .../qa-lab/src/docker-up.runtime.test.ts | 6 ++++- extensions/qa-lab/src/docker-up.runtime.ts | 2 ++ package.json | 2 ++ scripts/qa-lab-up.ts | 3 +++ 9 files changed, 75 insertions(+), 3 deletions(-) diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index 23642cb3532..cf16048b3c6 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -37,6 +37,20 @@ QA Lab page where an operator or automation loop can give the agent a QA mission, observe real channel behavior, and record what worked, failed, or stayed blocked. +For faster QA Lab UI iteration without rebuilding the Docker image each time, +start the stack with a bind-mounted QA Lab bundle: + +```bash +pnpm openclaw qa docker-build-image +pnpm qa:lab:build +pnpm qa:lab:up:fast +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. + ## Repo-backed seeds Seed assets live in `qa/`: diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index d3295d5a952..f373c32f8c7 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -69,6 +69,7 @@ export async function runQaLabUiCommand(opts: { controlUiUrl?: string; controlUiToken?: string; controlUiProxyTarget?: string; + uiDistDir?: string; autoKickoffTarget?: string; embeddedGateway?: string; sendKickoffOnStart?: boolean; @@ -81,6 +82,7 @@ export async function runQaLabUiCommand(opts: { controlUiUrl: opts.controlUiUrl, controlUiToken: opts.controlUiToken, controlUiProxyTarget: opts.controlUiProxyTarget, + uiDistDir: opts.uiDistDir, autoKickoffTarget: opts.autoKickoffTarget, embeddedGateway: opts.embeddedGateway, sendKickoffOnStart: opts.sendKickoffOnStart, @@ -95,6 +97,7 @@ export async function runQaDockerScaffoldCommand(opts: { providerBaseUrl?: string; image?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; }) { const outputDir = path.resolve(opts.outputDir); const result = await writeQaDockerHarnessFiles({ @@ -105,6 +108,7 @@ export async function runQaDockerScaffoldCommand(opts: { providerBaseUrl: opts.providerBaseUrl, imageName: opts.image, usePrebuiltImage: opts.usePrebuiltImage, + bindUiDist: opts.bindUiDist, }); process.stdout.write(`QA docker scaffold: ${result.outputDir}\n`); } @@ -124,6 +128,7 @@ export async function runQaDockerUpCommand(opts: { providerBaseUrl?: string; image?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; skipUiBuild?: boolean; }) { const result = await runQaDockerUp({ @@ -134,6 +139,7 @@ export async function runQaDockerUpCommand(opts: { providerBaseUrl: opts.providerBaseUrl, image: opts.image, usePrebuiltImage: opts.usePrebuiltImage, + bindUiDist: opts.bindUiDist, skipUiBuild: opts.skipUiBuild, }); process.stdout.write(`QA docker dir: ${result.outputDir}\n`); diff --git a/extensions/qa-lab/src/cli.ts b/extensions/qa-lab/src/cli.ts index 48421fe8c8b..326b02f643d 100644 --- a/extensions/qa-lab/src/cli.ts +++ b/extensions/qa-lab/src/cli.ts @@ -33,6 +33,7 @@ async function runQaUi(opts: { controlUiUrl?: string; controlUiToken?: string; controlUiProxyTarget?: string; + uiDistDir?: string; autoKickoffTarget?: string; embeddedGateway?: string; sendKickoffOnStart?: boolean; @@ -47,6 +48,7 @@ async function runQaDockerScaffold(opts: { qaLabPort?: number; image?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; }) { const runtime = await loadQaLabCliRuntime(); await runtime.runQaDockerScaffoldCommand(opts); @@ -64,6 +66,7 @@ async function runQaDockerUp(opts: { providerBaseUrl?: string; image?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; skipUiBuild?: boolean; }) { const runtime = await loadQaLabCliRuntime(); @@ -126,6 +129,7 @@ export function registerQaLabCli(program: Command) { "--control-ui-proxy-target ", "Optional upstream Control UI target for /control-ui proxying", ) + .option("--ui-dist-dir ", "Optional QA Lab UI asset directory override") .option("--auto-kickoff-target ", "Kickoff default target (direct or channel)") .option("--embedded-gateway ", "Embedded gateway mode hint", "enabled") .option( @@ -142,6 +146,7 @@ export function registerQaLabCli(program: Command) { controlUiUrl?: string; controlUiToken?: string; controlUiProxyTarget?: string; + uiDistDir?: string; autoKickoffTarget?: string; embeddedGateway?: string; sendKickoffOnStart?: boolean; @@ -158,6 +163,11 @@ export function registerQaLabCli(program: Command) { .option("--provider-base-url ", "Provider base URL for the QA gateway") .option("--image ", "Prebaked image name", "openclaw:qa-local-prebaked") .option("--use-prebuilt-image", "Use image: instead of build: in docker-compose", false) + .option( + "--bind-ui-dist", + "Bind-mount extensions/qa-lab/web/dist into the qa-lab container for faster UI refresh", + false, + ) .action( async (opts: { outputDir: string; @@ -166,6 +176,7 @@ export function registerQaLabCli(program: Command) { providerBaseUrl?: string; image?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; }) => { await runQaDockerScaffold(opts); }, @@ -186,6 +197,11 @@ export function registerQaLabCli(program: Command) { .option("--provider-base-url ", "Provider base URL for the QA gateway") .option("--image ", "Image tag", "openclaw:qa-local-prebaked") .option("--use-prebuilt-image", "Use image: instead of build: in docker-compose", false) + .option( + "--bind-ui-dist", + "Bind-mount extensions/qa-lab/web/dist into the qa-lab container for faster UI refresh", + false, + ) .option("--skip-ui-build", "Skip pnpm qa:lab:build before starting Docker", false) .action( async (opts: { @@ -195,6 +211,7 @@ export function registerQaLabCli(program: Command) { providerBaseUrl?: string; image?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; skipUiBuild?: boolean; }) => { await runQaDockerUp(opts); diff --git a/extensions/qa-lab/src/docker-harness.test.ts b/extensions/qa-lab/src/docker-harness.test.ts index 1391b10e379..f74dad030fd 100644 --- a/extensions/qa-lab/src/docker-harness.test.ts +++ b/extensions/qa-lab/src/docker-harness.test.ts @@ -27,6 +27,7 @@ describe("qa docker harness", () => { providerBaseUrl: "http://host.docker.internal:45123/v1", repoRoot: "/repo/openclaw", usePrebuiltImage: true, + bindUiDist: true, }); expect(result.files).toEqual( @@ -46,6 +47,7 @@ describe("qa docker harness", () => { expect(compose).toContain("qa-mock-openai:"); expect(compose).toContain("18889:18789"); expect(compose).toContain(' - "43124:43123"'); + expect(compose).toContain(":/opt/openclaw-qa-lab-ui:ro"); expect(compose).toContain(" - sh"); expect(compose).toContain(" - -lc"); expect(compose).toContain( @@ -54,6 +56,8 @@ describe("qa docker harness", () => { expect(compose).toContain(" - --control-ui-proxy-target"); expect(compose).toContain(' - "http://openclaw-qa-gateway:18789/"'); expect(compose).toContain(" - --send-kickoff-on-start"); + expect(compose).toContain(" - --ui-dist-dir"); + expect(compose).toContain(' - "/opt/openclaw-qa-lab-ui"'); expect(compose).toContain(":/opt/openclaw-repo:ro"); expect(compose).toContain("./state:/opt/openclaw-scaffold:ro"); expect(compose).toContain( @@ -84,6 +88,7 @@ describe("qa docker harness", () => { const readme = await readFile(path.join(outputDir, "README.md"), "utf8"); expect(readme).toContain("in-process restarts inside Docker"); + expect(readme).toContain("pnpm qa:lab:watch"); }); it("builds the reusable QA image with bundled QA extensions", async () => { diff --git a/extensions/qa-lab/src/docker-harness.ts b/extensions/qa-lab/src/docker-harness.ts index 973fa3e6d00..feb1c36b8b0 100644 --- a/extensions/qa-lab/src/docker-harness.ts +++ b/extensions/qa-lab/src/docker-harness.ts @@ -5,6 +5,7 @@ import { seedQaAgentWorkspace } from "./qa-agent-workspace.js"; import { buildQaGatewayConfig } from "./qa-gateway-config.js"; const QA_LAB_INTERNAL_PORT = 43123; +const QA_LAB_UI_OVERLAY_DIR = "/opt/openclaw-qa-lab-ui"; function toPosixRelative(fromDir: string, toPath: string): string { return path.relative(fromDir, toPath).split(path.sep).join("/"); @@ -28,6 +29,7 @@ function renderCompose(params: { repoRoot: string; imageName: string; usePrebuiltImage: boolean; + bindUiDist: boolean; gatewayPort: number; qaLabPort: number; gatewayToken: string; @@ -35,6 +37,10 @@ function renderCompose(params: { }) { const imageBlock = renderImageBlock(params); const repoMount = toPosixRelative(params.outputDir, params.repoRoot) || "."; + const qaLabUiMount = toPosixRelative( + params.outputDir, + path.join(params.repoRoot, "extensions", "qa-lab", "web", "dist"), + ); return `services: qa-mock-openai: @@ -64,7 +70,7 @@ ${ ${imageBlock} pull_policy: never ports: - "${params.qaLabPort}:${QA_LAB_INTERNAL_PORT}" - healthcheck: +${params.bindUiDist ? ` volumes:\n - ${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro\n` : ""} healthcheck: test: - CMD - node @@ -98,7 +104,7 @@ ${imageBlock} pull_policy: never - "http://openclaw-qa-gateway:18789/" - --control-ui-token - "${params.gatewayToken}" - - --auto-kickoff-target +${params.bindUiDist ? ` - --ui-dist-dir\n - "${QA_LAB_UI_OVERLAY_DIR}"\n` : ""} - --auto-kickoff-target - direct - --send-kickoff-on-start - --embedded-gateway @@ -171,6 +177,7 @@ function renderReadme(params: { gatewayPort: number; qaLabPort: number; usePrebuiltImage: boolean; + bindUiDist: boolean; includeQaLabUi: boolean; }) { return `# QA Docker Harness @@ -196,6 +203,14 @@ Suggested flow: - right: Slack-ish QA lab 5. The repo-backed kickoff task auto-injects on startup. +Fast UI refresh: + +- Start once with a prebuilt image + bind-mounted QA Lab assets: + - \`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. + Gateway: - health: \`http://127.0.0.1:${params.gatewayPort}/healthz\` @@ -218,6 +233,7 @@ export async function writeQaDockerHarnessFiles(params: { qaBusBaseUrl?: string; imageName?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; includeQaLabUi?: boolean; }) { const gatewayPort = params.gatewayPort ?? 18789; @@ -227,6 +243,7 @@ export async function writeQaDockerHarnessFiles(params: { const qaBusBaseUrl = params.qaBusBaseUrl ?? "http://qa-lab:43123"; const imageName = params.imageName ?? "openclaw:qa-local-prebaked"; const usePrebuiltImage = params.usePrebuiltImage ?? false; + const bindUiDist = params.bindUiDist ?? false; const includeQaLabUi = params.includeQaLabUi ?? true; await fs.mkdir(path.join(params.outputDir, "state", "seed-workspace"), { recursive: true }); @@ -260,6 +277,7 @@ export async function writeQaDockerHarnessFiles(params: { repoRoot: params.repoRoot, imageName, usePrebuiltImage, + bindUiDist, gatewayPort, qaLabPort, gatewayToken, @@ -285,6 +303,7 @@ export async function writeQaDockerHarnessFiles(params: { gatewayPort, qaLabPort, usePrebuiltImage, + bindUiDist, includeQaLabUi, }), "utf8", diff --git a/extensions/qa-lab/src/docker-up.runtime.test.ts b/extensions/qa-lab/src/docker-up.runtime.test.ts index c8d7e793ae9..43fdb6a09ae 100644 --- a/extensions/qa-lab/src/docker-up.runtime.test.ts +++ b/extensions/qa-lab/src/docker-up.runtime.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; @@ -68,6 +68,7 @@ describe("runQaDockerUp", () => { repoRoot: "/repo/openclaw", outputDir, usePrebuiltImage: true, + bindUiDist: true, skipUiBuild: true, }, { @@ -88,6 +89,9 @@ describe("runQaDockerUp", () => { `docker compose -f ${outputDir}/docker-compose.qa.yml up -d @/repo/openclaw`, `docker compose -f ${outputDir}/docker-compose.qa.yml ps --format json openclaw-qa-gateway @/repo/openclaw`, ]); + const compose = await readFile(path.join(outputDir, "docker-compose.qa.yml"), "utf8"); + expect(compose).toContain(":/opt/openclaw-qa-lab-ui:ro"); + expect(compose).toContain(" - --ui-dist-dir"); } finally { await rm(outputDir, { recursive: true, force: true }); } diff --git a/extensions/qa-lab/src/docker-up.runtime.ts b/extensions/qa-lab/src/docker-up.runtime.ts index c9ed7588156..3d935563610 100644 --- a/extensions/qa-lab/src/docker-up.runtime.ts +++ b/extensions/qa-lab/src/docker-up.runtime.ts @@ -247,6 +247,7 @@ export async function runQaDockerUp( providerBaseUrl?: string; image?: string; usePrebuiltImage?: boolean; + bindUiDist?: boolean; skipUiBuild?: boolean; }, deps?: { @@ -282,6 +283,7 @@ export async function runQaDockerUp( providerBaseUrl: params.providerBaseUrl, imageName: params.image, usePrebuiltImage: params.usePrebuiltImage, + bindUiDist: params.bindUiDist, includeQaLabUi: true, }); diff --git a/package.json b/package.json index a49238c0d93..28488c0b8c6 100644 --- a/package.json +++ b/package.json @@ -1153,6 +1153,8 @@ "qa:lab:build": "vite build --config extensions/qa-lab/web/vite.config.ts", "qa:lab:ui": "pnpm openclaw qa ui", "qa:lab:up": "node --import tsx scripts/qa-lab-up.ts", + "qa:lab:up:fast": "node --import tsx scripts/qa-lab-up.ts --use-prebuilt-image --bind-ui-dist --skip-ui-build", + "qa:lab:watch": "vite build --watch --config extensions/qa-lab/web/vite.config.ts", "release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts", diff --git a/scripts/qa-lab-up.ts b/scripts/qa-lab-up.ts index 1061000a090..6316d8646dd 100644 --- a/scripts/qa-lab-up.ts +++ b/scripts/qa-lab-up.ts @@ -10,6 +10,7 @@ const { values } = parseArgs({ "provider-base-url": { type: "string" }, image: { type: "string" }, "use-prebuilt-image": { type: "boolean" }, + "bind-ui-dist": { type: "boolean" }, "skip-ui-build": { type: "boolean" }, }, allowPositionals: false, @@ -25,6 +26,7 @@ Options: --provider-base-url --image --use-prebuilt-image + --bind-ui-dist --skip-ui-build -h, --help `); @@ -49,5 +51,6 @@ await runQaDockerUpCommand({ providerBaseUrl: values["provider-base-url"], image: values.image, usePrebuiltImage: values["use-prebuilt-image"], + bindUiDist: values["bind-ui-dist"], skipUiBuild: values["skip-ui-build"], });