Skip to content

Commit a1fe86a

Browse files
committed
feat(qa): add coverage scenario matching
1 parent 4a45098 commit a1fe86a

9 files changed

Lines changed: 231 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- QA-Lab: add `qa coverage --match <query>` so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.
910
- Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.
1011
- Build: include `ui:build` in the `full` and `ciArtifacts` profiles of `scripts/build-all.mjs` so `pnpm build` always rebuilds `dist/control-ui` after `tsdown` cleans `dist`, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)
1112
- Migrate: import supported Hermes, OpenCode, and Codex auth credentials into OpenClaw auth profiles when credential migration is selected, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.

docs/concepts/qa-e2e-automation.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,9 @@ The report should answer:
815815
- What follow-up scenarios are worth adding
816816

817817
For the inventory of available scenarios - useful when sizing follow-up work or wiring a new transport - run `pnpm openclaw qa coverage` (add `--json` for machine-readable output).
818+
When choosing focused proof for a touched behavior or file path, run `pnpm openclaw qa coverage --match <query>`.
819+
The match report searches scenario metadata, docs refs, code refs, coverage IDs, plugins, and provider requirements, then prints matching `qa suite --scenario ...` targets.
820+
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
818821

819822
For character and style checks, run the same scenario across multiple live model
820823
refs and write a judged Markdown report:

docs/help/testing.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ inside every shard.
154154
`aimock` starts a local AIMock-backed provider server for experimental
155155
fixture and protocol-mock coverage without replacing the scenario-aware
156156
`mock-openai` lane.
157+
- `pnpm openclaw qa coverage --match <query>`
158+
- Searches scenario IDs, titles, surfaces, coverage IDs, docs refs, code refs,
159+
plugins, and provider requirements, then prints matching suite targets.
160+
- Use this before a QA Lab run when you know the touched behavior or file path
161+
but not the smallest scenario. It is advisory only; still choose mock,
162+
live, Multipass, Matrix, or transport proof from the behavior being changed.
157163
- `pnpm test:plugins:kitchen-sink-live`
158164
- Runs the live OpenAI Kitchen Sink plugin gauntlet through QA Lab. It
159165
installs the external Kitchen Sink package, verifies the plugin SDK surface

extensions/qa-lab/src/cli.runtime.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -859,9 +859,7 @@ describe("qa cli runtime", () => {
859859
repoRoot: "/tmp/openclaw-repo",
860860
pack: "personal-admin",
861861
}),
862-
).rejects.toThrow(
863-
'--pack must be one of personal-agent, observability, got "personal-admin"',
864-
);
862+
).rejects.toThrow('--pack must be one of personal-agent, observability, got "personal-admin"');
865863
});
866864

867865
it("rejects unknown suite CLI auth modes", async () => {
@@ -1087,6 +1085,28 @@ describe("qa cli runtime", () => {
10871085
expectWriteContains(stdoutWrite, "memory.recall");
10881086
});
10891087

1088+
it("prints a focused scenario match report from coverage metadata", async () => {
1089+
await runQaCoverageReportCommand({
1090+
repoRoot: process.cwd(),
1091+
match: ["image roundtrip"],
1092+
});
1093+
1094+
expectWriteContains(stdoutWrite, "# QA Scenario Matches");
1095+
expectWriteContains(stdoutWrite, "image-generation-roundtrip");
1096+
expectWriteContains(stdoutWrite, "--scenario image-generation-roundtrip");
1097+
expect(stdoutWrite.mock.calls.flat().join("")).not.toContain("memory-recall");
1098+
});
1099+
1100+
it("rejects scenario match queries for tool coverage reports", async () => {
1101+
await expect(
1102+
runQaCoverageReportCommand({
1103+
repoRoot: process.cwd(),
1104+
tools: true,
1105+
match: ["runtime"],
1106+
}),
1107+
).rejects.toThrow("--match cannot be combined with --tools.");
1108+
});
1109+
10901110
it("prints a markdown tool coverage report from runtime tool fixtures", async () => {
10911111
await runQaCoverageReportCommand({ repoRoot: process.cwd(), tools: true });
10921112

extensions/qa-lab/src/cli.runtime.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import {
1212
import { resolveQaParityPackScenarioIds } from "./agentic-parity.js";
1313
import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js";
1414
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
15-
import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js";
15+
import {
16+
buildQaCoverageInventory,
17+
findQaScenarioMatches,
18+
renderQaCoverageMarkdownReport,
19+
renderQaScenarioMatchesMarkdownReport,
20+
} from "./coverage-report.js";
1621
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
1722
import { runQaDockerUp } from "./docker-up.runtime.js";
1823
import type { QaCliBackendAuthMode } from "./gateway-child.js";
@@ -786,13 +791,17 @@ export async function runQaCoverageReportCommand(opts: {
786791
json?: boolean;
787792
tools?: boolean;
788793
summary?: string;
794+
match?: string[];
789795
}) {
790796
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
791797
const outputPath = opts.output ? path.resolve(repoRoot, opts.output) : undefined;
792798
const scenarios = readQaScenarioPack().scenarios;
793799
let body: string;
794800
let outputLabel = "QA coverage report";
795801
if (opts.tools === true) {
802+
if (opts.match && opts.match.length > 0) {
803+
throw new Error("--match cannot be combined with --tools.");
804+
}
796805
const summary = opts.summary?.trim()
797806
? (JSON.parse(
798807
await fs.readFile(path.resolve(repoRoot, opts.summary), "utf8"),
@@ -810,10 +819,19 @@ export async function runQaCoverageReportCommand(opts: {
810819
if (opts.summary?.trim()) {
811820
throw new Error("--summary requires --tools.");
812821
}
813-
const inventory = buildQaCoverageInventory(scenarios);
814-
body = opts.json
815-
? `${JSON.stringify(inventory, null, 2)}\n`
816-
: renderQaCoverageMarkdownReport(inventory);
822+
const query = opts.match?.join(" ").trim();
823+
if (query) {
824+
const matches = findQaScenarioMatches(scenarios, query);
825+
body = opts.json
826+
? `${JSON.stringify({ query, matches }, null, 2)}\n`
827+
: renderQaScenarioMatchesMarkdownReport({ query, matches });
828+
outputLabel = "QA scenario match report";
829+
} else {
830+
const inventory = buildQaCoverageInventory(scenarios);
831+
body = opts.json
832+
? `${JSON.stringify(inventory, null, 2)}\n`
833+
: renderQaCoverageMarkdownReport(inventory);
834+
}
817835
}
818836

819837
if (outputPath) {

extensions/qa-lab/src/cli.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,7 @@ describe("qa cli registration", () => {
466466
output: ".artifacts/qa-coverage.md",
467467
json: true,
468468
tools: false,
469+
match: [],
469470
});
470471
});
471472

@@ -487,6 +488,26 @@ describe("qa cli registration", () => {
487488
tools: true,
488489
json: false,
489490
summary: ".artifacts/runtime-summary.json",
491+
match: [],
492+
});
493+
});
494+
495+
it("routes coverage match queries into the qa runtime command", async () => {
496+
await program.parseAsync([
497+
"node",
498+
"openclaw",
499+
"qa",
500+
"coverage",
501+
"--match",
502+
"image roundtrip",
503+
"--match",
504+
"native",
505+
]);
506+
507+
expect(runQaCoverageReportCommand).toHaveBeenCalledWith({
508+
tools: false,
509+
json: false,
510+
match: ["image roundtrip", "native"],
490511
});
491512
});
492513

extensions/qa-lab/src/cli.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ async function runQaCoverageReport(opts: {
7878
json?: boolean;
7979
tools?: boolean;
8080
summary?: string;
81+
match?: string[];
8182
}) {
8283
const runtime = await loadQaLabCliRuntime();
8384
await runtime.runQaCoverageReportCommand(opts);
@@ -404,13 +405,20 @@ export function registerQaLabCli(program: Command) {
404405
.option("--json", "Print JSON instead of Markdown", false)
405406
.option("--tools", "Print runtime tool fixture coverage instead of scenario coverage", false)
406407
.option("--summary <path>", "Runtime qa-suite-summary.json to overlay on --tools coverage")
408+
.option(
409+
"--match <query>",
410+
"Search scenario metadata and print matching qa suite targets (repeatable)",
411+
collectString,
412+
[],
413+
)
407414
.action(
408415
async (opts: {
409416
repoRoot?: string;
410417
output?: string;
411418
json?: boolean;
412419
tools?: boolean;
413420
summary?: string;
421+
match?: string[];
414422
}) => {
415423
await runQaCoverageReport(opts);
416424
},

extensions/qa-lab/src/coverage-report.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ type QaCoverageScenarioSummary = {
1313
risk: string;
1414
};
1515

16+
type QaScenarioSearchMatch = QaCoverageScenarioSummary & {
17+
coverageIds: string[];
18+
docsRefs: string[];
19+
codeRefs: string[];
20+
runtimeParityTier?: string;
21+
requiredProviderMode?: string;
22+
requiredProvider?: string;
23+
requiredModel?: string;
24+
};
25+
1626
type QaCoverageIntent = "primary" | "secondary";
1727

1828
type QaCoverageScenarioReference = QaCoverageScenarioSummary & {
@@ -70,6 +80,85 @@ function summarizeScenario(scenario: QaSeedScenarioWithSource): QaCoverageScenar
7080
};
7181
}
7282

83+
function normalizeSearchText(value: string) {
84+
return value.toLowerCase();
85+
}
86+
87+
function tokenizeScenarioSearchQuery(query: string) {
88+
return query
89+
.toLowerCase()
90+
.split(/\s+/u)
91+
.map((token) => token.trim())
92+
.filter(Boolean);
93+
}
94+
95+
function scenarioSearchText(scenario: QaSeedScenarioWithSource) {
96+
const config = scenario.execution.config ?? {};
97+
return normalizeSearchText(
98+
[
99+
scenario.id,
100+
scenario.title,
101+
scenario.sourcePath,
102+
scenario.surface,
103+
...(scenario.surfaces ?? []),
104+
scenario.category ?? "",
105+
scenario.runtimeParityTier ?? "",
106+
scenario.risk ?? "",
107+
scenario.riskLevel ?? "",
108+
scenario.objective,
109+
...scenario.successCriteria,
110+
...(scenario.capabilities ?? []),
111+
...(scenario.plugins ?? []),
112+
...(scenario.docsRefs ?? []),
113+
...(scenario.codeRefs ?? []),
114+
...(scenario.coverage?.primary ?? []),
115+
...(scenario.coverage?.secondary ?? []),
116+
...Object.entries(config).flatMap(([key, value]) => [
117+
key,
118+
typeof value === "string" ? value : "",
119+
]),
120+
].join("\n"),
121+
);
122+
}
123+
124+
function stringifyConfigValue(value: unknown) {
125+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
126+
}
127+
128+
function summarizeScenarioSearchMatch(scenario: QaSeedScenarioWithSource): QaScenarioSearchMatch {
129+
const config = scenario.execution.config ?? {};
130+
return {
131+
...summarizeScenario(scenario),
132+
coverageIds: [
133+
...(scenario.coverage?.primary ?? []),
134+
...(scenario.coverage?.secondary ?? []),
135+
].toSorted((left, right) => left.localeCompare(right)),
136+
docsRefs: [...(scenario.docsRefs ?? [])],
137+
codeRefs: [...(scenario.codeRefs ?? [])],
138+
runtimeParityTier: scenario.runtimeParityTier,
139+
requiredProviderMode: stringifyConfigValue(config.requiredProviderMode),
140+
requiredProvider: stringifyConfigValue(config.requiredProvider),
141+
requiredModel: stringifyConfigValue(config.requiredModel),
142+
};
143+
}
144+
145+
export function findQaScenarioMatches(
146+
scenarios: readonly QaSeedScenarioWithSource[],
147+
query: string,
148+
) {
149+
const tokens = tokenizeScenarioSearchQuery(query);
150+
if (tokens.length === 0) {
151+
return [];
152+
}
153+
return scenarios
154+
.filter((scenario) => {
155+
const haystack = scenarioSearchText(scenario);
156+
return tokens.every((token) => haystack.includes(token));
157+
})
158+
.map(summarizeScenarioSearchMatch)
159+
.toSorted((left, right) => left.id.localeCompare(right.id));
160+
}
161+
73162
function sortFeatures(features: readonly QaCoverageFeatureSummary[]) {
74163
return features.toSorted((left, right) => left.id.localeCompare(right.id));
75164
}
@@ -280,3 +369,52 @@ export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory):
280369

281370
return `${lines.join("\n").trimEnd()}\n`;
282371
}
372+
373+
function formatOptionalScenarioMetadata(match: QaScenarioSearchMatch) {
374+
const metadata = [
375+
match.runtimeParityTier ? `runtimeParityTier=${match.runtimeParityTier}` : "",
376+
match.requiredProviderMode ? `providerMode=${match.requiredProviderMode}` : "",
377+
match.requiredProvider ? `provider=${match.requiredProvider}` : "",
378+
match.requiredModel ? `model=${match.requiredModel}` : "",
379+
].filter(Boolean);
380+
return metadata.length > 0 ? metadata.join("; ") : "none";
381+
}
382+
383+
export function renderQaScenarioMatchesMarkdownReport(params: {
384+
query: string;
385+
matches: readonly QaScenarioSearchMatch[];
386+
}) {
387+
const scenarioArgs = params.matches.map((match) => `--scenario ${match.id}`).join(" ");
388+
const lines = [
389+
"# QA Scenario Matches",
390+
"",
391+
`- Query: ${params.query}`,
392+
`- Matches: ${params.matches.length}`,
393+
];
394+
395+
if (scenarioArgs) {
396+
lines.push(`- Suite command: \`pnpm openclaw qa suite ${scenarioArgs}\``);
397+
}
398+
lines.push("");
399+
400+
if (params.matches.length === 0) {
401+
lines.push("No QA scenarios matched the query.", "");
402+
return lines.join("\n");
403+
}
404+
405+
for (const match of params.matches) {
406+
lines.push(`- ${match.id}: ${match.title}`);
407+
lines.push(` - source: ${match.sourcePath}`);
408+
lines.push(` - surface: ${match.surfaces.join(", ")}`);
409+
lines.push(` - coverage: ${match.coverageIds.join(", ") || "none"}`);
410+
lines.push(` - live requirements: ${formatOptionalScenarioMetadata(match)}`);
411+
if (match.codeRefs.length > 0) {
412+
lines.push(` - code refs: ${match.codeRefs.join(", ")}`);
413+
}
414+
if (match.docsRefs.length > 0) {
415+
lines.push(` - docs refs: ${match.docsRefs.join(", ")}`);
416+
}
417+
}
418+
419+
return `${lines.join("\n").trimEnd()}\n`;
420+
}

scripts/qa-coverage-report.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { runQaCoverageReportCommand } from "../extensions/qa-lab/src/cli.runtime
22

33
type Options = {
44
json?: boolean;
5+
match?: string[];
56
output?: string;
67
repoRoot?: string;
78
summary?: string;
@@ -27,6 +28,7 @@ function parseArgs(args: string[]): Options {
2728
2829
Options:
2930
--json Print machine-readable JSON
31+
--match <query> Search scenario metadata and print matching suite targets
3032
--output <path> Write the report to a file
3133
--repo-root <path> Repository root to target
3234
--summary <path> Runtime qa-suite-summary.json to overlay on --tools coverage
@@ -37,6 +39,11 @@ Options:
3739
case "--json":
3840
opts.json = true;
3941
break;
42+
case "--match":
43+
opts.match ??= [];
44+
opts.match.push(takeValue(args, index, arg));
45+
index += 1;
46+
break;
4047
case "--output":
4148
opts.output = takeValue(args, index, arg);
4249
index += 1;
@@ -62,6 +69,7 @@ Options:
6269
const opts = parseArgs(process.argv.slice(2));
6370
await runQaCoverageReportCommand({
6471
...(opts.json ? { json: true } : {}),
72+
...(opts.match ? { match: opts.match } : {}),
6573
...(opts.output ? { output: opts.output } : {}),
6674
...(opts.repoRoot ? { repoRoot: opts.repoRoot } : {}),
6775
...(opts.summary ? { summary: opts.summary } : {}),

0 commit comments

Comments
 (0)