@roubo/shared 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,129 @@
1
+ // Pure canonicalisation of a TestCasesPlan into a deterministic string
2
+ // (spike-407 AC1 rules 1-5). The server hashes this string with node:crypto to
3
+ // detect staleness (FR-016); this module does NOT hash.
4
+ //
5
+ // Platform-agnostic: no fs, no node:crypto, no React. Safe in the Vite client
6
+ // build. Canonical contract types land with testbench-contracts (#6); until
7
+ // then this consumes the local types in testbench-domain-types.ts.
8
+ //
9
+ // See .specifications/testbench/spikes/spike-407-staleness-hash-reconcile.md
10
+ // AC1 (the rules) and AC2 (the worked example this module is verified against).
11
+
12
+ import type { Case, Observation, Step, TestCasesPlan } from "./testbench-domain-types";
13
+
14
+ // Byte-wise (code-point) comparison, NOT a locale collator. A locale-aware
15
+ // collator is environment-dependent and would make the canonical string (and
16
+ // therefore the hash) non-deterministic across machines. Comparing the raw
17
+ // UTF-16 code units of two strings is a total, stable, environment-independent
18
+ // order, which is what spike-407 AC1 rule 2 requires.
19
+ function compareCodePoints(a: string, b: string): number {
20
+ if (a < b) {
21
+ return -1;
22
+ }
23
+ if (a > b) {
24
+ return 1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ // String content-normalisation (spike-407 AC1 rule 3), applied to every
30
+ // included string value: NFC normalise, convert CRLF and lone CR to LF, trim
31
+ // leading/trailing whitespace, collapse each run of internal whitespace
32
+ // (spaces/tabs/newlines) to a single space.
33
+ function normalizeString(value: string): string {
34
+ return value.normalize("NFC").replace(/\r\n?/g, "\n").trim().replace(/\s+/g, " ");
35
+ }
36
+
37
+ // Per-observation projection in fixed canonical key order: id, expected.
38
+ function projectObservation(observation: Observation): { id: string; expected: string } {
39
+ return {
40
+ id: observation.id,
41
+ expected: normalizeString(observation.expected),
42
+ };
43
+ }
44
+
45
+ // Per-step projection in fixed canonical key order: id, instruction,
46
+ // observations (sorted by Observation.id).
47
+ function projectStep(step: Step): {
48
+ id: string;
49
+ instruction: string;
50
+ observations: ReturnType<typeof projectObservation>[];
51
+ } {
52
+ const observations = [...step.observations]
53
+ .sort((a, b) => compareCodePoints(a.id, b.id))
54
+ .map(projectObservation);
55
+ return {
56
+ id: step.id,
57
+ instruction: normalizeString(step.instruction),
58
+ observations,
59
+ };
60
+ }
61
+
62
+ // Per-case projection in fixed canonical key order: id, title, level, priority
63
+ // (when present), preconditions (when present and non-empty), steps (sorted by
64
+ // Step.id).
65
+ //
66
+ // `level` is an integer in the merged v1.1.0 shape; it is stringified before
67
+ // normalisation so the canonical form stays an all-strings projection. `priority`
68
+ // is optional in v1.1.0 (canonical authors omit it): an absent priority omits the
69
+ // key, exactly as preconditions does. The canonical product-dev metadata fields
70
+ // (area, type, tags, linked_*) are deliberately NOT projected: the staleness hash
71
+ // tracks the testable body (title, level, steps), so re-tagging or re-linking a
72
+ // case never marks it stale-for-retest. Like every TargetingField and unknown
73
+ // field, they are dropped.
74
+ //
75
+ // preconditions order is PRESERVED (not sorted). An absent and an empty
76
+ // preconditions list canonicalise identically: the key is omitted in both cases.
77
+ function projectCase(testCase: Case): Record<string, unknown> {
78
+ const projected: Record<string, unknown> = {
79
+ id: testCase.id,
80
+ title: normalizeString(testCase.title),
81
+ level: normalizeString(String(testCase.level)),
82
+ };
83
+
84
+ if (testCase.priority !== undefined) {
85
+ projected.priority = normalizeString(testCase.priority);
86
+ }
87
+
88
+ if (testCase.preconditions !== undefined && testCase.preconditions.length > 0) {
89
+ projected.preconditions = testCase.preconditions.map(normalizeString);
90
+ }
91
+
92
+ projected.steps = [...testCase.steps]
93
+ .sort((a, b) => compareCodePoints(a.id, b.id))
94
+ .map(projectStep);
95
+
96
+ return projected;
97
+ }
98
+
99
+ // Produce the deterministic canonical string for a SINGLE case (spike-407 AC1,
100
+ // the per-case projection). This is the per-case unit that `canonicalize(plan)`
101
+ // composes over the whole case set, exported so `testbench-domain.reconcile`
102
+ // can compare one plan case's canonical body against the snapshot stored on a
103
+ // recorded result, sharing one canonicalisation authority (no second copy of
104
+ // the projection rules). Applies the same projection + normalisation + fixed
105
+ // key order as the plan-level canonicalize: drops every TargetingField and
106
+ // unknown field, sorts steps/observations by id, preserves preconditions order.
107
+ export function canonicalizeCase(testCase: Case): string {
108
+ return JSON.stringify(projectCase(testCase));
109
+ }
110
+
111
+ // Produce the deterministic canonical string for a plan (spike-407 AC1).
112
+ //
113
+ // Rules, in order:
114
+ // 1. Project to included fields only (drop $schema, schemaVersion, specSlug,
115
+ // every TargetingField, and any unknown field).
116
+ // 2. Stable-id sort cases/steps/observations by code-point comparison;
117
+ // preconditions order preserved.
118
+ // 3. Normalise every included string value.
119
+ // 4. Serialise with fixed canonical key order and no insignificant whitespace.
120
+ // 5. Return the string (no hashing).
121
+ //
122
+ // The empty case set canonicalises to a fixed, stable, non-empty string:
123
+ // {"cases":[]}
124
+ export function canonicalize(plan: TestCasesPlan): string {
125
+ const cases = [...plan.cases].sort((a, b) => compareCodePoints(a.id, b.id)).map(projectCase);
126
+ // JSON.stringify with no spacing emits keys in insertion order (the fixed
127
+ // canonical order built above) and no insignificant whitespace.
128
+ return JSON.stringify({ cases });
129
+ }
@@ -0,0 +1,271 @@
1
+ // TestBench contracts: the single, compile-time source of truth (in `shared/`)
2
+ // for the two published, versioned TestBench files, `test-cases.json` and
3
+ // `test-results.json`. Both server and client validate against these schemas
4
+ // (FR-019, FR-020, FR-021). Shapes follow the architecture.md Data model table
5
+ // exactly; the runtime validators wrap `safeParse` (never throw) and return
6
+ // actionable, field-named errors.
7
+ //
8
+ // Scope note: this module authors the zod source schemas, the inferred types,
9
+ // the runtime validators, and the versioned `$id` constants. The roots carry
10
+ // `.meta({ $id })` so the generate script + CI drift guard (FR-023, #411) can
11
+ // emit versioned JSON Schema from them; the store IO (#11) is out of scope here.
12
+
13
+ import { z } from "zod";
14
+
15
+ // ── Versioned schema identifiers (NFR-005) ──
16
+ //
17
+ // Each published file carries a `$schema` URI whose path embeds a semver. A
18
+ // breaking change ships a major bump plus a migration registry entry; additive
19
+ // optional fields (like the reserved targeting fields below) do not bump the
20
+ // version. `TEST_CASES_SCHEMA_VERSION` / `TEST_RESULTS_SCHEMA_VERSION` are the
21
+ // matching `schemaVersion` string values kept consistent with the `$id` semver.
22
+
23
+ export const TEST_CASES_SCHEMA_ID = "https://roubo.dev/schema/testbench/test-cases/v1.1.0.json";
24
+ export const TEST_CASES_SCHEMA_VERSION = "1.1.0";
25
+
26
+ export const TEST_RESULTS_SCHEMA_ID = "https://roubo.dev/schema/testbench/test-results/v2.0.0.json";
27
+ export const TEST_RESULTS_SCHEMA_VERSION = "2.0.0";
28
+
29
+ // ── Shared leaf schemas ──
30
+
31
+ // The fixed derived-status set (FR-009). Owned by `testbench-domain` as a
32
+ // concept, but the literal enum lives here so both files can reference it.
33
+ export const CaseStatusSchema = z.enum([
34
+ "not_started",
35
+ "in_progress",
36
+ "passed",
37
+ "failed",
38
+ "blocked",
39
+ ]);
40
+ export type CaseStatus = z.infer<typeof CaseStatusSchema>;
41
+
42
+ // Author of a mark, override, or note (FR-012). `isSentinel` is set when git
43
+ // identity was unset at write time, so writes never fail on missing identity.
44
+ export const AuthorSchema = z
45
+ .object({
46
+ name: z.string(),
47
+ email: z.string(),
48
+ isSentinel: z.literal(true).optional(),
49
+ })
50
+ .strict();
51
+ export type Author = z.infer<typeof AuthorSchema>;
52
+
53
+ // Reserved, optional, additive targeting field (FR-019, NFR-005). Flat and
54
+ // all-optional: ignored by the in-app UI today, reserved for the future
55
+ // guided-execution Chrome extension. Present or absent, both validate; adding
56
+ // it to a step/observation is non-breaking.
57
+ export const TargetingFieldSchema = z
58
+ .object({
59
+ cssSelector: z.string().optional(),
60
+ ariaRole: z.string().optional(),
61
+ ariaName: z.string().optional(),
62
+ textAnchor: z.string().optional(),
63
+ routeContext: z.string().optional(),
64
+ region: z.string().optional(),
65
+ })
66
+ .strict();
67
+ export type TargetingField = z.infer<typeof TargetingFieldSchema>;
68
+
69
+ // ── test-cases.json (the source plan; never mutated) ──
70
+
71
+ export const ObservationSchema = z
72
+ .object({
73
+ id: z.string(),
74
+ expected: z.string(),
75
+ // Reserved per-observation targeting field (FR-019).
76
+ observe: TargetingFieldSchema.optional(),
77
+ })
78
+ .strict();
79
+ export type Observation = z.infer<typeof ObservationSchema>;
80
+
81
+ export const StepSchema = z
82
+ .object({
83
+ id: z.string(),
84
+ instruction: z.string(),
85
+ observations: z.array(ObservationSchema),
86
+ // Reserved per-step targeting field (FR-019).
87
+ target: TargetingFieldSchema.optional(),
88
+ })
89
+ .strict();
90
+ export type Step = z.infer<typeof StepSchema>;
91
+
92
+ // Recommended `type` vocabulary: the canonical product-dev set
93
+ // (functional, security, performance, accessibility, integration, negative,
94
+ // edge_case, e2e_flow) expanded with `reliability` and `structural`, both
95
+ // already in use across real specs. This is authoring guidance carried in the
96
+ // product-dev docs; the contract itself validates `type` as a permissive string
97
+ // (see CaseSchema), never a strict enum. The merged schema (v1.1.0) is strict on
98
+ // STRUCTURE (envelope, step ids, required fields) but lenient on open-ended
99
+ // metadata, so a newly-coined `type` can never silently make an entire spec
100
+ // undiscoverable (the failure mode v1.1.0 fixed).
101
+ export const RECOMMENDED_CASE_TYPES = [
102
+ "functional",
103
+ "security",
104
+ "performance",
105
+ "accessibility",
106
+ "integration",
107
+ "negative",
108
+ "edge_case",
109
+ "e2e_flow",
110
+ "reliability",
111
+ "structural",
112
+ ] as const;
113
+
114
+ export const CaseSchema = z
115
+ .object({
116
+ id: z.string(),
117
+ title: z.string(),
118
+ // Canonical feature area (kebab-case), e.g. "checkout". Required.
119
+ area: z.string(),
120
+ // Canonical level: an integer 1-4 (L1 smoke .. L4 exploratory). The merge
121
+ // keeps level from the canonical product-dev format (an integer), replacing
122
+ // the earlier string level.
123
+ level: z.number().int().min(1).max(4),
124
+ // Test flavor (see RECOMMENDED_CASE_TYPES). Permissive string by design.
125
+ type: z.string().min(1),
126
+ // Optional priority label (carried from the TestBench shape). Canonical
127
+ // authors omit it; the UI rollup buckets cases with no priority gracefully.
128
+ priority: z.string().optional(),
129
+ preconditions: z.array(z.string()).optional(),
130
+ steps: z.array(StepSchema),
131
+ // Canonical metadata: free-form tags + requirement/story traceability.
132
+ // Every case links at least one requirement; user-story links may be empty.
133
+ tags: z.array(z.string()),
134
+ linked_requirement_ids: z.array(z.string()).min(1),
135
+ linked_user_story_ids: z.array(z.string()),
136
+ })
137
+ .strict();
138
+ export type Case = z.infer<typeof CaseSchema>;
139
+
140
+ export const TestCasesPlanSchema = z
141
+ .object({
142
+ $schema: z.string(),
143
+ schemaVersion: z.string(),
144
+ specSlug: z.string(),
145
+ cases: z.array(CaseSchema),
146
+ })
147
+ .strict()
148
+ // Pass the literal `$id` key through `.meta()` so `z.toJSONSchema()` emits a
149
+ // top-level `$id` carrying the versioned identifier (NFR-005). The generate
150
+ // script + CI drift guard (FR-023) consume this.
151
+ .meta({ $id: TEST_CASES_SCHEMA_ID });
152
+ export type TestCasesPlan = z.infer<typeof TestCasesPlanSchema>;
153
+
154
+ // ── test-results.json (the sidecar; references cases by stable id) ──
155
+
156
+ export const ObservationMarkSchema = z
157
+ .object({
158
+ result: z.enum(["pass", "fail"]),
159
+ author: AuthorSchema,
160
+ timestamp: z.string(),
161
+ })
162
+ .strict();
163
+ export type ObservationMark = z.infer<typeof ObservationMarkSchema>;
164
+
165
+ // Recorded distinctly from `derivedStatus` (FR-010).
166
+ export const StatusOverrideSchema = z
167
+ .object({
168
+ status: CaseStatusSchema,
169
+ author: AuthorSchema,
170
+ timestamp: z.string(),
171
+ })
172
+ .strict();
173
+ export type StatusOverride = z.infer<typeof StatusOverrideSchema>;
174
+
175
+ // Append-only: no edit/delete path exists (FR-011). `statusAtWrite` captures
176
+ // the effective status at the moment the note was written.
177
+ export const NoteSchema = z
178
+ .object({
179
+ id: z.string(),
180
+ text: z.string(),
181
+ author: AuthorSchema,
182
+ timestamp: z.string(),
183
+ statusAtWrite: CaseStatusSchema,
184
+ })
185
+ .strict();
186
+ export type Note = z.infer<typeof NoteSchema>;
187
+
188
+ export const CaseResultSchema = z
189
+ .object({
190
+ // Keyed by observation id; results reference cases by stable id and never
191
+ // require editing test-cases.json (FR-020).
192
+ observationMarks: z.record(z.string(), ObservationMarkSchema),
193
+ derivedStatus: CaseStatusSchema,
194
+ statusOverride: StatusOverrideSchema.optional(),
195
+ notes: z.array(NoteSchema),
196
+ // A removed case's result is marked orphaned and retained, never deleted,
197
+ // and excluded from the rollup (FR-013, FR-017).
198
+ orphaned: z.literal(true).optional(),
199
+ // The per-case canonical body snapshot reconcile compares against the live
200
+ // plan to classify changed vs unchanged (#413, spike-407 AC3). Optional so a
201
+ // result with no stored snapshot still parses and is conservatively
202
+ // classified changed; persisting it lets the signal survive a round-trip to
203
+ // disk (#447).
204
+ caseCanon: z.string().optional(),
205
+ })
206
+ .strict();
207
+ export type CaseResult = z.infer<typeof CaseResultSchema>;
208
+
209
+ // The recorded-results body for a single spec: case results keyed by case id,
210
+ // plus a write timestamp. As of the v2.0.0 flatten (#493), one results file
211
+ // lives per worktree (sibling of test-cases.json), so there is exactly one of
212
+ // these per file and it sits at the top level. This stays a named type because
213
+ // it is also the API result shape the client reads (the route projects the file
214
+ // body down to it), so keeping the name avoids client churn.
215
+ export const BenchResultsSchema = z
216
+ .object({
217
+ // Keyed by case id.
218
+ caseResults: z.record(z.string(), CaseResultSchema),
219
+ updatedAt: z.string(),
220
+ })
221
+ .strict();
222
+ export type BenchResults = z.infer<typeof BenchResultsSchema>;
223
+
224
+ export const TestResultsFileSchema = z
225
+ .object({
226
+ $schema: z.string(),
227
+ schemaVersion: z.string(),
228
+ planHash: z.string(),
229
+ // Flattened in v2.0.0 (#493): one results file per worktree means exactly
230
+ // one bench per file, so case results sit at the top level rather than
231
+ // nested under a per-bench `benches` map. Keyed by case id.
232
+ caseResults: z.record(z.string(), CaseResultSchema),
233
+ updatedAt: z.string(),
234
+ })
235
+ .strict()
236
+ // Pass the literal `$id` key through `.meta()` so `z.toJSONSchema()` emits a
237
+ // top-level `$id` carrying the versioned identifier (NFR-005). The generate
238
+ // script + CI drift guard (FR-023) consume this.
239
+ .meta({ $id: TEST_RESULTS_SCHEMA_ID });
240
+ export type TestResultsFile = z.infer<typeof TestResultsFileSchema>;
241
+
242
+ // ── Runtime validators ──
243
+ //
244
+ // Both wrap `safeParse` (never throw, FR-021) and return a discriminated result.
245
+ // On failure, each zod issue becomes a clear `path: message` string keyed by
246
+ // the field that failed.
247
+
248
+ export type ValidationResult<T> = { ok: true; data: T } | { ok: false; errors: string[] };
249
+
250
+ function zodIssuesToFieldErrors(issues: z.ZodIssue[]): string[] {
251
+ return issues.map((issue) => {
252
+ const path = issue.path.join(".");
253
+ return path ? `${path}: ${issue.message}` : issue.message;
254
+ });
255
+ }
256
+
257
+ export function validateTestCases(raw: unknown): ValidationResult<TestCasesPlan> {
258
+ const parsed = TestCasesPlanSchema.safeParse(raw);
259
+ if (!parsed.success) {
260
+ return { ok: false, errors: zodIssuesToFieldErrors(parsed.error.issues) };
261
+ }
262
+ return { ok: true, data: parsed.data };
263
+ }
264
+
265
+ export function validateTestResults(raw: unknown): ValidationResult<TestResultsFile> {
266
+ const parsed = TestResultsFileSchema.safeParse(raw);
267
+ if (!parsed.success) {
268
+ return { ok: false, errors: zodIssuesToFieldErrors(parsed.error.issues) };
269
+ }
270
+ return { ok: true, data: parsed.data };
271
+ }
@@ -0,0 +1,128 @@
1
+ // Minimal, self-contained input types for the pure TestBench domain modules
2
+ // (testbench-domain, testbench-canonicalize).
3
+ //
4
+ // The canonical contract types are owned by testbench-contracts (issue #6),
5
+ // which is authored in parallel with this work (#412) and has NOT landed yet.
6
+ // To keep these pure modules self-contained, the shapes below are local copies
7
+ // that structurally match the architecture.md data model
8
+ // (.specifications/testbench/architecture.md, the Data model table). When #6
9
+ // lands, it owns the canonical zod-derived types and these local interfaces
10
+ // should be aligned with (or replaced by) the ones it exports.
11
+ //
12
+ // These modules are platform-agnostic: no fs, no node:crypto, no React. They
13
+ // must not break the Vite client build.
14
+
15
+ // The fixed status set (FR-009). "blocked" is NOT derivable from observation
16
+ // marks (marks are pass|fail only); it reaches a CaseResult only through an
17
+ // explicit override (FR-010), via effectiveStatus = override ?? derived.
18
+ export type CaseStatus = "not_started" | "in_progress" | "passed" | "failed" | "blocked";
19
+
20
+ // Author of a mark or note (FR-012). Included for structural fidelity with the
21
+ // architecture.md shape; the domain functions do not read it.
22
+ export interface Author {
23
+ name: string;
24
+ email: string;
25
+ isSentinel?: true;
26
+ }
27
+
28
+ // A single observation result keyed by observation id in a CaseResult's
29
+ // observationMarks map. Marks are pass|fail only.
30
+ export interface ObservationMark {
31
+ result: "pass" | "fail";
32
+ author: Author;
33
+ timestamp: string;
34
+ }
35
+
36
+ // Reserved, all-optional guided-execution targeting field (FR-019). Ignored by
37
+ // canonicalisation (see testbench-canonicalize): present on the input shape so
38
+ // these local types match the architecture.md Step/Observation shapes.
39
+ export interface TargetingField {
40
+ cssSelector?: string;
41
+ ariaRole?: string;
42
+ ariaName?: string;
43
+ textAnchor?: string;
44
+ routeContext?: string;
45
+ region?: string;
46
+ }
47
+
48
+ export interface Observation {
49
+ id: string;
50
+ expected: string;
51
+ observe?: TargetingField;
52
+ }
53
+
54
+ export interface Step {
55
+ id: string;
56
+ instruction: string;
57
+ observations: Observation[];
58
+ target?: TargetingField;
59
+ }
60
+
61
+ // Mirrors the merged v1.1.0 CaseSchema in testbench-contracts.ts: the canonical
62
+ // product-dev fields (area, integer level, type, tags, linked_*) unioned with
63
+ // the TestBench shape (optional priority, TestBench step shape). The pure domain
64
+ // modules only read id/title/level/priority/preconditions/steps; the canonical
65
+ // metadata fields ride along for structural fidelity with the contract type.
66
+ export interface Case {
67
+ id: string;
68
+ title: string;
69
+ area: string;
70
+ level: number;
71
+ type: string;
72
+ priority?: string;
73
+ preconditions?: string[];
74
+ steps: Step[];
75
+ tags: string[];
76
+ linked_requirement_ids: string[];
77
+ linked_user_story_ids: string[];
78
+ }
79
+
80
+ export interface TestCasesPlan {
81
+ $schema: string;
82
+ schemaVersion: string;
83
+ specSlug: string;
84
+ cases: Case[];
85
+ }
86
+
87
+ // ── Result-side input shapes (consumed by testbench-domain.reconcile) ──
88
+ //
89
+ // These mirror the architecture.md data model and the canonical zod shapes in
90
+ // testbench-contracts.ts (NoteSchema, StatusOverrideSchema, CaseResultSchema,
91
+ // BenchResultsSchema). They are local copies for the same reason as the shapes
92
+ // above: testbench-contracts owns the canonical types, and these should be
93
+ // aligned with (or replaced by) its exports when this module consolidates.
94
+
95
+ // An explicit status override, recorded distinctly from derivedStatus (FR-010).
96
+ export interface StatusOverride {
97
+ status: CaseStatus;
98
+ author: Author;
99
+ timestamp: string;
100
+ }
101
+
102
+ // An append-only note (FR-011). statusAtWrite captures the effective status at
103
+ // the moment the note was written.
104
+ export interface Note {
105
+ id: string;
106
+ text: string;
107
+ author: Author;
108
+ timestamp: string;
109
+ statusAtWrite: CaseStatus;
110
+ }
111
+
112
+ // A recorded result for one case, keyed by case id in BenchResults.caseResults.
113
+ //
114
+ // The shape is now derived from the published contract's CaseResultSchema
115
+ // (testbench-contracts), re-exported here so it is declared once. This is a
116
+ // type-only re-export: it is erased at compile time, so no zod runtime import
117
+ // is pulled into these pure, Vite-safe domain modules. caseCanon (the per-case
118
+ // canonical body snapshot reconcile compares against the live plan to classify
119
+ // changed vs unchanged) is one of the contract fields; a result with no stored
120
+ // snapshot is conservatively classified changed.
121
+ import type { CaseResult } from "./testbench-contracts";
122
+ export type { CaseResult };
123
+
124
+ // One bench's recorded results, keyed by case id.
125
+ export interface BenchResults {
126
+ caseResults: Record<string, CaseResult>;
127
+ updatedAt: string;
128
+ }