tuffgal 0.1.0-alpha.8 → 0.2.0-alpha.1

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 (97) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/README.md +82 -4
  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 +19 -2
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/config.d.ts +121 -10
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +114 -9
  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/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/reporter/assets/report.css +330 -50
  24. package/dist/reporter/assets/report.js +361 -55
  25. package/dist/reporter/template.d.ts +8 -1
  26. package/dist/reporter/template.d.ts.map +1 -1
  27. package/dist/reporter/template.js +447 -78
  28. package/dist/reporter/template.js.map +1 -1
  29. package/dist/reporter/writeReport.d.ts +2 -2
  30. package/dist/reporter/writeReport.d.ts.map +1 -1
  31. package/dist/reporter/writeReport.js +3 -3
  32. package/dist/reporter/writeReport.js.map +1 -1
  33. package/dist/runner/approve.d.ts +34 -9
  34. package/dist/runner/approve.d.ts.map +1 -1
  35. package/dist/runner/approve.js +54 -10
  36. package/dist/runner/approve.js.map +1 -1
  37. package/dist/runner/approveFrom.d.ts +58 -0
  38. package/dist/runner/approveFrom.d.ts.map +1 -0
  39. package/dist/runner/approveFrom.js +384 -0
  40. package/dist/runner/approveFrom.js.map +1 -0
  41. package/dist/runner/breakpointPasses.d.ts +39 -0
  42. package/dist/runner/breakpointPasses.d.ts.map +1 -0
  43. package/dist/runner/breakpointPasses.js +107 -0
  44. package/dist/runner/breakpointPasses.js.map +1 -0
  45. package/dist/runner/bridges/database.d.ts +4 -3
  46. package/dist/runner/bridges/database.d.ts.map +1 -1
  47. package/dist/runner/bridges/database.js +4 -3
  48. package/dist/runner/bridges/database.js.map +1 -1
  49. package/dist/runner/exitCode.d.ts +34 -0
  50. package/dist/runner/exitCode.d.ts.map +1 -0
  51. package/dist/runner/exitCode.js +41 -0
  52. package/dist/runner/exitCode.js.map +1 -0
  53. package/dist/runner/manifest.d.ts +149 -0
  54. package/dist/runner/manifest.d.ts.map +1 -0
  55. package/dist/runner/manifest.js +229 -0
  56. package/dist/runner/manifest.js.map +1 -0
  57. package/dist/runner/mode.d.ts +41 -0
  58. package/dist/runner/mode.d.ts.map +1 -0
  59. package/dist/runner/mode.js +43 -0
  60. package/dist/runner/mode.js.map +1 -0
  61. package/dist/runner/orphanScan.d.ts +68 -0
  62. package/dist/runner/orphanScan.d.ts.map +1 -0
  63. package/dist/runner/orphanScan.js +150 -0
  64. package/dist/runner/orphanScan.js.map +1 -0
  65. package/dist/runner/run.d.ts +85 -1
  66. package/dist/runner/run.d.ts.map +1 -1
  67. package/dist/runner/run.js +267 -41
  68. package/dist/runner/run.js.map +1 -1
  69. package/dist/runner/runAction.d.ts +22 -0
  70. package/dist/runner/runAction.d.ts.map +1 -1
  71. package/dist/runner/runAction.js +146 -24
  72. package/dist/runner/runAction.js.map +1 -1
  73. package/dist/runner/runStory.d.ts +40 -2
  74. package/dist/runner/runStory.d.ts.map +1 -1
  75. package/dist/runner/runStory.js +195 -62
  76. package/dist/runner/runStory.js.map +1 -1
  77. package/dist/runner/storyFilter.d.ts +8 -0
  78. package/dist/runner/storyFilter.d.ts.map +1 -1
  79. package/dist/runner/storyFilter.js +11 -0
  80. package/dist/runner/storyFilter.js.map +1 -1
  81. package/dist/schema/action.d.ts +11 -11
  82. package/dist/schema/result.d.ts +124 -6
  83. package/dist/schema/result.d.ts.map +1 -1
  84. package/dist/schema/result.js.map +1 -1
  85. package/dist/schema/story.d.ts +15 -4
  86. package/dist/schema/story.d.ts.map +1 -1
  87. package/dist/schema/story.js +35 -11
  88. package/dist/schema/story.js.map +1 -1
  89. package/dist/screenshots/baselineStore.d.ts +111 -5
  90. package/dist/screenshots/baselineStore.d.ts.map +1 -1
  91. package/dist/screenshots/baselineStore.js +132 -20
  92. package/dist/screenshots/baselineStore.js.map +1 -1
  93. package/dist/screenshots/capture.d.ts +10 -5
  94. package/dist/screenshots/capture.d.ts.map +1 -1
  95. package/dist/screenshots/capture.js +31 -18
  96. package/dist/screenshots/capture.js.map +1 -1
  97. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,138 @@ this project uses [Pride Versioning](https://pridever.org) → `PROUD.DEFAULT.SH
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ _Nothing just yet_
10
+
11
+ ## [0.2.0-alpha.1] — 2026-07-10
12
+
13
+ CI-owned baselines. CI is now the sole writer of committed baselines. Local
14
+ runs are advisory self-diffs. Committed visual changes are a PR review
15
+ gate, approved from a CI candidate tree rather than by any local `approve`.
16
+
17
+ ### Added
18
+
19
+ - Run modes. `tuffgal run` resolves `--ci` / `--local`
20
+ - Explicit flag wins
21
+ - With neither, CI when `$CI` is truth-y, else local
22
+ - CI mode writes a self-contained candidate tree with a `results.json` copy
23
+ - Never writes committed baselines
24
+ - A missing baseline is `new` (candidate only)
25
+ - An orphaned one is `deleted`
26
+ - `approve --from <dir>` promotes a CI candidate tree into `paths.baselines`
27
+ - The only code path that writes committed baselines
28
+ - Refuses any candidate whose `results.json` is not a clean CI run (`mode !== 'ci'` or `totals.failed > 0`)
29
+ - `--prune` retires baselines marked `deleted`
30
+ - Environment manifest at `baselines/manifest.json`
31
+ - Written by `approve --from` and checked by `run --ci`
32
+ - Pixel-affecting mismatch still runs the comparison but banners the report and exits `3`
33
+ - Local advisory mode compares against a git-ignored per-machine cache
34
+ - Plain `approve` now refreshes that cache
35
+ - CI exit codes
36
+ - `2` (pending `new`/`changed`/`deleted` to approve)
37
+ - `3` (environment mismatch)
38
+ - Precedence `1` > `3` > `2`
39
+ - `results.json` fields
40
+ - `mode`
41
+ - `environment` (`expected`/`actual`/`mismatch`/`mismatchKeys`)
42
+ - `deleted[]`
43
+ - `totals.deleted`
44
+ - `totals.new`
45
+ - Report additions
46
+ - Environment-mismatch banner
47
+ - Deleted baselines section
48
+ - Per-action candidate + a11y-only-drift notes
49
+
50
+ ### Changed
51
+
52
+ - **Breaking:** `tuffgal approve` no longer writes committed baselines
53
+ - Plain `approve` targets the local cache only
54
+ - Committed baselines are written solely by `approve --from` (CI/bot promo path)
55
+ - Local runs are advisory
56
+ - Exit `0` on visual drift
57
+ - Exit `1` on failed stories only
58
+ - Exit codes `2` and `3` are CI-mode only
59
+ - In CI mode an accessibility-tree drift now gates status
60
+ - Pixels can match while the aria snapshot differs
61
+ - Flips the action to `changed`
62
+ - Stays advisory (informational) in local mode
63
+ - Every committed baseline, candidate, and cache write is losslessly recompressed
64
+ - pngjs, deflate level 9 + adaptive filtering
65
+ - Never lossy, never quantized
66
+
67
+ ## [0.1.0-alpha.14] — 2026-07-04
68
+
69
+ ### Fixed
70
+
71
+ A `viewport` capture no longer resets the page's scroll offset before shooting.
72
+
73
+ ## [0.1.0-alpha.13] — 2026-06-30
74
+
75
+ ### Changed
76
+
77
+ HTML report filters have been merged with the summary row. Plus a few other
78
+ miscellaneous UI/UX improvements.
79
+
80
+ ## [0.1.0-alpha.12] — 2026-06-27
81
+
82
+ ### Fixed
83
+
84
+ `tuffgal` did nothing when launched through the installed `.bin` symlink,
85
+ e.g. `npm run`, `npx`, or a bare `tuffgal`. The entry-point check compared
86
+ the unresolved symlink path against the symlink-resolved module URL, so
87
+ `main()` never ran. The script now resolves `argv[1]` through realpath
88
+ before the comparison.
89
+
90
+ ## [0.1.0-alpha.11] — 2026-06-26
91
+
92
+ ### Added
93
+
94
+ - Approve by breakpoint (e.g. `approve --desktop`, `approve --breakpoint <name>`)
95
+ - Approve by story (e.g. `approve user-logs-in`)
96
+ - `captureMode` config to limit screenshots to viewport-only or full page
97
+ - Documentation for CLI and reports
98
+ - `interactiveMode` config to view baseline/actual/diffs in a single-image view
99
+ - New baselines can now be filtered in the HTML report
100
+ - New baselines are now listed in the terminal summary report
101
+
102
+ ### Changed
103
+
104
+ - Screenshots default to viewport-only (were previously full page)
105
+
106
+ ### Fixed
107
+
108
+ - Multiple breakpoint runs no longer leak database state
109
+ - Report filters now only show matching actions inside each story
110
+
111
+ ## [0.1.0-alpha.10] — 2026-06-22
112
+
113
+ ### Added
114
+
115
+ - `${breakpoint}` interpolation token
116
+
117
+ ### Fixed
118
+
119
+ - Per-breakpoint database isolation
120
+
121
+ ## [0.1.0-alpha.9] — 2026-06-21
122
+
123
+ ### Added
124
+
125
+ - Breakpoint modes, replacing the single `viewport` config
126
+ - `mobile` 375×667 / `tablet` 768×1024 / `laptop` 1024×768 / `desktop` 1280×800
127
+ - Each mode runs in its own browser context + produces its own baselines
128
+ - Each `config.breakpoints` entry is a bare name (registry dimensions) or `{ name, width?, height? }` to override that mode's size; an omitted axis inherits the registry default
129
+ - Order preserved; when a name repeats, the first entry wins
130
+ - Omit `breakpoints` to run a single `desktop` mode (1280×800)
131
+ - Per-story `breakpoints` field, same shape, that **replaces** the project's modes for that story — a story may run a mode the project does not configure, and its overrides resolve against the registry
132
+ - Breakpoint grouping in the HTML report, each group labelled with the real capture size; a single-mode story renders as a flat list with no breakpoint chrome
133
+ - Pre-breakpoint baselines at `<action>/0.png` are read as fallbacks until promoted
134
+
135
+ ### Removed
136
+
137
+ **Breaking:** `viewport` config field + per-story `viewport` override. Set
138
+ a single mode's size with `breakpoints: [{ name: 'desktop', width, height }]`
139
+ instead.
140
+
9
141
  ## [0.1.0-alpha.8] — 2026-06-20
10
142
 
11
143
  ### Fixed
@@ -117,7 +249,14 @@ styling or interactivity.
117
249
  Initial public alpha. Tuffgal extracted from [Linklater](https://github.com/nschneble/linklater)'s
118
250
  in-tree visual testing workspace.
119
251
 
120
- [Unreleased]: https://github.com/nschneble/tuffgal/compare/v0.1.0-alpha.8...HEAD
252
+ [Unreleased]: https://github.com/nschneble/tuffgal/compare/v0.2.0-alpha.1...HEAD
253
+ [0.2.0-alpha.1]: https://github.com/nschneble/tuffgal/releases/tag/v0.2.0-alpha.1
254
+ [0.1.0-alpha.14]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.14
255
+ [0.1.0-alpha.13]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.13
256
+ [0.1.0-alpha.12]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.12
257
+ [0.1.0-alpha.11]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.11
258
+ [0.1.0-alpha.10]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.10
259
+ [0.1.0-alpha.9]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.9
121
260
  [0.1.0-alpha.8]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.8
122
261
  [0.1.0-alpha.7]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.7
123
262
  [0.1.0-alpha.6]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.6
package/README.md CHANGED
@@ -6,7 +6,7 @@ JSON-driven visual regression testing for web apps.
6
6
 
7
7
  <img src="tuffgal.png" alt="Tuffgal" />
8
8
 
9
- **Status:** Pre-1.0. Published on npm as `tuffgal@0.1.0-alpha.7` with
9
+ **Status:** Pre-1.0. Published on npm as `tuffgal@0.1.0-alpha.{n}` with
10
10
  provenance. [Linklater](https://github.com/nschneble/linklater) is the
11
11
  pilot consumer.
12
12
 
@@ -18,23 +18,99 @@ Tuffgal sits between component tests (which are fast but mocked) and
18
18
  end-to-end tests (which are real but verbose). You write **actions**
19
19
  (atomic user steps) and **stories** (chains of actions) as pure JSON. The
20
20
  harness runs them in a real browser, captures a screenshot after each
21
- story, and pixel-diffs against a baseline you commit alongside your code.
21
+ action, and pixel-diffs against a committed baseline.
22
22
 
23
23
  When a screenshot changes, a human reviews the diff and decides what to do.
24
24
 
25
+ ## CI owns the baselines
26
+
27
+ Committed baselines are the source of truth for what your UI is supposed to look
28
+ like, and **CI is their only writer**. That makes a visual change a pull-request
29
+ review gate, exactly like a code change: CI renders the current UI, diffs it
30
+ against the committed baselines, and if anything drifted it fails the check and
31
+ publishes the new screenshots as an artifact. A human looks at the diff and,
32
+ if the change is intended, promotes those screenshots into the committed set. No
33
+ local run can overwrite the baselines your reviewers rely on.
34
+
35
+ A `tuffgal run` executes under one of two modes:
36
+
37
+ - **CI mode** compares against the committed baselines (`paths.baselines`) and
38
+ never writes them. A missing baseline is `new`, an orphaned one is `deleted`,
39
+ and drift is `changed` — all of which are written into a self-contained
40
+ **candidate tree** under `<report>/candidates/` for approval, not committed in
41
+ place. CI mode gates: pending changes exit `2`.
42
+ - **Local mode** is advisory. It compares against a gitignored, per-machine
43
+ cache (`paths.localCache`) so you can self-diff while you iterate, seeding a
44
+ missing entry on first sight. A local run **never reads or writes the
45
+ committed baselines**, and never fails your build on visual drift — it surfaces
46
+ the signal in the report and on stdout and **exits `0`**. Only a failed story
47
+ (a step that threw) exits `1`; the CI-gating codes `2` and `3` never fire
48
+ locally.
49
+
50
+ Mode resolves from `--ci` / `--local` (an explicit flag always wins). With
51
+ neither, Tuffgal picks CI when `$CI` is truthy and local otherwise, so the same
52
+ `tuffgal run` does the right thing on a laptop and in a workflow.
53
+
54
+ ### Approving a visual change
55
+
56
+ Two ways to accept a `changed`/`new` candidate into the committed baselines,
57
+ both writing through the single `approve --from` promotion path:
58
+
59
+ - **Download and commit.** Grab the `candidates` artifact CI uploaded, run
60
+ `tuffgal approve --from <dir>` against it, and commit the resulting baselines.
61
+ `--from` refuses any candidate that is not a clean CI run (`mode !== 'ci'` or a
62
+ failed story), so a local or broken run can never be promoted. Add `--prune`
63
+ to also retire baselines the run flagged as `deleted`.
64
+ - **Comment on the PR.** The intended flow is a `@tuffgal approve` PR comment
65
+ that runs the same promotion on the CI side. The Action that handles this is a
66
+ downstream, not-yet-shipped repo, so treat this as the direction of travel
67
+ rather than a shipped button today.
68
+
69
+ Plain `tuffgal approve` (no `--from`) targets your **local cache** only — it
70
+ accepts your own self-diff so your next local run is clean. It does not touch the
71
+ committed set.
72
+
73
+ ### The environment manifest
74
+
75
+ `approve --from` also writes `baselines/manifest.json` recording the environment
76
+ the promoted baselines were captured under (capture schema, browser version,
77
+ platform, capture mode, breakpoints, device scale factor, frozen time). On the
78
+ next `run --ci` Tuffgal checks the live capture environment against it. A
79
+ pixel-affecting mismatch still runs the comparison, but banners the report and
80
+ exits `3` — the signal is "expect a full re-approve", distinct from ordinary
81
+ pending changes.
82
+
83
+ ### Exit codes
84
+
85
+ | Code | Mode | Meaning |
86
+ | ---- | ---- | ------------------------------------------------------------------------- |
87
+ | `0` | both | Clean: no failures; in CI, no pending changes and the environment matched |
88
+ | `1` | both | One or more stories failed (a step threw). Highest precedence |
89
+ | `3` | CI | Committed environment manifest diverged from this run's environment |
90
+ | `2` | CI | Pending baseline changes to approve (`new`, `changed`, or `deleted`) |
91
+
92
+ Precedence is `1` > `3` > `2` > `0`. Local mode only ever exits `1` or `0`.
93
+
25
94
  ## What ships in v1
26
95
 
27
96
  - 9 step primitives composed into actions: `click`, `input`, `intercept`,
28
97
  `navigate`, `read`, `scroll`, `type`, `wait`, `waitFor`
29
98
  - An implicit screenshot at the end of every action
99
+ - Named breakpoint modes (`mobile`, `tablet`, `laptop`, `desktop`) keyed to
100
+ Tailwind widths. Pick which to run per project or per story. Each mode
101
+ gets its own baseline and a per-mode group in the HTML report
30
102
  - DAG scheduler with `needs`/`produces` labels and parallel workers
31
- - SSIM-gated visual diff + pixelmatch overlay + a11y-tree snapshots
103
+ - CI-owned baselines: CI is the sole writer, local runs are advisory self-diff,
104
+ approval flows through a CI candidate tree + environment manifest
105
+ - SSIM-gated visual diff + pixelmatch overlay + a11y-tree snapshots (in CI an
106
+ a11y-tree drift also flips a pixel-passing action to `changed`)
32
107
  - Trace zip on failure (Playwright trace viewer)
33
108
  - Clock freeze (`page.clock.install`)
34
109
  - Storage-state persistence across stories
35
110
  - Static HTML reporter
36
111
  - V8 coverage (optional via `monocart-coverage-reports`)
37
- - Per-run DB reset + per-story fixture hooks (consumer-supplied via config)
112
+ - Per-run DB reset + per-breakpoint fixture hooks (consumer-supplied via config),
113
+ plus a `${breakpoint}` interpolation token for per-mode test data
38
114
  - Process supervisor for dev-server hot-reload rot (it happens)
39
115
 
40
116
  ## What's explicitly out of scope (v1)
@@ -63,10 +139,12 @@ composite action.
63
139
  - [Authoring guide](docs/authoring.md)
64
140
  - [Changelog](CHANGELOG.md)
65
141
  - [CI integration](docs/ci.md)
142
+ - [CLI reference](docs/cli.md)
66
143
  - [Config reference](docs/config.md)
67
144
  - [Migrating from Cypress](docs/migration-cypress.md)
68
145
  - [Migrating from Playwright](docs/migration-playwright.md)
69
146
  - [Product requirements](docs/prd-v1.md)
147
+ - [Reporting data (results.json)](docs/reporting.md)
70
148
  - [Supervisor](docs/supervisor.md)
71
149
 
72
150
  ## License
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,53 @@
1
1
  #!/usr/bin/env node
2
+ declare const COMMANDS: readonly ["approve", "help", "init", "run", "supervise"];
3
+ type Command = (typeof COMMANDS)[number];
4
+ interface ParsedArguments {
5
+ command: Command;
6
+ coverage: boolean;
7
+ headed: boolean;
8
+ manageServers: boolean;
9
+ healthcheckIntervalMs?: number;
10
+ idleLimitMs?: number;
11
+ maxRespawns?: number;
12
+ maxRuntimeMs?: number;
13
+ newOnly: boolean;
14
+ /**
15
+ * `approve --from <dir>`: promote a CI candidate tree into the committed
16
+ * baselines. The one command that writes `paths.baselines`.
17
+ */
18
+ from?: string;
19
+ /** `approve --prune`: delete orphaned baselines listed in the candidate results. */
20
+ prune: boolean;
21
+ /** `--ci` flag: force CI comparison mode. Mutually exclusive with `--local`. */
22
+ ci: boolean;
23
+ /** `--local` flag: force local (advisory) mode. Mutually exclusive with `--ci`. */
24
+ local: boolean;
25
+ storyFilter?: string;
26
+ /** Bare positional argument (e.g. `approve user-logs-in`). */
27
+ positional?: string;
28
+ /** `--breakpoint <name>` (repeatable) plus `--<name>` shorthands. */
29
+ breakpoints: string[];
30
+ workers?: number;
31
+ }
32
+ export declare function parseArguments(argv: string[]): ParsedArguments;
33
+ /**
34
+ * Validates cross-command flag placement, returning the first error message or
35
+ * `undefined` when the flag set is legal for the parsed command. Pure (no
36
+ * `process.exit`) so the command × flag matrix is unit-testable in isolation;
37
+ * `main` calls it and routes any message through {@link failExit}. Covers the
38
+ * flags that only mean something on a specific subcommand:
39
+ * - `--new-only`, `--breakpoint`/`--<name>`, and a bare positional narrow an
40
+ * approval set — meaningless on any command but `approve`.
41
+ * - `--from`/`--prune` promote a CI candidate tree — only on `approve`.
42
+ * - `--ci`/`--local` pick the comparison contract — meaningful only on `run`.
43
+ *
44
+ * `approve` itself is two disjoint modes: plain (cache refresh, accepts the
45
+ * local-narrowing filters) and `--from` (baseline promotion, a whole-tree copy).
46
+ * The filters target the plain cache mode, so `--from` rejects them for v1 —
47
+ * partial `--from` approvals are a PRD backlog item. `--prune` is a `--from`
48
+ * modifier and is meaningless without it.
49
+ */
50
+ export declare function validateCommandFlags(args: ParsedArguments): string | undefined;
51
+ export declare function isMainEntry(moduleUrl: string, scriptPath: string | undefined): boolean;
2
52
  export {};
3
53
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAcA,QAAA,MAAM,QAAQ,0DAA2D,CAAC;AAC1E,KAAK,OAAO,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAKzC,UAAU,eAAe;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,OAAO,CAAC;IACvB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,KAAK,EAAE,OAAO,CAAC;IACf,gFAAgF;IAChF,EAAE,EAAE,OAAO,CAAC;IACZ,mFAAmF;IACnF,KAAK,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,eAAe,CA8G9D;AA6ED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,eAAe,GACpB,MAAM,GAAG,SAAS,CAoCpB;AAoGD,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,OAAO,CAWT"}
package/dist/cli.js CHANGED
@@ -1,11 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
2
5
  import { approveAll } from "./runner/approve.js";
6
+ import { approveFrom } from "./runner/approveFrom.js";
3
7
  import { init } from "./commands/init.js";
4
- import { loadConfig } from "./config.js";
8
+ import { BREAKPOINTS, loadConfig } from "./config.js";
9
+ import { deriveExitCode } from "./runner/exitCode.js";
10
+ import { resolveRunMode } from "./runner/mode.js";
5
11
  import { runAll } from "./runner/run.js";
12
+ import { normaliseStoryArg } from "./runner/storyFilter.js";
6
13
  import { supervise } from "./commands/supervise.js";
7
14
  const COMMANDS = ['approve', 'help', 'init', 'run', 'supervise'];
8
- function parseArguments(argv) {
15
+ /** Registry breakpoint names that double as `--<name>` approve shorthands. */
16
+ const BREAKPOINT_FLAGS = Object.keys(BREAKPOINTS);
17
+ export function parseArguments(argv) {
9
18
  const [command, ...rest] = argv;
10
19
  const parsed = {
11
20
  command: COMMANDS.includes(command)
@@ -15,6 +24,17 @@ function parseArguments(argv) {
15
24
  manageServers: false,
16
25
  coverage: false,
17
26
  newOnly: false,
27
+ prune: false,
28
+ ci: false,
29
+ local: false,
30
+ breakpoints: [],
31
+ };
32
+ const addBreakpoint = (name) => {
33
+ if (name === undefined || name === '') {
34
+ throw new Error('--breakpoint requires a mode name (e.g. desktop)');
35
+ }
36
+ if (!parsed.breakpoints.includes(name))
37
+ parsed.breakpoints.push(name);
18
38
  };
19
39
  for (let index = 0; index < rest.length; index += 1) {
20
40
  const arg = rest[index];
@@ -30,6 +50,23 @@ function parseArguments(argv) {
30
50
  else if (arg.startsWith('--story=')) {
31
51
  parsed.storyFilter = arg.slice('--story='.length);
32
52
  }
53
+ else if (arg === '--breakpoint') {
54
+ addBreakpoint(rest[index + 1]);
55
+ index += 1;
56
+ }
57
+ else if (arg.startsWith('--breakpoint=')) {
58
+ addBreakpoint(arg.slice('--breakpoint='.length));
59
+ }
60
+ else if (arg.startsWith('--') &&
61
+ BREAKPOINT_FLAGS.includes(arg.slice(2))) {
62
+ addBreakpoint(arg.slice(2));
63
+ }
64
+ else if (!arg.startsWith('-')) {
65
+ if (parsed.positional !== undefined) {
66
+ throw new Error(`unexpected extra argument "${arg}" (already got "${parsed.positional}")`);
67
+ }
68
+ parsed.positional = arg;
69
+ }
33
70
  else if (arg === '--workers') {
34
71
  parsed.workers = numericFlag('--workers', rest[index + 1]);
35
72
  index += 1;
@@ -46,6 +83,22 @@ function parseArguments(argv) {
46
83
  else if (arg === '--new-only') {
47
84
  parsed.newOnly = true;
48
85
  }
86
+ else if (arg === '--from') {
87
+ parsed.from = stringFlag('--from', rest[index + 1]);
88
+ index += 1;
89
+ }
90
+ else if (arg.startsWith('--from=')) {
91
+ parsed.from = stringFlag('--from', arg.slice('--from='.length));
92
+ }
93
+ else if (arg === '--prune') {
94
+ parsed.prune = true;
95
+ }
96
+ else if (arg === '--ci') {
97
+ parsed.ci = true;
98
+ }
99
+ else if (arg === '--local') {
100
+ parsed.local = true;
101
+ }
49
102
  else if (arg === '--healthcheck-interval') {
50
103
  parsed.healthcheckIntervalMs = numericFlag('--healthcheck-interval', rest[index + 1]);
51
104
  index += 1;
@@ -93,13 +146,20 @@ function numericFlag(flag, raw) {
93
146
  }
94
147
  return value;
95
148
  }
149
+ /** Parses a string-valued CLI flag, rejecting a missing/empty value. */
150
+ function stringFlag(flag, raw) {
151
+ if (raw === undefined || raw === '' || raw.startsWith('-')) {
152
+ throw new Error(`${flag} requires a value (got "${raw ?? ''}")`);
153
+ }
154
+ return raw;
155
+ }
96
156
  function printHelp() {
97
157
  process.stdout.write([
98
158
  'Usage: tuffgal <command> [options]',
99
159
  '',
100
160
  'Commands:',
101
161
  ' run Run every story under the configured stories directory.',
102
- ' approve Promote every "changed" actual to its baseline.',
162
+ ' approve Refresh the local comparison cache from the last run.',
103
163
  ' init Scaffold a tuffgal.config.ts in the current directory.',
104
164
  ' supervise Long-running wrapper around devServers.command with',
105
165
  ' healthcheck restart, idle auto-term, and wall-clock cap.',
@@ -107,13 +167,24 @@ function printHelp() {
107
167
  '',
108
168
  'Options:',
109
169
  ' --story <name> Filter to a single story (filename or story text).',
170
+ ' --ci Force CI mode (compare vs committed baselines). Default when $CI is set.',
171
+ ' --local Force local advisory mode (self-diff vs per-machine cache).',
110
172
  ' --headed Show the browser while running.',
111
173
  ' --workers N Override the worker pool size (default min(cpus/2, 4)).',
112
174
  ' --manage-servers Spawn devServers.command, wait, run, then kill it.',
113
175
  ' --coverage Capture V8 JS + CSS coverage and emit a monocart report.',
114
176
  '',
115
- 'Approve options:',
116
- ' --new-only Only promote new baselines; skip changed.',
177
+ 'Approve options (local cache refresh):',
178
+ ' <story> Positional story to approve (name or path); same as --story.',
179
+ ' --new-only Only refresh new cache entries; skip changed.',
180
+ ' --breakpoint <name> Only approve this mode; repeatable. Also --desktop/--mobile/etc.',
181
+ '',
182
+ 'Approve options (CI baseline promotion):',
183
+ ' --from <dir> Promote a CI candidate tree into the committed baselines.',
184
+ ' The only command that writes paths.baselines. Rejects the',
185
+ ' cache-refresh filters above (whole-tree copy).',
186
+ ' --prune With --from, delete orphaned baselines the candidate',
187
+ ' run recorded as deleted. Requires --from.',
117
188
  '',
118
189
  'Supervise options:',
119
190
  ' --healthcheck-interval N Probe interval in ms (default 30_000).',
@@ -122,14 +193,69 @@ function printHelp() {
122
193
  ' --max-respawns N Respawn budget after unhealthy/exit (default 3).',
123
194
  ].join('\n') + '\n');
124
195
  }
196
+ function failExit(message) {
197
+ process.stderr.write(`tuffgal error: ${message}\n`);
198
+ process.exit(1);
199
+ }
200
+ /**
201
+ * Validates cross-command flag placement, returning the first error message or
202
+ * `undefined` when the flag set is legal for the parsed command. Pure (no
203
+ * `process.exit`) so the command × flag matrix is unit-testable in isolation;
204
+ * `main` calls it and routes any message through {@link failExit}. Covers the
205
+ * flags that only mean something on a specific subcommand:
206
+ * - `--new-only`, `--breakpoint`/`--<name>`, and a bare positional narrow an
207
+ * approval set — meaningless on any command but `approve`.
208
+ * - `--from`/`--prune` promote a CI candidate tree — only on `approve`.
209
+ * - `--ci`/`--local` pick the comparison contract — meaningful only on `run`.
210
+ *
211
+ * `approve` itself is two disjoint modes: plain (cache refresh, accepts the
212
+ * local-narrowing filters) and `--from` (baseline promotion, a whole-tree copy).
213
+ * The filters target the plain cache mode, so `--from` rejects them for v1 —
214
+ * partial `--from` approvals are a PRD backlog item. `--prune` is a `--from`
215
+ * modifier and is meaningless without it.
216
+ */
217
+ export function validateCommandFlags(args) {
218
+ if (args.command !== 'approve') {
219
+ if (args.newOnly) {
220
+ return '--new-only is only valid with the `approve` subcommand';
221
+ }
222
+ if (args.breakpoints.length > 0) {
223
+ return '--breakpoint (and --desktop/--mobile/…) is only valid with the `approve` subcommand';
224
+ }
225
+ if (args.positional !== undefined) {
226
+ return `unexpected argument "${args.positional}"`;
227
+ }
228
+ if (args.from !== undefined) {
229
+ return '--from is only valid with the `approve` subcommand';
230
+ }
231
+ if (args.prune) {
232
+ return '--prune is only valid with the `approve` subcommand';
233
+ }
234
+ }
235
+ if (args.prune && args.from === undefined) {
236
+ return '--prune requires --from (it prunes orphans listed in the candidate results)';
237
+ }
238
+ if (args.from !== undefined) {
239
+ if (args.storyFilter !== undefined || args.positional !== undefined) {
240
+ return '--from cannot be combined with a story filter (--from promotes the whole candidate tree)';
241
+ }
242
+ if (args.newOnly) {
243
+ return '--from cannot be combined with --new-only';
244
+ }
245
+ if (args.breakpoints.length > 0) {
246
+ return '--from cannot be combined with --breakpoint';
247
+ }
248
+ }
249
+ if (args.command !== 'run' && (args.ci || args.local)) {
250
+ return '--ci and --local are only valid with the `run` subcommand';
251
+ }
252
+ return undefined;
253
+ }
125
254
  async function main() {
126
255
  const args = parseArguments(process.argv.slice(2));
127
- if (args.newOnly &&
128
- (args.command === 'run' ||
129
- args.command === 'supervise' ||
130
- args.command === 'init')) {
131
- process.stderr.write('tuffgal error: --new-only is only valid with the `approve` subcommand\n');
132
- process.exit(1);
256
+ const flagError = validateCommandFlags(args);
257
+ if (flagError) {
258
+ failExit(flagError);
133
259
  }
134
260
  if (args.command === 'help') {
135
261
  printHelp();
@@ -141,21 +267,58 @@ async function main() {
141
267
  }
142
268
  const config = await loadConfig(process.cwd());
143
269
  if (args.command === 'run') {
270
+ let mode;
271
+ try {
272
+ mode = resolveRunMode({
273
+ ci: args.ci,
274
+ local: args.local,
275
+ env: process.env,
276
+ });
277
+ }
278
+ catch (error) {
279
+ failExit(error instanceof Error ? error.message : String(error));
280
+ }
144
281
  const result = await runAll(config, {
145
282
  storyFilter: args.storyFilter,
146
283
  headed: args.headed,
147
284
  workers: args.workers,
148
285
  manageServers: args.manageServers,
149
286
  coverage: args.coverage,
287
+ mode,
150
288
  });
151
- process.exit(result.totals.failed > 0 ? 1 : 0);
289
+ process.exit(deriveExitCode(mode, result.totals, result.environment?.mismatch ?? false));
152
290
  }
153
291
  if (args.command === 'approve') {
292
+ if (args.from !== undefined) {
293
+ // Baseline-promotion mode: promote a CI candidate tree into the committed
294
+ // baselines. `validateCommandFlags` has already rejected the local-mode
295
+ // filters, so nothing here narrows the tree.
296
+ try {
297
+ const summary = await approveFrom(config, {
298
+ from: resolve(process.cwd(), args.from),
299
+ prune: args.prune,
300
+ });
301
+ process.stdout.write(`\nPromoted ${summary.written} baselines; pruned ${summary.pruned} orphans.\n`);
302
+ }
303
+ catch (error) {
304
+ failExit(error instanceof Error ? error.message : String(error));
305
+ }
306
+ return;
307
+ }
308
+ // A story may be named positionally (`approve user-logs-in`) or via
309
+ // `--story`, but not both.
310
+ if (args.positional !== undefined && args.storyFilter !== undefined) {
311
+ failExit('name a story positionally or with --story, not both');
312
+ }
313
+ const storyFilter = args.positional !== undefined
314
+ ? normaliseStoryArg(args.positional)
315
+ : args.storyFilter;
154
316
  const summary = await approveAll(config, {
155
- storyFilter: args.storyFilter,
317
+ storyFilter,
156
318
  newOnly: args.newOnly,
319
+ breakpoints: args.breakpoints,
157
320
  });
158
- process.stdout.write(`\nApproved ${summary.approved} baselines; skipped ${summary.skipped} actions.\n`);
321
+ process.stdout.write(`\nRefreshed ${summary.approved} cache entries; skipped ${summary.skipped} actions.\n`);
159
322
  }
160
323
  if (args.command === 'supervise') {
161
324
  await supervise(config, {
@@ -166,8 +329,31 @@ async function main() {
166
329
  });
167
330
  }
168
331
  }
169
- main().catch((error) => {
170
- process.stderr.write(`tuffgal error: ${error instanceof Error ? error.message : String(error)}\n`);
171
- process.exit(1);
172
- });
332
+ // Only drive the CLI when run as the entry point. Importing this module (e.g.
333
+ // from a unit test exercising `parseArguments`) must not kick off a real run.
334
+ //
335
+ // `argv[1]` must be resolved through realpath before comparing: when installed,
336
+ // the `tuffgal` command is a symlink (`node_modules/.bin/tuffgal` ->
337
+ // `../tuffgal/dist/cli.js`). Node leaves that symlink unresolved in `argv[1]`
338
+ // but resolves it in `import.meta.url`, so a raw comparison never matches and
339
+ // `main()` silently never runs. Resolving both sides to the real file fixes it.
340
+ export function isMainEntry(moduleUrl, scriptPath) {
341
+ if (!scriptPath) {
342
+ return false;
343
+ }
344
+ let resolvedPath;
345
+ try {
346
+ resolvedPath = realpathSync(scriptPath);
347
+ }
348
+ catch {
349
+ resolvedPath = scriptPath;
350
+ }
351
+ return moduleUrl === pathToFileURL(resolvedPath).href;
352
+ }
353
+ if (isMainEntry(import.meta.url, process.argv[1])) {
354
+ main().catch((error) => {
355
+ process.stderr.write(`tuffgal error: ${error instanceof Error ? error.message : String(error)}\n`);
356
+ process.exit(1);
357
+ });
358
+ }
173
359
  //# sourceMappingURL=cli.js.map