@sdods/contracts 0.7.2 → 0.9.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.
@@ -25,7 +25,18 @@ export const DataSourceSchema = z.discriminatedUnion('type', [
25
25
  export const UserPoolSchema = z.object({
26
26
  dataset: z.string(),
27
27
  roleColumn: z.string().default('role'),
28
- leaseStore: z.enum(['file', 'db']).default('file'),
28
+ /**
29
+ * Where leases are recorded. Only `file` exists: O_EXCL lock files under `.sdods/leases`, which
30
+ * every worker on one machine sees. `db` was accepted here for a long time and silently ignored,
31
+ * so a sharded run that believed its machines shared leases did not (#153).
32
+ */
33
+ leaseStore: z
34
+ .enum(['file'], {
35
+ error: (issue) => issue.input === 'db'
36
+ ? "leaseStore 'db' was never implemented; use 'file' (see #153)"
37
+ : `leaseStore must be 'file' (got ${JSON.stringify(issue.input)})`,
38
+ })
39
+ .default('file'),
29
40
  leaseTtlMs: z
30
41
  .number()
31
42
  .int()
@@ -50,8 +61,26 @@ export const UserPoolSchema = z.object({
50
61
  * explicit exclusive lease even in a shared pool.
51
62
  */
52
63
  mode: z.enum(['exclusive', 'shared']).default('exclusive'),
53
- /** How long to wait for a free account before failing. Was hard-coded at 30s. */
54
- waitMs: z.number().int().positive().default(30_000),
64
+ /**
65
+ * How long to wait for a free account before failing. Unset, it is 30s with `leaseScope: worker`.
66
+ * With `leaseScope: scenario` waiting is the expected path rather than a symptom, so unset it is
67
+ * `leaseTtlMs` — the longest a live scenario may legitimately hold an account — and the time
68
+ * spent waiting is added to the scenario's timeout instead of eating into it.
69
+ */
70
+ waitMs: z.number().int().positive().optional(),
71
+ /**
72
+ * How long an exclusive lease is held.
73
+ *
74
+ * `worker` (default) — from the first scenario that asks until the Playwright worker exits. A
75
+ * worker keeps one account across its scenarios, which suits per-worker setup, but a role with
76
+ * fewer accounts than `--workers` starves every other worker: each waits `waitMs` and fails with
77
+ * USER_POOL_EXHAUSTED (#153).
78
+ *
79
+ * `scenario` — released when the scenario ends, including leases taken by the
80
+ * `I use a leased user …` steps. One account then serialises the scenarios that need it instead
81
+ * of starving whole workers; waiters queue in arrival order.
82
+ */
83
+ leaseScope: z.enum(['scenario', 'worker']).default('worker'),
55
84
  });
56
85
  export const AuthStrategySchema = z.enum([
57
86
  'none',
@@ -89,10 +118,22 @@ export const ProjectAuthSchema = z.object({
89
118
  })
90
119
  .optional(),
91
120
  });
121
+ /**
122
+ * Settings for one named visual baseline (`the page should match the visual baseline "<name>"`).
123
+ * `mask` is added to the project/env `screenshots.mask`, never a replacement for it.
124
+ */
125
+ export const BaselineConfigSchema = z.object({
126
+ mask: z.array(z.string()).optional(),
127
+ maxDiffPixelRatio: z.number().min(0).max(1).optional(),
128
+ });
92
129
  export const ScreenshotConfigSchema = z.object({
93
130
  policy: z.record(z.string(), ShotPolicySchema).default({ default: 'on-failure' }),
94
131
  fullPage: z.boolean().default(false),
95
132
  mask: z.array(z.string()).default([]),
133
+ /** Share of pixels allowed to differ before a visual baseline check fails (0.01 = 1%). */
134
+ maxDiffPixelRatio: z.number().min(0).max(1).default(0.01),
135
+ /** Per-baseline overrides, keyed by the baseline name without `.png`. */
136
+ baselines: z.record(z.string(), BaselineConfigSchema).default({}),
96
137
  viewport: z
97
138
  .object({ width: z.number().int().positive(), height: z.number().int().positive() })
98
139
  .default({ width: 1280, height: 720 }),
@@ -163,6 +204,77 @@ export const EVIDENCE_DEFAULTS = {
163
204
  trace: 'on-first-retry',
164
205
  redactTraces: true,
165
206
  };
207
+ /**
208
+ * Selectors the canvas steps (`canvas.steps.ts`) use to find nodes, handles, edges, node fields and
209
+ * badges on a graph editor. The defaults are the DOM React Flow (v11 and v12) renders; a project on
210
+ * another editor, or with its own markers, overrides only the keys that differ.
211
+ *
212
+ * Values are CSS selector templates. `{id}`, `{handle}`, `{source}`, `{target}`, `{field}` and
213
+ * `{badge}` are replaced by the step argument, escaped for a quoted CSS string, so write them inside
214
+ * quotes: `[data-id="{id}"]`. `{testIdAttribute}` is replaced by the project's attribute name.
215
+ */
216
+ export const CANVAS_DEFAULTS = {
217
+ /** The editor surface; every other lookup is scoped inside it. */
218
+ root: '.react-flow',
219
+ /** Every node, for counting. */
220
+ nodes: '.react-flow__node',
221
+ /** One node by id. */
222
+ node: '.react-flow__node[data-id="{id}"]',
223
+ /** A handle, searched inside its node. */
224
+ handle: '.react-flow__handle[data-handleid="{handle}"]',
225
+ /** An edge by source and target node id. React Flow's default edge aria-label. */
226
+ edge: '.react-flow__edge[aria-label="Edge from {source} to {target}"]',
227
+ /** A field, searched inside its node. A wrapper is fine: the editable control inside it is filled. */
228
+ field: '[name="{field}"], [aria-label="{field}"], [{testIdAttribute}="{field}"]',
229
+ /** A badge, searched inside its node. */
230
+ badge: '[{testIdAttribute}="{badge}"]',
231
+ /** What a node matches once selected; `I select the node` waits for it. Empty skips the check. */
232
+ selected: '.selected',
233
+ };
234
+ const CANVAS_PLACEHOLDERS = [
235
+ 'id',
236
+ 'handle',
237
+ 'source',
238
+ 'target',
239
+ 'field',
240
+ 'badge',
241
+ 'testIdAttribute',
242
+ ];
243
+ /** A selector template that must use `required` and may use nothing but the known placeholders. */
244
+ const canvasTemplate = (fallback, required) => z
245
+ .string()
246
+ .superRefine((value, ctx) => {
247
+ for (const name of required) {
248
+ if (!value.includes(`{${name}}`)) {
249
+ ctx.addIssue({
250
+ code: 'custom',
251
+ message: `must contain the {${name}} placeholder, or every lookup matches the same element`,
252
+ });
253
+ }
254
+ }
255
+ for (const [, name] of value.matchAll(/\{(\w+)\}/g)) {
256
+ if (!CANVAS_PLACEHOLDERS.includes(name) ||
257
+ (name !== 'testIdAttribute' && !required.includes(name))) {
258
+ ctx.addIssue({
259
+ code: 'custom',
260
+ message: `{${name}} is not a placeholder here; use ${required.map((r) => `{${r}}`).join(', ') || 'none'}${required.length ? ' or ' : ''}{testIdAttribute}`,
261
+ });
262
+ }
263
+ }
264
+ })
265
+ .default(fallback);
266
+ export const CanvasConfigSchema = z
267
+ .object({
268
+ root: canvasTemplate(CANVAS_DEFAULTS.root, []),
269
+ nodes: canvasTemplate(CANVAS_DEFAULTS.nodes, []),
270
+ node: canvasTemplate(CANVAS_DEFAULTS.node, ['id']),
271
+ handle: canvasTemplate(CANVAS_DEFAULTS.handle, ['handle']),
272
+ edge: canvasTemplate(CANVAS_DEFAULTS.edge, ['source', 'target']),
273
+ field: canvasTemplate(CANVAS_DEFAULTS.field, ['field']),
274
+ badge: canvasTemplate(CANVAS_DEFAULTS.badge, ['badge']),
275
+ selected: z.string().default(CANVAS_DEFAULTS.selected),
276
+ })
277
+ .default({ ...CANVAS_DEFAULTS });
166
278
  /**
167
279
  * Scenarios that must pass before anything else in the run starts: probes, login, seeding.
168
280
  *
@@ -184,6 +296,37 @@ export const PerfBudgetsSchema = z.object({
184
296
  ttfbMs: z.number().positive().optional(),
185
297
  apiP95Ms: z.number().positive().optional(),
186
298
  });
299
+ /** axe impact levels, weakest first. Mirrored by `IMPACT_ORDER` in core's a11y audit. */
300
+ export const A11yImpactSchema = z.enum(['minor', 'moderate', 'serious', 'critical']);
301
+ /**
302
+ * The audit every `@a11y` UI or hybrid scenario gets at its end.
303
+ *
304
+ * `failOn` is the weakest impact that fails the scenario: `serious` fails on serious and critical
305
+ * violations and reports the rest. `include` narrows the audit to these selectors, `exclude` leaves
306
+ * these selectors out (a third-party widget the team does not own, for instance). Both are passed
307
+ * to axe as they are, so a selector that matches nothing in `include` fails the audit rather than
308
+ * scanning an empty page.
309
+ */
310
+ export const A11yConfigSchema = z.object({
311
+ failOn: A11yImpactSchema.default('serious'),
312
+ include: z.array(z.string().min(1)).default([]),
313
+ exclude: z.array(z.string().min(1)).default([]),
314
+ });
315
+ /** The env-level patch: every key optional and default-free, for the reason `EvidencePatchSchema` gives. */
316
+ export const A11yPatchSchema = z.object({
317
+ failOn: A11yImpactSchema.optional(),
318
+ include: z.array(z.string().min(1)).optional(),
319
+ exclude: z.array(z.string().min(1)).optional(),
320
+ });
321
+ /** Branch names `integrations.github.evidence.branch` refuses: prune force-rewrites the branch. */
322
+ export const EVIDENCE_PROTECTED_BRANCHES = [
323
+ 'main',
324
+ 'master',
325
+ 'develop',
326
+ 'development',
327
+ 'trunk',
328
+ 'gh-pages',
329
+ ];
187
330
  export const GitHubIntegrationSchema = z.object({
188
331
  enabled: z.boolean().default(false),
189
332
  owner: z.string().optional(),
@@ -195,6 +338,46 @@ export const GitHubIntegrationSchema = z.object({
195
338
  labels: z.array(z.string()).default(['sdods']),
196
339
  tokenEnv: z.string().default('GITHUB_TOKEN'),
197
340
  uploadToRelease: z.string().optional(),
341
+ /**
342
+ * Where issue evidence (screenshots, video, GIF preview) is hosted so it renders inline in a
343
+ * private repository. Absent or `host: none` keeps the links described above.
344
+ */
345
+ evidence: z
346
+ .object({
347
+ host: z.enum(['none', 'branch']).default('none'),
348
+ /** `owner/name`; default: the issue repository */
349
+ repo: z
350
+ .string()
351
+ .regex(/^[\w.-]+\/[\w.-]+$/, 'must be owner/name')
352
+ .optional(),
353
+ /**
354
+ * An orphan branch SDODS creates and `evidence prune` rewrites. The names people work on are
355
+ * refused here; the repository's default branch is refused at upload and prune time.
356
+ */
357
+ branch: z
358
+ .string()
359
+ .min(1)
360
+ .refine((b) => !EVIDENCE_PROTECTED_BRANCHES.includes(b.toLowerCase()), {
361
+ message: `must not be ${EVIDENCE_PROTECTED_BRANCHES.join(', ')}: the evidence branch is rewritten by evidence prune, which would erase that branch's history`,
362
+ })
363
+ .default('sdods-evidence'),
364
+ /** env var NAME of a token for the evidence repo; default: `tokenEnv` */
365
+ tokenEnv: z.string().optional(),
366
+ maxFileBytes: z
367
+ .number()
368
+ .int()
369
+ .positive()
370
+ .default(5 * 1024 * 1024),
371
+ maxRunBytes: z
372
+ .number()
373
+ .int()
374
+ .positive()
375
+ .default(25 * 1024 * 1024),
376
+ retainDays: z.number().int().positive().default(14),
377
+ /** an inline GIF of the video's last seconds when ffmpeg is on PATH */
378
+ gifPreview: z.boolean().default(true),
379
+ })
380
+ .optional(),
198
381
  });
199
382
  export const JiraIntegrationSchema = z.object({
200
383
  enabled: z.boolean().default(false),
@@ -299,6 +482,27 @@ export const ScheduleSchema = z.object({
299
482
  notify: z.array(z.enum(['github', 'jira', 'webhook'])).default([]),
300
483
  retentionRuns: z.number().int().positive().optional(),
301
484
  });
485
+ /**
486
+ * Requirement traceability (`@req:<id>` tags → `sdods report traceability`).
487
+ *
488
+ * The id is opaque: SDODS does not decide whether it names a Jira story, a line in a requirements
489
+ * document or a clause of a regulation. That choice belongs to whoever signs the export.
490
+ */
491
+ export const TraceabilitySchema = z.object({
492
+ /**
493
+ * A YAML or CSV file (relative to the project root) listing the requirement ids and titles.
494
+ * When set, lint rejects ids that are not in it and the export lists requirements no scenario
495
+ * covers.
496
+ */
497
+ requirements: z.string().min(1).optional(),
498
+ /** URL template for a requirement, `{id}` replaced by the id: `https://jira.example.com/browse/{id}`. */
499
+ link: z
500
+ .string()
501
+ .refine((s) => s.includes('{id}'), 'link must contain the {id} placeholder')
502
+ .optional(),
503
+ /** Lint fails a scenario that carries no `@req:` tag (directly or from its Feature/Rule). */
504
+ require: z.boolean().default(false),
505
+ });
302
506
  /**
303
507
  * Hierarchy: organization → workspace → project → module.
304
508
  * A module is a feature area of an application (auth, inventory, checkout …) that owns a
@@ -416,6 +620,8 @@ export const ProjectConfigSchema = z.object({
416
620
  })
417
621
  .default({ core: { exclude: [] } }),
418
622
  routes: z.record(z.string(), z.string()).default({}),
623
+ /** Selectors for the graph-editor steps in `canvas.steps.ts`; React Flow's DOM by default. */
624
+ canvas: CanvasConfigSchema,
419
625
  tags: z
420
626
  .object({
421
627
  suites: z.array(z.string()).min(1).default(['smoke', 'regression', 'sanity']),
@@ -436,6 +642,8 @@ export const ProjectConfigSchema = z.object({
436
642
  policy: { default: 'on-failure' },
437
643
  fullPage: false,
438
644
  mask: [],
645
+ maxDiffPixelRatio: 0.01,
646
+ baselines: {},
439
647
  viewport: { width: 1280, height: 720 },
440
648
  onlyOnFailure: false,
441
649
  }),
@@ -462,6 +670,7 @@ export const ProjectConfigSchema = z.object({
462
670
  fullyParallel: z.boolean().default(true),
463
671
  setup: SetupSchema.optional(),
464
672
  perf: z.object({ budgets: PerfBudgetsSchema.default({}) }).default({ budgets: {} }),
673
+ a11y: A11yConfigSchema.default({ failOn: 'serious', include: [], exclude: [] }),
465
674
  integrations: IntegrationsSchema.default({ custom: [] }),
466
675
  mcp: McpConfigSchema.default({ servers: {} }),
467
676
  agents: AgentsConfigSchema.default({
@@ -481,6 +690,8 @@ export const ProjectConfigSchema = z.object({
481
690
  junit: z.boolean().default(false),
482
691
  })
483
692
  .default({ cucumberHtml: false, allure: false, junit: false }),
693
+ /** Requirement traceability: `@req:<id>` validation and the `sdods report traceability` export. */
694
+ traceability: TraceabilitySchema.optional(),
484
695
  /** How the CI matrix (`sdods project list --matrix`) treats this project. */
485
696
  ci: z
486
697
  .object({
package/dist/types.d.ts CHANGED
@@ -33,6 +33,8 @@ export interface RunManifest {
33
33
  playwrightVersion?: string;
34
34
  exitCode?: number;
35
35
  ingestedAt?: string;
36
+ /** Whether the process gates passed, when the run was `--process` with gates. */
37
+ gatesPassed?: boolean;
36
38
  }
37
39
  export interface RunTotals {
38
40
  total: number;
@@ -142,6 +144,38 @@ export interface StepResult {
142
144
  startedAt?: string;
143
145
  finishedAt?: string;
144
146
  }
147
+ /**
148
+ * One failed visual baseline check, written next to the scenario's screenshots
149
+ * (`<runDir>/<slug>/<fingerprint>/r<retry>/visual/<name>.failure.json`) so it survives the CI
150
+ * artifact upload and ingest. `sdods baselines` and the server's accept route read it.
151
+ */
152
+ export interface VisualFailure {
153
+ /** baseline name without `.png` */
154
+ name: string;
155
+ /** the file name Playwright compared, e.g. `inventory.png` */
156
+ snapshot: string;
157
+ project: string;
158
+ runnerProject: string;
159
+ /** `process.platform` of the machine that ran the check: the baseline belongs to it */
160
+ platform: string;
161
+ fingerprint: string;
162
+ scenarioName: string;
163
+ featureUri: string;
164
+ retry: number;
165
+ stepIndex: number;
166
+ /** `changed`: pixels differ; `size`: dimensions differ; `missing`: no baseline yet */
167
+ reason: 'changed' | 'size' | 'missing';
168
+ /** baseline path, relative to the project root */
169
+ baseline: string;
170
+ /** image paths, relative to the run directory */
171
+ actual: string;
172
+ expected?: string;
173
+ diff?: string;
174
+ diffPixels?: number;
175
+ diffRatio?: number;
176
+ maxDiffPixelRatio: number;
177
+ recordedAt: string;
178
+ }
145
179
  export type ArtifactKind = 'screenshot' | 'trace' | 'video' | 'har' | 'log' | 'report' | 'visual' | 'diff' | 'attachment';
146
180
  export type ArtifactPhase = 'before' | 'after' | 'scenario-start' | 'scenario-end' | 'failure' | 'expected' | 'actual' | 'diff';
147
181
  export interface ScreenshotArtifact {
@@ -267,6 +301,24 @@ export interface RunSummary {
267
301
  messages?: string;
268
302
  junit?: string;
269
303
  };
304
+ /** The process gate verdict, when the run was `--process` with gates. */
305
+ gates?: GateResult;
306
+ }
307
+ /** One evaluated process gate (`gates:` on a process). */
308
+ export interface GateRow {
309
+ gate: 'minPassRate' | 'maxFlaky' | 'a11y' | 'perfBudgets';
310
+ /** What the process asks for, as written. */
311
+ threshold: string;
312
+ /** What the run produced. */
313
+ actual: string;
314
+ passed: boolean;
315
+ detail?: string;
316
+ }
317
+ /** Every gate a process declares, evaluated over one run. Written to `gates.json` in the run dir. */
318
+ export interface GateResult {
319
+ process: string;
320
+ passed: boolean;
321
+ rows: GateRow[];
270
322
  }
271
323
  export interface Evidence {
272
324
  file: string;
@@ -411,6 +463,114 @@ export interface CoverageReport {
411
463
  uncoveredModules: string[];
412
464
  };
413
465
  }
466
+ /** Outcome of one requirement in the chosen run. `not-covered` needs a requirements file. */
467
+ export type TraceRequirementStatus = 'passed' | 'failed' | 'not-run' | 'not-covered';
468
+ /** Outcome of one scenario across every runner project / example row it ran as. */
469
+ export type TraceScenarioStatus = 'passed' | 'failed' | 'skipped' | 'not-run';
470
+ /** The final attempt of one scenario in one runner project (and one Examples row for outlines). */
471
+ export interface TraceResult {
472
+ runnerProject: string;
473
+ layer?: string;
474
+ browser?: string;
475
+ /** 1-based Examples row for a Scenario Outline, null for a plain scenario. */
476
+ exampleIndex: number | null;
477
+ status: SuiteStatus;
478
+ attempts: number;
479
+ /** failed at least once, then passed on a retry */
480
+ flaky: boolean;
481
+ durationMs?: number;
482
+ startedAt?: string;
483
+ finishedAt?: string;
484
+ error?: string;
485
+ }
486
+ export interface TraceScenario {
487
+ /** feature file, relative to the project root */
488
+ feature: string;
489
+ featureName: string;
490
+ line: number;
491
+ name: string;
492
+ /** effective tags: Feature + Rule + Scenario + Examples */
493
+ tags: string[];
494
+ requirements: string[];
495
+ status: TraceScenarioStatus;
496
+ results: TraceResult[];
497
+ }
498
+ export interface TraceRequirement {
499
+ id: string;
500
+ title?: string;
501
+ url?: string;
502
+ /**
503
+ * Whether the id is listed in the requirements file: `true`/`false` when one is configured,
504
+ * `null` when the project has none (ids are then whatever the tags say).
505
+ */
506
+ declared: boolean | null;
507
+ status: TraceRequirementStatus;
508
+ scenarios: TraceScenario[];
509
+ }
510
+ export interface TraceRunInfo {
511
+ id: string;
512
+ env?: string;
513
+ trigger?: string;
514
+ command?: string;
515
+ tagsExpr?: string;
516
+ startedAt?: string;
517
+ finishedAt?: string;
518
+ exitCode?: number;
519
+ git?: {
520
+ sha?: string;
521
+ branch?: string;
522
+ dirty?: boolean;
523
+ };
524
+ ci?: {
525
+ provider?: string;
526
+ runId?: string;
527
+ url?: string;
528
+ };
529
+ sdodsVersion?: string;
530
+ playwrightVersion?: string;
531
+ /** where scenario results were read from */
532
+ resultsSource: 'cucumber-messages' | 'scenario-meta' | 'none';
533
+ }
534
+ /**
535
+ * Filled in by a person, never by SDODS. Sign-off authority is human and not delegable, so the
536
+ * export carries the empty block and every field stays null in what the framework writes.
537
+ */
538
+ export interface TraceSignOff {
539
+ signedBy: null;
540
+ role: null;
541
+ date: null;
542
+ decision: null;
543
+ notes: null;
544
+ }
545
+ export interface TraceabilityReport {
546
+ kind: 'sdods-traceability';
547
+ version: 1;
548
+ project: string;
549
+ projectName: string;
550
+ generatedAt: string;
551
+ /** requirements file, relative to the project root */
552
+ requirementsFile?: string;
553
+ run: TraceRunInfo | null;
554
+ requirements: TraceRequirement[];
555
+ /** scenarios that carry no `@req:` tag */
556
+ untraced: Array<Pick<TraceScenario, 'feature' | 'line' | 'name' | 'status'>>;
557
+ summary: {
558
+ requirements: number;
559
+ covered: number;
560
+ passed: number;
561
+ failed: number;
562
+ notRun: number;
563
+ /** null when no requirements file is configured: uncovered ids cannot be known */
564
+ notCovered: number | null;
565
+ /** ids tagged on scenarios but missing from the requirements file */
566
+ undeclared: number;
567
+ scenarios: number;
568
+ tracedScenarios: number;
569
+ untracedScenarios: number;
570
+ };
571
+ signOff: TraceSignOff;
572
+ notes: string[];
573
+ }
414
574
  export interface ProjectProposal {
415
575
  slug: string;
416
576
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/contracts",
3
- "version": "0.7.2",
3
+ "version": "0.9.0",
4
4
  "description": "Shared schemas, identifiers and DTO types for SDODS.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",