ccqa 0.1.5 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/run.ts DELETED
@@ -1,258 +0,0 @@
1
- import { Command } from "commander";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import {
6
- parseSpecPath,
7
- getTestScript,
8
- listAllSpecs,
9
- listSpecsForFeature,
10
- } from "../store/index.ts";
11
- import * as log from "./logger.ts";
12
-
13
- type VitestAssertionResult = {
14
- status: "passed" | "failed" | "skipped" | "pending" | "todo";
15
- title: string;
16
- fullName: string;
17
- duration?: number;
18
- failureMessages?: string[];
19
- };
20
-
21
- type VitestTestResult = {
22
- name: string;
23
- status: "passed" | "failed";
24
- assertionResults: VitestAssertionResult[];
25
- };
26
-
27
- type VitestJsonReport = {
28
- numTotalTests: number;
29
- numPassedTests: number;
30
- numFailedTests: number;
31
- numPendingTests: number;
32
- startTime: number;
33
- success: boolean;
34
- testResults: VitestTestResult[];
35
- };
36
-
37
- type SpecRunSummary = {
38
- featureName: string;
39
- specName: string;
40
- scriptFile: string;
41
- report: VitestJsonReport | null;
42
- exitCode: number;
43
- };
44
-
45
- export const runCommand = new Command("run")
46
- .argument("[target]", "Spec to run: '<feature>/<spec>', '<feature>', or omit for all")
47
- .description("Run generated agent-browser test scripts")
48
- .action(async (target?: string) => {
49
- await runTests(target);
50
- });
51
-
52
- async function runTests(target?: string): Promise<void> {
53
- log.header("run", target);
54
-
55
- const specs = await resolveSpecs(target);
56
-
57
- if (specs.length === 0) {
58
- log.error("no test scripts found");
59
- log.hint("run 'ccqa generate <feature>/<spec>' first to generate tests");
60
- process.exit(1);
61
- }
62
-
63
- const tmpDir = await mkdtemp(join(tmpdir(), "ccqa-run-"));
64
- const summaries: SpecRunSummary[] = [];
65
- let overallExitCode = 0;
66
-
67
- try {
68
- for (let i = 0; i < specs.length; i++) {
69
- const { featureName, specName } = specs[i]!;
70
- const scriptFile = await getTestScript(featureName, specName);
71
- if (!scriptFile) {
72
- log.warn(`${featureName}/${specName}: no test.spec.ts found`);
73
- continue;
74
- }
75
-
76
- log.info(`▶ ${featureName}/${specName}`);
77
- log.meta("test", scriptFile);
78
- log.blank();
79
-
80
- const reportFile = join(tmpDir, `report-${i}.json`);
81
- const proc = Bun.spawn(
82
- [
83
- "bunx",
84
- "vitest",
85
- "run",
86
- scriptFile,
87
- "--reporter=json",
88
- `--outputFile.json=${reportFile}`,
89
- ],
90
- { stdout: "pipe", stderr: "pipe" },
91
- );
92
-
93
- await Promise.all([
94
- streamFiltered(proc.stdout, Bun.stdout),
95
- streamFiltered(proc.stderr, Bun.stderr),
96
- ]);
97
- const exitCode = await proc.exited;
98
- if (exitCode !== 0) overallExitCode = exitCode;
99
-
100
- const report = await readReport(reportFile);
101
- summaries.push({ featureName, specName, scriptFile, report, exitCode });
102
- log.blank();
103
- }
104
-
105
- printSummary(summaries);
106
- } finally {
107
- await rm(tmpDir, { recursive: true, force: true });
108
- }
109
-
110
- process.exit(overallExitCode);
111
- }
112
-
113
- async function readReport(path: string): Promise<VitestJsonReport | null> {
114
- try {
115
- const raw = await readFile(path, "utf8");
116
- return JSON.parse(raw) as VitestJsonReport;
117
- } catch {
118
- return null;
119
- }
120
- }
121
-
122
- const useColor = process.stdout.isTTY && process.env.NO_COLOR == null;
123
- const C = {
124
- reset: useColor ? "\x1b[0m" : "",
125
- bold: useColor ? "\x1b[1m" : "",
126
- dim: useColor ? "\x1b[2m" : "",
127
- green: useColor ? "\x1b[32m" : "",
128
- red: useColor ? "\x1b[31m" : "",
129
- yellow: useColor ? "\x1b[33m" : "",
130
- cyan: useColor ? "\x1b[36m" : "",
131
- gray: useColor ? "\x1b[90m" : "",
132
- };
133
-
134
- function printSummary(summaries: SpecRunSummary[]): void {
135
- process.stdout.write(
136
- `\n${C.cyan}${C.bold}──────── ccqa summary ────────${C.reset}\n\n`,
137
- );
138
-
139
- let totalTests = 0;
140
- let totalPassed = 0;
141
- let totalFailed = 0;
142
- let totalSkipped = 0;
143
-
144
- for (const s of summaries) {
145
- const header = `${C.bold}${s.featureName}/${s.specName}${C.reset}`;
146
- if (!s.report) {
147
- const ok = s.exitCode === 0;
148
- const icon = ok ? `${C.green}✔${C.reset}` : `${C.red}✖${C.reset}`;
149
- process.stdout.write(`${icon} ${header} ${C.dim}(no report)${C.reset}\n`);
150
- continue;
151
- }
152
-
153
- totalTests += s.report.numTotalTests;
154
- totalPassed += s.report.numPassedTests;
155
- totalFailed += s.report.numFailedTests;
156
- totalSkipped += s.report.numPendingTests;
157
-
158
- const ok = s.report.success;
159
- const icon = ok ? `${C.green}✔${C.reset}` : `${C.red}✖${C.reset}`;
160
- const countColor = ok ? C.green : C.red;
161
- process.stdout.write(
162
- `${icon} ${header} ${countColor}${s.report.numPassedTests}/${s.report.numTotalTests}${C.reset} ${C.dim}passed${C.reset}\n`,
163
- );
164
-
165
- for (const file of s.report.testResults) {
166
- for (const a of file.assertionResults) {
167
- const aIcon = assertionIcon(a.status);
168
- const dur = a.duration != null ? ` ${C.gray}${formatDuration(a.duration)}${C.reset}` : "";
169
- process.stdout.write(` ${aIcon} ${a.fullName}${dur}\n`);
170
- if (a.status === "failed" && a.failureMessages?.length) {
171
- for (const msg of a.failureMessages) {
172
- const firstLine = msg.split("\n")[0] ?? msg;
173
- process.stdout.write(` ${C.red}${firstLine}${C.reset}\n`);
174
- }
175
- }
176
- }
177
- }
178
- }
179
-
180
- const specsPassed = summaries.filter((s) => s.exitCode === 0).length;
181
- const specsFailed = summaries.filter((s) => s.exitCode !== 0).length;
182
- process.stdout.write("\n");
183
- process.stdout.write(
184
- ` ${C.bold}Specs${C.reset} ${summaries.length} ` +
185
- `(${C.green}${specsPassed} passed${C.reset}, ${specsFailed > 0 ? C.red : C.dim}${specsFailed} failed${C.reset})\n`,
186
- );
187
- process.stdout.write(
188
- ` ${C.bold}Tests${C.reset} ${totalTests} ` +
189
- `(${C.green}${totalPassed} passed${C.reset}, ${totalFailed > 0 ? C.red : C.dim}${totalFailed} failed${C.reset}, ${C.yellow}${totalSkipped} skipped${C.reset})\n`,
190
- );
191
- process.stdout.write("\n");
192
- }
193
-
194
- function assertionIcon(status: VitestAssertionResult["status"]): string {
195
- switch (status) {
196
- case "passed":
197
- return `${C.green}✔${C.reset}`;
198
- case "failed":
199
- return `${C.red}✖${C.reset}`;
200
- case "skipped":
201
- case "pending":
202
- case "todo":
203
- return `${C.yellow}◌${C.reset}`;
204
- }
205
- }
206
-
207
- function formatDuration(ms: number): string {
208
- if (ms < 1000) return `${Math.round(ms)}ms`;
209
- return `${(ms / 1000).toFixed(2)}s`;
210
- }
211
-
212
- const NOISE_LINE_PATTERNS = [/^JSON report written to /];
213
-
214
- async function streamFiltered(
215
- source: ReadableStream<Uint8Array>,
216
- sink: { write: (chunk: Uint8Array) => unknown },
217
- ): Promise<void> {
218
- const decoder = new TextDecoder();
219
- const encoder = new TextEncoder();
220
- let buffer = "";
221
- const reader = source.getReader();
222
- try {
223
- while (true) {
224
- const { value, done } = await reader.read();
225
- if (done) break;
226
- buffer += decoder.decode(value, { stream: true });
227
- let nl = buffer.indexOf("\n");
228
- while (nl !== -1) {
229
- const line = buffer.slice(0, nl);
230
- buffer = buffer.slice(nl + 1);
231
- if (!NOISE_LINE_PATTERNS.some((p) => p.test(line))) {
232
- sink.write(encoder.encode(line + "\n"));
233
- }
234
- nl = buffer.indexOf("\n");
235
- }
236
- }
237
- buffer += decoder.decode();
238
- if (buffer.length > 0 && !NOISE_LINE_PATTERNS.some((p) => p.test(buffer))) {
239
- sink.write(encoder.encode(buffer));
240
- }
241
- } finally {
242
- reader.releaseLock();
243
- }
244
- }
245
-
246
- async function resolveSpecs(target?: string): Promise<Array<{ featureName: string; specName: string }>> {
247
- if (!target) {
248
- return listAllSpecs();
249
- }
250
-
251
- if (target.includes("/")) {
252
- const { featureName, specName } = parseSpecPath(target);
253
- return [{ featureName, specName }];
254
- }
255
-
256
- const specNames = await listSpecsForFeature(target);
257
- return specNames.map((specName) => ({ featureName: target, specName }));
258
- }
@@ -1,124 +0,0 @@
1
- import { Command } from "commander";
2
- import { buildSetupTraceSystemPrompt, buildSetupTracePrompt } from "../prompts/trace.ts";
3
- import { invokeClaudeStreaming } from "../claude/invoke.ts";
4
- import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
5
- import { ensureCcqaDir, readSetupSpecFile, saveSetupActions, saveSetupRoute } from "../store/index.ts";
6
- import { parseSetupSpec } from "../spec/parser.ts";
7
- import { parseAbAction, parseStatusLine, parseRouteStep } from "./trace.ts";
8
- import type { Route, RouteStep, TraceAction } from "../types.ts";
9
- import * as log from "./logger.ts";
10
-
11
- export const traceSetupCommand = new Command("trace-setup")
12
- .argument("<name>", "Setup name to trace (e.g. login)")
13
- .description("Trace a setup procedure using dummy placeholder values")
14
- .action(async (name: string) => {
15
- await runTraceSetup(name);
16
- });
17
-
18
- async function runTraceSetup(name: string): Promise<void> {
19
- log.header("trace-setup", name);
20
-
21
- await ensureCcqaDir();
22
-
23
- const specContent = await readSetupSpecFile(name);
24
- const spec = parseSetupSpec(specContent);
25
-
26
- // Replace {{key}} with dummy values for actual browser operation
27
- const resolvedSpec = replacePlaceholdersWithDummies(spec);
28
-
29
- log.meta("setup", spec.title);
30
- log.meta("steps", spec.steps.length);
31
- if (spec.placeholders) {
32
- log.meta("placeholders", Object.keys(spec.placeholders).join(", "));
33
- }
34
- log.blank();
35
-
36
- const systemPrompt = buildSetupTraceSystemPrompt(resolvedSpec);
37
- const prompt = buildSetupTracePrompt(resolvedSpec);
38
-
39
- log.info("Running agent-browser session...");
40
- log.blank();
41
-
42
- const routeSteps: RouteStep[] = [];
43
- let overallStatus: "passed" | "failed" = "passed";
44
- const traceActions: TraceAction[] = [];
45
-
46
- const { isError } = await invokeClaudeStreaming(
47
- {
48
- prompt,
49
- systemPrompt,
50
- allowedTools: ["Bash(*)", "Read", "Grep", "Glob"],
51
- onAbAction: (abAction: string) => {
52
- const action = parseAbAction(abAction);
53
- if (action) traceActions.push(action);
54
- },
55
- onAbActionFailed: () => {
56
- traceActions.pop();
57
- },
58
- },
59
- (msg: SDKMessage) => {
60
- if (msg.type !== "assistant") return;
61
-
62
- for (const block of msg.message.content ?? []) {
63
- if (block.type !== "text" || !block.text) continue;
64
- const text = block.text;
65
-
66
- const statusLine = parseStatusLine(text);
67
- if (statusLine) log.step(statusLine.type, statusLine.stepId, statusLine.detail);
68
-
69
- for (const line of text.split("\n")) {
70
- const trimmed = line.trim();
71
- if (trimmed.startsWith("ROUTE_STEP|")) {
72
- const routeStep = parseRouteStep(trimmed);
73
- if (routeStep) {
74
- routeSteps.push(routeStep);
75
- if (routeStep.status === "FAILED") overallStatus = "failed";
76
- }
77
- } else if (trimmed.startsWith("AB_ACTION|snapshot|") || trimmed.startsWith("AB_ACTION|assert|")) {
78
- const action = parseAbAction(trimmed);
79
- if (action) traceActions.push(action);
80
- }
81
- }
82
- }
83
- },
84
- );
85
-
86
- if (isError) overallStatus = "failed";
87
-
88
- const timestamp = new Date().toISOString();
89
- const route: Route = { specName: name, timestamp, status: overallStatus, steps: routeSteps };
90
-
91
- const [routePath, actionsPath] = await Promise.all([
92
- saveSetupRoute(name, route),
93
- saveSetupActions(name, traceActions),
94
- ]);
95
-
96
- log.blank();
97
- log.meta("route", routePath);
98
- log.meta("saved", actionsPath);
99
- log.meta("actions", traceActions.length);
100
- log.meta("status", overallStatus.toUpperCase());
101
- log.hint(`run 'ccqa generate-setup ${name}' to generate and validate the setup`);
102
- }
103
-
104
- function replacePlaceholdersWithDummies(spec: ReturnType<typeof parseSetupSpec>): typeof spec {
105
- if (!spec.placeholders) return spec;
106
-
107
- const dummies = spec.placeholders as Record<string, { dummy: string; description?: string }>;
108
- const resolve = (text: string): string => {
109
- let result = text;
110
- for (const [key, def] of Object.entries(dummies)) {
111
- result = result.replaceAll(`{{${key}}}`, def.dummy);
112
- }
113
- return result;
114
- };
115
-
116
- return {
117
- ...spec,
118
- steps: spec.steps.map((step) => ({
119
- ...step,
120
- instruction: resolve(step.instruction),
121
- expected: resolve(step.expected),
122
- })),
123
- };
124
- }
@@ -1,233 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { parseAbAction, parseStatusLine, parseRouteStep } from "./trace.ts";
3
-
4
- describe("parseAbAction", () => {
5
- test("returns null for non-AB_ACTION lines", () => {
6
- expect(parseAbAction("some text")).toBeNull();
7
- expect(parseAbAction("")).toBeNull();
8
- expect(parseAbAction("ROUTE_STEP|step-01|title")).toBeNull();
9
- });
10
-
11
- test("returns null for unknown commands", () => {
12
- expect(parseAbAction("AB_ACTION|unknown|arg")).toBeNull();
13
- expect(parseAbAction("AB_ACTION|navigate|http://example.com")).toBeNull();
14
- });
15
-
16
- test("parses open", () => {
17
- expect(parseAbAction("AB_ACTION|open|http://localhost:3000")).toEqual({
18
- command: "open",
19
- value: "http://localhost:3000",
20
- });
21
- });
22
-
23
- test("parses press", () => {
24
- expect(parseAbAction("AB_ACTION|press|Enter")).toEqual({
25
- command: "press",
26
- value: "Enter",
27
- });
28
- });
29
-
30
- test("parses scroll", () => {
31
- expect(parseAbAction("AB_ACTION|scroll|down|300")).toEqual({
32
- command: "scroll",
33
- direction: "down",
34
- pixels: "300",
35
- });
36
- });
37
-
38
- test("parses snapshot", () => {
39
- expect(parseAbAction("AB_ACTION|snapshot|Login page loaded")).toEqual({
40
- command: "snapshot",
41
- observation: "Login page loaded",
42
- });
43
- });
44
-
45
- test("parses click", () => {
46
- expect(parseAbAction("AB_ACTION|click|[aria-label='Login']|Login")).toEqual({
47
- command: "click",
48
- selector: "[aria-label='Login']",
49
- label: "Login",
50
- });
51
- });
52
-
53
- test("parses dblclick", () => {
54
- expect(parseAbAction("AB_ACTION|dblclick|[aria-label='Item']|Item")).toEqual({
55
- command: "dblclick",
56
- selector: "[aria-label='Item']",
57
- label: "Item",
58
- });
59
- });
60
-
61
- test("parses check", () => {
62
- expect(parseAbAction("AB_ACTION|check|[aria-label='Agree']|Agree")).toEqual({
63
- command: "check",
64
- selector: "[aria-label='Agree']",
65
- label: "Agree",
66
- });
67
- });
68
-
69
- test("parses uncheck", () => {
70
- expect(parseAbAction("AB_ACTION|uncheck|[aria-label='Agree']|Agree")).toEqual({
71
- command: "uncheck",
72
- selector: "[aria-label='Agree']",
73
- label: "Agree",
74
- });
75
- });
76
-
77
- test("parses hover", () => {
78
- expect(parseAbAction("AB_ACTION|hover|[aria-label='Menu']|Menu")).toEqual({
79
- command: "hover",
80
- selector: "[aria-label='Menu']",
81
- label: "Menu",
82
- });
83
- });
84
-
85
- test("parses wait with selector", () => {
86
- expect(parseAbAction("AB_ACTION|wait|[aria-label='Loading']|Loading")).toEqual({
87
- command: "wait",
88
- selector: "[aria-label='Loading']",
89
- label: "Loading",
90
- });
91
- });
92
-
93
- test("parses wait --text as text= selector", () => {
94
- expect(parseAbAction("AB_ACTION|wait|--text|Done")).toEqual({
95
- command: "wait",
96
- selector: "text=Done",
97
- label: undefined,
98
- });
99
- });
100
-
101
- test("parses fill", () => {
102
- expect(parseAbAction("AB_ACTION|fill|[aria-label='Email']|user@example.com|Email")).toEqual({
103
- command: "fill",
104
- selector: "[aria-label='Email']",
105
- value: "user@example.com",
106
- label: "Email",
107
- });
108
- });
109
-
110
- test("parses type", () => {
111
- expect(parseAbAction("AB_ACTION|type|[aria-label='Search']|query text|Search")).toEqual({
112
- command: "type",
113
- selector: "[aria-label='Search']",
114
- value: "query text",
115
- label: "Search",
116
- });
117
- });
118
-
119
- test("parses select", () => {
120
- expect(parseAbAction("AB_ACTION|select|[aria-label='Color']|red|Color")).toEqual({
121
- command: "select",
122
- selector: "[aria-label='Color']",
123
- value: "red",
124
- label: "Color",
125
- });
126
- });
127
-
128
- test("parses drag", () => {
129
- expect(parseAbAction("AB_ACTION|drag|[aria-label='Source']|[aria-label='Target']|Source")).toEqual({
130
- command: "drag",
131
- selector: "[aria-label='Source']",
132
- target: "[aria-label='Target']",
133
- label: "Source",
134
- });
135
- });
136
- });
137
-
138
- describe("parseStatusLine", () => {
139
- test("parses STEP_START", () => {
140
- expect(parseStatusLine("STEP_START|step-01|Login")).toEqual({
141
- type: "STEP_START",
142
- stepId: "step-01",
143
- detail: "Login",
144
- });
145
- });
146
-
147
- test("parses STEP_DONE", () => {
148
- expect(parseStatusLine("STEP_DONE|step-01|Verified redirect")).toEqual({
149
- type: "STEP_DONE",
150
- stepId: "step-01",
151
- detail: "Verified redirect",
152
- });
153
- });
154
-
155
- test("parses ASSERTION_FAILED", () => {
156
- expect(parseStatusLine("ASSERTION_FAILED|step-03|app-bug: button not disabled")).toEqual({
157
- type: "ASSERTION_FAILED",
158
- stepId: "step-03",
159
- detail: "app-bug: button not disabled",
160
- });
161
- });
162
-
163
- test("parses STEP_SKIPPED", () => {
164
- expect(parseStatusLine("STEP_SKIPPED|step-02|previous step failed")).toEqual({
165
- type: "STEP_SKIPPED",
166
- stepId: "step-02",
167
- detail: "previous step failed",
168
- });
169
- });
170
-
171
- test("parses RUN_COMPLETED passed", () => {
172
- expect(parseStatusLine("RUN_COMPLETED|passed|All steps done")).toEqual({
173
- type: "RUN_COMPLETED",
174
- stepId: "passed",
175
- detail: "All steps done",
176
- });
177
- });
178
-
179
- test("returns null for non-matching lines", () => {
180
- expect(parseStatusLine("some random text")).toBeNull();
181
- expect(parseStatusLine("")).toBeNull();
182
- expect(parseStatusLine("ROUTE_STEP|step-01|title|ACTION:did|OBS:saw|STATUS:PASSED")).toBeNull();
183
- });
184
-
185
- test("returns first matching line from multi-line text", () => {
186
- const text = "some preamble\nSTEP_START|step-01|Title\nmore text";
187
- expect(parseStatusLine(text)).toEqual({
188
- type: "STEP_START",
189
- stepId: "step-01",
190
- detail: "Title",
191
- });
192
- });
193
- });
194
-
195
- describe("parseRouteStep", () => {
196
- test("parses a complete ROUTE_STEP line", () => {
197
- const line = "ROUTE_STEP|step-01|Login|ACTION:filled form and pressed Enter|OBSERVATION:redirected to /dashboard|STATUS:PASSED";
198
- expect(parseRouteStep(line)).toEqual({
199
- title: "Login",
200
- action: "filled form and pressed Enter",
201
- observation: "redirected to /dashboard",
202
- status: "PASSED",
203
- });
204
- });
205
-
206
- test("parses FAILED status", () => {
207
- const line = "ROUTE_STEP|step-02|Check|ACTION:clicked button|OBSERVATION:nothing happened|STATUS:FAILED";
208
- expect(parseRouteStep(line)?.status).toBe("FAILED");
209
- });
210
-
211
- test("parses SKIPPED status", () => {
212
- const line = "ROUTE_STEP|step-03|Skip|ACTION:skipped|OBSERVATION:n/a|STATUS:SKIPPED";
213
- expect(parseRouteStep(line)?.status).toBe("SKIPPED");
214
- });
215
-
216
- test("returns null when fewer than 6 parts", () => {
217
- expect(parseRouteStep("ROUTE_STEP|step-01|title|ACTION:did")).toBeNull();
218
- expect(parseRouteStep("")).toBeNull();
219
- });
220
-
221
- test("defaults to FAILED for unrecognized status", () => {
222
- const line = "ROUTE_STEP|step-01|Title|ACTION:did|OBSERVATION:saw|STATUS:UNKNOWN";
223
- expect(parseRouteStep(line)?.status).toBe("FAILED");
224
- });
225
-
226
- test("strips ACTION: and OBSERVATION: prefixes", () => {
227
- const line = "ROUTE_STEP|step-01|Title|ACTION:my action|OBSERVATION:my observation|STATUS:PASSED";
228
- const result = parseRouteStep(line);
229
- expect(result?.action).toBe("my action");
230
- expect(result?.observation).toBe("my observation");
231
- });
232
- });
233
-