mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 18:33:39 +02:00
feat: add one-command qa lab docker launcher
This commit is contained in:
@@ -75,10 +75,13 @@ self-check, and writes a Markdown report under `.artifacts/qa-e2e/`.
|
||||
Private debugger UI:
|
||||
|
||||
```bash
|
||||
pnpm qa:lab:build
|
||||
pnpm openclaw qa ui
|
||||
pnpm qa:lab:up
|
||||
```
|
||||
|
||||
That one command builds the QA site, starts the Docker-backed gateway + QA Lab
|
||||
stack, and prints the QA Lab URL. From that site you can pick scenarios, choose
|
||||
the model lane, launch individual runs, and watch results live.
|
||||
|
||||
Full repo-backed QA suite:
|
||||
|
||||
```bash
|
||||
@@ -96,10 +99,10 @@ Current scope is intentionally narrow:
|
||||
- threaded routing grammar
|
||||
- channel-owned message actions
|
||||
- Markdown reporting
|
||||
- Docker-backed QA site with run controls
|
||||
|
||||
Follow-up work will add:
|
||||
|
||||
- Dockerized OpenClaw orchestration
|
||||
- provider/model matrix execution
|
||||
- richer scenario discovery
|
||||
- OpenClaw-native orchestration later
|
||||
|
||||
@@ -21,13 +21,21 @@ Current pieces:
|
||||
- `qa/`: repo-backed seed assets for the kickoff task and baseline QA
|
||||
scenarios.
|
||||
|
||||
The long-term goal is a two-pane QA site:
|
||||
The current QA operator flow is a two-pane QA site:
|
||||
|
||||
- Left: Gateway dashboard (Control UI) with the agent.
|
||||
- Right: QA Lab, showing the Slack-ish transcript and scenario plan.
|
||||
|
||||
That lets an operator or automation loop give the agent a QA mission, observe
|
||||
real channel behavior, and record what worked, failed, or stayed blocked.
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
pnpm qa:lab:up
|
||||
```
|
||||
|
||||
That builds the QA site, starts the Docker-backed gateway lane, and exposes the
|
||||
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.
|
||||
|
||||
## Repo-backed seeds
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Most days:
|
||||
- Faster local full-suite run on a roomy machine: `pnpm test:max`
|
||||
- Direct Vitest watch loop: `pnpm test:watch`
|
||||
- Direct file targeting now routes extension/channel paths too: `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts`
|
||||
- Docker-backed QA site: `pnpm qa:lab:up`
|
||||
|
||||
When you touch tests or want extra confidence:
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
|
||||
import { runQaDockerUp } from "./docker-up.runtime.js";
|
||||
import { startQaLabServer } from "./lab-server.js";
|
||||
import { startQaMockOpenAiServer } from "./mock-openai-server.js";
|
||||
import { runQaSuite } from "./suite.js";
|
||||
@@ -107,6 +108,31 @@ export async function runQaDockerBuildImageCommand(opts: { image?: string }) {
|
||||
process.stdout.write(`QA docker image: ${result.imageName}\n`);
|
||||
}
|
||||
|
||||
export async function runQaDockerUpCommand(opts: {
|
||||
outputDir?: string;
|
||||
gatewayPort?: number;
|
||||
qaLabPort?: number;
|
||||
providerBaseUrl?: string;
|
||||
image?: string;
|
||||
usePrebuiltImage?: boolean;
|
||||
skipUiBuild?: boolean;
|
||||
}) {
|
||||
const result = await runQaDockerUp({
|
||||
repoRoot: process.cwd(),
|
||||
outputDir: opts.outputDir ? path.resolve(opts.outputDir) : undefined,
|
||||
gatewayPort: Number.isFinite(opts.gatewayPort) ? opts.gatewayPort : undefined,
|
||||
qaLabPort: Number.isFinite(opts.qaLabPort) ? opts.qaLabPort : undefined,
|
||||
providerBaseUrl: opts.providerBaseUrl,
|
||||
image: opts.image,
|
||||
usePrebuiltImage: opts.usePrebuiltImage,
|
||||
skipUiBuild: opts.skipUiBuild,
|
||||
});
|
||||
process.stdout.write(`QA docker dir: ${result.outputDir}\n`);
|
||||
process.stdout.write(`QA Lab UI: ${result.qaLabUrl}\n`);
|
||||
process.stdout.write(`Gateway UI: ${result.gatewayUrl}\n`);
|
||||
process.stdout.write(`Stop: ${result.stopCommand}\n`);
|
||||
}
|
||||
|
||||
export async function runQaMockOpenAiCommand(opts: { host?: string; port?: number }) {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: opts.host,
|
||||
|
||||
@@ -57,6 +57,19 @@ async function runQaDockerBuildImage(opts: { image?: string }) {
|
||||
await runtime.runQaDockerBuildImageCommand(opts);
|
||||
}
|
||||
|
||||
async function runQaDockerUp(opts: {
|
||||
outputDir?: string;
|
||||
gatewayPort?: number;
|
||||
qaLabPort?: number;
|
||||
providerBaseUrl?: string;
|
||||
image?: string;
|
||||
usePrebuiltImage?: boolean;
|
||||
skipUiBuild?: boolean;
|
||||
}) {
|
||||
const runtime = await loadQaLabCliRuntime();
|
||||
await runtime.runQaDockerUpCommand(opts);
|
||||
}
|
||||
|
||||
async function runQaMockOpenAi(opts: { host?: string; port?: number }) {
|
||||
const runtime = await loadQaLabCliRuntime();
|
||||
await runtime.runQaMockOpenAiCommand(opts);
|
||||
@@ -165,6 +178,29 @@ export function registerQaLabCli(program: Command) {
|
||||
await runQaDockerBuildImage(opts);
|
||||
});
|
||||
|
||||
qa.command("up")
|
||||
.description("Build the QA site, start the Docker-backed QA stack, and print the QA Lab URL")
|
||||
.option("--output-dir <path>", "Output directory for docker-compose + state files")
|
||||
.option("--gateway-port <port>", "Gateway host port", (value: string) => Number(value))
|
||||
.option("--qa-lab-port <port>", "QA lab host port", (value: string) => Number(value))
|
||||
.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("--skip-ui-build", "Skip pnpm qa:lab:build before starting Docker", false)
|
||||
.action(
|
||||
async (opts: {
|
||||
outputDir?: string;
|
||||
gatewayPort?: number;
|
||||
qaLabPort?: number;
|
||||
providerBaseUrl?: string;
|
||||
image?: string;
|
||||
usePrebuiltImage?: boolean;
|
||||
skipUiBuild?: boolean;
|
||||
}) => {
|
||||
await runQaDockerUp(opts);
|
||||
},
|
||||
);
|
||||
|
||||
qa.command("mock-openai")
|
||||
.description("Run the local mock OpenAI Responses API server for QA")
|
||||
.option("--host <host>", "Bind host", "127.0.0.1")
|
||||
|
||||
85
extensions/qa-lab/src/docker-up.runtime.test.ts
Normal file
85
extensions/qa-lab/src/docker-up.runtime.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runQaDockerUp } from "./docker-up.runtime.js";
|
||||
|
||||
describe("runQaDockerUp", () => {
|
||||
it("builds the QA UI, writes the harness, starts compose, and waits for health", async () => {
|
||||
const calls: string[] = [];
|
||||
const fetchCalls: string[] = [];
|
||||
const responseQueue = [false, true, false, true];
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
|
||||
try {
|
||||
const result = await runQaDockerUp(
|
||||
{
|
||||
repoRoot: "/repo/openclaw",
|
||||
outputDir,
|
||||
gatewayPort: 18889,
|
||||
qaLabPort: 43124,
|
||||
},
|
||||
{
|
||||
async runCommand(command, args, cwd) {
|
||||
calls.push([command, ...args, `@${cwd}`].join(" "));
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
fetchImpl: vi.fn(async (input: string) => {
|
||||
fetchCalls.push(input);
|
||||
return { ok: responseQueue.shift() ?? true };
|
||||
}),
|
||||
sleepImpl: vi.fn(async () => {}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
"pnpm qa:lab:build @/repo/openclaw",
|
||||
expect.stringContaining(
|
||||
`docker compose -f ${outputDir}/docker-compose.qa.yml up --build -d @/repo/openclaw`,
|
||||
),
|
||||
]);
|
||||
expect(fetchCalls).toEqual([
|
||||
"http://127.0.0.1:43124/healthz",
|
||||
"http://127.0.0.1:43124/healthz",
|
||||
"http://127.0.0.1:18889/healthz",
|
||||
"http://127.0.0.1:18889/healthz",
|
||||
]);
|
||||
expect(result.qaLabUrl).toBe("http://127.0.0.1:43124");
|
||||
expect(result.gatewayUrl).toBe("http://127.0.0.1:18889/");
|
||||
expect(result.composeFile).toBe(`${outputDir}/docker-compose.qa.yml`);
|
||||
expect(result.stopCommand).toBe(`docker compose -f ${outputDir}/docker-compose.qa.yml down`);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips UI build and compose --build for prebuilt images", async () => {
|
||||
const calls: string[] = [];
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
|
||||
try {
|
||||
await runQaDockerUp(
|
||||
{
|
||||
repoRoot: "/repo/openclaw",
|
||||
outputDir,
|
||||
usePrebuiltImage: true,
|
||||
skipUiBuild: true,
|
||||
},
|
||||
{
|
||||
async runCommand(command, args, cwd) {
|
||||
calls.push([command, ...args, `@${cwd}`].join(" "));
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
fetchImpl: vi.fn(async () => ({ ok: true })),
|
||||
sleepImpl: vi.fn(async () => {}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
`docker compose -f ${outputDir}/docker-compose.qa.yml up -d @/repo/openclaw`,
|
||||
]);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
148
extensions/qa-lab/src/docker-up.runtime.ts
Normal file
148
extensions/qa-lab/src/docker-up.runtime.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { writeQaDockerHarnessFiles } from "./docker-harness.js";
|
||||
|
||||
type QaDockerUpResult = {
|
||||
outputDir: string;
|
||||
composeFile: string;
|
||||
qaLabUrl: string;
|
||||
gatewayUrl: string;
|
||||
stopCommand: string;
|
||||
};
|
||||
|
||||
type RunCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
type FetchLike = (input: string) => Promise<{ ok: boolean }>;
|
||||
|
||||
const DEFAULT_QA_DOCKER_DIR = path.resolve(process.cwd(), ".artifacts/qa-docker");
|
||||
|
||||
function describeError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
|
||||
async function execCommand(command: string, args: string[], cwd: string) {
|
||||
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
execFile(command, args, { cwd, encoding: "utf8" }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(
|
||||
new Error(
|
||||
stderr.trim() || stdout.trim() || `Command failed: ${[command, ...args].join(" ")}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(
|
||||
url: string,
|
||||
deps: {
|
||||
fetchImpl: FetchLike;
|
||||
sleepImpl: (ms: number) => Promise<unknown>;
|
||||
timeoutMs?: number;
|
||||
pollMs?: number;
|
||||
},
|
||||
) {
|
||||
const timeoutMs = deps.timeoutMs ?? 120_000;
|
||||
const pollMs = deps.pollMs ?? 1_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await deps.fetchImpl(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`Health check returned non-OK for ${url}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await deps.sleepImpl(pollMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for ${url}${lastError ? `: ${describeError(lastError)}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runQaDockerUp(
|
||||
params: {
|
||||
repoRoot?: string;
|
||||
outputDir?: string;
|
||||
gatewayPort?: number;
|
||||
qaLabPort?: number;
|
||||
providerBaseUrl?: string;
|
||||
image?: string;
|
||||
usePrebuiltImage?: boolean;
|
||||
skipUiBuild?: boolean;
|
||||
},
|
||||
deps?: {
|
||||
runCommand?: RunCommand;
|
||||
fetchImpl?: FetchLike;
|
||||
sleepImpl?: (ms: number) => Promise<unknown>;
|
||||
},
|
||||
): Promise<QaDockerUpResult> {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
||||
const outputDir = path.resolve(params.outputDir ?? DEFAULT_QA_DOCKER_DIR);
|
||||
const gatewayPort = params.gatewayPort ?? 18789;
|
||||
const qaLabPort = params.qaLabPort ?? 43124;
|
||||
const runCommand = deps?.runCommand ?? execCommand;
|
||||
const fetchImpl =
|
||||
deps?.fetchImpl ??
|
||||
(async (input: string) => {
|
||||
return await fetch(input);
|
||||
});
|
||||
const sleepImpl = deps?.sleepImpl ?? sleep;
|
||||
|
||||
if (!params.skipUiBuild) {
|
||||
await runCommand("pnpm", ["qa:lab:build"], repoRoot);
|
||||
}
|
||||
|
||||
await writeQaDockerHarnessFiles({
|
||||
outputDir,
|
||||
repoRoot,
|
||||
gatewayPort,
|
||||
qaLabPort,
|
||||
providerBaseUrl: params.providerBaseUrl,
|
||||
imageName: params.image,
|
||||
usePrebuiltImage: params.usePrebuiltImage,
|
||||
includeQaLabUi: true,
|
||||
});
|
||||
|
||||
const composeFile = path.join(outputDir, "docker-compose.qa.yml");
|
||||
const composeArgs = ["compose", "-f", composeFile, "up"];
|
||||
if (!params.usePrebuiltImage) {
|
||||
composeArgs.push("--build");
|
||||
}
|
||||
composeArgs.push("-d");
|
||||
|
||||
await runCommand("docker", composeArgs, repoRoot);
|
||||
|
||||
const qaLabUrl = `http://127.0.0.1:${qaLabPort}`;
|
||||
const gatewayUrl = `http://127.0.0.1:${gatewayPort}/`;
|
||||
|
||||
await waitForHealth(`${qaLabUrl}/healthz`, { fetchImpl, sleepImpl });
|
||||
await waitForHealth(`${gatewayUrl}healthz`, { fetchImpl, sleepImpl });
|
||||
|
||||
return {
|
||||
outputDir,
|
||||
composeFile,
|
||||
qaLabUrl,
|
||||
gatewayUrl,
|
||||
stopCommand: `docker compose -f ${composeFile} down`,
|
||||
};
|
||||
}
|
||||
@@ -1115,6 +1115,7 @@
|
||||
"qa:e2e": "node --import tsx scripts/qa-e2e.ts",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
53
scripts/qa-lab-up.ts
Normal file
53
scripts/qa-lab-up.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { parseArgs } from "node:util";
|
||||
import { runQaDockerUpCommand } from "../extensions/qa-lab/src/cli.runtime.ts";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
help: { type: "boolean", short: "h" },
|
||||
"output-dir": { type: "string" },
|
||||
"gateway-port": { type: "string" },
|
||||
"qa-lab-port": { type: "string" },
|
||||
"provider-base-url": { type: "string" },
|
||||
image: { type: "string" },
|
||||
"use-prebuilt-image": { type: "boolean" },
|
||||
"skip-ui-build": { type: "boolean" },
|
||||
},
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
process.stdout.write(`Usage: pnpm qa:lab:up [options]
|
||||
|
||||
Options:
|
||||
--output-dir <path>
|
||||
--gateway-port <port>
|
||||
--qa-lab-port <port>
|
||||
--provider-base-url <url>
|
||||
--image <name>
|
||||
--use-prebuilt-image
|
||||
--skip-ui-build
|
||||
-h, --help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const parsePort = (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`Invalid port: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
await runQaDockerUpCommand({
|
||||
outputDir: values["output-dir"],
|
||||
gatewayPort: parsePort(values["gateway-port"]),
|
||||
qaLabPort: parsePort(values["qa-lab-port"]),
|
||||
providerBaseUrl: values["provider-base-url"],
|
||||
image: values.image,
|
||||
usePrebuiltImage: values["use-prebuilt-image"],
|
||||
skipUiBuild: values["skip-ui-build"],
|
||||
});
|
||||
Reference in New Issue
Block a user