feat: add fast qa lab ui refresh mode

This commit is contained in:
Peter Steinberger
2026-04-07 09:44:21 +01:00
parent 36aeef30c2
commit 54a884865e
9 changed files with 75 additions and 3 deletions

View File

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

View File

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

View File

@@ -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 <url>",
"Optional upstream Control UI target for /control-ui proxying",
)
.option("--ui-dist-dir <path>", "Optional QA Lab UI asset directory override")
.option("--auto-kickoff-target <kind>", "Kickoff default target (direct or channel)")
.option("--embedded-gateway <mode>", "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 <url>", "Provider base URL for the QA gateway")
.option("--image <name>", "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 <url>", "Provider base URL for the QA gateway")
.option("--image <name>", "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);

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <url>
--image <name>
--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"],
});