mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
feat: add fast qa lab ui refresh mode
This commit is contained in:
@@ -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/`:
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user