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.
@@ -0,0 +1,107 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseSpecPath, getCcqaDir, getFeatureDir, getSpecDir, routeToMarkdown } from "./index.ts";
3
+ import type { Route } from "../types.ts";
4
+
5
+ describe("parseSpecPath", () => {
6
+ test("parses valid feature/spec path", () => {
7
+ expect(parseSpecPath("feat/spec")).toEqual({ featureName: "feat", specName: "spec" });
8
+ expect(parseSpecPath("tasks/create-and-complete")).toEqual({
9
+ featureName: "tasks",
10
+ specName: "create-and-complete",
11
+ });
12
+ });
13
+
14
+ test("throws on single segment", () => {
15
+ expect(() => parseSpecPath("feat")).toThrow();
16
+ });
17
+
18
+ test("throws on three segments", () => {
19
+ expect(() => parseSpecPath("a/b/c")).toThrow();
20
+ });
21
+
22
+ test("throws on empty string", () => {
23
+ expect(() => parseSpecPath("")).toThrow();
24
+ });
25
+
26
+ test("throws on empty feature name", () => {
27
+ expect(() => parseSpecPath("/spec")).toThrow();
28
+ });
29
+
30
+ test("throws on empty spec name", () => {
31
+ expect(() => parseSpecPath("feat/")).toThrow();
32
+ });
33
+ });
34
+
35
+ describe("path helpers", () => {
36
+ test("getCcqaDir uses process.cwd by default", () => {
37
+ expect(getCcqaDir()).toBe(`${process.cwd()}/.ccqa`);
38
+ });
39
+
40
+ test("getCcqaDir uses provided cwd", () => {
41
+ expect(getCcqaDir("/custom")).toBe("/custom/.ccqa");
42
+ });
43
+
44
+ test("getFeatureDir returns correct path", () => {
45
+ expect(getFeatureDir("my-feature", "/custom")).toBe("/custom/.ccqa/features/my-feature");
46
+ });
47
+
48
+ test("getSpecDir returns correct path", () => {
49
+ expect(getSpecDir("my-feature", "my-spec", "/custom")).toBe(
50
+ "/custom/.ccqa/features/my-feature/test-cases/my-spec",
51
+ );
52
+ });
53
+
54
+
55
+ });
56
+
57
+ describe("routeToMarkdown", () => {
58
+ const baseRoute: Route = {
59
+ specName: "create-and-complete",
60
+ timestamp: "2026-03-28T00:00:00.000Z",
61
+ status: "passed",
62
+ steps: [],
63
+ };
64
+
65
+ test("generates correct frontmatter", () => {
66
+ const md = routeToMarkdown(baseRoute);
67
+ expect(md).toContain('specName: "create-and-complete"');
68
+ expect(md).toContain('timestamp: "2026-03-28T00:00:00.000Z"');
69
+ expect(md).toContain('status: "passed"');
70
+ });
71
+
72
+ test("generates step section", () => {
73
+ const route: Route = {
74
+ ...baseRoute,
75
+ steps: [
76
+ { title: "Login", action: "filled form", observation: "redirected", status: "PASSED" },
77
+ ],
78
+ };
79
+ const md = routeToMarkdown(route);
80
+ expect(md).toContain("## Login");
81
+ expect(md).toContain("- **action**: filled form");
82
+ expect(md).toContain("- **observation**: redirected");
83
+ expect(md).toContain("- **status**: PASSED");
84
+ });
85
+
86
+ test("includes reason when present", () => {
87
+ const route: Route = {
88
+ ...baseRoute,
89
+ steps: [
90
+ { title: "Fail", action: "clicked", observation: "nothing", status: "FAILED", reason: "button disabled" },
91
+ ],
92
+ };
93
+ const md = routeToMarkdown(route);
94
+ expect(md).toContain("- **reason**: button disabled");
95
+ });
96
+
97
+ test("omits reason line when absent", () => {
98
+ const route: Route = {
99
+ ...baseRoute,
100
+ steps: [
101
+ { title: "Pass", action: "clicked", observation: "ok", status: "PASSED" },
102
+ ],
103
+ };
104
+ const md = routeToMarkdown(route);
105
+ expect(md).not.toContain("- **reason**");
106
+ });
107
+ });
@@ -0,0 +1,193 @@
1
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Route, TraceAction } from "../types.ts";
4
+
5
+ const CCQA_DIR = ".ccqa";
6
+
7
+ export function getCcqaDir(cwd: string = process.cwd()): string {
8
+ return join(cwd, CCQA_DIR);
9
+ }
10
+
11
+ // "tasks/create-and-complete" → { featureName: "tasks", specName: "create-and-complete" }
12
+ export function parseSpecPath(specPath: string): { featureName: string; specName: string } {
13
+ const parts = specPath.split("/");
14
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
15
+ throw new Error(`Invalid spec path: "${specPath}". Expected format: "<feature>/<spec>"`);
16
+ }
17
+ return { featureName: parts[0], specName: parts[1] };
18
+ }
19
+
20
+ export function getFeatureDir(featureName: string, cwd?: string): string {
21
+ return join(getCcqaDir(cwd), "features", featureName);
22
+ }
23
+
24
+ export function getSpecDir(featureName: string, specName: string, cwd?: string): string {
25
+ return join(getFeatureDir(featureName, cwd), "test-cases", specName);
26
+ }
27
+
28
+
29
+ export async function ensureCcqaDir(cwd?: string): Promise<void> {
30
+ await mkdir(join(getCcqaDir(cwd), "features"), { recursive: true });
31
+ }
32
+
33
+
34
+ export async function readSpecFile(featureName: string, specName: string, cwd?: string): Promise<string> {
35
+ const specPath = join(getSpecDir(featureName, specName, cwd), "test-spec.md");
36
+ return readFile(specPath, "utf-8").catch(() => {
37
+ throw new Error(`Spec file not found: ${specPath}`);
38
+ });
39
+ }
40
+
41
+ export async function saveRoute(featureName: string, specName: string, route: Route, cwd?: string): Promise<string> {
42
+ const specDir = getSpecDir(featureName, specName, cwd);
43
+ await mkdir(specDir, { recursive: true });
44
+ const routePath = join(specDir, "route.md");
45
+ await writeFile(routePath, routeToMarkdown(route), "utf-8");
46
+ return routePath;
47
+ }
48
+
49
+ export async function saveTraceActions(
50
+ featureName: string,
51
+ specName: string,
52
+ actions: TraceAction[],
53
+ cwd?: string,
54
+ ): Promise<string> {
55
+ const specDir = getSpecDir(featureName, specName, cwd);
56
+ await mkdir(specDir, { recursive: true });
57
+ const actionsPath = join(specDir, "actions.json");
58
+ await writeFile(actionsPath, JSON.stringify(actions, null, 2), "utf-8");
59
+ return actionsPath;
60
+ }
61
+
62
+ // --- Setup (shared procedures) ---
63
+
64
+ export function getSetupDir(name: string, cwd?: string): string {
65
+ return join(getCcqaDir(cwd), "setups", name);
66
+ }
67
+
68
+ export async function readSetupSpecFile(name: string, cwd?: string): Promise<string> {
69
+ const specPath = join(getSetupDir(name, cwd), "setup-spec.md");
70
+ return readFile(specPath, "utf-8").catch(() => {
71
+ throw new Error(`Setup spec not found: ${specPath}`);
72
+ });
73
+ }
74
+
75
+ export async function saveSetupActions(name: string, actions: TraceAction[], cwd?: string): Promise<string> {
76
+ const dir = getSetupDir(name, cwd);
77
+ await mkdir(dir, { recursive: true });
78
+ const path = join(dir, "actions.json");
79
+ await writeFile(path, JSON.stringify(actions, null, 2), "utf-8");
80
+ return path;
81
+ }
82
+
83
+ export async function getSetupActions(name: string, cwd?: string): Promise<{ path: string; actions: TraceAction[] }> {
84
+ const path = join(getSetupDir(name, cwd), "actions.json");
85
+ const content = await readFile(path, "utf-8").catch(() => {
86
+ throw new Error(`No setup actions found for: ${name}. Run \`ccqa trace-setup ${name}\` first.`);
87
+ });
88
+ return { path, actions: JSON.parse(content) as TraceAction[] };
89
+ }
90
+
91
+ export async function saveSetupRoute(name: string, route: Route, cwd?: string): Promise<string> {
92
+ const dir = getSetupDir(name, cwd);
93
+ await mkdir(dir, { recursive: true });
94
+ const routePath = join(dir, "route.md");
95
+ await writeFile(routePath, routeToMarkdown(route), "utf-8");
96
+ return routePath;
97
+ }
98
+
99
+ export async function saveSetupTestScript(name: string, content: string, cwd?: string): Promise<string> {
100
+ const dir = getSetupDir(name, cwd);
101
+ await mkdir(dir, { recursive: true });
102
+ const path = join(dir, "test.spec.ts");
103
+ await writeFile(path, content, "utf-8");
104
+ return path;
105
+ }
106
+
107
+ export async function removeSetupTestScript(name: string, cwd?: string): Promise<void> {
108
+ const path = join(getSetupDir(name, cwd), "test.spec.ts");
109
+ const { unlink } = await import("node:fs/promises");
110
+ await unlink(path).catch(() => {});
111
+ }
112
+
113
+ // --- Trace Actions ---
114
+
115
+ export async function getTraceActions(
116
+ featureName: string,
117
+ specName: string,
118
+ cwd?: string,
119
+ ): Promise<{ path: string; actions: TraceAction[] }> {
120
+ const path = join(getSpecDir(featureName, specName, cwd), "actions.json");
121
+ const content = await readFile(path, "utf-8").catch(() => {
122
+ throw new Error(`No trace actions found for spec: ${featureName}/${specName}. Run \`ccqa trace\` first.`);
123
+ });
124
+ return { path, actions: JSON.parse(content) as TraceAction[] };
125
+ }
126
+
127
+ export async function saveTestScript(
128
+ featureName: string,
129
+ specName: string,
130
+ content: string,
131
+ cwd?: string,
132
+ ): Promise<string> {
133
+ const specDir = getSpecDir(featureName, specName, cwd);
134
+ await mkdir(specDir, { recursive: true });
135
+ const scriptPath = join(specDir, "test.spec.ts");
136
+ await writeFile(scriptPath, content, "utf-8");
137
+ return scriptPath;
138
+ }
139
+
140
+ export async function getTestScript(featureName: string, specName: string, cwd?: string): Promise<string | null> {
141
+ const path = join(getSpecDir(featureName, specName, cwd), "test.spec.ts");
142
+ return stat(path).then(() => path).catch(() => null);
143
+ }
144
+
145
+ export async function listAllSpecs(cwd?: string): Promise<Array<{ featureName: string; specName: string }>> {
146
+ const featuresDir = join(getCcqaDir(cwd), "features");
147
+ const featureDirs = await readdir(featuresDir).catch(() => []);
148
+
149
+ const perFeature = await Promise.all(
150
+ featureDirs.map(async (featureName) => {
151
+ const testCasesDir = join(featuresDir, featureName, "test-cases");
152
+ const specDirs = await readdir(testCasesDir).catch(() => []);
153
+ const entries = await Promise.all(
154
+ specDirs.map(async (specName) => {
155
+ const scriptFile = join(testCasesDir, specName, "test.spec.ts");
156
+ const exists = await stat(scriptFile).then(() => true).catch(() => false);
157
+ return exists ? { featureName, specName } : null;
158
+ }),
159
+ );
160
+ return entries.filter((e): e is { featureName: string; specName: string } => e !== null);
161
+ }),
162
+ );
163
+
164
+ return perFeature.flat();
165
+ }
166
+
167
+ export async function listSpecsForFeature(featureName: string, cwd?: string): Promise<string[]> {
168
+ const testCasesDir = join(getFeatureDir(featureName, cwd), "test-cases");
169
+ return readdir(testCasesDir).catch(() => []);
170
+ }
171
+
172
+
173
+ export function routeToMarkdown(route: Route): string {
174
+ const lines: string[] = [
175
+ "---",
176
+ `specName: "${route.specName}"`,
177
+ `timestamp: "${route.timestamp}"`,
178
+ `status: "${route.status}"`,
179
+ "---",
180
+ "",
181
+ ];
182
+
183
+ for (const step of route.steps) {
184
+ lines.push(`## ${step.title}`);
185
+ lines.push(`- **action**: ${step.action}`);
186
+ lines.push(`- **observation**: ${step.observation}`);
187
+ lines.push(`- **status**: ${step.status}`);
188
+ if (step.reason) lines.push(`- **reason**: ${step.reason}`);
189
+ lines.push("");
190
+ }
191
+
192
+ return lines.join("\n");
193
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { TestStepSchema, TestSpecSchema, RouteStepSchema, RouteSchema } from "./types.ts";
3
+
4
+ describe("TestStepSchema", () => {
5
+ test("accepts valid step data", () => {
6
+ const result = TestStepSchema.safeParse({
7
+ id: "step-01",
8
+ title: "Login",
9
+ instruction: "Go to login page",
10
+ expected: "Login form is visible",
11
+ });
12
+ expect(result.success).toBe(true);
13
+ });
14
+
15
+ test("rejects missing required fields", () => {
16
+ expect(TestStepSchema.safeParse({ id: "step-01" }).success).toBe(false);
17
+ });
18
+ });
19
+
20
+ describe("TestSpecSchema", () => {
21
+ test("accepts valid spec with optional prerequisites absent", () => {
22
+ const result = TestSpecSchema.safeParse({
23
+ title: "My Test",
24
+ baseUrl: "http://localhost:3000",
25
+ steps: [],
26
+ });
27
+ expect(result.success).toBe(true);
28
+ });
29
+
30
+ test("accepts valid spec with prerequisites", () => {
31
+ const result = TestSpecSchema.safeParse({
32
+ title: "My Test",
33
+ baseUrl: "http://localhost:3000",
34
+ prerequisites: "Must be logged in",
35
+ steps: [],
36
+ });
37
+ expect(result.success).toBe(true);
38
+ });
39
+
40
+ test("rejects missing title", () => {
41
+ expect(
42
+ TestSpecSchema.safeParse({ baseUrl: "http://localhost", steps: [] }).success,
43
+ ).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("RouteStepSchema", () => {
48
+ test("accepts valid PASSED status", () => {
49
+ const result = RouteStepSchema.safeParse({
50
+ title: "Login",
51
+ action: "filled form",
52
+ observation: "redirected",
53
+ status: "PASSED",
54
+ });
55
+ expect(result.success).toBe(true);
56
+ });
57
+
58
+ test("accepts FAILED and SKIPPED status", () => {
59
+ const base = { title: "t", action: "a", observation: "o" };
60
+ expect(RouteStepSchema.safeParse({ ...base, status: "FAILED" }).success).toBe(true);
61
+ expect(RouteStepSchema.safeParse({ ...base, status: "SKIPPED" }).success).toBe(true);
62
+ });
63
+
64
+ test("rejects invalid status", () => {
65
+ const result = RouteStepSchema.safeParse({
66
+ title: "t", action: "a", observation: "o", status: "UNKNOWN",
67
+ });
68
+ expect(result.success).toBe(false);
69
+ });
70
+
71
+ test("reason field is optional", () => {
72
+ const withReason = RouteStepSchema.safeParse({
73
+ title: "t", action: "a", observation: "o", status: "FAILED", reason: "bug",
74
+ });
75
+ const withoutReason = RouteStepSchema.safeParse({
76
+ title: "t", action: "a", observation: "o", status: "PASSED",
77
+ });
78
+ expect(withReason.success).toBe(true);
79
+ expect(withoutReason.success).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe("RouteSchema", () => {
84
+ test("accepts valid passed/failed status", () => {
85
+ const base = { specName: "check", timestamp: "2026-01-01T00:00:00Z", steps: [] };
86
+ expect(RouteSchema.safeParse({ ...base, status: "passed" }).success).toBe(true);
87
+ expect(RouteSchema.safeParse({ ...base, status: "failed" }).success).toBe(true);
88
+ });
89
+
90
+ test("rejects invalid status", () => {
91
+ const result = RouteSchema.safeParse({
92
+ specName: "check", timestamp: "2026-01-01T00:00:00Z", status: "PASSED", steps: [],
93
+ });
94
+ expect(result.success).toBe(false);
95
+ });
96
+ });
package/src/types.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+
3
+ export const TestStepSchema = z.object({
4
+ id: z.string(),
5
+ title: z.string(),
6
+ instruction: z.string(),
7
+ expected: z.string(),
8
+ });
9
+ export type TestStep = z.infer<typeof TestStepSchema>;
10
+
11
+ export const SetupRefSchema = z.object({
12
+ name: z.string(),
13
+ params: z.record(z.string(), z.string()).optional(),
14
+ });
15
+ export type SetupRef = z.infer<typeof SetupRefSchema>;
16
+
17
+ export const TestSpecSchema = z.object({
18
+ title: z.string(),
19
+ baseUrl: z.string(),
20
+ prerequisites: z.string().optional(),
21
+ setups: z.array(SetupRefSchema).optional(),
22
+ steps: z.array(TestStepSchema),
23
+ });
24
+ export type TestSpec = z.infer<typeof TestSpecSchema>;
25
+
26
+ export const PlaceholderDefSchema = z.object({
27
+ dummy: z.string(),
28
+ description: z.string().optional(),
29
+ });
30
+
31
+ export const SetupSpecSchema = z.object({
32
+ title: z.string(),
33
+ placeholders: z.record(z.string(), PlaceholderDefSchema).optional(),
34
+ steps: z.array(TestStepSchema),
35
+ });
36
+ export type SetupSpec = z.infer<typeof SetupSpecSchema>;
37
+
38
+ export const RouteStepSchema = z.object({
39
+ title: z.string(),
40
+ action: z.string(),
41
+ observation: z.string(),
42
+ status: z.enum(["PASSED", "FAILED", "SKIPPED"]),
43
+ reason: z.string().optional(),
44
+ });
45
+ export type RouteStep = z.infer<typeof RouteStepSchema>;
46
+
47
+ export const RouteSchema = z.object({
48
+ specName: z.string(),
49
+ timestamp: z.string(),
50
+ status: z.enum(["passed", "failed"]),
51
+ steps: z.array(RouteStepSchema),
52
+ });
53
+ export type Route = z.infer<typeof RouteSchema>;
54
+
55
+
56
+ export type TraceCommand =
57
+ | "cookies_clear"
58
+ | "open" | "click" | "dblclick" | "fill" | "type"
59
+ | "check" | "uncheck" | "press" | "select"
60
+ | "hover" | "scroll" | "drag" | "wait" | "snapshot"
61
+ | "assert";
62
+
63
+ export type AssertType =
64
+ | "text_visible" | "text_not_visible"
65
+ | "element_visible" | "element_not_visible"
66
+ | "url_contains"
67
+ | "element_enabled" | "element_disabled"
68
+ | "element_checked" | "element_unchecked";
69
+
70
+ export interface TraceAction {
71
+ command: TraceCommand;
72
+ selector?: string;
73
+ label?: string;
74
+ value?: string;
75
+ /** For drag: destination selector */
76
+ target?: string;
77
+ /** For scroll: direction (up/down/left/right) and optional pixels */
78
+ direction?: string;
79
+ pixels?: string;
80
+ observation?: string;
81
+ /** Only for command: "assert" */
82
+ assertType?: AssertType;
83
+ }
84
+
85
+ export type StepStatus = "STEP_START" | "STEP_DONE" | "ASSERTION_FAILED" | "STEP_SKIPPED" | "RUN_COMPLETED";
86
+
87
+ export interface ParsedStatusLine {
88
+ type: StepStatus;
89
+ stepId: string;
90
+ detail: string;
91
+ }