tuffgal 0.1.0-alpha.9 → 0.2.0-alpha.2

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +149 -2
  2. package/README.md +78 -3
  3. package/dist/cli.d.ts +50 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +204 -18
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/init.d.ts +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +11 -1
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/config.d.ts +38 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +20 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/coverage/screens.d.ts +11 -6
  16. package/dist/coverage/screens.d.ts.map +1 -1
  17. package/dist/coverage/screens.js +12 -7
  18. package/dist/coverage/screens.js.map +1 -1
  19. package/dist/reporter/assets/report.css +415 -51
  20. package/dist/reporter/assets/report.js +502 -68
  21. package/dist/reporter/template.d.ts +8 -1
  22. package/dist/reporter/template.d.ts.map +1 -1
  23. package/dist/reporter/template.js +517 -85
  24. package/dist/reporter/template.js.map +1 -1
  25. package/dist/reporter/writeReport.d.ts +2 -2
  26. package/dist/reporter/writeReport.d.ts.map +1 -1
  27. package/dist/reporter/writeReport.js +3 -3
  28. package/dist/reporter/writeReport.js.map +1 -1
  29. package/dist/runner/approve.d.ts +34 -9
  30. package/dist/runner/approve.d.ts.map +1 -1
  31. package/dist/runner/approve.js +54 -10
  32. package/dist/runner/approve.js.map +1 -1
  33. package/dist/runner/approveFrom.d.ts +58 -0
  34. package/dist/runner/approveFrom.d.ts.map +1 -0
  35. package/dist/runner/approveFrom.js +384 -0
  36. package/dist/runner/approveFrom.js.map +1 -0
  37. package/dist/runner/breakpointPasses.d.ts +39 -0
  38. package/dist/runner/breakpointPasses.d.ts.map +1 -0
  39. package/dist/runner/breakpointPasses.js +107 -0
  40. package/dist/runner/breakpointPasses.js.map +1 -0
  41. package/dist/runner/bridges/database.d.ts +4 -3
  42. package/dist/runner/bridges/database.d.ts.map +1 -1
  43. package/dist/runner/bridges/database.js +4 -3
  44. package/dist/runner/bridges/database.js.map +1 -1
  45. package/dist/runner/exitCode.d.ts +34 -0
  46. package/dist/runner/exitCode.d.ts.map +1 -0
  47. package/dist/runner/exitCode.js +41 -0
  48. package/dist/runner/exitCode.js.map +1 -0
  49. package/dist/runner/manifest.d.ts +149 -0
  50. package/dist/runner/manifest.d.ts.map +1 -0
  51. package/dist/runner/manifest.js +229 -0
  52. package/dist/runner/manifest.js.map +1 -0
  53. package/dist/runner/mode.d.ts +41 -0
  54. package/dist/runner/mode.d.ts.map +1 -0
  55. package/dist/runner/mode.js +43 -0
  56. package/dist/runner/mode.js.map +1 -0
  57. package/dist/runner/orphanScan.d.ts +68 -0
  58. package/dist/runner/orphanScan.d.ts.map +1 -0
  59. package/dist/runner/orphanScan.js +150 -0
  60. package/dist/runner/orphanScan.js.map +1 -0
  61. package/dist/runner/run.d.ts +85 -1
  62. package/dist/runner/run.d.ts.map +1 -1
  63. package/dist/runner/run.js +267 -41
  64. package/dist/runner/run.js.map +1 -1
  65. package/dist/runner/runAction.d.ts +16 -0
  66. package/dist/runner/runAction.d.ts.map +1 -1
  67. package/dist/runner/runAction.js +112 -13
  68. package/dist/runner/runAction.js.map +1 -1
  69. package/dist/runner/runStory.d.ts +15 -4
  70. package/dist/runner/runStory.d.ts.map +1 -1
  71. package/dist/runner/runStory.js +41 -19
  72. package/dist/runner/runStory.js.map +1 -1
  73. package/dist/runner/storyFilter.d.ts +8 -0
  74. package/dist/runner/storyFilter.d.ts.map +1 -1
  75. package/dist/runner/storyFilter.js +11 -0
  76. package/dist/runner/storyFilter.js.map +1 -1
  77. package/dist/schema/action.d.ts +11 -11
  78. package/dist/schema/result.d.ts +125 -6
  79. package/dist/schema/result.d.ts.map +1 -1
  80. package/dist/schema/result.js.map +1 -1
  81. package/dist/screenshots/baselineStore.d.ts +92 -10
  82. package/dist/screenshots/baselineStore.d.ts.map +1 -1
  83. package/dist/screenshots/baselineStore.js +102 -18
  84. package/dist/screenshots/baselineStore.js.map +1 -1
  85. package/dist/screenshots/capture.d.ts +10 -5
  86. package/dist/screenshots/capture.d.ts.map +1 -1
  87. package/dist/screenshots/capture.js +31 -18
  88. package/dist/screenshots/capture.js.map +1 -1
  89. package/package.json +1 -1
@@ -0,0 +1,384 @@
1
+ import { lstat, readFile, readdir, rm } from 'node:fs/promises';
2
+ import { join, relative, sep } from 'node:path';
3
+ import { PNG } from 'pngjs';
4
+ import { ACTION_NAME_PATTERN, BREAKPOINT_SEGMENT_PATTERN, writePng, writeText, } from "../screenshots/baselineStore.js";
5
+ import { CAPTURE_SCHEMA, MANIFEST_FILENAME, SCHEMA_VERSION, validateManifestShape, } from "./manifest.js";
6
+ import { A11Y_SUFFIX, LEGACY_A11Y, LEGACY_BREAKPOINT, LEGACY_PNG, } from "./orphanScan.js";
7
+ import { parseRunResult } from "../schema/result.js";
8
+ /**
9
+ * Error thrown when the candidate tree fails a trust-boundary check. Carries no
10
+ * extra data — the message is the whole contract. Distinct type so the CLI can
11
+ * present it plainly; every throw here happens BEFORE any write, so the promise
12
+ * rejecting guarantees `paths.baselines` was not touched.
13
+ */
14
+ export class ApproveFromError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'ApproveFromError';
18
+ }
19
+ }
20
+ /**
21
+ * Promotes a CI candidate tree (`<dir>/<action>/<breakpoint>.png` + optional
22
+ * `.a11y.yaml`, plus a top-level `results.json`) into the committed
23
+ * `paths.baselines` set, and writes `<baselines>/manifest.json` from the
24
+ * candidate run's captured environment.
25
+ *
26
+ * TRUST BOUNDARY. `<dir>` is an untrusted artifact — a human or bot unzipped it
27
+ * from CI. This is the only path that writes committed baselines, so it is
28
+ * fail-closed and validate-all-then-write:
29
+ * 1. `results.json` must parse, describe a clean CI run (`mode: 'ci'` with
30
+ * `totals.failed === 0` — never promote a local or broken run's partial
31
+ * tree, see {@link assertPromotableRun}), and carry a well-shaped
32
+ * `environment.actual` block (validated against the manifest shape, not
33
+ * just present).
34
+ * 2. Every tree entry is validated (allowed shape, safe names, no symlinks, no
35
+ * traversal) and every PNG is decoded to prove it is a real PNG.
36
+ * 3. ONLY after the whole tree passes are any bytes written. A single bad
37
+ * entry aborts with {@link ApproveFromError} and zero filesystem changes.
38
+ *
39
+ * Every candidate file's bytes are read once during phase 1 and RETAINED for
40
+ * phase 2, which writes exactly those bytes — the sources are never re-read, so
41
+ * the committed baseline is provably the payload that passed validation (no
42
+ * decode-swap / symlink-swap window between the check and the write).
43
+ *
44
+ * PNGs are written through {@link writePng} (decode→recompress→encode), so the
45
+ * promoted baseline is losslessly recompressed and provably decodable in one
46
+ * step — never a raw `copyFile` of unvalidated artifact bytes.
47
+ */
48
+ export async function approveFrom(config, options) {
49
+ const candidateDir = options.from;
50
+ await assertRealDirectory(candidateDir);
51
+ // Phase 1 — validate the whole tree and build a write plan. Every source is
52
+ // read exactly ONCE here and its bytes are retained on the plan; no writes yet.
53
+ const result = await readCandidateResults(candidateDir);
54
+ assertPromotableRun(result);
55
+ const environment = extractEnvironment(result);
56
+ const plan = await planWrites(candidateDir, config.paths.baselines);
57
+ await assertAllPngsDecode(plan);
58
+ const pruneTargets = options.prune
59
+ ? computePruneTargets(result, config.paths.baselines)
60
+ : [];
61
+ // Phase 2 — every check passed; commit the RETAINED bytes. Nothing is re-read
62
+ // from the candidate tree here, so the bytes committed are exactly the bytes
63
+ // that were validated (no decode-swap / symlink-swap window).
64
+ for (const write of plan) {
65
+ if (write.kind === 'png') {
66
+ await writePng(write.destination, write.bytes);
67
+ }
68
+ else {
69
+ await writeText(write.destination, write.bytes.toString('utf8'));
70
+ }
71
+ }
72
+ await writeManifest(config.paths.baselines, environment);
73
+ let pruned = 0;
74
+ for (const group of pruneTargets) {
75
+ for (const path of group) {
76
+ await rm(path, { force: true });
77
+ }
78
+ pruned += 1;
79
+ }
80
+ return {
81
+ written: plan.filter((write) => write.kind === 'png').length,
82
+ pruned,
83
+ };
84
+ }
85
+ /** Aborts unless `dir` exists and is a real directory (not a symlink). */
86
+ async function assertRealDirectory(dir) {
87
+ let stats;
88
+ try {
89
+ stats = await lstat(dir);
90
+ }
91
+ catch {
92
+ throw new ApproveFromError(`--from directory not found: ${dir}`);
93
+ }
94
+ if (stats.isSymbolicLink()) {
95
+ throw new ApproveFromError(`--from path is a symlink, refusing: ${dir}`);
96
+ }
97
+ if (!stats.isDirectory()) {
98
+ throw new ApproveFromError(`--from path is not a directory: ${dir}`);
99
+ }
100
+ }
101
+ /**
102
+ * Reads + parses `<dir>/results.json`. Absence is a hard error: the candidate
103
+ * artifact is incomplete without it (prune targets and the environment manifest
104
+ * both come from here).
105
+ */
106
+ async function readCandidateResults(dir) {
107
+ const resultsPath = join(dir, 'results.json');
108
+ let raw;
109
+ try {
110
+ raw = await readFile(resultsPath, 'utf8');
111
+ }
112
+ catch {
113
+ throw new ApproveFromError(`Candidate tree is missing results.json at ${resultsPath}`);
114
+ }
115
+ try {
116
+ return parseRunResult(raw, resultsPath);
117
+ }
118
+ catch (error) {
119
+ throw new ApproveFromError(error instanceof Error ? error.message : String(error));
120
+ }
121
+ }
122
+ /**
123
+ * Refuses to promote a candidate tree that did not come from a clean CI run.
124
+ * Three fail-closed gates, all derived from the candidate's own `results.json`:
125
+ * - `mode !== 'ci'` — a local (advisory) run's candidate tree is rendered
126
+ * against the per-machine cache on the developer's own platform, so
127
+ * promoting it commits cross-platform pixels the CI gate would immediately
128
+ * re-flag. Only a CI-mode run's renders are eligible for the committed set.
129
+ * - `totals.failed` is not a number — `parseRunResult` validates only that
130
+ * `stories` is an array, so a truncated or foreign artifact can reach here
131
+ * with `totals` (or `totals.failed`) missing or non-numeric. Reading it
132
+ * unguarded would either throw a raw `TypeError` (not an `ApproveFromError`,
133
+ * breaking the trust-boundary contract) or, worse, evaluate `undefined > 0`
134
+ * to `false` and SILENTLY pass the gate. A non-numeric `failed` is therefore
135
+ * an explicit fail-closed refusal, not an assumed-zero.
136
+ * - `totals.failed > 0` — a run with failed stories produced a PARTIAL tree
137
+ * (broken stories wrote no candidate, or wrote a candidate mid-failure), so
138
+ * promoting it would commit an incomplete baseline set. A broken run is
139
+ * never a source of truth.
140
+ * All abort before any write. `mode` is read defensively (older artifacts may
141
+ * omit it) — a missing `mode` is treated as non-`ci` and refused, since only a
142
+ * current CI run stamps `mode: 'ci'`.
143
+ */
144
+ function assertPromotableRun(result) {
145
+ if (result.mode !== 'ci') {
146
+ throw new ApproveFromError(`Candidate results.json was produced by a ${result.mode ?? 'pre-mode'} ` +
147
+ 'run, not a CI run — only `mode: "ci"` candidates may be promoted into ' +
148
+ 'committed baselines. Re-run under `tuffgal run --ci`.');
149
+ }
150
+ if (typeof result.totals?.failed !== 'number') {
151
+ throw new ApproveFromError('Candidate results.json is missing a numeric totals.failed count — the ' +
152
+ 'artifact is truncated or was not produced by tuffgal. Refusing to ' +
153
+ 'promote a run whose pass/fail status cannot be verified.');
154
+ }
155
+ if (result.totals.failed > 0) {
156
+ throw new ApproveFromError(`Candidate results.json reports ${result.totals.failed} failed ` +
157
+ 'story(ies) — a broken run produces a partial candidate tree that must ' +
158
+ 'never be promoted. Fix the failures and re-run before approving.');
159
+ }
160
+ }
161
+ /**
162
+ * Pulls the captured environment (`environment.actual`) out of the candidate
163
+ * run's results and validates its SHAPE before it can be promoted. Two failure
164
+ * modes, both fail closed:
165
+ * - Missing: a candidate artifact produced by a pre-manifest tuffgal cannot
166
+ * populate `<baselines>/manifest.json`, and writing baselines without the
167
+ * manifest that gates them would silently defeat the environment-drift
168
+ * check. The user re-runs with a manifest-aware tuffgal.
169
+ * - Present but wrong-shaped: `parseRunResult` only checks that `stories` is
170
+ * an array, so a well-formed-but-false `environment.actual` block would
171
+ * otherwise flow straight into the committed manifest and defeat the drift
172
+ * gate from the inside. It is run through the SAME {@link
173
+ * validateManifestShape} check `readManifest` uses (reused, not duplicated),
174
+ * so a malformed block aborts before any write.
175
+ */
176
+ function extractEnvironment(result) {
177
+ const actual = result.environment?.actual;
178
+ if (!actual) {
179
+ throw new ApproveFromError('Candidate results.json has no environment block — it was produced by a ' +
180
+ 'pre-manifest tuffgal. Re-run with a manifest-aware version before ' +
181
+ 'approving, so baselines/manifest.json can be written.');
182
+ }
183
+ const invalid = validateManifestShape(actual);
184
+ if (invalid) {
185
+ throw new ApproveFromError(`Candidate results.json has a malformed environment block: ${invalid}`);
186
+ }
187
+ return actual;
188
+ }
189
+ /**
190
+ * Walks the candidate tree and returns the validated set of files to write.
191
+ * Enforces the whole allow-list: top level holds only `results.json` and action
192
+ * directories; each action directory holds only `<breakpoint>.png` /
193
+ * `<breakpoint>.a11y.yaml` (or legacy `0.png` / `a11y.yaml`). Every other shape
194
+ * — stray extensions, nested dirs, dotfiles, symlinks, traversal names — aborts.
195
+ * Every PNG is decoded here so a corrupt payload fails validation, not a write.
196
+ */
197
+ async function planWrites(candidateDir, baselinesDir) {
198
+ const plan = [];
199
+ const topEntries = await readdir(candidateDir, { withFileTypes: true });
200
+ for (const entry of topEntries) {
201
+ assertSafeName(entry.name, candidateDir);
202
+ const entryPath = join(candidateDir, entry.name);
203
+ await assertNotSymlink(entryPath);
204
+ if (entry.name === 'results.json') {
205
+ continue; // consumed separately; never written into baselines
206
+ }
207
+ if (!entry.isDirectory()) {
208
+ throw new ApproveFromError(`Unexpected top-level file in candidate tree: ${entry.name} ` +
209
+ '(only results.json and action directories are allowed)');
210
+ }
211
+ if (!ACTION_NAME_PATTERN.test(entry.name)) {
212
+ throw new ApproveFromError(`Invalid action directory name "${entry.name}" — must be ` +
213
+ 'lowercase-kebab ([a-z0-9-]+)');
214
+ }
215
+ plan.push(...(await planActionDir(candidateDir, baselinesDir, entry.name)));
216
+ }
217
+ return plan;
218
+ }
219
+ /**
220
+ * Validates one action directory's contents and returns its planned writes. Each
221
+ * source file is read exactly once here (after its symlink/name checks pass) and
222
+ * its bytes are retained on the {@link PlannedWrite} for phase 2 — the source is
223
+ * never re-read, closing the validate-then-swap TOCTOU window.
224
+ */
225
+ async function planActionDir(candidateDir, baselinesDir, action) {
226
+ const actionPath = join(candidateDir, action);
227
+ const files = await readdir(actionPath, { withFileTypes: true });
228
+ const plan = [];
229
+ for (const file of files) {
230
+ assertSafeName(file.name, candidateDir);
231
+ const source = join(actionPath, file.name);
232
+ await assertNotSymlink(source);
233
+ if (!file.isFile()) {
234
+ throw new ApproveFromError(`Unexpected non-file entry in candidate tree: ${action}/${file.name} ` +
235
+ '(action directories hold only PNG + a11y.yaml files)');
236
+ }
237
+ const kind = classifyCandidateFile(file.name, `${action}/${file.name}`);
238
+ // Read once, after the symlink check, and retain the bytes — phase 2 writes
239
+ // exactly these, never re-reading `source`.
240
+ const bytes = await readFile(source);
241
+ // The destination is derived purely from validated names — never from any
242
+ // path string in the artifact — so it always lands inside <baselines>.
243
+ plan.push({
244
+ source,
245
+ destination: join(baselinesDir, action, file.name),
246
+ kind,
247
+ bytes,
248
+ });
249
+ }
250
+ return plan;
251
+ }
252
+ /**
253
+ * Decodes every planned PNG's RETAINED bytes (validation phase) so a corrupt or
254
+ * non-PNG payload aborts the whole approve with zero writes. Decoding the bytes
255
+ * already held on the plan (not a fresh read) is what makes the decode-check and
256
+ * the eventual write see the same payload. `writePng`'s own recompress step
257
+ * SWALLOWS a decode failure (it falls back to writing the given bytes), which is
258
+ * the right behaviour for a trusted capture but wrong for an untrusted artifact
259
+ * — a corrupt candidate PNG must fail closed, not land verbatim as a baseline.
260
+ */
261
+ async function assertAllPngsDecode(plan) {
262
+ for (const write of plan) {
263
+ if (write.kind !== 'png')
264
+ continue;
265
+ try {
266
+ PNG.sync.read(write.bytes);
267
+ }
268
+ catch {
269
+ throw new ApproveFromError(`Candidate PNG is not a decodable PNG: ${write.source}`);
270
+ }
271
+ }
272
+ }
273
+ /**
274
+ * Classifies a candidate file inside an action dir, validating its breakpoint
275
+ * stem. Accepts breakpoint-keyed `<bp>.png` / `<bp>.a11y.yaml` and the legacy
276
+ * `0.png` / `a11y.yaml` pair. Anything else aborts.
277
+ */
278
+ function classifyCandidateFile(name, label) {
279
+ if (name === LEGACY_A11Y)
280
+ return 'a11y'; // legacy companion
281
+ if (name === LEGACY_PNG)
282
+ return 'png'; // legacy PNG
283
+ if (name.endsWith(A11Y_SUFFIX)) {
284
+ assertBreakpointStem(name.slice(0, -A11Y_SUFFIX.length), label);
285
+ return 'a11y';
286
+ }
287
+ if (name.endsWith('.png')) {
288
+ assertBreakpointStem(name.slice(0, -'.png'.length), label);
289
+ return 'png';
290
+ }
291
+ throw new ApproveFromError(`Unexpected file in candidate tree: ${label} ` +
292
+ '(only <breakpoint>.png and <breakpoint>.a11y.yaml are allowed)');
293
+ }
294
+ /** Aborts unless `stem` matches the runner's own breakpoint-segment shape. */
295
+ function assertBreakpointStem(stem, label) {
296
+ if (!BREAKPOINT_SEGMENT_PATTERN.test(stem)) {
297
+ throw new ApproveFromError(`Invalid breakpoint name in candidate tree: ${label} ` +
298
+ '(must be [a-z0-9_-]+)');
299
+ }
300
+ }
301
+ /**
302
+ * Rejects a filesystem name that is a dotfile or carries a path separator /
303
+ * traversal segment. `readdir` yields single path segments, so a `..` or a
304
+ * separator here means a hostile or malformed entry, not normal nesting.
305
+ */
306
+ function assertSafeName(name, context) {
307
+ if (name === '' || name === '.' || name === '..') {
308
+ throw new ApproveFromError(`Unsafe entry name "${name}" in candidate tree under ${context}`);
309
+ }
310
+ if (name.startsWith('.')) {
311
+ throw new ApproveFromError(`Dotfile "${name}" not allowed in candidate tree under ${context}`);
312
+ }
313
+ if (name.includes('/') || name.includes('\\') || name.includes(sep)) {
314
+ throw new ApproveFromError(`Path separator in candidate entry name "${name}" under ${context}`);
315
+ }
316
+ }
317
+ /** Aborts if `path` is a symlink — never follow a link out of the tree. */
318
+ async function assertNotSymlink(path) {
319
+ const stats = await lstat(path);
320
+ if (stats.isSymbolicLink()) {
321
+ throw new ApproveFromError(`Symlink not allowed in candidate tree: ${path}`);
322
+ }
323
+ }
324
+ /**
325
+ * Recomputes the local prune targets for `--prune` from the candidate run's
326
+ * `results.deleted`, entirely under the resolved `baselinesDir`. The artifact's
327
+ * own `baselinePaths[]` strings are ignored — they describe the CI machine's
328
+ * filesystem and could point anywhere. Instead each entry's `action` +
329
+ * `breakpoint` is re-validated and re-rooted here, so a prune can only ever
330
+ * delete files inside this repo's baselines directory.
331
+ */
332
+ function computePruneTargets(result, baselinesDir) {
333
+ const groups = [];
334
+ for (const entry of result.deleted ?? []) {
335
+ if (!ACTION_NAME_PATTERN.test(entry.action)) {
336
+ throw new ApproveFromError(`results.deleted has an invalid action name "${entry.action}"`);
337
+ }
338
+ const actionDir = join(baselinesDir, entry.action);
339
+ const paths = entry.breakpoint === LEGACY_BREAKPOINT
340
+ ? [join(actionDir, LEGACY_PNG), join(actionDir, LEGACY_A11Y)]
341
+ : breakpointKeyedPaths(entry.breakpoint, actionDir);
342
+ for (const path of paths) {
343
+ assertInsideBaselines(path, baselinesDir);
344
+ }
345
+ groups.push(paths);
346
+ }
347
+ return groups;
348
+ }
349
+ /** The PNG + a11y companion paths for a breakpoint-keyed orphan. */
350
+ function breakpointKeyedPaths(breakpoint, actionDir) {
351
+ if (!BREAKPOINT_SEGMENT_PATTERN.test(breakpoint)) {
352
+ throw new ApproveFromError(`results.deleted has an invalid breakpoint name "${breakpoint}"`);
353
+ }
354
+ return [
355
+ join(actionDir, `${breakpoint}.png`),
356
+ join(actionDir, `${breakpoint}${A11Y_SUFFIX}`),
357
+ ];
358
+ }
359
+ /**
360
+ * Belt-and-suspenders: even though prune paths are recomputed from validated
361
+ * names, assert the resolved path stays inside `baselinesDir` before any delete.
362
+ * A path that escapes (it should be impossible after name validation) aborts.
363
+ */
364
+ function assertInsideBaselines(path, baselinesDir) {
365
+ const rel = relative(baselinesDir, path);
366
+ if (rel === '' || rel.startsWith('..') || rel.includes(`..${sep}`)) {
367
+ throw new ApproveFromError(`Refusing to prune a path outside the baselines directory: ${path}`);
368
+ }
369
+ }
370
+ /**
371
+ * Writes `<baselines>/manifest.json` from the candidate run's captured
372
+ * environment. `schemaVersion`/`captureSchema` are re-stamped from this
373
+ * tuffgal's constants (the single source of truth for the file format), so a
374
+ * candidate carrying a stale schema number is normalised on promotion.
375
+ */
376
+ async function writeManifest(baselinesDir, environment) {
377
+ const manifest = {
378
+ ...environment,
379
+ schemaVersion: SCHEMA_VERSION,
380
+ captureSchema: CAPTURE_SCHEMA,
381
+ };
382
+ await writeText(join(baselinesDir, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`);
383
+ }
384
+ //# sourceMappingURL=approveFrom.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approveFrom.js","sourceRoot":"","sources":["../../src/runner/approveFrom.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,39 @@
1
+ import type { ResolvedBreakpoint, ResolvedConfig } from '../config.ts';
2
+ import type { StoryResult } from '../schema/result.ts';
3
+ import type { ScheduledStory } from './scheduler.ts';
4
+ /**
5
+ * True when `item` renders at `breakpoint` — i.e. its resolved run set (its own
6
+ * `breakpoints` override, else the project default) contains a breakpoint with
7
+ * the same name AND dimensions.
8
+ */
9
+ export declare function storyRendersAt(item: ScheduledStory, config: ResolvedConfig, breakpoint: ResolvedBreakpoint): boolean;
10
+ /**
11
+ * The ordered list of breakpoint passes a run executes. Multi-breakpoint runs
12
+ * are driven as one pass per breakpoint — a full reset/seed then the whole
13
+ * schedule — so each breakpoint sees a pristine database instead of inheriting
14
+ * the previous breakpoint's mutations (the password a `mobile` pass changed no
15
+ * longer leaks into the `desktop` pass). Order follows `config.breakpoints`
16
+ * first so the default run keeps its configured order, then appends any extra
17
+ * breakpoints that only per-story overrides introduce. Deduped by name+dims.
18
+ */
19
+ export declare function resolveBreakpointPasses(stories: ScheduledStory[], config: ResolvedConfig): ResolvedBreakpoint[];
20
+ /**
21
+ * Re-keys a pass's stories so the scheduler only waits on prerequisites that
22
+ * actually run in THIS pass. A `needs` label whose producer renders at a
23
+ * different breakpoint than this pass would otherwise never clear and deadlock
24
+ * the drain; that producer instead persisted its auth state to disk in its own
25
+ * (earlier) pass, so the dependency is already satisfied off-disk. `produces` is
26
+ * left intact so failure cascades still propagate within the pass.
27
+ */
28
+ export declare function adaptNeedsForPass(participating: ScheduledStory[]): ScheduledStory[];
29
+ /**
30
+ * Folds one story's per-pass results back into the single `StoryResult` the
31
+ * report expects. Each part is the same story at one breakpoint; their actions
32
+ * (already tagged with their breakpoint) concatenate in pass order, the status
33
+ * is the worst across passes, the window spans the earliest start to the latest
34
+ * finish, and the first trace zip produced wins (mirroring the single-pass
35
+ * "first failing breakpoint" rule). A single part returns an equivalent result,
36
+ * so single-breakpoint runs are unchanged.
37
+ */
38
+ export declare function mergeStoryResults(parts: StoryResult[]): StoryResult;
39
+ //# sourceMappingURL=breakpointPasses.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"breakpointPasses.d.ts","sourceRoot":"","sources":["../../src/runner/breakpointPasses.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAYrD;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,cAAc,EACpB,MAAM,EAAE,cAAc,EACtB,UAAU,EAAE,kBAAkB,GAC7B,OAAO,CAGT;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,cAAc,EAAE,EACzB,MAAM,EAAE,cAAc,GACrB,kBAAkB,EAAE,CActB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,aAAa,EAAE,cAAc,EAAE,GAC9B,cAAc,EAAE,CASlB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,WAAW,CA6BnE"}
@@ -0,0 +1,107 @@
1
+ import { mergeStoryStatus, resolveRunSet } from "./runStory.js";
2
+ /**
3
+ * A breakpoint pass is uniquely a (name, width, height) triple: two stories may
4
+ * both name `desktop` but override its dimensions differently, and those are
5
+ * genuinely separate render targets that each want their own clean database.
6
+ */
7
+ function passKey(breakpoint) {
8
+ return `${breakpoint.name}@${breakpoint.width}x${breakpoint.height}`;
9
+ }
10
+ /**
11
+ * True when `item` renders at `breakpoint` — i.e. its resolved run set (its own
12
+ * `breakpoints` override, else the project default) contains a breakpoint with
13
+ * the same name AND dimensions.
14
+ */
15
+ export function storyRendersAt(item, config, breakpoint) {
16
+ const key = passKey(breakpoint);
17
+ return resolveRunSet(item.story, config).some((bp) => passKey(bp) === key);
18
+ }
19
+ /**
20
+ * The ordered list of breakpoint passes a run executes. Multi-breakpoint runs
21
+ * are driven as one pass per breakpoint — a full reset/seed then the whole
22
+ * schedule — so each breakpoint sees a pristine database instead of inheriting
23
+ * the previous breakpoint's mutations (the password a `mobile` pass changed no
24
+ * longer leaks into the `desktop` pass). Order follows `config.breakpoints`
25
+ * first so the default run keeps its configured order, then appends any extra
26
+ * breakpoints that only per-story overrides introduce. Deduped by name+dims.
27
+ */
28
+ export function resolveBreakpointPasses(stories, config) {
29
+ const seen = new Set();
30
+ const passes = [];
31
+ const add = (bp) => {
32
+ const key = passKey(bp);
33
+ if (seen.has(key))
34
+ return;
35
+ seen.add(key);
36
+ passes.push(bp);
37
+ };
38
+ for (const bp of config.breakpoints)
39
+ add(bp);
40
+ for (const item of stories) {
41
+ for (const bp of resolveRunSet(item.story, config))
42
+ add(bp);
43
+ }
44
+ return passes;
45
+ }
46
+ /**
47
+ * Re-keys a pass's stories so the scheduler only waits on prerequisites that
48
+ * actually run in THIS pass. A `needs` label whose producer renders at a
49
+ * different breakpoint than this pass would otherwise never clear and deadlock
50
+ * the drain; that producer instead persisted its auth state to disk in its own
51
+ * (earlier) pass, so the dependency is already satisfied off-disk. `produces` is
52
+ * left intact so failure cascades still propagate within the pass.
53
+ */
54
+ export function adaptNeedsForPass(participating) {
55
+ const producesInPass = new Set();
56
+ for (const item of participating) {
57
+ for (const label of item.produces)
58
+ producesInPass.add(label);
59
+ }
60
+ return participating.map((item) => ({
61
+ ...item,
62
+ needs: item.needs.filter((label) => producesInPass.has(label)),
63
+ }));
64
+ }
65
+ /**
66
+ * Folds one story's per-pass results back into the single `StoryResult` the
67
+ * report expects. Each part is the same story at one breakpoint; their actions
68
+ * (already tagged with their breakpoint) concatenate in pass order, the status
69
+ * is the worst across passes, the window spans the earliest start to the latest
70
+ * finish, and the first trace zip produced wins (mirroring the single-pass
71
+ * "first failing breakpoint" rule). A single part returns an equivalent result,
72
+ * so single-breakpoint runs are unchanged.
73
+ */
74
+ export function mergeStoryResults(parts) {
75
+ if (parts.length === 1)
76
+ return parts[0];
77
+ const first = parts[0];
78
+ let status = first.status;
79
+ let startedAt = first.startedAt;
80
+ let finishedAt = first.finishedAt;
81
+ let durationMs = 0;
82
+ let tracePath;
83
+ const actions = [];
84
+ for (const part of parts) {
85
+ status = mergeStoryStatus(status, part.status);
86
+ if (part.startedAt < startedAt)
87
+ startedAt = part.startedAt;
88
+ if (part.finishedAt > finishedAt)
89
+ finishedAt = part.finishedAt;
90
+ durationMs += part.durationMs;
91
+ if (tracePath === undefined && part.tracePath !== undefined) {
92
+ tracePath = part.tracePath;
93
+ }
94
+ actions.push(...part.actions);
95
+ }
96
+ return {
97
+ story: first.story,
98
+ file: first.file,
99
+ status,
100
+ startedAt,
101
+ finishedAt,
102
+ durationMs,
103
+ actions,
104
+ tracePath,
105
+ };
106
+ }
107
+ //# sourceMappingURL=breakpointPasses.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"breakpointPasses.js","sourceRoot":"","sources":["../../src/runner/breakpointPasses.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,9 @@
1
1
  import type { ResolvedConfig } from '../../config.ts';
2
2
  /**
3
- * Invokes the consumer-supplied database reset. Called once at the start of
4
- * a run, before any story dispatches. No-op when the consumer did not supply
5
- * a `database.reset` function (e.g., a static site with no backend).
3
+ * Invokes the consumer-supplied database reset. Called once per breakpoint
4
+ * pass, before that pass's stories dispatch, so each breakpoint renders against
5
+ * a freshly seeded database. No-op when the consumer did not supply a
6
+ * `database.reset` function (e.g., a static site with no backend).
6
7
  */
7
8
  export declare function resetDatabase(config: ResolvedConfig): Promise<void>;
8
9
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../../src/runner/bridges/database.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAMzE;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CASf"}
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../../src/runner/bridges/database.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAMzE;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CASf"}
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Invokes the consumer-supplied database reset. Called once at the start of
3
- * a run, before any story dispatches. No-op when the consumer did not supply
4
- * a `database.reset` function (e.g., a static site with no backend).
2
+ * Invokes the consumer-supplied database reset. Called once per breakpoint
3
+ * pass, before that pass's stories dispatch, so each breakpoint renders against
4
+ * a freshly seeded database. No-op when the consumer did not supply a
5
+ * `database.reset` function (e.g., a static site with no backend).
5
6
  */
6
7
  export async function resetDatabase(config) {
7
8
  const reset = config.database?.reset;
@@ -1 +1 @@
1
- {"version":3,"file":"database.js","sourceRoot":"","sources":["../../../src/runner/bridges/database.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAsB;IACxD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC;IACrC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO;IACT,CAAC;IACD,MAAM,KAAK,EAAE,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,MAAsB,EACtB,IAAY;IAEZ,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC3C,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;QACrE,MAAM,IAAI,KAAK,CACb,qBAAqB,IAAI,2CAA2C,KAAK,EAAE,CAC5E,CAAC;IACJ,CAAC;IACD,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["../../../src/runner/bridges/database.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAsB;IACxD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC;IACrC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO;IACT,CAAC;IACD,MAAM,KAAK,EAAE,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,MAAsB,EACtB,IAAY;IAEZ,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC3C,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;QACrE,MAAM,IAAI,KAAK,CACb,qBAAqB,IAAI,2CAA2C,KAAK,EAAE,CAC5E,CAAC;IACJ,CAAC;IACD,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;AACzB,CAAC"}
@@ -0,0 +1,34 @@
1
+ import type { RunMode } from './mode.ts';
2
+ import type { RunResult } from '../schema/result.ts';
3
+ /**
4
+ * The process exit code the CLI reports for a completed run. A small, pure
5
+ * derivation kept apart from the run driver so its precedence table is
6
+ * unit-testable in isolation.
7
+ *
8
+ * Precedence, highest wins:
9
+ * - `1` — one or more stories failed (a step threw / the harness broke).
10
+ * Always outranks every other signal: a broken run's failure is the
11
+ * headline, not the visual diff it never got to compute cleanly.
12
+ * - `3` — CI mode only: the committed environment manifest is present but its
13
+ * pixel-affecting keys diverge from this run's capture environment (a
14
+ * platform/browser/capture-mode/… drift). The comparison still ran — this
15
+ * is not an abort — but the diffs are computed against baselines rendered in
16
+ * a different environment, so the signal is "expect a full re-approve",
17
+ * distinct from ordinary pending changes. Outranks `2`: an environment
18
+ * mismatch subsumes and explains the `new`/`changed` churn it produces.
19
+ * - `2` — CI mode only: pending baseline changes a human must approve —
20
+ * `new` or `changed` stories, or `deleted` (orphaned committed baselines an
21
+ * approve/prune should retire). This is the PR-gate signal — the run itself
22
+ * succeeded, but committed baselines don't yet reflect the current UI. A
23
+ * deleted-only run still gates: the baseline set has drifted from the story
24
+ * set even though nothing rendered differently.
25
+ * - `0` — clean: every story passed, the environment matched (or no manifest
26
+ * to check), and no baselines were orphaned.
27
+ *
28
+ * Local mode never emits `2` or `3`: local runs are advisory and never read
29
+ * `paths.baselines` at all (no committed manifest to check, no committed set to
30
+ * gate against), so `new`/`changed`/`deleted`/mismatch there are informational.
31
+ * It keeps the "fail only on failed" behaviour (`1` or `0`).
32
+ */
33
+ export declare function deriveExitCode(mode: RunMode, totals: RunResult['totals'], mismatch: boolean): 0 | 1 | 2 | 3;
34
+ //# sourceMappingURL=exitCode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exitCode.d.ts","sourceRoot":"","sources":["../../src/runner/exitCode.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,EAC3B,QAAQ,EAAE,OAAO,GAChB,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAOf"}