@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.
- package/config-schema.ts +432 -0
- package/deep-merge.ts +38 -0
- package/gate-overrides-contract.ts +120 -0
- package/integration-types.ts +124 -0
- package/package.json +39 -0
- package/plugin-consent-schema.ts +80 -0
- package/plugin-enable-state-schema.ts +30 -0
- package/plugin-manifest-schema.ts +179 -0
- package/plugin-manifest.ts +55 -0
- package/plugin-runtime-types.ts +50 -0
- package/provision-descriptor-schema.ts +103 -0
- package/testbench-canonicalize.ts +129 -0
- package/testbench-contracts.ts +271 -0
- package/testbench-domain-types.ts +128 -0
- package/testbench-domain.ts +232 -0
- package/testbench-targeting-schema.ts +132 -0
- package/types.ts +1975 -0
- package/work-units-contract.ts +155 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Pure derived-status state machine (FR-009).
|
|
2
|
+
//
|
|
3
|
+
// Platform-agnostic: no fs, no node:crypto, no React. Safe in the Vite client
|
|
4
|
+
// build. Canonical contract types land with testbench-contracts (#6); until
|
|
5
|
+
// then this consumes the local types in testbench-domain-types.ts.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
BenchResults,
|
|
9
|
+
CaseResult,
|
|
10
|
+
CaseStatus,
|
|
11
|
+
ObservationMark,
|
|
12
|
+
TestCasesPlan,
|
|
13
|
+
} from "./testbench-domain-types";
|
|
14
|
+
import { canonicalizeCase } from "./testbench-canonicalize";
|
|
15
|
+
|
|
16
|
+
// Derive a case's status purely from its observation marks (FR-009).
|
|
17
|
+
//
|
|
18
|
+
// The "all observations marked" denominator is NOT inferable from the marks map
|
|
19
|
+
// alone (the map only holds marked observations), so the full observation-id set
|
|
20
|
+
// is passed alongside the marks.
|
|
21
|
+
//
|
|
22
|
+
// Truth table (a single fail short-circuits to "failed", even when other
|
|
23
|
+
// observations are still unmarked, per issue #508):
|
|
24
|
+
// - any observation marked fail => "failed"
|
|
25
|
+
// - no observations marked (and no fail) => "not_started"
|
|
26
|
+
// - some but not all observations marked (no fail) => "in_progress"
|
|
27
|
+
// - all observations marked AND all are pass => "passed"
|
|
28
|
+
//
|
|
29
|
+
// "blocked" is NEVER derived here: marks are pass|fail only, so blocked is
|
|
30
|
+
// reachable only through an explicit override (FR-010), via
|
|
31
|
+
// effectiveStatus = override ?? deriveStatus(...). CaseStatus still includes
|
|
32
|
+
// "blocked" for that override path.
|
|
33
|
+
//
|
|
34
|
+
// Edge: a case with zero observations defined is treated as "not_started".
|
|
35
|
+
// There is nothing for the reviewer to mark, so the case has not been started;
|
|
36
|
+
// it can never auto-advance to passed/failed without an observation to mark.
|
|
37
|
+
export function deriveStatus(
|
|
38
|
+
observationIds: string[],
|
|
39
|
+
marks: Record<string, ObservationMark>,
|
|
40
|
+
): CaseStatus {
|
|
41
|
+
const total = observationIds.length;
|
|
42
|
+
|
|
43
|
+
// Zero observations defined: nothing to mark, so not started.
|
|
44
|
+
if (total === 0) {
|
|
45
|
+
return "not_started";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let markedCount = 0;
|
|
49
|
+
let anyFail = false;
|
|
50
|
+
for (const id of observationIds) {
|
|
51
|
+
const mark = marks[id];
|
|
52
|
+
if (mark === undefined) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
markedCount += 1;
|
|
56
|
+
if (mark.result === "fail") {
|
|
57
|
+
anyFail = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A single failed observation moves the case to "failed" immediately, even
|
|
62
|
+
// when other observations are still unmarked (issue #508).
|
|
63
|
+
if (anyFail) {
|
|
64
|
+
return "failed";
|
|
65
|
+
}
|
|
66
|
+
if (markedCount === 0) {
|
|
67
|
+
return "not_started";
|
|
68
|
+
}
|
|
69
|
+
if (markedCount < total) {
|
|
70
|
+
return "in_progress";
|
|
71
|
+
}
|
|
72
|
+
// All observations are marked and none failed.
|
|
73
|
+
return "passed";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Deterministic reconcile (FR-017, spike-407 AC3/AC4/AC5).
|
|
77
|
+
//
|
|
78
|
+
// Diffs a source plan against recorded results by stable case id and classifies
|
|
79
|
+
// each case as added / unchanged / changed / removed. It is non-destructive:
|
|
80
|
+
// removed cases that carry authored results are flagged orphaned (never deleted
|
|
81
|
+
// here), so no ObservationMark, Note, or StatusOverride is ever lost. Physical
|
|
82
|
+
// deletion is a SEPARATE explicit operation (purgeOrphans below), per the issue
|
|
83
|
+
// AC and spike-407 AC5.
|
|
84
|
+
|
|
85
|
+
// The four id buckets a reconcile produces (spike-407 AC3).
|
|
86
|
+
export interface ReconcileClassification {
|
|
87
|
+
// In the plan, no recorded result yet: new, nothing authored to preserve.
|
|
88
|
+
added: string[];
|
|
89
|
+
// In both, canonical case body matches the stored snapshot.
|
|
90
|
+
unchanged: string[];
|
|
91
|
+
// In both, canonical case body differs (or no stored snapshot): marks/notes/
|
|
92
|
+
// override retained, reviewer signalled to re-review.
|
|
93
|
+
changed: string[];
|
|
94
|
+
// Recorded result with no matching plan case: orphan candidate, never deleted.
|
|
95
|
+
removed: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ReconcileResult {
|
|
99
|
+
classification: ReconcileClassification;
|
|
100
|
+
// A non-destructive reconciled BenchResults: orphans flagged, changed cases'
|
|
101
|
+
// snapshot refreshed and derivedStatus recomputed from their kept marks, every
|
|
102
|
+
// other datum copied verbatim. The caller persists this; reconcile never
|
|
103
|
+
// deletes.
|
|
104
|
+
nextResults: BenchResults;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Gather every observation id defined across a plan case's steps, so a changed
|
|
108
|
+
// case's derivedStatus can be recomputed from its (kept) marks against the new
|
|
109
|
+
// plan's observation set.
|
|
110
|
+
function planCaseObservationIds(plan: TestCasesPlan, caseId: string): string[] {
|
|
111
|
+
const planCase = plan.cases.find((c) => c.id === caseId);
|
|
112
|
+
if (planCase === undefined) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const ids: string[] = [];
|
|
116
|
+
for (const step of planCase.steps) {
|
|
117
|
+
for (const observation of step.observations) {
|
|
118
|
+
ids.push(observation.id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return ids;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function reconcile(plan: TestCasesPlan, results: BenchResults): ReconcileResult {
|
|
125
|
+
const planCanonById = new Map<string, string>();
|
|
126
|
+
for (const planCase of plan.cases) {
|
|
127
|
+
planCanonById.set(planCase.id, canonicalizeCase(planCase));
|
|
128
|
+
}
|
|
129
|
+
const planIds = new Set(planCanonById.keys());
|
|
130
|
+
const resultIds = new Set(Object.keys(results.caseResults));
|
|
131
|
+
|
|
132
|
+
const classification: ReconcileClassification = {
|
|
133
|
+
added: [],
|
|
134
|
+
unchanged: [],
|
|
135
|
+
changed: [],
|
|
136
|
+
removed: [],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
for (const caseId of planIds) {
|
|
140
|
+
if (!resultIds.has(caseId)) {
|
|
141
|
+
// Authored nothing yet: safe, nothing to preserve.
|
|
142
|
+
classification.added.push(caseId);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// A result exists; did the case body change? A result with no stored
|
|
146
|
+
// snapshot is conservatively classified changed (prompts re-review, loses
|
|
147
|
+
// nothing). The stored snapshot vs published-contract gap is tracked in #447.
|
|
148
|
+
const stored = results.caseResults[caseId].caseCanon;
|
|
149
|
+
if (stored !== undefined && stored === planCanonById.get(caseId)) {
|
|
150
|
+
classification.unchanged.push(caseId);
|
|
151
|
+
} else {
|
|
152
|
+
classification.changed.push(caseId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const caseId of resultIds) {
|
|
157
|
+
if (!planIds.has(caseId)) {
|
|
158
|
+
// Orphan candidate: results are NOT touched in this loop.
|
|
159
|
+
classification.removed.push(caseId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Build the non-destructive reconciled results. Every recorded result is
|
|
164
|
+
// copied; only additive/metadata mutations are applied.
|
|
165
|
+
const nextCaseResults: Record<string, CaseResult> = {};
|
|
166
|
+
for (const [caseId, result] of Object.entries(results.caseResults)) {
|
|
167
|
+
nextCaseResults[caseId] = copyCaseResult(result);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// changed cases: keep every mark, note, and override; only refresh the
|
|
171
|
+
// per-case snapshot and recompute derivedStatus from the kept marks.
|
|
172
|
+
for (const caseId of classification.changed) {
|
|
173
|
+
const next = nextCaseResults[caseId];
|
|
174
|
+
next.caseCanon = planCanonById.get(caseId);
|
|
175
|
+
next.derivedStatus = deriveStatus(planCaseObservationIds(plan, caseId), next.observationMarks);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// removed cases: flag orphaned, retain on disk, exclude from the rollup.
|
|
179
|
+
for (const caseId of classification.removed) {
|
|
180
|
+
nextCaseResults[caseId].orphaned = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
classification,
|
|
185
|
+
nextResults: {
|
|
186
|
+
caseResults: nextCaseResults,
|
|
187
|
+
updatedAt: results.updatedAt,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Deep-copy a CaseResult so reconcile/purge never mutate the caller's input
|
|
193
|
+
// (these are pure functions). Marks, notes, and the override are copied by value.
|
|
194
|
+
function copyCaseResult(result: CaseResult): CaseResult {
|
|
195
|
+
const observationMarks: Record<string, ObservationMark> = {};
|
|
196
|
+
for (const [id, mark] of Object.entries(result.observationMarks)) {
|
|
197
|
+
observationMarks[id] = { ...mark, author: { ...mark.author } };
|
|
198
|
+
}
|
|
199
|
+
const copy: CaseResult = {
|
|
200
|
+
observationMarks,
|
|
201
|
+
derivedStatus: result.derivedStatus,
|
|
202
|
+
notes: result.notes.map((note) => ({ ...note, author: { ...note.author } })),
|
|
203
|
+
};
|
|
204
|
+
if (result.statusOverride !== undefined) {
|
|
205
|
+
copy.statusOverride = {
|
|
206
|
+
...result.statusOverride,
|
|
207
|
+
author: { ...result.statusOverride.author },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (result.orphaned !== undefined) {
|
|
211
|
+
copy.orphaned = result.orphaned;
|
|
212
|
+
}
|
|
213
|
+
if (result.caseCanon !== undefined) {
|
|
214
|
+
copy.caseCanon = result.caseCanon;
|
|
215
|
+
}
|
|
216
|
+
return copy;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Purge orphaned results (FR-017, spike-407 AC5). This is the ONLY delete path,
|
|
220
|
+
// kept SEPARATE from reconcile so physical deletion always requires an explicit,
|
|
221
|
+
// distinct operation. Pure: returns a new BenchResults dropping every entry
|
|
222
|
+
// flagged orphaned, leaving the input untouched.
|
|
223
|
+
export function purgeOrphans(results: BenchResults): BenchResults {
|
|
224
|
+
const caseResults: Record<string, CaseResult> = {};
|
|
225
|
+
for (const [caseId, result] of Object.entries(results.caseResults)) {
|
|
226
|
+
if (result.orphaned === true) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
caseResults[caseId] = copyCaseResult(result);
|
|
230
|
+
}
|
|
231
|
+
return { caseResults, updatedAt: results.updatedAt };
|
|
232
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Spike #408: representative zod source for the FR-019 guided-execution
|
|
2
|
+
// targeting-field unions, used to prove z.toJSONSchema() output quality and to
|
|
3
|
+
// drive the generate:schema script + CI drift guard.
|
|
4
|
+
//
|
|
5
|
+
// This is a SPIKE fixture, not the authored TestBench contract. The real
|
|
6
|
+
// test-cases.json / test-results.json schemas are authored under #6; this file
|
|
7
|
+
// exists only to exercise the worst-case shape (an optional union over five
|
|
8
|
+
// distinct targeting strategies) through the generation pipeline so the
|
|
9
|
+
// pipeline itself can be committed and gated before the contracts land.
|
|
10
|
+
//
|
|
11
|
+
// FR-019 reserves OPTIONAL, additive targeting fields:
|
|
12
|
+
// - a per-step `target`
|
|
13
|
+
// - a per-observation `observe`
|
|
14
|
+
// each expressible as one of: CSS selector, ARIA role + accessible name,
|
|
15
|
+
// visible-text anchor, route/URL context, or region. Both fields share the
|
|
16
|
+
// same union shape, so a single `TargetSchema` models both.
|
|
17
|
+
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
// Versioned identifier (NFR-005: semver-style versioning lives on the schema's
|
|
21
|
+
// $id). Spike finding: zod's `meta({ id })` registers the schema for internal
|
|
22
|
+
// $ref/$defs reuse but does NOT emit a top-level `$id`. To publish `$id` into
|
|
23
|
+
// the output you pass the literal `$id` key through `meta()` (see the root
|
|
24
|
+
// schema below). That single key is the only post-processing needed; no shim.
|
|
25
|
+
export const TESTBENCH_TARGETING_SCHEMA_ID =
|
|
26
|
+
"https://roubo.dev/schema/testbench-targeting.spike/v0.1.0.json";
|
|
27
|
+
|
|
28
|
+
// ── Targeting strategies ──
|
|
29
|
+
// Each member is a closed object discriminated by a literal `kind`, so the
|
|
30
|
+
// union round-trips through JSON Schema as a clean oneOf with per-branch
|
|
31
|
+
// required/additionalProperties.
|
|
32
|
+
|
|
33
|
+
const CssSelectorTarget = z
|
|
34
|
+
.object({
|
|
35
|
+
kind: z.literal("css"),
|
|
36
|
+
// A CSS selector string, e.g. `#submit` or `button.primary`.
|
|
37
|
+
selector: z.string().min(1),
|
|
38
|
+
})
|
|
39
|
+
.strict()
|
|
40
|
+
.describe("Target an element by CSS selector.");
|
|
41
|
+
|
|
42
|
+
const AriaRoleTarget = z
|
|
43
|
+
.object({
|
|
44
|
+
kind: z.literal("role"),
|
|
45
|
+
// An ARIA role, e.g. `button`, `link`, `heading`.
|
|
46
|
+
role: z.string().min(1),
|
|
47
|
+
// The accessible name the element is matched on.
|
|
48
|
+
name: z.string().min(1),
|
|
49
|
+
})
|
|
50
|
+
.strict()
|
|
51
|
+
.describe("Target an element by ARIA role and accessible name.");
|
|
52
|
+
|
|
53
|
+
const VisibleTextTarget = z
|
|
54
|
+
.object({
|
|
55
|
+
kind: z.literal("text"),
|
|
56
|
+
// Visible text content used as an anchor.
|
|
57
|
+
text: z.string().min(1),
|
|
58
|
+
// When true, match the whole visible string rather than a substring.
|
|
59
|
+
exact: z.boolean().optional(),
|
|
60
|
+
})
|
|
61
|
+
.strict()
|
|
62
|
+
.describe("Target an element by a visible-text anchor.");
|
|
63
|
+
|
|
64
|
+
const RouteTarget = z
|
|
65
|
+
.object({
|
|
66
|
+
kind: z.literal("route"),
|
|
67
|
+
// A route or URL-path context, e.g. `/settings/testbench`.
|
|
68
|
+
path: z.string().min(1),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
.describe("Scope targeting to a route/URL context.");
|
|
72
|
+
|
|
73
|
+
const RegionTarget = z
|
|
74
|
+
.object({
|
|
75
|
+
kind: z.literal("region"),
|
|
76
|
+
// A named landmark/region, e.g. `main`, `navigation`, or a labelled section.
|
|
77
|
+
region: z.string().min(1),
|
|
78
|
+
})
|
|
79
|
+
.strict()
|
|
80
|
+
.describe("Scope targeting to a named page region or landmark.");
|
|
81
|
+
|
|
82
|
+
// The shared optional targeting union. Discriminated on `kind` so consumers get
|
|
83
|
+
// an exhaustive, type-safe switch and JSON Schema emits a clean oneOf.
|
|
84
|
+
export const TargetSchema = z
|
|
85
|
+
.discriminatedUnion("kind", [
|
|
86
|
+
CssSelectorTarget,
|
|
87
|
+
AriaRoleTarget,
|
|
88
|
+
VisibleTextTarget,
|
|
89
|
+
RouteTarget,
|
|
90
|
+
RegionTarget,
|
|
91
|
+
])
|
|
92
|
+
.describe(
|
|
93
|
+
"An optional, additive guided-execution targeting selector (FR-019): one of a CSS selector, ARIA role + accessible name, visible-text anchor, route/URL context, or named region.",
|
|
94
|
+
);
|
|
95
|
+
export type Target = z.infer<typeof TargetSchema>;
|
|
96
|
+
|
|
97
|
+
// ── Representative step / observation carriers ──
|
|
98
|
+
// These show the union in its real position: an OPTIONAL field on a step and on
|
|
99
|
+
// an observation. Everything else is intentionally minimal; this is a fixture.
|
|
100
|
+
|
|
101
|
+
const Step = z
|
|
102
|
+
.object({
|
|
103
|
+
// Human-readable instruction for the guided-execution step.
|
|
104
|
+
instruction: z.string().min(1),
|
|
105
|
+
// FR-019: optional per-step target. Additive; absence means "unspecified".
|
|
106
|
+
target: TargetSchema.optional(),
|
|
107
|
+
})
|
|
108
|
+
.strict();
|
|
109
|
+
|
|
110
|
+
const Observation = z
|
|
111
|
+
.object({
|
|
112
|
+
// What the tester should look for.
|
|
113
|
+
prompt: z.string().min(1),
|
|
114
|
+
// FR-019: optional per-observation observe target. Same union as `target`.
|
|
115
|
+
observe: TargetSchema.optional(),
|
|
116
|
+
})
|
|
117
|
+
.strict();
|
|
118
|
+
|
|
119
|
+
// Root schema tying the fixture together and carrying the versioned $id.
|
|
120
|
+
export const TestbenchTargetingSpikeSchema = z
|
|
121
|
+
.object({
|
|
122
|
+
steps: z.array(Step),
|
|
123
|
+
observations: z.array(Observation),
|
|
124
|
+
})
|
|
125
|
+
.strict()
|
|
126
|
+
.meta({
|
|
127
|
+
$id: TESTBENCH_TARGETING_SCHEMA_ID,
|
|
128
|
+
title: "TestBench Targeting (spike)",
|
|
129
|
+
description:
|
|
130
|
+
"Spike #408 fixture proving z.toJSONSchema() output quality for the FR-019 optional per-step `target` and per-observation `observe` targeting unions. Not the authored TestBench contract (#6).",
|
|
131
|
+
});
|
|
132
|
+
export type TestbenchTargetingSpike = z.infer<typeof TestbenchTargetingSpikeSchema>;
|