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
package/CHANGELOG.md CHANGED
@@ -6,7 +6,147 @@ this project uses [Pride Versioning](https://pridever.org) → `PROUD.DEFAULT.SH
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- _Nothing right now_
9
+ _Nothing just yet_
10
+
11
+ ## [0.2.0-alpha.2] — 2026-07-12
12
+
13
+ Report improvements. The HTML report picks up a breakpoint filter, sharper
14
+ status filtering, and an interactive viewer redesigned around
15
+ blink-comparing what changed.
16
+
17
+ ### Added
18
+
19
+ - Breakpoint filter pills in the report summary
20
+ - `npm run preview -- --interactive` renders sample report in interactive mode
21
+ - `sizeMismatch` on `results.json` action results
22
+
23
+ ### Changed
24
+
25
+ - A status-filtered story now shows only its matching actions
26
+ - Candidate note names its referent
27
+ - Redesigned interactive mode so resting state is the selected variant
28
+ - Press and hold the screenshot to flip to its baseline counterpart
29
+ - The Baseline/Actual/Diff chips are always visible
30
+ - The Showing caption tracks whatever is displayed
31
+ - Resized-screenshot note folded into one accessible sentence
32
+ - Summary status filters restyled as colorful pills
33
+
34
+ ### Fixed
35
+
36
+ - A press-to-native-image-drag no longer leaves the preview stuck
37
+ - Bulk expand/collapse no longer pre-expands hidden screenshot panels
38
+ - "Expanded changed in 1 stories" pluralization in bulk-toggle announcement
39
+ - `npm run preview` silently never rendered interactive mode
40
+
41
+ ## [0.2.0-alpha.1] — 2026-07-10
42
+
43
+ CI-owned baselines. CI is now the sole writer of committed baselines. Local
44
+ runs are advisory self-diffs. Committed visual changes are a PR review
45
+ gate, approved from a CI candidate tree rather than by any local `approve`.
46
+
47
+ ### Added
48
+
49
+ - Run modes. `tuffgal run` resolves `--ci` / `--local`
50
+ - Explicit flag wins
51
+ - With neither, CI when `$CI` is truth-y, else local
52
+ - CI mode writes a self-contained candidate tree with a `results.json` copy
53
+ - Never writes committed baselines
54
+ - A missing baseline is `new` (candidate only)
55
+ - An orphaned one is `deleted`
56
+ - `approve --from <dir>` promotes a CI candidate tree into `paths.baselines`
57
+ - The only code path that writes committed baselines
58
+ - Refuses any candidate whose `results.json` is not a clean CI run (`mode !== 'ci'` or `totals.failed > 0`)
59
+ - `--prune` retires baselines marked `deleted`
60
+ - Environment manifest at `baselines/manifest.json`
61
+ - Written by `approve --from` and checked by `run --ci`
62
+ - Pixel-affecting mismatch still runs the comparison but banners the report and exits `3`
63
+ - Local advisory mode compares against a git-ignored per-machine cache
64
+ - Plain `approve` now refreshes that cache
65
+ - CI exit codes
66
+ - `2` (pending `new`/`changed`/`deleted` to approve)
67
+ - `3` (environment mismatch)
68
+ - Precedence `1` > `3` > `2`
69
+ - `results.json` fields
70
+ - `mode`
71
+ - `environment` (`expected`/`actual`/`mismatch`/`mismatchKeys`)
72
+ - `deleted[]`
73
+ - `totals.deleted`
74
+ - `totals.new`
75
+ - Report additions
76
+ - Environment-mismatch banner
77
+ - Deleted baselines section
78
+ - Per-action candidate + a11y-only-drift notes
79
+
80
+ ### Changed
81
+
82
+ - **Breaking:** `tuffgal approve` no longer writes committed baselines
83
+ - Plain `approve` targets the local cache only
84
+ - Committed baselines are written solely by `approve --from` (CI/bot promo path)
85
+ - Local runs are advisory
86
+ - Exit `0` on visual drift
87
+ - Exit `1` on failed stories only
88
+ - Exit codes `2` and `3` are CI-mode only
89
+ - In CI mode an accessibility-tree drift now gates status
90
+ - Pixels can match while the aria snapshot differs
91
+ - Flips the action to `changed`
92
+ - Stays advisory (informational) in local mode
93
+ - Every committed baseline, candidate, and cache write is losslessly recompressed
94
+ - pngjs, deflate level 9 + adaptive filtering
95
+ - Never lossy, never quantized
96
+
97
+ ## [0.1.0-alpha.14] — 2026-07-04
98
+
99
+ ### Fixed
100
+
101
+ A `viewport` capture no longer resets the page's scroll offset before shooting.
102
+
103
+ ## [0.1.0-alpha.13] — 2026-06-30
104
+
105
+ ### Changed
106
+
107
+ HTML report filters have been merged with the summary row. Plus a few other
108
+ miscellaneous UI/UX improvements.
109
+
110
+ ## [0.1.0-alpha.12] — 2026-06-27
111
+
112
+ ### Fixed
113
+
114
+ `tuffgal` did nothing when launched through the installed `.bin` symlink,
115
+ e.g. `npm run`, `npx`, or a bare `tuffgal`. The entry-point check compared
116
+ the unresolved symlink path against the symlink-resolved module URL, so
117
+ `main()` never ran. The script now resolves `argv[1]` through realpath
118
+ before the comparison.
119
+
120
+ ## [0.1.0-alpha.11] — 2026-06-26
121
+
122
+ ### Added
123
+
124
+ - Approve by breakpoint (e.g. `approve --desktop`, `approve --breakpoint <name>`)
125
+ - Approve by story (e.g. `approve user-logs-in`)
126
+ - `captureMode` config to limit screenshots to viewport-only or full page
127
+ - Documentation for CLI and reports
128
+ - `interactiveMode` config to view baseline/actual/diffs in a single-image view
129
+ - New baselines can now be filtered in the HTML report
130
+ - New baselines are now listed in the terminal summary report
131
+
132
+ ### Changed
133
+
134
+ - Screenshots default to viewport-only (were previously full page)
135
+
136
+ ### Fixed
137
+
138
+ - Multiple breakpoint runs no longer leak database state
139
+ - Report filters now only show matching actions inside each story
140
+
141
+ ## [0.1.0-alpha.10] — 2026-06-22
142
+
143
+ ### Added
144
+
145
+ - `${breakpoint}` interpolation token
146
+
147
+ ### Fixed
148
+
149
+ - Per-breakpoint database isolation
10
150
 
11
151
  ## [0.1.0-alpha.9] — 2026-06-21
12
152
 
@@ -139,7 +279,14 @@ styling or interactivity.
139
279
  Initial public alpha. Tuffgal extracted from [Linklater](https://github.com/nschneble/linklater)'s
140
280
  in-tree visual testing workspace.
141
281
 
142
- [Unreleased]: https://github.com/nschneble/tuffgal/compare/v0.1.0-alpha.9...HEAD
282
+ [Unreleased]: https://github.com/nschneble/tuffgal/compare/v0.2.0-alpha.2...HEAD
283
+ [0.2.0-alpha.2]: https://github.com/nschneble/tuffgal/releases/tag/v0.2.0-alpha.2
284
+ [0.2.0-alpha.1]: https://github.com/nschneble/tuffgal/releases/tag/v0.2.0-alpha.1
285
+ [0.1.0-alpha.14]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.14
286
+ [0.1.0-alpha.13]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.13
287
+ [0.1.0-alpha.12]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.12
288
+ [0.1.0-alpha.11]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.11
289
+ [0.1.0-alpha.10]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.10
143
290
  [0.1.0-alpha.9]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.9
144
291
  [0.1.0-alpha.8]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.8
145
292
  [0.1.0-alpha.7]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.7
package/README.md CHANGED
@@ -18,10 +18,79 @@ 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`,
@@ -31,13 +100,17 @@ When a screenshot changes, a human reviews the diff and decides what to do.
31
100
  Tailwind widths. Pick which to run per project or per story. Each mode
32
101
  gets its own baseline and a per-mode group in the HTML report
33
102
  - DAG scheduler with `needs`/`produces` labels and parallel workers
34
- - 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`)
35
107
  - Trace zip on failure (Playwright trace viewer)
36
108
  - Clock freeze (`page.clock.install`)
37
109
  - Storage-state persistence across stories
38
110
  - Static HTML reporter
39
111
  - V8 coverage (optional via `monocart-coverage-reports`)
40
- - 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
41
114
  - Process supervisor for dev-server hot-reload rot (it happens)
42
115
 
43
116
  ## What's explicitly out of scope (v1)
@@ -66,10 +139,12 @@ composite action.
66
139
  - [Authoring guide](docs/authoring.md)
67
140
  - [Changelog](CHANGELOG.md)
68
141
  - [CI integration](docs/ci.md)
142
+ - [CLI reference](docs/cli.md)
69
143
  - [Config reference](docs/config.md)
70
144
  - [Migrating from Cypress](docs/migration-cypress.md)
71
145
  - [Migrating from Playwright](docs/migration-playwright.md)
72
146
  - [Product requirements](docs/prd-v1.md)
147
+ - [Reporting data (results.json)](docs/reporting.md)
73
148
  - [Supervisor](docs/supervisor.md)
74
149
 
75
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