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 +0 -0
- package/package.json +1 -4
- package/src/cli/index.ts +5 -1
- package/src/cli/run.ts +187 -14
- package/src/codegen/actions-to-script.ts +3 -2
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.
|
|
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(
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
139
|
+
totalTests += s.report.numTotalTests;
|
|
140
|
+
totalPassed += s.report.numPassedTests;
|
|
141
|
+
totalFailed += s.report.numFailedTests;
|
|
142
|
+
totalSkipped += s.report.numPendingTests;
|
|
40
143
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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()}\`;`,
|