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,150 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * A11y companion suffix for a breakpoint-keyed baseline PNG. `<action>/<bp>.png`
5
+ * carries `<action>/<bp>.a11y.yaml` beside it (see `baselineStore.pathsFor`).
6
+ * Exported so `approve --from` derives the same companion filename from the
7
+ * shared constant instead of re-literalling the suffix at its own producer/
8
+ * consumer boundary.
9
+ */
10
+ export const A11Y_SUFFIX = '.a11y.yaml';
11
+ /**
12
+ * Pre-breakpoint (legacy) baseline files: a project baselined before the
13
+ * per-breakpoint layout committed `<action>/0.png` + `<action>/a11y.yaml`. The
14
+ * scan keys these under the synthetic breakpoint name `'legacy'` so a prune step
15
+ * can delete them without re-deriving the layout. Exported so `approve --from`
16
+ * shares the same legacy-layout literals rather than re-declaring them.
17
+ */
18
+ export const LEGACY_PNG = '0.png';
19
+ export const LEGACY_A11Y = 'a11y.yaml';
20
+ export const LEGACY_BREAKPOINT = 'legacy';
21
+ /**
22
+ * Non-action files that may sit at the baselines root and must never be treated
23
+ * as orphan candidates. `manifest.json` (the environment manifest, wave 6) is
24
+ * the known one; it is a file, not an action directory, so the readdir walk
25
+ * already skips it by virtue of only descending into directories — this set is a
26
+ * belt-and-suspenders guard for any future flat file a sibling wave drops here.
27
+ */
28
+ const NON_ACTION_ENTRIES = new Set(['manifest.json']);
29
+ /**
30
+ * Whether the orphan scan should run for this invocation. Two gates, both
31
+ * must hold:
32
+ * - CI mode only. Local runs never read `paths.baselines` (they self-diff
33
+ * against the per-machine cache), so there is no committed set to orphan.
34
+ * - Unfiltered runs only. A `--story` filter runs a deliberate subset, so
35
+ * every unselected story's baseline would look orphaned when it is merely
36
+ * unvisited; a filtered run must never mark a live baseline `deleted`.
37
+ *
38
+ * Kept pure and separate from `run.ts` so the CI/local × filtered/unfiltered
39
+ * matrix is unit-testable without launching a browser.
40
+ */
41
+ export function shouldScanForOrphans(mode, storyFilter) {
42
+ return mode === 'ci' && storyFilter === undefined;
43
+ }
44
+ /**
45
+ * The distinct set of action names that ran at least one result this run. An
46
+ * action directory under `paths.baselines` whose name is absent from this set
47
+ * had no story compare against it — it is a candidate orphan. Derived from the
48
+ * flat `stories[].actions[]` walk so `skipped` entries (an earlier step failed)
49
+ * still count the action as executed: the baseline was reachable this run, the
50
+ * story simply broke before reaching it, which is a failure to surface — not a
51
+ * baseline to retire.
52
+ */
53
+ export function executedActionNames(stories) {
54
+ const names = new Set();
55
+ for (const story of stories) {
56
+ for (const action of story.actions) {
57
+ names.add(action.action);
58
+ }
59
+ }
60
+ return names;
61
+ }
62
+ /**
63
+ * Scans `baselinesDir` for orphaned committed baselines: action directories no
64
+ * story executed this run. Detection only — the returned entries carry the
65
+ * absolute paths a later `approve --prune` deletes; this function never touches
66
+ * the filesystem beyond reading directory listings.
67
+ *
68
+ * Caller contract (enforced in `run.ts`, documented here so the module reads
69
+ * standalone): only meaningful for an UNFILTERED CI run. A `--story`-filtered run
70
+ * executes a deliberate subset, so every unselected story's baseline would look
71
+ * orphaned; the caller skips the scan entirely on a filtered run rather than
72
+ * mark live baselines `deleted`. Local mode never reads `paths.baselines` at all,
73
+ * so it never calls this.
74
+ *
75
+ * Layout handled (mirrors `baselineStore.pathsFor`):
76
+ * - breakpoint-keyed `<action>/<breakpoint>.png` (+ `.a11y.yaml` companion)
77
+ * - legacy `<action>/0.png` (+ `a11y.yaml` companion)
78
+ *
79
+ * One {@link DeletedBaseline} per orphaned breakpoint (or the single `'legacy'`
80
+ * group), each bundling the PNG with its a11y companion when present. Files that
81
+ * are neither a recognised PNG nor a matching companion are ignored, so a stray
82
+ * file in an orphaned dir neither crashes the scan nor smuggles an untracked
83
+ * path into the prune list.
84
+ */
85
+ export async function scanOrphanedBaselines(baselinesDir, executed) {
86
+ let entries;
87
+ try {
88
+ const dirents = await readdir(baselinesDir, { withFileTypes: true });
89
+ entries = dirents.map((dirent) => ({
90
+ name: dirent.name,
91
+ isDir: dirent.isDirectory(),
92
+ }));
93
+ }
94
+ catch {
95
+ // No baselines directory yet (a never-approved project) — nothing to orphan.
96
+ return [];
97
+ }
98
+ const orphans = [];
99
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
100
+ if (!entry.isDir)
101
+ continue;
102
+ if (NON_ACTION_ENTRIES.has(entry.name))
103
+ continue;
104
+ if (executed.has(entry.name))
105
+ continue;
106
+ const actionDir = join(baselinesDir, entry.name);
107
+ orphans.push(...(await scanOrphanedActionDir(entry.name, actionDir)));
108
+ }
109
+ return orphans;
110
+ }
111
+ /**
112
+ * Groups one orphaned action directory's baseline files into per-breakpoint
113
+ * {@link DeletedBaseline} entries. Each PNG anchors a group; its a11y companion
114
+ * (breakpoint-keyed `<bp>.a11y.yaml`, or legacy `a11y.yaml`) joins the same group
115
+ * when it exists on disk. Files matching neither shape are skipped.
116
+ */
117
+ async function scanOrphanedActionDir(action, actionDir) {
118
+ let files;
119
+ try {
120
+ files = await readdir(actionDir);
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ const present = new Set(files);
126
+ const groups = [];
127
+ for (const file of files.slice().sort()) {
128
+ if (file === LEGACY_PNG) {
129
+ const paths = [join(actionDir, LEGACY_PNG)];
130
+ if (present.has(LEGACY_A11Y))
131
+ paths.push(join(actionDir, LEGACY_A11Y));
132
+ groups.push({
133
+ action,
134
+ breakpoint: LEGACY_BREAKPOINT,
135
+ baselinePaths: paths,
136
+ });
137
+ continue;
138
+ }
139
+ if (file.endsWith('.png')) {
140
+ const breakpoint = file.slice(0, -'.png'.length);
141
+ const companion = `${breakpoint}${A11Y_SUFFIX}`;
142
+ const paths = [join(actionDir, file)];
143
+ if (present.has(companion))
144
+ paths.push(join(actionDir, companion));
145
+ groups.push({ action, breakpoint, baselinePaths: paths });
146
+ }
147
+ }
148
+ return groups;
149
+ }
150
+ //# sourceMappingURL=orphanScan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orphanScan.js","sourceRoot":"","sources":["../../src/runner/orphanScan.ts"],"names":[],"mappings":"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"}
@@ -1,11 +1,21 @@
1
1
  import type { ResolvedConfig } from '../config.ts';
2
- import type { RunResult } from '../schema/result.ts';
2
+ import type { EnvironmentReport, RunResult, StoryResult, StoryStatus } from '../schema/result.ts';
3
+ import { type RunMode } from './mode.ts';
4
+ import { type CapturedBrowser } from './manifest.ts';
3
5
  export interface RunCliOptions {
4
6
  storyFilter?: string;
5
7
  headed: boolean;
6
8
  workers?: number;
7
9
  manageServers?: boolean;
8
10
  coverage?: boolean;
11
+ /**
12
+ * Resolved comparison contract (see {@link RunMode}). Governs baseline-write
13
+ * vs candidate-write in `runAction` and the run's exit-code derivation. The
14
+ * CLI always supplies it (resolved from `--ci`/`--local`/`$CI`); it stays
15
+ * optional so existing programmatic callers keep compiling, defaulting to
16
+ * `local` (the legacy auto-write behaviour) when omitted.
17
+ */
18
+ mode?: RunMode;
9
19
  }
10
20
  /**
11
21
  * Loads every action and story from the configured paths, resets the
@@ -15,4 +25,78 @@ export interface RunCliOptions {
15
25
  * code.
16
26
  */
17
27
  export declare function runAll(config: ResolvedConfig, options: RunCliOptions): Promise<RunResult>;
28
+ /**
29
+ * Copies the just-written `results.json` into `<report>/candidates/` so the
30
+ * candidate tree is a self-contained approval artifact. `writeReport` has
31
+ * already written `<report>/results.json`; this places a sibling copy under
32
+ * candidates. The candidates dir may not exist yet (a run where every action
33
+ * passed writes no candidate renders), so it is created first — an empty
34
+ * `candidates/` carrying only `results.json` still correctly describes "nothing
35
+ * to approve".
36
+ */
37
+ export declare function copyResultsIntoCandidates(reportDir: string): Promise<void>;
38
+ /**
39
+ * The baseline root the screen-coverage metric measures against: committed
40
+ * `paths.baselines` in CI mode, per-machine `paths.localCache` in local mode.
41
+ * A thin, coverage-named alias over the shared {@link comparisonRootFor} so the
42
+ * metric counts screens against the SAME set the run (via `runAction`) diffed
43
+ * against — the mode→root mapping now lives once, in `mode.ts`. Preserved as a
44
+ * named export because the coverage metric's PRD invariant (a local run never
45
+ * reads `paths.baselines`) reads most clearly against a coverage-specific name.
46
+ */
47
+ export declare function coverageComparisonRoot(config: ResolvedConfig, mode: RunMode): string;
48
+ /**
49
+ * Builds the run's {@link EnvironmentReport}: the environment this run captured
50
+ * under (`actual`), and — in CI mode — the committed `<baselines>/manifest.json`
51
+ * (`expected`) plus whether their pixel-affecting keys diverge.
52
+ *
53
+ * The live browser-version probe ({@link captureBrowserIdentity}) runs in CI
54
+ * mode ONLY. Local mode never reads `paths.baselines`, so `expected` is always
55
+ * `null` and `browserVersion` is never compared against anything — launching a
56
+ * throwaway chromium just to stamp a value nothing reads is pure cost, so local
57
+ * mode fills the browser identity with an empty sentinel and skips the probe.
58
+ *
59
+ * In CI mode a missing manifest is the bootstrap case (no expectation yet, no
60
+ * mismatch); a malformed one surfaces as a mismatch note (see
61
+ * {@link compareEnvironment}). `expected` carries the parsed manifest only when
62
+ * it read cleanly, so a malformed file reports `null` here while still driving a
63
+ * mismatch.
64
+ *
65
+ * `probe` is the live browser-identity reader, injected (defaulting to the real
66
+ * {@link captureBrowserIdentity}) so a test can assert it is invoked in CI mode
67
+ * and NEVER invoked in local mode without launching a real chromium.
68
+ */
69
+ export declare function resolveEnvironmentReport(config: ResolvedConfig, mode: RunMode, probe?: () => Promise<CapturedBrowser>): Promise<EnvironmentReport>;
70
+ /**
71
+ * Rolls a set of story results into the outcome counts. `deleted` counts
72
+ * orphaned baselines, which are a run-level detection (not a per-story outcome),
73
+ * so it always seeds `0` here; `runAll` overwrites the run-total `deleted` from
74
+ * the orphan scan. Per-breakpoint pass summaries keep `0`, which is correct —
75
+ * an orphan is not attributable to a single breakpoint pass.
76
+ */
77
+ export declare function summarise(results: StoryResult[]): RunResult['totals'];
78
+ /**
79
+ * One streaming finish line: `<symbol> <actionCount> <elapsed>s <stem>`. The
80
+ * count is how many steps the story ran, the elapsed time is wall-clock
81
+ * duration to hundredths of a second, and the stem is the story file without
82
+ * its `.json` extension.
83
+ */
84
+ export declare function formatResultLine(status: StoryStatus, actionCount: number, durationMs: number, file: string): string;
85
+ /**
86
+ * One Summary bullet for a breakpoint pass: `• <parts> on "<name>" breakpoint`,
87
+ * where `<parts>` joins only the nonzero outcome categories in a fixed order
88
+ * (passed, new, changed, failed) — e.g. `2 passed, 1 changed`. Falls back to
89
+ * `0 passed` for the degenerate all-zero pass the run loop never actually
90
+ * emits (it `continue`s past a pass with no participating stories).
91
+ */
92
+ export declare function formatSummaryBullet(name: string, counts: RunResult['totals']): string;
93
+ /**
94
+ * The breakpoint names that drove a merged story to its status — the modes the
95
+ * reader actually needs to inspect. A `changed` story that drifted only at
96
+ * `desktop` tags `[desktop]`, not the `mobile` pass that stayed clean. Returns
97
+ * empty (no tag) when the story spans a single breakpoint, since there is
98
+ * nothing to disambiguate. The merged actions still carry their `breakpoint`
99
+ * tag, so this is derived, not separately tracked.
100
+ */
101
+ export declare function drivingBreakpoints(result: StoryResult): string[];
18
102
  //# sourceMappingURL=run.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/runner/run.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,qBAAqB,CAAC;AAmBlE,MAAM,WAAW,aAAa;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAID;;;;;;GAMG;AACH,wBAAsB,MAAM,CAC1B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,SAAS,CAAC,CAsEpB"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/runner/run.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAsB,cAAc,EAAE,MAAM,cAAc,CAAC;AAEvE,OAAO,KAAK,EACV,iBAAiB,EACjB,SAAS,EACT,WAAW,EACX,WAAW,EACZ,MAAM,qBAAqB,CAAC;AA8B7B,OAAO,EAAqB,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,aAAa;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAUD;;;;;;GAMG;AACH,wBAAsB,MAAM,CAC1B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,SAAS,CAAC,CA0KpB;AA4DD;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAIf;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,OAAO,GACZ,MAAM,CAER;AA6BD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,OAAO,EACb,KAAK,GAAE,MAAM,OAAO,CAAC,eAAe,CAA0B,GAC7D,OAAO,CAAC,iBAAiB,CAAC,CAe5B;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC,QAAQ,CAAC,CASrE;AAUD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,MAAM,CAIR;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,GAC1B,MAAM,CAQR;AAoBD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,CAuBhE"}
@@ -1,7 +1,8 @@
1
- import { mkdir, writeFile } from 'node:fs/promises';
1
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises';
2
2
  import { cpus } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
+ import { chromium } from 'playwright';
5
6
  import { loadActions, loadStories } from "../schema/load.js";
6
7
  import { writeReport } from "../reporter/writeReport.js";
7
8
  import { computeFlowCoverage } from "../coverage/flows.js";
@@ -9,9 +10,14 @@ import { computeScreenCoverage } from "../coverage/screens.js";
9
10
  import { resetDatabase } from "./bridges/database.js";
10
11
  import { startManagedDevServers, } from "./bridges/devServers.js";
11
12
  import { CoverageCollector } from "./coverage.js";
12
- import { runStory } from "./runStory.js";
13
+ import { mergeStoryStatus, runStory } from "./runStory.js";
13
14
  import { buildSchedule, drainSchedule, } from "./scheduler.js";
15
+ import { adaptNeedsForPass, mergeStoryResults, resolveBreakpointPasses, storyRendersAt, } from "./breakpointPasses.js";
14
16
  import { storyMatchesFilter } from "./storyFilter.js";
17
+ import { candidatesDir } from "../screenshots/baselineStore.js";
18
+ import { executedActionNames, scanOrphanedBaselines, shouldScanForOrphans, } from "./orphanScan.js";
19
+ import { comparisonRootFor } from "./mode.js";
20
+ import { captureEnvironment, compareEnvironment, readManifest, } from "./manifest.js";
15
21
  const HEARTBEAT_FILE = '.heartbeat';
16
22
  /**
17
23
  * Loads every action and story from the configured paths, resets the
@@ -22,6 +28,7 @@ const HEARTBEAT_FILE = '.heartbeat';
22
28
  */
23
29
  export async function runAll(config, options) {
24
30
  const startedAt = new Date();
31
+ const mode = options.mode ?? 'local';
25
32
  let managedServers;
26
33
  if (options.manageServers) {
27
34
  managedServers = await startManagedDevServers(config);
@@ -33,10 +40,6 @@ export async function runAll(config, options) {
33
40
  ? new CoverageCollector(config.paths.report)
34
41
  : undefined;
35
42
  try {
36
- if (config.database?.reset) {
37
- process.stdout.write('Resetting test database…\n');
38
- await resetDatabase(config);
39
- }
40
43
  const actions = await loadActions(config.paths.actions);
41
44
  const allStories = await loadStories(config.paths.stories);
42
45
  const scheduled = buildSchedule(allStories);
@@ -47,23 +50,100 @@ export async function runAll(config, options) {
47
50
  throw new Error(`No story matched filter "${options.storyFilter}"`);
48
51
  }
49
52
  const workerCount = resolveWorkerCount(config, options.workers);
50
- process.stdout.write(`Scheduling ${subset.length} stories on ${workerCount} worker${workerCount === 1 ? '' : 's'}.\n`);
51
- const results = await drainSchedule(subset, workerCount, (item) => runScheduledStory(item, actions, config, options.headed, coverage), (item) => process.stdout.write(`▶ ${item.file}\n`), (item, result) => process.stdout.write(` ${result.status.toUpperCase()} ${item.file} (${result.actions.length} actions, ${result.durationMs} ms)\n`));
53
+ const passes = resolveBreakpointPasses(subset, config);
54
+ const multiPass = passes.length > 1;
55
+ process.stdout.write(`Scheduling ${subset.length} stories on ${workerCount} worker${workerCount === 1 ? '' : 's'}` +
56
+ (multiPass ? ` across ${passes.length} breakpoint passes` : '') +
57
+ '.\n');
58
+ // Run each breakpoint as its own pass: a full reset/seed, then the whole
59
+ // schedule rendered at that one breakpoint. This is what keeps breakpoints
60
+ // isolated — a destructive story can mutate the seeded database in the
61
+ // `mobile` pass without the `desktop` pass ever seeing it, because the next
62
+ // pass starts from a fresh reset. Results are merged back per story below.
63
+ const partsByFile = new Map();
64
+ const order = [];
65
+ const passSummaries = [];
66
+ for (const breakpoint of passes) {
67
+ const participating = subset.filter((item) => storyRendersAt(item, config, breakpoint));
68
+ if (participating.length === 0)
69
+ continue;
70
+ // Header for every pass — single- and multi-breakpoint alike. The reset
71
+ // sits beneath the header so the "fresh database" line reads as part of
72
+ // this pass's setup; a trailing blank line then separates setup from the
73
+ // streaming result lines.
74
+ process.stdout.write(`\nStarting "${breakpoint.name}" breakpoint pass at ${breakpoint.width}x${breakpoint.height}\n`);
75
+ if (config.database?.reset) {
76
+ process.stdout.write('Resetting test database…\n');
77
+ await resetDatabase(config);
78
+ }
79
+ process.stdout.write('\n');
80
+ const passResults = await drainSchedule(adaptNeedsForPass(participating), workerCount, (item) => runScheduledStory(item, actions, config, options.headed, coverage, breakpoint, mode), () => { }, (_item, result) => process.stdout.write(`${formatResultLine(result.status, result.actions.length, result.durationMs, result.file)}\n`));
81
+ passSummaries.push({
82
+ name: breakpoint.name,
83
+ counts: summarise(passResults),
84
+ });
85
+ for (const result of passResults) {
86
+ let parts = partsByFile.get(result.file);
87
+ if (!parts) {
88
+ parts = [];
89
+ partsByFile.set(result.file, parts);
90
+ order.push(result.file);
91
+ }
92
+ parts.push(result);
93
+ }
94
+ }
95
+ const results = order.map((file) => mergeStoryResults(partsByFile.get(file)));
52
96
  const finishedAt = new Date();
97
+ // Screen coverage measures how many screens have a baseline in the set this
98
+ // run compares against — the committed `paths.baselines` in CI mode, the
99
+ // per-machine `paths.localCache` in local mode. Local runs never read
100
+ // `paths.baselines` (PRD invariant). Both the metric and `runAction` pick
101
+ // their root through the shared `comparisonRootFor`, so the metric measures
102
+ // against the exact set the run diffed against; its meaning ("screens with a
103
+ // baseline to diff against") is preserved rather than blanked.
53
104
  const [screens, flows] = await Promise.all([
54
- computeScreenCoverage(config.paths.actions, config.paths.baselines),
105
+ computeScreenCoverage(config.paths.actions, coverageComparisonRoot(config, mode)),
55
106
  computeFlowCoverage(config.flowInventory, allStories),
56
107
  ]);
108
+ // Orphan scan: committed baselines whose action ran no story this run are
109
+ // retired candidates (status `deleted`). Only meaningful for an UNFILTERED
110
+ // CI run — a `--story` filter runs a deliberate subset, so unselected
111
+ // stories' baselines would look orphaned when they are merely unvisited; we
112
+ // skip the scan rather than mark live baselines deleted. Local mode never
113
+ // reads `paths.baselines`, so it never scans. Detection only — pruning is a
114
+ // later wave, so the baselines directory stays untouched here.
115
+ const deleted = shouldScanForOrphans(mode, options.storyFilter)
116
+ ? await scanOrphanedBaselines(config.paths.baselines, executedActionNames(results))
117
+ : [];
118
+ const totals = summarise(results);
119
+ totals.deleted = deleted.length;
120
+ // Capture-environment provenance: what this run rendered under, and — in CI
121
+ // mode — whether it drifted from the committed baselines' manifest. Local
122
+ // mode never reads `paths.baselines`, so `expected` stays null and mismatch
123
+ // is always false (see resolveEnvironmentReport).
124
+ const environment = await resolveEnvironmentReport(config, mode);
57
125
  const runResult = {
58
126
  startedAt: startedAt.toISOString(),
59
127
  finishedAt: finishedAt.toISOString(),
60
128
  durationMs: finishedAt.getTime() - startedAt.getTime(),
61
- totals: summarise(results),
129
+ mode,
130
+ totals,
131
+ environment,
62
132
  customCoverage: { screens, flows },
133
+ deleted,
63
134
  stories: results,
64
135
  };
65
- const reportPath = await writeReport(config.paths.report, runResult);
66
- writeRunSummary(results, runResult.totals, reportPath);
136
+ const reportPath = await writeReport(config.paths.report, runResult, config.interactiveMode);
137
+ // In CI mode the `<report>/candidates/` tree is the self-contained approval
138
+ // artifact — copy the run's `results.json` beside the candidate renders so a
139
+ // downstream `approve --from <candidates>` has the outcome data (which
140
+ // actions are new/changed, and later the environment/deleted blocks) without
141
+ // needing the rest of the report dir. Only meaningful in CI mode, where the
142
+ // candidate tree exists.
143
+ if (mode === 'ci') {
144
+ await copyResultsIntoCandidates(config.paths.report);
145
+ }
146
+ writeRunSummary(passSummaries, reportPath);
67
147
  if (coverage) {
68
148
  const coveragePath = await coverage.generate();
69
149
  process.stdout.write(`Coverage: ${coveragePath}\n`);
@@ -76,7 +156,7 @@ export async function runAll(config, options) {
76
156
  }
77
157
  }
78
158
  }
79
- function runScheduledStory(item, actions, config, headed, coverage) {
159
+ function runScheduledStory(item, actions, config, headed, coverage, breakpoint, mode) {
80
160
  return runStory({
81
161
  story: item.story,
82
162
  file: item.file,
@@ -86,6 +166,8 @@ function runScheduledStory(item, actions, config, headed, coverage) {
86
166
  config,
87
167
  headed,
88
168
  coverage,
169
+ breakpoint,
170
+ mode,
89
171
  });
90
172
  }
91
173
  function matchesFilter(item, filter) {
@@ -111,44 +193,188 @@ async function touchHeartbeat(config) {
111
193
  // hiccup should not fail the entire run.
112
194
  }
113
195
  }
114
- function summarise(results) {
196
+ /**
197
+ * Copies the just-written `results.json` into `<report>/candidates/` so the
198
+ * candidate tree is a self-contained approval artifact. `writeReport` has
199
+ * already written `<report>/results.json`; this places a sibling copy under
200
+ * candidates. The candidates dir may not exist yet (a run where every action
201
+ * passed writes no candidate renders), so it is created first — an empty
202
+ * `candidates/` carrying only `results.json` still correctly describes "nothing
203
+ * to approve".
204
+ */
205
+ export async function copyResultsIntoCandidates(reportDir) {
206
+ const dir = candidatesDir(reportDir);
207
+ await mkdir(dir, { recursive: true });
208
+ await copyFile(join(reportDir, 'results.json'), join(dir, 'results.json'));
209
+ }
210
+ /**
211
+ * The baseline root the screen-coverage metric measures against: committed
212
+ * `paths.baselines` in CI mode, per-machine `paths.localCache` in local mode.
213
+ * A thin, coverage-named alias over the shared {@link comparisonRootFor} so the
214
+ * metric counts screens against the SAME set the run (via `runAction`) diffed
215
+ * against — the mode→root mapping now lives once, in `mode.ts`. Preserved as a
216
+ * named export because the coverage metric's PRD invariant (a local run never
217
+ * reads `paths.baselines`) reads most clearly against a coverage-specific name.
218
+ */
219
+ export function coverageComparisonRoot(config, mode) {
220
+ return comparisonRootFor(config, mode);
221
+ }
222
+ /**
223
+ * Reads the launched Chromium's `browser.version()` so the run's environment
224
+ * manifest records the exact browser build the baselines were rendered against —
225
+ * the single pixel-affecting fact that only a live browser can report. Launched
226
+ * throwaway (not the per-story browsers, which spin up and tear down inside the
227
+ * schedule) because the version is invariant across stories, so one probe covers
228
+ * the whole run. Kept narrow: launch, read, close.
229
+ */
230
+ async function captureBrowserIdentity() {
231
+ const browser = await chromium.launch({ headless: true });
232
+ try {
233
+ return { name: 'chromium', version: browser.version() };
234
+ }
235
+ finally {
236
+ await browser.close();
237
+ }
238
+ }
239
+ /**
240
+ * The browser identity stamped into a LOCAL run's environment block. Local mode
241
+ * skips the live probe (see {@link resolveEnvironmentReport}), so `version` is an
242
+ * empty sentinel: it is shape-valid for the manifest, never compared (local
243
+ * `expected` is always null), and never promoted (local candidates are refused
244
+ * by `approve --from`). Kept as chromium's name for provenance, since that is
245
+ * still the browser a local run renders under.
246
+ */
247
+ const LOCAL_BROWSER_IDENTITY = { name: 'chromium', version: '' };
248
+ /**
249
+ * Builds the run's {@link EnvironmentReport}: the environment this run captured
250
+ * under (`actual`), and — in CI mode — the committed `<baselines>/manifest.json`
251
+ * (`expected`) plus whether their pixel-affecting keys diverge.
252
+ *
253
+ * The live browser-version probe ({@link captureBrowserIdentity}) runs in CI
254
+ * mode ONLY. Local mode never reads `paths.baselines`, so `expected` is always
255
+ * `null` and `browserVersion` is never compared against anything — launching a
256
+ * throwaway chromium just to stamp a value nothing reads is pure cost, so local
257
+ * mode fills the browser identity with an empty sentinel and skips the probe.
258
+ *
259
+ * In CI mode a missing manifest is the bootstrap case (no expectation yet, no
260
+ * mismatch); a malformed one surfaces as a mismatch note (see
261
+ * {@link compareEnvironment}). `expected` carries the parsed manifest only when
262
+ * it read cleanly, so a malformed file reports `null` here while still driving a
263
+ * mismatch.
264
+ *
265
+ * `probe` is the live browser-identity reader, injected (defaulting to the real
266
+ * {@link captureBrowserIdentity}) so a test can assert it is invoked in CI mode
267
+ * and NEVER invoked in local mode without launching a real chromium.
268
+ */
269
+ export async function resolveEnvironmentReport(config, mode, probe = captureBrowserIdentity) {
270
+ if (mode !== 'ci') {
271
+ // Local mode never gates on `browserVersion` (expected is always null), so
272
+ // skip the browser launch entirely. The empty sentinel is shape-valid and
273
+ // never promoted — a local run writes no candidate tree, and `approve --from`
274
+ // refuses non-ci candidates outright.
275
+ const actual = captureEnvironment(config, LOCAL_BROWSER_IDENTITY);
276
+ return { expected: null, actual, mismatch: false, mismatchKeys: [] };
277
+ }
278
+ const actual = captureEnvironment(config, await probe());
279
+ const read = await readManifest(config.paths.baselines);
280
+ const { mismatch, mismatchKeys } = compareEnvironment(read, actual);
281
+ const expected = read.status === 'ok' ? read.manifest : null;
282
+ return { expected, actual, mismatch, mismatchKeys };
283
+ }
284
+ /**
285
+ * Rolls a set of story results into the outcome counts. `deleted` counts
286
+ * orphaned baselines, which are a run-level detection (not a per-story outcome),
287
+ * so it always seeds `0` here; `runAll` overwrites the run-total `deleted` from
288
+ * the orphan scan. Per-breakpoint pass summaries keep `0`, which is correct —
289
+ * an orphan is not attributable to a single breakpoint pass.
290
+ */
291
+ export function summarise(results) {
115
292
  return {
116
293
  stories: results.length,
117
294
  passed: results.filter((result) => result.status === 'pass').length,
118
295
  changed: results.filter((result) => result.status === 'changed').length,
119
296
  failed: results.filter((result) => result.status === 'failed').length,
297
+ new: results.filter((result) => result.status === 'new').length,
298
+ deleted: 0,
120
299
  };
121
300
  }
301
+ /** Status → leading glyph for a streaming result line. */
302
+ const STATUS_SYMBOL = {
303
+ pass: '✓',
304
+ changed: '~',
305
+ new: '+',
306
+ failed: '✗',
307
+ };
122
308
  /**
123
- * Emits the end-of-run tail. The streaming `▶`/`PASS|CHANGED|FAILED` lines
124
- * already cover the heartbeat; this groups the noteworthy stories
125
- * (changed + failed) in finish order so a user scanning the terminal lands
126
- * on the actionable list directly above the totals. The `Report:` line is
127
- * a `file://` URL so terminals that recognise file URIs (iTerm2, Warp, VS
128
- * Code) render it as a clickable link.
309
+ * One streaming finish line: `<symbol> <actionCount> <elapsed>s <stem>`. The
310
+ * count is how many steps the story ran, the elapsed time is wall-clock
311
+ * duration to hundredths of a second, and the stem is the story file without
312
+ * its `.json` extension.
129
313
  */
130
- function writeRunSummary(results, totals, reportPath) {
131
- const changed = results.filter((result) => result.status === 'changed');
132
- const failed = results.filter((result) => result.status === 'failed');
133
- if (changed.length > 0 || failed.length > 0) {
134
- process.stdout.write('\n');
135
- if (changed.length > 0) {
136
- process.stdout.write('Changed:\n');
137
- for (const result of changed) {
138
- process.stdout.write(` ${formatSummaryLine(result)}\n`);
139
- }
140
- }
141
- if (failed.length > 0) {
142
- process.stdout.write('Failed:\n');
143
- for (const result of failed) {
144
- process.stdout.write(` ${formatSummaryLine(result)}\n`);
145
- }
146
- }
314
+ export function formatResultLine(status, actionCount, durationMs, file) {
315
+ const elapsed = (durationMs / 1000).toFixed(2);
316
+ const stem = file.replace(/\.json$/, '');
317
+ return `${STATUS_SYMBOL[status]} ${actionCount} ${elapsed}s ${stem}`;
318
+ }
319
+ /**
320
+ * One Summary bullet for a breakpoint pass: `• <parts> on "<name>" breakpoint`,
321
+ * where `<parts>` joins only the nonzero outcome categories in a fixed order
322
+ * (passed, new, changed, failed) — e.g. `2 passed, 1 changed`. Falls back to
323
+ * `0 passed` for the degenerate all-zero pass the run loop never actually
324
+ * emits (it `continue`s past a pass with no participating stories).
325
+ */
326
+ export function formatSummaryBullet(name, counts) {
327
+ const parts = [];
328
+ if (counts.passed > 0)
329
+ parts.push(`${counts.passed} passed`);
330
+ if (counts.new > 0)
331
+ parts.push(`${counts.new} new`);
332
+ if (counts.changed > 0)
333
+ parts.push(`${counts.changed} changed`);
334
+ if (counts.failed > 0)
335
+ parts.push(`${counts.failed} failed`);
336
+ const summary = parts.length > 0 ? parts.join(', ') : '0 passed';
337
+ return `• ${summary} on "${name}" breakpoint`;
338
+ }
339
+ /**
340
+ * Emits the end-of-run tail: a `Summary` section with one bullet per breakpoint
341
+ * pass — counted from that pass's own results, not the merged-across-passes
342
+ * rollup — then the `Report:` line. The report link is a `file://` URL so
343
+ * terminals that recognise file URIs (iTerm2, Warp, VS Code) render it as a
344
+ * clickable link.
345
+ */
346
+ function writeRunSummary(passSummaries, reportPath) {
347
+ process.stdout.write('\nSummary\n');
348
+ for (const pass of passSummaries) {
349
+ process.stdout.write(`${formatSummaryBullet(pass.name, pass.counts)}\n`);
147
350
  }
148
- process.stdout.write(`\nTotals: ${totals.passed} pass · ${totals.changed} changed · ${totals.failed} failed\n`);
149
- process.stdout.write(`Report: ${pathToFileURL(reportPath).href}\n`);
351
+ process.stdout.write(`\nReport: ${pathToFileURL(reportPath).href}\n`);
150
352
  }
151
- function formatSummaryLine(result) {
152
- return `${result.status.toUpperCase()} ${result.file} — ${result.story} (${result.actions.length} actions, ${result.durationMs} ms)`;
353
+ /**
354
+ * The breakpoint names that drove a merged story to its status — the modes the
355
+ * reader actually needs to inspect. A `changed` story that drifted only at
356
+ * `desktop` tags `[desktop]`, not the `mobile` pass that stayed clean. Returns
357
+ * empty (no tag) when the story spans a single breakpoint, since there is
358
+ * nothing to disambiguate. The merged actions still carry their `breakpoint`
359
+ * tag, so this is derived, not separately tracked.
360
+ */
361
+ export function drivingBreakpoints(result) {
362
+ const order = [];
363
+ const perBreakpoint = new Map();
364
+ for (const action of result.actions) {
365
+ const breakpoint = action.breakpoint;
366
+ if (!breakpoint)
367
+ continue;
368
+ if (!perBreakpoint.has(breakpoint)) {
369
+ perBreakpoint.set(breakpoint, 'pass');
370
+ order.push(breakpoint);
371
+ }
372
+ perBreakpoint.set(breakpoint, mergeStoryStatus(perBreakpoint.get(breakpoint),
373
+ // A skipped action means an earlier action in that breakpoint failed.
374
+ action.status === 'skipped' ? 'failed' : action.status));
375
+ }
376
+ if (order.length <= 1)
377
+ return [];
378
+ return order.filter((breakpoint) => perBreakpoint.get(breakpoint) === result.status);
153
379
  }
154
380
  //# sourceMappingURL=run.js.map