ccqa 0.1.0
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/LICENSE +21 -0
- package/README.md +278 -0
- package/bin/ccqa.ts +2 -0
- package/package.json +38 -0
- package/src/claude/invoke.test.ts +167 -0
- package/src/claude/invoke.ts +238 -0
- package/src/cli/generate-setup.ts +215 -0
- package/src/cli/generate.ts +224 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/logger.ts +45 -0
- package/src/cli/run.ts +65 -0
- package/src/cli/trace-setup.ts +124 -0
- package/src/cli/trace.test.ts +233 -0
- package/src/cli/trace.ts +244 -0
- package/src/codegen/actions-to-script.ts +188 -0
- package/src/prompts/codegen.ts +73 -0
- package/src/prompts/trace.ts +278 -0
- package/src/runtime/test-helpers.ts +77 -0
- package/src/spec/parser.test.ts +135 -0
- package/src/spec/parser.ts +96 -0
- package/src/store/index.test.ts +107 -0
- package/src/store/index.ts +193 -0
- package/src/types.test.ts +96 -0
- package/src/types.ts +91 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StepStatus } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
const STEP_ICONS: Record<StepStatus, string> = {
|
|
4
|
+
STEP_START: "▶",
|
|
5
|
+
STEP_DONE: "✓",
|
|
6
|
+
ASSERTION_FAILED: "✗",
|
|
7
|
+
STEP_SKIPPED: "⊘",
|
|
8
|
+
RUN_COMPLETED: "■",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function header(command: string, target?: string): void {
|
|
12
|
+
process.stdout.write(`\nccqa ${command}${target ? ` ${target}` : ""}\n\n`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function meta(key: string, value: string | number): void {
|
|
16
|
+
process.stdout.write(` ${key}: ${value}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function blank(): void {
|
|
20
|
+
process.stdout.write("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function info(message: string): void {
|
|
24
|
+
process.stdout.write(`${message}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function step(type: StepStatus, stepId: string, detail: string): void {
|
|
28
|
+
process.stdout.write(` ${STEP_ICONS[type]} [${stepId}] ${detail}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function bash(command: string): void {
|
|
32
|
+
process.stdout.write(` $ ${command.slice(0, 120)}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function error(message: string): void {
|
|
36
|
+
process.stderr.write(`error: ${message}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function warn(message: string): void {
|
|
40
|
+
process.stderr.write(`warn: ${message}\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hint(message: string): void {
|
|
44
|
+
process.stdout.write(`\nhint: ${message}\n`);
|
|
45
|
+
}
|
package/src/cli/run.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
parseSpecPath,
|
|
4
|
+
getTestScript,
|
|
5
|
+
listAllSpecs,
|
|
6
|
+
listSpecsForFeature,
|
|
7
|
+
} from "../store/index.ts";
|
|
8
|
+
import * as log from "./logger.ts";
|
|
9
|
+
|
|
10
|
+
export const runCommand = new Command("run")
|
|
11
|
+
.argument("[target]", "Spec to run: '<feature>/<spec>', '<feature>', or omit for all")
|
|
12
|
+
.description("Run generated agent-browser test scripts")
|
|
13
|
+
.action(async (target?: string) => {
|
|
14
|
+
await runTests(target);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function runTests(target?: string): Promise<void> {
|
|
18
|
+
log.header("run", target);
|
|
19
|
+
|
|
20
|
+
const specs = await resolveSpecs(target);
|
|
21
|
+
|
|
22
|
+
if (specs.length === 0) {
|
|
23
|
+
log.error("no test scripts found");
|
|
24
|
+
log.hint("run 'ccqa generate <feature>/<spec>' first to generate tests");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let overallExitCode = 0;
|
|
29
|
+
|
|
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`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
log.info(`${featureName}/${specName}`);
|
|
38
|
+
log.meta("test", scriptFile);
|
|
39
|
+
log.blank();
|
|
40
|
+
|
|
41
|
+
const proc = Bun.spawn(["bunx", "vitest", "run", scriptFile], {
|
|
42
|
+
stdout: "inherit",
|
|
43
|
+
stderr: "inherit",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const exitCode = await proc.exited;
|
|
47
|
+
if (exitCode !== 0) overallExitCode = exitCode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
process.exit(overallExitCode);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function resolveSpecs(target?: string): Promise<Array<{ featureName: string; specName: string }>> {
|
|
54
|
+
if (!target) {
|
|
55
|
+
return listAllSpecs();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (target.includes("/")) {
|
|
59
|
+
const { featureName, specName } = parseSpecPath(target);
|
|
60
|
+
return [{ featureName, specName }];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const specNames = await listSpecsForFeature(target);
|
|
64
|
+
return specNames.map((specName) => ({ featureName: target, specName }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
|