ccqa 0.1.1 → 0.1.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/bin/ccqa.ts CHANGED
File without changes
package/package.json CHANGED
@@ -1,14 +1,11 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "bin": {
7
7
  "ccqa": "./bin/ccqa.ts"
8
8
  },
9
- "exports": {
10
- "./test-helpers": "./src/runtime/test-helpers.ts"
11
- },
12
9
  "files": [
13
10
  "bin/",
14
11
  "src/"
package/src/cli/index.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
2
3
  import { traceCommand } from "./trace.ts";
3
4
  import { generateCommand } from "./generate.ts";
4
5
  import { runCommand } from "./run.ts";
5
6
  import { traceSetupCommand } from "./trace-setup.ts";
6
7
  import { generateSetupCommand } from "./generate-setup.ts";
7
8
 
9
+ const packageJsonPath = new URL("../../package.json", import.meta.url);
10
+ const { version } = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version: string };
11
+
8
12
  const program = new Command();
9
13
 
10
14
  program
11
15
  .name("ccqa")
12
16
  .description("E2E test CLI using Claude Code + agent-browser")
13
- .version("0.1.0");
17
+ .version(version);
14
18
 
15
19
  program.addCommand(traceCommand);
16
20
  program.addCommand(generateCommand);
package/src/cli/run.ts CHANGED
@@ -1,4 +1,7 @@
1
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";
2
5
  import {
3
6
  parseSpecPath,
4
7
  getTestScript,
@@ -7,6 +10,38 @@ import {
7
10
  } from "../store/index.ts";
8
11
  import * as log from "./logger.ts";
9
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
+
10
45
  export const runCommand = new Command("run")
11
46
  .argument("[target]", "Spec to run: '<feature>/<spec>', '<feature>', or omit for all")
12
47
  .description("Run generated agent-browser test scripts")
@@ -25,29 +60,167 @@ async function runTests(target?: string): Promise<void> {
25
60
  process.exit(1);
26
61
  }
27
62
 
63
+ const tmpDir = await mkdtemp(join(tmpdir(), "ccqa-run-"));
64
+ const summaries: SpecRunSummary[] = [];
28
65
  let overallExitCode = 0;
29
66
 
30
- for (const { featureName, specName } of specs) {
31
- const scriptFile = await getTestScript(featureName, specName);
32
- if (!scriptFile) {
33
- log.warn(`${featureName}/${specName}: no test.spec.ts found`);
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=default",
88
+ "--reporter=json",
89
+ `--outputFile.json=${reportFile}`,
90
+ ],
91
+ { stdout: "pipe", stderr: "pipe" },
92
+ );
93
+
94
+ await Promise.all([
95
+ streamFiltered(proc.stdout, Bun.stdout),
96
+ streamFiltered(proc.stderr, Bun.stderr),
97
+ ]);
98
+ const exitCode = await proc.exited;
99
+ if (exitCode !== 0) overallExitCode = exitCode;
100
+
101
+ const report = await readReport(reportFile);
102
+ summaries.push({ featureName, specName, scriptFile, report, exitCode });
103
+ log.blank();
104
+ }
105
+
106
+ printSummary(summaries);
107
+ } finally {
108
+ await rm(tmpDir, { recursive: true, force: true });
109
+ }
110
+
111
+ process.exit(overallExitCode);
112
+ }
113
+
114
+ async function readReport(path: string): Promise<VitestJsonReport | null> {
115
+ try {
116
+ const raw = await readFile(path, "utf8");
117
+ return JSON.parse(raw) as VitestJsonReport;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function printSummary(summaries: SpecRunSummary[]): void {
124
+ process.stdout.write("\n────── ccqa summary ──────\n\n");
125
+
126
+ let totalTests = 0;
127
+ let totalPassed = 0;
128
+ let totalFailed = 0;
129
+ let totalSkipped = 0;
130
+
131
+ for (const s of summaries) {
132
+ const header = `${s.featureName}/${s.specName}`;
133
+ if (!s.report) {
134
+ const icon = s.exitCode === 0 ? "✓" : "✗";
135
+ process.stdout.write(`${icon} ${header} (no report)\n`);
34
136
  continue;
35
137
  }
36
138
 
37
- log.info(`${featureName}/${specName}`);
38
- log.meta("test", scriptFile);
39
- log.blank();
139
+ totalTests += s.report.numTotalTests;
140
+ totalPassed += s.report.numPassedTests;
141
+ totalFailed += s.report.numFailedTests;
142
+ totalSkipped += s.report.numPendingTests;
40
143
 
41
- const proc = Bun.spawn(["bunx", "vitest", "run", scriptFile], {
42
- stdout: "inherit",
43
- stderr: "inherit",
44
- });
144
+ const icon = s.report.success ? "" : "";
145
+ process.stdout.write(
146
+ `${icon} ${header} (${s.report.numPassedTests}/${s.report.numTotalTests} passed)\n`,
147
+ );
45
148
 
46
- const exitCode = await proc.exited;
47
- if (exitCode !== 0) overallExitCode = exitCode;
149
+ for (const file of s.report.testResults) {
150
+ for (const a of file.assertionResults) {
151
+ const aIcon = assertionIcon(a.status);
152
+ const dur = a.duration != null ? ` ${formatDuration(a.duration)}` : "";
153
+ process.stdout.write(` ${aIcon} ${a.fullName}${dur}\n`);
154
+ if (a.status === "failed" && a.failureMessages?.length) {
155
+ for (const msg of a.failureMessages) {
156
+ const firstLine = msg.split("\n")[0] ?? msg;
157
+ process.stdout.write(` ${firstLine}\n`);
158
+ }
159
+ }
160
+ }
161
+ }
48
162
  }
49
163
 
50
- process.exit(overallExitCode);
164
+ process.stdout.write("\n");
165
+ process.stdout.write(
166
+ ` Specs ${summaries.length} (${summaries.filter((s) => s.exitCode === 0).length} passed, ${summaries.filter((s) => s.exitCode !== 0).length} failed)\n`,
167
+ );
168
+ process.stdout.write(
169
+ ` Tests ${totalTests} (${totalPassed} passed, ${totalFailed} failed, ${totalSkipped} skipped)\n`,
170
+ );
171
+ process.stdout.write("\n");
172
+ }
173
+
174
+ function assertionIcon(status: VitestAssertionResult["status"]): string {
175
+ switch (status) {
176
+ case "passed":
177
+ return "✓";
178
+ case "failed":
179
+ return "✗";
180
+ case "skipped":
181
+ case "pending":
182
+ case "todo":
183
+ return "⊘";
184
+ }
185
+ }
186
+
187
+ function formatDuration(ms: number): string {
188
+ if (ms < 1000) return `${Math.round(ms)}ms`;
189
+ return `${(ms / 1000).toFixed(2)}s`;
190
+ }
191
+
192
+ const NOISE_LINE_PATTERNS = [/^JSON report written to /];
193
+
194
+ async function streamFiltered(
195
+ source: ReadableStream<Uint8Array>,
196
+ sink: { write: (chunk: Uint8Array) => unknown },
197
+ ): Promise<void> {
198
+ const decoder = new TextDecoder();
199
+ const encoder = new TextEncoder();
200
+ let buffer = "";
201
+ const reader = source.getReader();
202
+ try {
203
+ while (true) {
204
+ const { value, done } = await reader.read();
205
+ if (done) break;
206
+ buffer += decoder.decode(value, { stream: true });
207
+ let nl = buffer.indexOf("\n");
208
+ while (nl !== -1) {
209
+ const line = buffer.slice(0, nl);
210
+ buffer = buffer.slice(nl + 1);
211
+ if (!NOISE_LINE_PATTERNS.some((p) => p.test(line))) {
212
+ sink.write(encoder.encode(line + "\n"));
213
+ }
214
+ nl = buffer.indexOf("\n");
215
+ }
216
+ }
217
+ buffer += decoder.decode();
218
+ if (buffer.length > 0 && !NOISE_LINE_PATTERNS.some((p) => p.test(buffer))) {
219
+ sink.write(encoder.encode(buffer));
220
+ }
221
+ } finally {
222
+ reader.releaseLock();
223
+ }
51
224
  }
52
225
 
53
226
  async function resolveSpecs(target?: string): Promise<Array<{ featureName: string; specName: string }>> {
@@ -11,12 +11,13 @@ export interface SetupScript {
11
11
  }
12
12
 
13
13
  export function actionsToScript(actions: TraceAction[], title: string, setupScripts?: SetupScript[]): string {
14
- const helpersImport = "ccqa/test-helpers";
14
+ // Resolve the helpers path relative to this file so it works from any cwd
15
+ const helpersPath = new URL("../runtime/test-helpers.ts", import.meta.url).pathname;
15
16
 
16
17
  const imports = [
17
18
  `import { test } from "vitest";`,
18
19
  `import { spawnSync } from "node:child_process";`,
19
- `import { ab, abWait, abAssertTextVisible, abAssertVisible, abAssertNotVisible, abAssertUrl, abAssertEnabled, abAssertDisabled, abAssertChecked, abAssertUnchecked } from ${JSON.stringify(helpersImport)};`,
20
+ `import { ab, abWait, abAssertTextVisible, abAssertVisible, abAssertNotVisible, abAssertUrl, abAssertEnabled, abAssertDisabled, abAssertChecked, abAssertUnchecked } from ${JSON.stringify(helpersPath)};`,
20
21
  "",
21
22
  `// Single session shared across all tests — reset per run via cookies clear in first test`,
22
23
  `process.env.AGENT_BROWSER_SESSION = \`ccqa-run-\${Date.now()}\`;`,