@juangadm/pre-post 1.3.0 → 1.4.0

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 (70) hide show
  1. package/README.md +42 -288
  2. package/dist/baseline.d.ts +26 -1
  3. package/dist/baseline.d.ts.map +1 -1
  4. package/dist/baseline.js +92 -19
  5. package/dist/baseline.js.map +1 -1
  6. package/dist/bin/cli.js +20 -16
  7. package/dist/bin/cli.js.map +1 -1
  8. package/dist/browser.d.ts +31 -1
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +119 -7
  11. package/dist/browser.js.map +1 -1
  12. package/dist/commands/compare.d.ts.map +1 -1
  13. package/dist/commands/compare.js +5 -5
  14. package/dist/commands/compare.js.map +1 -1
  15. package/dist/commands/detect.d.ts +5 -0
  16. package/dist/commands/detect.d.ts.map +1 -1
  17. package/dist/commands/detect.js +3 -0
  18. package/dist/commands/detect.js.map +1 -1
  19. package/dist/commands/doctor.d.ts.map +1 -1
  20. package/dist/commands/doctor.js +7 -0
  21. package/dist/commands/doctor.js.map +1 -1
  22. package/dist/commands/pr.d.ts.map +1 -1
  23. package/dist/commands/pr.js +119 -97
  24. package/dist/commands/pr.js.map +1 -1
  25. package/dist/comparison.d.ts +5 -0
  26. package/dist/comparison.d.ts.map +1 -1
  27. package/dist/comparison.js +5 -5
  28. package/dist/comparison.js.map +1 -1
  29. package/dist/diff-worker.d.ts +1 -0
  30. package/dist/diff-worker.d.ts.map +1 -1
  31. package/dist/diff-worker.js +1 -0
  32. package/dist/diff-worker.js.map +1 -1
  33. package/dist/diff.d.ts +42 -0
  34. package/dist/diff.d.ts.map +1 -1
  35. package/dist/diff.js +142 -24
  36. package/dist/diff.js.map +1 -1
  37. package/dist/doctor.d.ts +4 -1
  38. package/dist/doctor.d.ts.map +1 -1
  39. package/dist/doctor.js +5 -1
  40. package/dist/doctor.js.map +1 -1
  41. package/dist/drift.d.ts +29 -0
  42. package/dist/drift.d.ts.map +1 -0
  43. package/dist/drift.js +106 -0
  44. package/dist/drift.js.map +1 -0
  45. package/dist/errors.d.ts +6 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +6 -0
  48. package/dist/errors.js.map +1 -1
  49. package/dist/report.d.ts +20 -1
  50. package/dist/report.d.ts.map +1 -1
  51. package/dist/report.js +118 -39
  52. package/dist/report.js.map +1 -1
  53. package/dist/routes/imports.d.ts +21 -1
  54. package/dist/routes/imports.d.ts.map +1 -1
  55. package/dist/routes/imports.js +32 -2
  56. package/dist/routes/imports.js.map +1 -1
  57. package/dist/routes.d.ts +18 -0
  58. package/dist/routes.d.ts.map +1 -1
  59. package/dist/routes.js +110 -29
  60. package/dist/routes.js.map +1 -1
  61. package/dist/run.d.ts +46 -12
  62. package/dist/run.d.ts.map +1 -1
  63. package/dist/run.js +138 -17
  64. package/dist/run.js.map +1 -1
  65. package/dist/types.d.ts +61 -1
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js +8 -0
  68. package/dist/types.js.map +1 -1
  69. package/package.json +2 -2
  70. package/skill/SKILL.md +18 -9
package/README.md CHANGED
@@ -1,330 +1,84 @@
1
1
  # pre-post
2
2
 
3
- Before/after screenshots for pull requests. One command detects the routes your branch
4
- changed, captures each one before and after, uploads the images to GitHub, and puts them at
5
- the top of the PR description where a reviewer can judge them at a glance.
3
+ **See the change before you read the diff.**
6
4
 
7
- It works out both sides itself, cheapest option first:
8
-
9
- | | Pre (the baseline) | Post (this branch) |
10
- |---|---|---|
11
- | 1 | `--before` | `--after` |
12
- | 2 | `before` in `.pre-post.json` | the preview deployment for this commit |
13
- | 3 | the production deployment for the base commit | a local dev server |
14
- | 4 | whatever is on production now | |
15
- | 5 | the base commit, served locally | |
16
-
17
- Rows 2 to 4 need no dev environment at all, which is the point: a preview deployment and a
18
- production URL are enough for anyone who can open the PR. Deployments come from the GitHub
19
- Deployments API, so Vercel, Cloudflare Pages, Netlify and Render all work with no extra
20
- token and nothing to configure; a host that records only a commit status is read from the
21
- deployment bot's own PR comment instead. Row 4 covers repositories that do not deploy every
22
- push to their default branch, so nothing is deployed at the fork point — it prints which
23
- commit Pre actually came from rather than implying the base.
24
-
25
- pre-post never guesses a baseline. If no deployment can be found it says so and tells you
26
- the one flag that fixes it, because a baseline that is quietly the wrong site reads as 100%
27
- changed on every route and looks exactly like a real result.
28
-
29
- The last baseline needs no network at all: it checks the base commit into a throwaway
30
- worktree and boots its dev script. That keeps pre-post working inside a sandboxed agent
31
- container, a CI job, or behind an egress allowlist — and it compares against exactly what
32
- the branch forked from, rendered in the same browser as the Post side.
33
-
34
- > Originally forked from [before-and-after](https://github.com/vercel-labs/before-and-after) by James Clements / Vercel Labs.
5
+ pre-post puts before/after screenshots of every page your branch touched at the top of the
6
+ pull request. One command finds the changed routes, captures Pre and Post, highlights what
7
+ moved, and updates the PR description. No setup, no pasting images.
35
8
 
36
9
  ```
37
10
  $ npx -y @juangadm/pre-post@latest pr
38
11
 
39
12
  Routes (nextjs-app, 41ms): /, /pricing
40
- / medium src/app/page.tsx imports src/components/ui/button.tsx (2 hops)
41
- /pricing medium src/app/pricing/page.tsx imports src/components/ui/button.tsx (2 hops)
42
- Capturing 8 screenshots (2 route(s) × 2 viewport(s)) ...
43
- changed /pricing @ mobile (1.42%, 1811ms)
13
+ Capturing 4 screenshots (2 route(s) × 1 viewport(s)) ...
44
14
  changed /pricing @ desktop (0.64%, 2036ms)
45
- same / @ mobile (0.00%, 1990ms)
46
15
  same / @ desktop (0.00%, 2211ms)
47
- Publishing 10 image(s) to acme/web@pre-post-assets ...
48
16
  Updated PR description: https://github.com/acme/web/pull/42
49
- pre-post · PR #42 · 2 route(s) · 2 viewport(s) · 6.8s
50
- / desktop no change
51
- / mobile no change
52
- /pricing desktop 0.64% changed
53
- /pricing mobile 1.4% changed
54
- Comment: https://github.com/acme/web/pull/42
55
17
  ```
56
18
 
57
- ## How it works
58
-
59
- 1. **Routes.** Diffs the branch against the merge base with `main` — fetching that branch
60
- first when the checkout does not have it, which is the normal shape in CI and in the
61
- web/sandbox editors. When no shared history can be established it stops with one
62
- sentence rather than reporting an empty diff, and `--base <ref>` names the commit
63
- directly. Then it follows the import
64
- graph: a change to `components/ui/button.tsx` marks every page that imports it. Next.js
65
- App Router and Pages Router, Vite apps (React Router, file-based `src/pages`), and a
66
- generic fallback. Monorepos are handled by picking the app that owns the changed files.
67
- 2. **Capture.** Playwright + Chromium headless shell. The page's own clock is held
68
- still while it loads and then run forward by a fixed budget, so a page that animates
69
- on a timer is photographed at the same frame on both sides however fast each host
70
- answered. Reduced motion, animations finished, caret hidden, fonts and images settled,
71
- layout stable, lazy content primed. 2x device scale, full page (capped at 2400 CSS
72
- px), desktop (add mobile with `-r`).
73
- All routes and viewports run concurrently.
74
- 3. **Diff.** Pure-JS pixel comparison in worker threads. Reports the percentage changed,
75
- the bounding box, and a tight crop of the changed region. A route counts as changed when
76
- the painted difference covers at least `minChangedArea` CSS pixels² (default 100, roughly
77
- a third of a 16px icon) or at least `threshold` of the canvas — the first rule is what
78
- decides on a page, the second on a small image.
79
- 4. **Layout shift.** A padding change near the top of a page moves everything below it, and
80
- pixel diffing counts every moved pixel as changed: a change a designer would call
81
- "slightly roomier" reads as most of the page repainted, and the crop is suppressed just
82
- when it would be most useful. So the two sides are checked for a single vertical offset
83
- first — how far the content moved, and from which row. When one is found, Pre is re-spaced
84
- into Post's layout and the two are compared there: the crop comes from that pair, and the
85
- comment says `Content shifted down 48px` instead of quoting a percentage. Rows Post gained
86
- are left as background rather than skipped, so content inserted above the shift — the
87
- banner that caused it — still reads as new rather than as nothing. The offset is only
88
- accepted when it accounts for most of the difference across the rows both sides share.
89
- The raw numbers are left alone: a move is a visual change, and reporting less of one would
90
- hide it. Reflow, where content moves both across and down, has no single offset to find,
91
- so it is reported the way it always was.
92
- 5. **Publish.** Images go to a `pre-post-assets` branch in the same repository via the GitHub
93
- API, as one commit per run. Nothing is committed to the PR branch, no CI is triggered,
94
- and the blob URLs render on private repos — a screenshot is visible to exactly whoever
95
- can see the repository. `pre-post prune` removes images for PRs closed more than 90 days
96
- ago, but note that it commits a deletion rather than rewriting history: the older commits
97
- still hold the blobs, so a link handed out earlier keeps working. Treat anything captured
98
- as permanent, and think twice before pointing pre-post at a preview holding real data.
99
- See [Screenshot storage](#screenshot-storage) for how fast the branch grows.
100
- 6. **Describe.** The images go in a delimited block at the top of the PR description,
101
- replaced in place on every run and leaving your own text untouched. Changed routes show
102
- a Pre/Post crop with the full page collapsed underneath; unchanged routes fold into a
103
- single line. A pure layout shift — nothing changed once the move is undone — has no
104
- region to crop, so it shows the full pages directly under the `Content shifted down 40px.
105
- Nothing else changed.` line, with no collapsed section repeating them. If the PR cannot be edited — a fork, a read-only token — it falls back to
106
- one sticky comment.
19
+ ## Get started
107
20
 
108
- ## Install
21
+ Pick one. None of them installs anything permanently.
109
22
 
110
- Two ways. Neither installs anything permanently.
111
-
112
- ### Claude Code — easiest
23
+ **In Claude Code**: install the skill once, then say `/pre-post` after a UI change.
113
24
 
114
25
  ```bash
115
26
  npx skills add juangadm/pre-post -y
116
27
  ```
117
28
 
118
- Then say `/pre-post` after a UI change. Install once, never type a flag. No npm
119
- knowledge needed.
120
-
121
- ### Terminal
29
+ **In a terminal**: run it on a branch with an open PR.
122
30
 
123
31
  ```bash
124
32
  npx -y @juangadm/pre-post@latest pr
125
33
  ```
126
34
 
127
- Run it on a branch with an open PR. No install step.
35
+ **On every PR, automatically**: add the [GitHub Action](https://github.com/juangadm/pre-post/blob/main/docs/github-action.md). No tokens or
36
+ secrets needed.
128
37
 
129
- ### Both need
38
+ ### You'll need
130
39
 
131
- | | |
132
- |---|---|
133
- | **Node 20+** | The one hard requirement. Check with `node --version`. |
134
- | **A GitHub token** | `gh auth login`, or set `GH_TOKEN`. Needs write access — it publishes images and edits the PR. |
135
- | **An open PR** | Where the images go. Without one, pre-post prints the markdown to paste. |
40
+ - **Node 20+**
41
+ - **A GitHub token** with write access: `gh auth login`, or set `GH_TOKEN`
42
+ - **An open PR**: without one, pre-post prints the markdown for you to paste
136
43
 
137
- First run also pulls a Chromium headless shell (~80 MB), then reuses it. Stuck? Run
44
+ The first run downloads a small browser (~80 MB). Stuck? Run
138
45
  `npx -y @juangadm/pre-post@latest doctor`.
139
46
 
140
- ### GitHub Action: every PR, no tokens
141
-
142
- For a team, or for work done in cloud agent sessions: GitHub runs pre-post on each push to a
143
- PR and posts with the job's own token. Nobody sets up anything per person, and there are no
144
- secrets to add. Add `.github/workflows/pre-post.yml`:
145
-
146
- ```yaml
147
- name: pre-post
148
- on: pull_request
149
- permissions: { contents: write, pull-requests: write }
150
- concurrency: { group: 'pre-post-${{ github.event.pull_request.number }}', cancel-in-progress: true }
151
- jobs:
152
- pre-post:
153
- if: github.event.pull_request.head.repo.full_name == github.repository
154
- runs-on: ubuntu-latest
155
- steps:
156
- - uses: actions/checkout@v4
157
- with: { ref: '${{ github.event.pull_request.head.sha }}', fetch-depth: 0 }
158
- - uses: juangadm/pre-post@v1
159
- ```
160
-
161
- The runner builds the base commit and the PR's commit and serves both, the same way pre-post
162
- does on a laptop with nothing deployed. No preview deployment is involved, so Vercel's
163
- Deployment Protection never gets in the way.
164
-
165
- - **Your app must start with its dev script without secrets.** If it needs environment
166
- variables, give them to the job with `env:`.
167
- - **pnpm or yarn** comes from the `packageManager` field in package.json. Without one, set
168
- it up before the pre-post step (e.g. `pnpm/action-setup`), as any other job would.
169
- - **Fork PRs are skipped:** their token cannot write, so there is nowhere to post.
170
- - **Flags** go in `with: { args: '--responsive' }`.
171
- - **The run's images**, including `sheet.png`, are kept as a workflow artifact even when
172
- posting fails.
173
- - **Pinned:** `@v1` runs the pre-post version released with that tag, never whatever npm
174
- calls `latest`.
175
-
176
- ## Usage
47
+ ## Everyday commands
177
48
 
178
49
  ```bash
179
- pre-post pr # everything, on the current branch's PR
180
- pre-post pr --before https://acme.com # pin the baseline (saved to .pre-post.json)
181
- pre-post pr --no-local-baseline # never build the base commit locally
182
- pre-post pr --routes /pricing,/docs # explicit routes
183
- pre-post pr --viewports desktop,1440x900 # custom viewports
184
- pre-post pr --dry-run # capture + diff locally, post nothing
185
- pre-post pr --json # machine-readable output
186
-
187
- pre-post https://acme.com http://localhost:3000 --routes /pricing # ad-hoc comparison
188
- pre-post before.png after.png # diff two images
189
- pre-post detect # which routes does this branch touch?
190
- pre-post login https://staging.acme.com # sign in once; the session is reused
191
- pre-post prune --days 90 # clean up the assets branch
192
- pre-post doctor # browser, token, dev server, config
193
- ```
194
-
195
- When something needs a human, the CLI exits with code 3 and one sentence saying what to do
196
- (log in, start the dev server, pass `--before`). Re-running picks up where it left off.
197
-
198
- Exit codes, so a script or an agent can branch on them:
199
-
200
- | code | meaning |
201
- |---|---|
202
- | 0 | done; for `doctor`, `pre-post pr` can run |
203
- | 1 | the run failed — every capture errored, or an unexpected error; for `doctor`, a required check failed |
204
- | 2 | the arguments could not be parsed |
205
- | 3 | something needs a human; the message says what |
206
-
207
- `doctor` marks a check **FAIL** only when `pre-post pr` has no way to proceed without it —
208
- the browser, a GitHub token, and being inside a git repository. Everything else prints as
209
- **note**: no dev server running, or no `--before` saved, narrows *which* strategy a run
210
- picks rather than stopping it, so those never change the exit code.
211
-
212
- Results go into a delimited block at the top of the PR description, which re-runs replace in
213
- place, leaving your own text untouched. If the PR cannot be edited — a fork, a read-only
214
- token — it falls back to a single sticky comment.
215
-
216
- ## Screenshot storage
217
-
218
- Every real run adds one commit to the `pre-post-assets` branch holding that run's images —
219
- roughly 100–200 KB for a small PR, and again on every re-run. As a rough guide, 20 PRs a
220
- week at 3 runs each is about 9 MB a week, or ~450 MB a year.
221
-
222
- Two things to know before that adds up:
223
-
224
- - **`prune` tidies the branch; it does not shrink the repository.** It commits a deletion,
225
- so the images stay in history and every link already posted in a PR keeps working. The
226
- repository's size only ever grows.
227
- - **A plain `git clone` downloads every branch**, screenshots included. Teammates who never
228
- look at them can skip the branch in an existing clone (git 2.29+):
229
-
230
- ```bash
231
- git config --add remote.origin.fetch '^refs/heads/pre-post-assets'
232
- ```
233
-
234
- A weekly prune keeps the branch's own listing short. As a scheduled workflow:
235
-
236
- ```yaml
237
- # .github/workflows/pre-post-prune.yml
238
- on:
239
- schedule: [{ cron: '17 6 * * 1' }] # Mondays
240
- workflow_dispatch:
241
- permissions: { contents: write, pull-requests: read }
242
- jobs:
243
- prune:
244
- runs-on: ubuntu-latest
245
- steps:
246
- - uses: actions/checkout@v4
247
- - run: npx -y @juangadm/pre-post@latest prune --days 90
248
- env: { GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }
50
+ pre-post pr # screenshot this branch's PR
51
+ pre-post pr --routes /pricing,/docs # only these pages
52
+ pre-post pr -r # desktop and mobile
53
+ pre-post pr --before https://acme.com # set the "before" site (remembered)
54
+ pre-post pr --dry-run # try it without posting
55
+ pre-post login https://staging.acme.com # sign in once for protected sites
56
+ pre-post doctor # check your setup
249
57
  ```
250
58
 
251
- It removes folders for PRs closed more than 90 days ago, and runs made before a PR was
252
- opened once they are that old. Actually reclaiming the space means rewriting the branch's
253
- history, which also breaks the images in those older PRs — so it is left as your call.
254
-
255
- ## Configuration
256
-
257
- Optional `.pre-post.json` in the repo root:
59
+ If pre-post needs something from you, it stops with one sentence saying exactly what.
258
60
 
259
- ```json
260
- {
261
- "before": "https://acme.com",
262
- "routes": ["/"],
263
- "samples": { "/blog/[slug]": "/blog/hello-world" },
264
- "viewports": ["desktop"],
265
- "fullPage": true,
266
- "maxHeight": 2400,
267
- "scale": 2,
268
- "threshold": 0.001,
269
- "minChangedArea": 100,
270
- "maxRoutes": 6,
271
- "ignore": ["apps/docs"],
272
- "headers": {},
273
- "assetsBranch": "pre-post-assets",
274
- "baselineSetup": "pnpm run build:packages"
275
- }
276
- ```
277
-
278
- `baselineSetup` runs in the app directory between the install and the dev server when the
279
- baseline is built from source. Use it for whatever the app needs before it can boot — a
280
- workspace build, a codegen step. In a turborepo it is inferred: `turbo run build
281
- --filter=<app>^...`, which builds the packages the app imports and leaves the app to the
282
- dev server. Setting it turns the guess off.
61
+ ## How it decides what "before" is
283
62
 
284
- A command that fails stops the run with one instruction, rather than quietly comparing
285
- against something else.
63
+ Post (your branch) is the PR's preview deployment, or a local dev server. Pre is the
64
+ production site your branch forked from. When nothing is deployed, pre-post builds the base
65
+ commit itself, so it works in CI and in sandboxes too. It never guesses: if it can't find a
66
+ trustworthy baseline, it tells you which flag to pass.
286
67
 
287
- Env files copied into the baseline's worktree get their origin variables rewritten to the
288
- port the baseline actually listens on, before the setup step runs so a build cannot bake in
289
- the old one. Without this an auth-gated app rejects its own callbacks and every capture is a
290
- loading skeleton. The names rewritten are the ones that can only mean the app's own address
291
- — `BETTER_AUTH_URL`, `NEXTAUTH_URL`, `AUTH_URL`, `APP_URL`, `SITE_URL`, `CANONICAL_URL` and
292
- their `_ORIGIN`/`_BASE_URL` forms, with a `NEXT_PUBLIC_` / `VITE_` / `PUBLIC_` prefix
293
- optional. Generic names are left alone: `BASE_URL`, `SERVER_URL` and `PUBLIC_URL` are as
294
- often a backend on another port or a path prefix, and `DATABASE_URL` is never an origin.
68
+ Screenshots are stored on a `pre-post-assets` branch in your own repo, so only people who can
69
+ see the repo can see them. Nothing is committed to your PR branch.
295
70
 
296
- Environment:
71
+ ## Learn more
297
72
 
298
- | Variable | Purpose |
73
+ | | |
299
74
  |---|---|
300
- | `PRE_POST_GH_TOKEN` | GitHub token read before the two below. Use it where a hosted environment sets `GH_TOKEN` itself |
301
- | `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token (default: `gh auth token`) |
302
- | `VERCEL_AUTOMATION_BYPASS_SECRET` | Bypass Vercel Deployment Protection on preview and production URLs |
303
- | `PRE_POST_CONCURRENCY` | Parallel pages (default 6) |
304
- | `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` | Use a specific Chromium binary |
305
- | `GH_REPO` | `owner/repo` when the remote URL cannot be parsed |
306
-
307
- ## Library
308
-
309
- ```ts
310
- import { runPr, captureScreenshot, diffImages, detectRoutesForRepo } from '@juangadm/pre-post';
311
- ```
312
-
313
- ## Development
314
-
315
- ```bash
316
- pnpm install
317
- pnpm build
318
- pnpm test:unit
319
- TEST_BROWSER=true pnpm test # needs a Chromium; npx playwright-core install chromium-headless-shell
320
- ```
321
-
322
- pnpm and npm cannot share a `node_modules`. pnpm builds a symlinked tree that npm
323
- cannot read, so `npm install` over it fails with `Cannot read properties of null
324
- (reading 'edgesOut')`, which names none of that. Run `rm -rf node_modules
325
- site/node_modules` before switching either way. npm also installs the CLI only —
326
- `site/` is a pnpm workspace member, so use pnpm to work on the site.
75
+ | [How it works](https://github.com/juangadm/pre-post/blob/main/docs/how-it-works.md) | How Pre and Post are chosen, capture, diffing, layout shifts |
76
+ | [GitHub Action](https://github.com/juangadm/pre-post/blob/main/docs/github-action.md) | Run on every PR with no per-person setup |
77
+ | [Reference](https://github.com/juangadm/pre-post/blob/main/docs/reference.md) | All commands, exit codes, `.pre-post.json`, environment variables |
78
+ | [Screenshot storage](https://github.com/juangadm/pre-post/blob/main/docs/storage.md) | How much space it uses and how to prune it |
79
+ | [Contributing](https://github.com/juangadm/pre-post/blob/main/CONTRIBUTING.md) | Build and test locally |
327
80
 
328
- ## License
81
+ ## Credits
329
82
 
330
- MIT
83
+ Forked from [before-and-after](https://github.com/vercel-labs/before-and-after) by James
84
+ Clements / Vercel Labs. MIT licensed.
@@ -11,13 +11,24 @@
11
11
  * The cost is time: a checkout, an install and a dev-server boot. So callers
12
12
  * reach for it after a reachable deployment, not before one.
13
13
  */
14
+ import { ChildProcess } from 'child_process';
14
15
  import { NeedsHumanError } from './errors.js';
15
16
  import { Stopwatch } from './timings.js';
16
17
  export interface LocalBaseline {
17
18
  url: string;
18
19
  /** Stop the dev server and delete the worktree. Safe to call twice. */
19
20
  stop: () => Promise<void>;
21
+ /** Everything the dev server printed, so a failure can be explained with its own words. */
22
+ logFile?: string;
20
23
  }
24
+ /**
25
+ * Where a dev server's output goes: a file per server, never the terminal.
26
+ *
27
+ * It used to go nowhere (`stdio: 'ignore'`), so when a server answered 500
28
+ * there was no way to say why. A file rather than a pipe: the server is
29
+ * detached and must not stall when this process stops reading.
30
+ */
31
+ export declare function serverLogFile(side: 'pre' | 'post', port: number): string;
21
32
  interface PackageManager {
22
33
  bin: string;
23
34
  install: string[];
@@ -172,7 +183,7 @@ export declare function installDeps(pm: PackageManager, cwd: string, timeoutMs:
172
183
  /**
173
184
  * The install failed and nothing else can rescue this run.
174
185
  *
175
- * Its own error rather than a quiet null, for the reason docs/portability.md
186
+ * Its own error rather than a quiet null, for the reason docs/internal/portability.md
176
187
  * §1 gives about base resolution: a run that compared nothing must not exit
177
188
  * clean. A null here fell through to "no baseline", and on a repository with a
178
189
  * configured production URL it fell through to comparing against that instead
@@ -257,6 +268,20 @@ export declare function reusableInstall(repoRoot: string, sha: string): Promise<
257
268
  * removed so the normal install starts from a clean tree.
258
269
  */
259
270
  export declare function reuseInstall(repoRoot: string, worktree: string, dirs: string[]): Promise<boolean>;
271
+ /** How long a dev server gets to exit on SIGTERM before it is killed. */
272
+ export declare const SERVER_STOP_TIMEOUT_MS = 5000;
273
+ /**
274
+ * SIGTERM the server's process group, wait for it to exit, then make sure.
275
+ *
276
+ * The wait is on the child itself — the package manager, which exits once the
277
+ * server under it has — because Node reaps it, so its exit is a fact. Asking
278
+ * the group with signal 0 was not: in a container whose PID 1 does not reap,
279
+ * dead members linger as zombies that still answer, and every stop waited out
280
+ * the full timeout. Once the child is gone, or `timeoutMs` has passed, the
281
+ * whole group is SIGKILLed, so nothing it started is left writing into a
282
+ * worktree about to be removed.
283
+ */
284
+ export declare function stopGroup(child: ChildProcess, timeoutMs?: number): Promise<void>;
260
285
  export interface BaselineOptions {
261
286
  repoRoot: string;
262
287
  /** Commit to serve. Omit to serve the working tree as it stands. */
@@ -1 +1 @@
1
- {"version":3,"file":"baseline.d.ts","sourceRoot":"","sources":["../src/baseline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAOH,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAGzC,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,uEAAuE;IACvE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAGD,UAAU,cAAc;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,kFAAkF;IAClF,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,MAAM,EAAE,CAAC;IAClD;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAA;KAAE,CAAC;CAC/D;AAgBD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,cAAc,CAUnF;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAUjF;AAED,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,EAAE,EAAE,cAAc,GAAG,IAAI,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,EAAE,cAAc,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,EACjB,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAgB,GACrC,aAAa,CAIf;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAUxG;AAUD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAkBnF;AA+BD,oDAAoD;AACpD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAGhD;AAED,0EAA0E;AAC1E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAYhG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAkBzF;AAED,gCAAgC;AAChC,wBAAgB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAS1C;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,EAAE,EAAE,OAAO,CAAC;IACZ,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,8DAA8D;IAC9D,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;AAwC3I;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;;;;;;;;GASG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,cAAc,EAClB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,aAA0B,EAC/B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAQxB;AAED;;;;;;;;;GASG;AACH;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED,qBAAa,oBAAqB,SAAQ,eAAe;aAC3B,MAAM,EAAE,aAAa;gBAArB,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;CAIhF;AAED,+DAA+D;AAC/D,MAAM,WAAW,SAAS;IACxB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,sEAAsE;IACtE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAcD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAWvF;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAIjG;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAErE;AAED,qBAAa,kBAAmB,SAAQ,eAAe;gBACzC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;CAIzC;AAwBD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAoB7F;AAqDD;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAavG;AAgBD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACnC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,kEAAkE;IAClE,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAE1F;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,KAAK,CAAC,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAExG"}
1
+ {"version":3,"file":"baseline.d.ts","sourceRoot":"","sources":["../src/baseline.ts"],"names":[],"mappings":"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"}
package/dist/baseline.js CHANGED
@@ -19,6 +19,18 @@ import path from 'path';
19
19
  import { NeedsHumanError } from './errors.js';
20
20
  import { devScript, readPackage } from './pkg.js';
21
21
  import { findAppRoots } from './routes.js';
22
+ /**
23
+ * Where a dev server's output goes: a file per server, never the terminal.
24
+ *
25
+ * It used to go nowhere (`stdio: 'ignore'`), so when a server answered 500
26
+ * there was no way to say why. A file rather than a pipe: the server is
27
+ * detached and must not stall when this process stops reading.
28
+ */
29
+ export function serverLogFile(side, port) {
30
+ const dir = path.join(os.tmpdir(), 'pre-post', 'servers');
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ return path.join(dir, `${side}-${port}-${Date.now()}.log`);
33
+ }
22
34
  const NPM = { bin: 'npm', install: ['install'], retry: { argv: ['install', '--legacy-peer-deps'], when: isPeerConflict }, run: (s, a) => ['run', s, '--', ...a] };
23
35
  const PNPM = { bin: 'pnpm', install: ['install', '--prefer-offline'], run: (s, a) => ['run', s, ...a] };
24
36
  const YARN = { bin: 'yarn', install: ['install'], run: (s, a) => ['run', s, ...a] };
@@ -317,7 +329,7 @@ export async function installDeps(pm, cwd, timeoutMs, run = runInstall, signal)
317
329
  /**
318
330
  * The install failed and nothing else can rescue this run.
319
331
  *
320
- * Its own error rather than a quiet null, for the reason docs/portability.md
332
+ * Its own error rather than a quiet null, for the reason docs/internal/portability.md
321
333
  * §1 gives about base resolution: a run that compared nothing must not exit
322
334
  * clean. A null here fell through to "no baseline", and on a repository with a
323
335
  * configured production URL it fell through to comparing against that instead
@@ -542,6 +554,44 @@ export async function reuseInstall(repoRoot, worktree, dirs) {
542
554
  await Promise.all(targets.map(t => fs.promises.rm(t.to, { recursive: true, force: true })));
543
555
  return false;
544
556
  }
557
+ /** Signal a detached child's whole process group; false when it is already gone. */
558
+ function signalGroup(child, signal) {
559
+ if (!child.pid)
560
+ return false;
561
+ try {
562
+ process.kill(-child.pid, signal);
563
+ return true;
564
+ }
565
+ catch {
566
+ return false;
567
+ }
568
+ }
569
+ /** How long a dev server gets to exit on SIGTERM before it is killed. */
570
+ export const SERVER_STOP_TIMEOUT_MS = 5_000;
571
+ /**
572
+ * SIGTERM the server's process group, wait for it to exit, then make sure.
573
+ *
574
+ * The wait is on the child itself — the package manager, which exits once the
575
+ * server under it has — because Node reaps it, so its exit is a fact. Asking
576
+ * the group with signal 0 was not: in a container whose PID 1 does not reap,
577
+ * dead members linger as zombies that still answer, and every stop waited out
578
+ * the full timeout. Once the child is gone, or `timeoutMs` has passed, the
579
+ * whole group is SIGKILLed, so nothing it started is left writing into a
580
+ * worktree about to be removed.
581
+ */
582
+ export async function stopGroup(child, timeoutMs = SERVER_STOP_TIMEOUT_MS) {
583
+ if (child.exitCode !== null || child.signalCode !== null) {
584
+ signalGroup(child, 'SIGKILL');
585
+ return;
586
+ }
587
+ const exited = new Promise(resolve => child.once('exit', () => resolve()));
588
+ if (!signalGroup(child, 'SIGTERM'))
589
+ return;
590
+ let timer;
591
+ await Promise.race([exited, new Promise(resolve => { timer = setTimeout(resolve, timeoutMs); })]);
592
+ clearTimeout(timer);
593
+ signalGroup(child, 'SIGKILL');
594
+ }
545
595
  async function waitForServer(url, timeoutMs, alive) {
546
596
  const deadline = Date.now() + timeoutMs;
547
597
  while (Date.now() < deadline) {
@@ -602,28 +652,28 @@ async function serveLocally(opts) {
602
652
  let child = null;
603
653
  let stopped = false;
604
654
  // A detached dev server survives Ctrl-C and keeps its port, so tear it down
605
- // on the signals that end the run as well as on the normal path.
606
- const onSignal = () => { void cleanup(); };
655
+ // on the signals that end the run as well as on the normal path. Those
656
+ // paths cannot wait for anything — an 'exit' handler runs no more event
657
+ // loop — so they get the synchronous teardown.
658
+ const onSignal = () => { cleanupNow(); };
607
659
  const untrap = () => {
608
660
  process.off('SIGINT', onSignal);
609
661
  process.off('SIGTERM', onSignal);
610
662
  process.off('exit', onSignal);
611
663
  };
612
- const cleanup = async () => {
664
+ // Guard on the thing that actually matters: only a throwaway worktree may be
665
+ // removed. Checking `opts.sha` instead would let any future caller that
666
+ // points `worktree` at a real checkout delete uncommitted work.
667
+ const throwaway = worktree !== opts.repoRoot;
668
+ /** The process is gone, a best-effort worktree removal, no waiting. */
669
+ const cleanupNow = () => {
613
670
  if (stopped)
614
671
  return;
615
672
  stopped = true;
616
673
  untrap();
617
- if (child && child.exitCode === null) {
618
- try {
619
- process.kill(-child.pid, 'SIGTERM');
620
- }
621
- catch { /* already gone */ }
622
- }
623
- // Guard on the thing that actually matters: only a throwaway worktree may be
624
- // removed. Checking `opts.sha` instead would let any future caller that
625
- // points `worktree` at a real checkout delete uncommitted work.
626
- if (worktree === opts.repoRoot)
674
+ if (child)
675
+ signalGroup(child, 'SIGKILL');
676
+ if (!throwaway)
627
677
  return;
628
678
  try {
629
679
  execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: opts.repoRoot, stdio: 'ignore' });
@@ -632,6 +682,26 @@ async function serveLocally(opts) {
632
682
  fs.rmSync(worktree, { recursive: true, force: true });
633
683
  }
634
684
  };
685
+ /**
686
+ * Stop the server, and only once it has exited, remove its worktree.
687
+ *
688
+ * The order is the point. Removing the tree under a dev server that is
689
+ * still shutting down races its last writes into `.next`, and the removal
690
+ * blocked the event loop while it walked the copied node_modules.
691
+ */
692
+ const cleanup = async () => {
693
+ if (stopped)
694
+ return;
695
+ stopped = true;
696
+ untrap();
697
+ if (child)
698
+ await stopGroup(child);
699
+ if (!throwaway)
700
+ return;
701
+ if (await gitAsync(['worktree', 'remove', '--force', worktree], opts.repoRoot) === null) {
702
+ await fs.promises.rm(worktree, { recursive: true, force: true });
703
+ }
704
+ };
635
705
  if (opts.sha) {
636
706
  // Asynchronous: in a large repository a checkout takes seconds, and the
637
707
  // other side is booting meanwhile.
@@ -746,7 +816,7 @@ async function serveLocally(opts) {
746
816
  log(attempt.output);
747
817
  await cleanup();
748
818
  // A command the repository asked for is the install's case exactly
749
- // (docs/portability.md §1): yielding here lets the run fall through to a
819
+ // (docs/internal/portability.md §1): yielding here lets the run fall through to a
750
820
  // configured production URL and publish a different comparison as if it
751
821
  // were this one, or die further along on an error about dev servers that
752
822
  // names nothing the reader can fix. Only the inferred build yields —
@@ -756,12 +826,15 @@ async function serveLocally(opts) {
756
826
  return skip(`\`${setup.label}\` failed.`);
757
827
  }
758
828
  }
829
+ const logFile = serverLogFile(side, port);
830
+ const out = fs.openSync(logFile, 'a');
759
831
  child = spawn(pm.bin, pm.run(script, ['--port', String(port)]), {
760
832
  cwd: appDir,
761
- stdio: 'ignore',
833
+ stdio: ['ignore', out, out],
762
834
  detached: true,
763
835
  env: { ...process.env, PORT: String(port), BROWSER: 'none' },
764
836
  });
837
+ fs.closeSync(out);
765
838
  child.unref();
766
839
  const ready = await timed('boot', waitForServer(url, Math.max(1, deadline - Date.now()), () => !!child && child.exitCode === null && !opts.signal?.aborted));
767
840
  if (await aborted())
@@ -773,13 +846,13 @@ async function serveLocally(opts) {
773
846
  const exitCode = child?.exitCode ?? null;
774
847
  await cleanup();
775
848
  if (exitCode !== null) {
776
- return skip(`${pm.bin} ${script} exited with code ${exitCode} before serving; run it in ${appIn} to see why.`);
849
+ return skip(`${pm.bin} ${script} exited with code ${exitCode} before serving; its output is in ${logFile}.`);
777
850
  }
778
- return skip(`${pm.bin} ${script} did not start serving within ${Math.round(timeoutMs / 1000)}s (missing env vars are the usual cause).`);
851
+ return skip(`${pm.bin} ${script} did not start serving within ${Math.round(timeoutMs / 1000)}s (missing env vars are the usual cause); its output is in ${logFile}.`);
779
852
  }
780
853
  process.once('SIGINT', onSignal);
781
854
  process.once('SIGTERM', onSignal);
782
855
  process.once('exit', onSignal);
783
- return { url, stop: cleanup };
856
+ return { url, stop: cleanup, logFile };
784
857
  }
785
858
  //# sourceMappingURL=baseline.js.map