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.
- package/CHANGELOG.md +140 -1
- package/README.md +82 -4
- package/dist/cli.d.ts +50 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +204 -18
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +19 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/config.d.ts +121 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +114 -9
- package/dist/config.js.map +1 -1
- package/dist/coverage/screens.d.ts +11 -6
- package/dist/coverage/screens.d.ts.map +1 -1
- package/dist/coverage/screens.js +12 -7
- package/dist/coverage/screens.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/reporter/assets/report.css +330 -50
- package/dist/reporter/assets/report.js +361 -55
- package/dist/reporter/template.d.ts +8 -1
- package/dist/reporter/template.d.ts.map +1 -1
- package/dist/reporter/template.js +447 -78
- package/dist/reporter/template.js.map +1 -1
- package/dist/reporter/writeReport.d.ts +2 -2
- package/dist/reporter/writeReport.d.ts.map +1 -1
- package/dist/reporter/writeReport.js +3 -3
- package/dist/reporter/writeReport.js.map +1 -1
- package/dist/runner/approve.d.ts +34 -9
- package/dist/runner/approve.d.ts.map +1 -1
- package/dist/runner/approve.js +54 -10
- package/dist/runner/approve.js.map +1 -1
- package/dist/runner/approveFrom.d.ts +58 -0
- package/dist/runner/approveFrom.d.ts.map +1 -0
- package/dist/runner/approveFrom.js +384 -0
- package/dist/runner/approveFrom.js.map +1 -0
- package/dist/runner/breakpointPasses.d.ts +39 -0
- package/dist/runner/breakpointPasses.d.ts.map +1 -0
- package/dist/runner/breakpointPasses.js +107 -0
- package/dist/runner/breakpointPasses.js.map +1 -0
- package/dist/runner/bridges/database.d.ts +4 -3
- package/dist/runner/bridges/database.d.ts.map +1 -1
- package/dist/runner/bridges/database.js +4 -3
- package/dist/runner/bridges/database.js.map +1 -1
- package/dist/runner/exitCode.d.ts +34 -0
- package/dist/runner/exitCode.d.ts.map +1 -0
- package/dist/runner/exitCode.js +41 -0
- package/dist/runner/exitCode.js.map +1 -0
- package/dist/runner/manifest.d.ts +149 -0
- package/dist/runner/manifest.d.ts.map +1 -0
- package/dist/runner/manifest.js +229 -0
- package/dist/runner/manifest.js.map +1 -0
- package/dist/runner/mode.d.ts +41 -0
- package/dist/runner/mode.d.ts.map +1 -0
- package/dist/runner/mode.js +43 -0
- package/dist/runner/mode.js.map +1 -0
- package/dist/runner/orphanScan.d.ts +68 -0
- package/dist/runner/orphanScan.d.ts.map +1 -0
- package/dist/runner/orphanScan.js +150 -0
- package/dist/runner/orphanScan.js.map +1 -0
- package/dist/runner/run.d.ts +85 -1
- package/dist/runner/run.d.ts.map +1 -1
- package/dist/runner/run.js +267 -41
- package/dist/runner/run.js.map +1 -1
- package/dist/runner/runAction.d.ts +22 -0
- package/dist/runner/runAction.d.ts.map +1 -1
- package/dist/runner/runAction.js +146 -24
- package/dist/runner/runAction.js.map +1 -1
- package/dist/runner/runStory.d.ts +40 -2
- package/dist/runner/runStory.d.ts.map +1 -1
- package/dist/runner/runStory.js +195 -62
- package/dist/runner/runStory.js.map +1 -1
- package/dist/runner/storyFilter.d.ts +8 -0
- package/dist/runner/storyFilter.d.ts.map +1 -1
- package/dist/runner/storyFilter.js +11 -0
- package/dist/runner/storyFilter.js.map +1 -1
- package/dist/schema/action.d.ts +11 -11
- package/dist/schema/result.d.ts +124 -6
- package/dist/schema/result.d.ts.map +1 -1
- package/dist/schema/result.js.map +1 -1
- package/dist/schema/story.d.ts +15 -4
- package/dist/schema/story.d.ts.map +1 -1
- package/dist/schema/story.js +35 -11
- package/dist/schema/story.js.map +1 -1
- package/dist/screenshots/baselineStore.d.ts +111 -5
- package/dist/screenshots/baselineStore.d.ts.map +1 -1
- package/dist/screenshots/baselineStore.js +132 -20
- package/dist/screenshots/baselineStore.js.map +1 -1
- package/dist/screenshots/capture.d.ts +10 -5
- package/dist/screenshots/capture.d.ts.map +1 -1
- package/dist/screenshots/capture.js +31 -18
- package/dist/screenshots/capture.js.map +1 -1
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|
|
317
|
+
storyFilter,
|
|
156
318
|
newOnly: args.newOnly,
|
|
319
|
+
breakpoints: args.breakpoints,
|
|
157
320
|
});
|
|
158
|
-
process.stdout.write(`\
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|