@graphty/visual-review 0.1.3 → 0.2.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/README.md +74 -32
- package/capture/capture.mjs +11 -1
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -3
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +68 -33
- package/trusted/lib/accept.mjs +67 -28
- package/trusted/lib/config.mjs +7 -3
- package/trusted/lib/github.mjs +100 -20
- package/trusted/lib/serve.mjs +445 -111
- package/trusted/page/review.css +19 -0
- package/trusted/page/review.js +341 -116
package/README.md
CHANGED
|
@@ -43,8 +43,9 @@ your login.
|
|
|
43
43
|
4. **Gate (CI).** The "Visual gate" job fails while a pull request holds a difference nobody
|
|
44
44
|
accepted, or a baseline file changed without a review record naming it.
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
Every story needs a baseline you approved before a pull request can merge. A story with no
|
|
47
|
+
baseline blocks every pull request until you accept it, either on a pull request or by seeding it
|
|
48
|
+
from the default branch, so seed a project's baselines before its stories start blocking work.
|
|
48
49
|
|
|
49
50
|
## Requirements
|
|
50
51
|
|
|
@@ -146,8 +147,12 @@ Per project:
|
|
|
146
147
|
| `seedFromDefaultBranch` | `true` | `false`: the project's first baselines are accepted on a pull request, not seeded from the default branch |
|
|
147
148
|
| `waitFor` | none | After a story renders, call `method()` on every element matching `selector` and wait for the promise it returns, for a component that keeps drawing after Storybook says it is done. A console line containing `failOnConsole` fails the story |
|
|
148
149
|
|
|
149
|
-
Project ids are letters, digits
|
|
150
|
-
|
|
150
|
+
Project ids are lowercase letters, digits and `-` (results.json allows no others), and so are
|
|
151
|
+
the names of Chromatic modes, which a capture refuses before it starts. Every project is gated;
|
|
152
|
+
there is no setting that turns the gate off, and a config that sets `gate` is refused. The pull
|
|
153
|
+
request gate reads the config as it is on the base branch, so a pull request cannot move
|
|
154
|
+
`baselines` out from under it or drop a project from the gate; a project that a pull request adds
|
|
155
|
+
to its own config is gated too.
|
|
151
156
|
|
|
152
157
|
## The GitHub Actions workflows
|
|
153
158
|
|
|
@@ -180,8 +185,10 @@ Both need nothing but the default `GITHUB_TOKEN`: the capture job reads Actions
|
|
|
180
185
|
|
|
181
186
|
## Your first review: seeding baselines
|
|
182
187
|
|
|
183
|
-
A project has no baselines until you accept some, and
|
|
184
|
-
|
|
188
|
+
A project has no baselines until you accept some, and the gate fails closed until it has them:
|
|
189
|
+
every pull request is blocked by each of its stories (`new` or `no baseline yet`), with a message
|
|
190
|
+
saying to seed the project. After the setup pull request merges, the default branch's push runs
|
|
191
|
+
the capture:
|
|
185
192
|
|
|
186
193
|
1. Find that run's id: `gh run list --workflow visual-review.yml --branch main --limit 1`.
|
|
187
194
|
2. Start the page with `--master-run <run id>` ([Opening the review page](#opening-the-review-page))
|
|
@@ -217,10 +224,10 @@ token is kept in the work directory, so the URL stays valid across restarts; del
|
|
|
217
224
|
|
|
218
225
|
The address always names the screen you are on, after the token: the targets list; a pull
|
|
219
226
|
request (or master) and project with the grid's filter and text; or one story with its view,
|
|
220
|
-
zoom, changed box and
|
|
221
|
-
`#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off`.
|
|
222
|
-
A link's `box` and `
|
|
223
|
-
|
|
227
|
+
zoom, changed box, blink and Spotlight flash, for example
|
|
228
|
+
`#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off&flash=off`.
|
|
229
|
+
A link's `box`, `blink` and `flash` apply to the page it opens; the choice this browser remembers
|
|
230
|
+
for B, L and F in Spotlight is left as it was.
|
|
224
231
|
Opening that address, in another tab or on another device, opens the same screen. **Copy link**
|
|
225
232
|
at the top right copies it. The link carries your session token, so it works on your iPad the way
|
|
226
233
|
the printed URL does; keep it to yourself as you would that URL. All of it sits after `#`, which a
|
|
@@ -245,6 +252,10 @@ starts the same server from your own shell.
|
|
|
245
252
|
pull request. Merge the default branch into the pull request's branch (by merge, never
|
|
246
253
|
rebase) and wait for CI.
|
|
247
254
|
- **capture failed**: the `visual` job produced no results. Re-run that job in GitHub Actions.
|
|
255
|
+
- **CI still running**, **waiting for CI**, **downloading the captures**: there is nothing
|
|
256
|
+
to review yet; reload the page in a moment.
|
|
257
|
+
- **artifact expired**: GitHub deleted the capture after 30 days and it was never
|
|
258
|
+
downloaded here. Re-run the `visual` job.
|
|
248
259
|
- **incomplete: N of M stories**: the capture stopped part way. Re-run the job.
|
|
249
260
|
- **not seeded from master**: this project is not reviewed on the default branch
|
|
250
261
|
(`"seedFromDefaultBranch": false` in the config); its first baselines are accepted on a pull
|
|
@@ -290,7 +301,9 @@ starts the same server from your own shell.
|
|
|
290
301
|
scroll it was opened at; **Highlight**, the changed pixels in solid red laid over both images
|
|
291
302
|
themselves, in both panes, where **Blink** (L) flashes the red pixels on and off at Flash's
|
|
292
303
|
pace (remembered in this browser); and **Spotlight**, the new image dimmed everywhere except around the changed pixels
|
|
293
|
-
(each grown by 10 image pixels), which finds a one-pixel change
|
|
304
|
+
(each grown by 10 image pixels), which finds a one-pixel change, where **Spotlight flash** (F
|
|
305
|
+
in Spotlight) shows the spotlighted baseline and the spotlighted new image one after the other
|
|
306
|
+
at Flash's pace, the pane's label saying which (remembered in this browser). Flash, Highlight and
|
|
294
307
|
Spotlight need two images; on a new or removed story they are off and the page says why
|
|
295
308
|
("New story, no baseline", "Only one image: this story was removed"). Badges here:
|
|
296
309
|
**size changed** (in image pixels), **flaky** (the two captures differed, then matched), and
|
|
@@ -311,20 +324,22 @@ story is new or looks different from the default branch's newest capture of it),
|
|
|
311
324
|
story no longer exists, lost a mode, or whose story's own parameters now exclude it), `unstable`
|
|
312
325
|
(two captures of the same commit differed), `failed` (did not render, even after one retry).
|
|
313
326
|
|
|
314
|
-
`no baseline yet` items
|
|
315
|
-
|
|
316
|
-
|
|
327
|
+
`no baseline yet` items block the pull request like `new` ones: they count as needing a decision,
|
|
328
|
+
Accept all includes them, and the story screen offers Accept, Reject and Exclude for them. Accepting
|
|
329
|
+
one makes its capture the story's first baseline. The grid also lists them under their own filter.
|
|
330
|
+
Seed them from the default branch (below), or accept them on the pull request.
|
|
317
331
|
|
|
318
332
|
## Keys
|
|
319
333
|
|
|
320
334
|
| Key | Action |
|
|
321
335
|
| ------------ | ------------------------------------------------------------------------------ |
|
|
322
336
|
| J / K | Next / previous item of this pass (decided items stay in it) |
|
|
323
|
-
| A | Accept an undecided item
|
|
337
|
+
| A | Accept an undecided item, once both its images are shown |
|
|
324
338
|
| R | Reject an undecided item (asks for a reason, then Enter) |
|
|
325
339
|
| E | Exclude an undecided item (asks for a reason, then Enter, then a confirmation) |
|
|
326
340
|
| U | Undo the item's decision (on the grid: each tile's Undo button) |
|
|
327
341
|
| F | Flash between baseline and new; F again returns to side by side |
|
|
342
|
+
| F | In Spotlight: flash the spotlighted baseline and new, or stop flashing |
|
|
328
343
|
| H | Highlight changed pixels; H again returns to side by side |
|
|
329
344
|
| S | Spotlight the changes; S again returns to side by side |
|
|
330
345
|
| Z | Next zoom: fit to screen, real size, 2x, 4x, 8x, then fit again |
|
|
@@ -338,6 +353,8 @@ for them. Seed them from the default branch (below), or accept them on the pull
|
|
|
338
353
|
|
|
339
354
|
No key reverses a decision. A, R and E do nothing on an item that is already decided, and say
|
|
340
355
|
so; to change a decision, press U (or the Undo button) first. The same key twice never undoes.
|
|
356
|
+
A held A, R, E or U decides once, and a double click on a decision button decides only the item
|
|
357
|
+
it was clicked on, never the next one.
|
|
341
358
|
|
|
342
359
|
## What each decision does
|
|
343
360
|
|
|
@@ -354,7 +371,9 @@ so; to change a decision, press U (or the Undo button) first. The same key twice
|
|
|
354
371
|
runner), re-run the `visual` job instead, since the newest attempt replaces the old results.
|
|
355
372
|
- **Undo** (U, or a tile's Undo on the grid) clears a decision before Finish; it is the only way
|
|
356
373
|
to change one. The grid also undoes a whole component or project, after a second press.
|
|
357
|
-
Decisions are kept across server restarts.
|
|
374
|
+
Decisions are kept across server restarts. A decision applies only to the image it was taken
|
|
375
|
+
on: when a new run or a re-run attempt captures that item differently, it is undecided again
|
|
376
|
+
(the old decision stays saved, and comes back if the image does).
|
|
358
377
|
- After Finish, accepts and exclusions are cleared; rejects stay, marked as already posted, and
|
|
359
378
|
still show as rejected on the next CI run while the capture is unchanged. Finish does not post
|
|
360
379
|
them twice. They live in the work directory's `state/` (the config's `workDir`), not in the
|
|
@@ -424,15 +443,23 @@ names the captured commit, so Finish's seed branch starts from that commit.
|
|
|
424
443
|
A story with no baseline on the default branch is in the "no baseline yet" state. On every pull
|
|
425
444
|
request, CI compares its capture with the default branch's newest capture of that story:
|
|
426
445
|
|
|
427
|
-
- **The pull request does not change it:** `no baseline yet` (`unseeded`).
|
|
428
|
-
not block the pull request, and it is never accepted by Accept all.
|
|
446
|
+
- **The pull request does not change it:** `no baseline yet` (`unseeded`).
|
|
429
447
|
- **The pull request adds the story, or changes how it looks** (for example an agent fixing a
|
|
430
|
-
story you rejected): `new`.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
448
|
+
story you rejected): `new`.
|
|
449
|
+
|
|
450
|
+
Both block the pull request until you decide. Review them there; accepting one creates its first
|
|
451
|
+
baseline in that pull request's accept commit. Seeding never restarts from scratch: each round
|
|
452
|
+
accepts what now looks right, and every story still without a baseline keeps blocking pull
|
|
453
|
+
requests until it is accepted or seeded.
|
|
454
|
+
|
|
455
|
+
The same holds for a project with no baselines at all: the gate fails closed. Every project in the
|
|
456
|
+
config is gated from the start, so every pull request is blocked by the stories of an unseeded
|
|
457
|
+
project, and the gate's message says how to unblock it: seed the project (capture a known-good
|
|
458
|
+
commit with `visual-seed.yml`, or take the default branch's newest run, review it with
|
|
459
|
+
`serve --master-run <run id>`, merge the seed pull request, then merge the default branch into the
|
|
460
|
+
blocked one), or accept the items on that pull request. A project with
|
|
461
|
+
`"seedFromDefaultBranch": false` is told to accept them on the pull request. Seed only from a commit
|
|
462
|
+
whose images a person already reviewed.
|
|
436
463
|
|
|
437
464
|
If the default branch's capture could not be downloaded (its artifacts expired, or no run there has
|
|
438
465
|
finished one), every story without a baseline is `new` on that pull request. Re-run its `visual` job
|
|
@@ -543,14 +570,13 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
|
|
|
543
570
|
|
|
544
571
|
## What the gate does and does not guarantee
|
|
545
572
|
|
|
546
|
-
- A pull request cannot pass the gate while its capture of a
|
|
547
|
-
`unchanged
|
|
573
|
+
- A pull request cannot pass the gate while its capture of a gated project holds anything but
|
|
574
|
+
`unchanged` or `excluded` items, including after "Re-run failed jobs" (the
|
|
548
575
|
highest attempt's artifact counts); a missing, unfinished or invalid capture blocks it too. A
|
|
549
576
|
rejected item stays blocking until a code change makes it match the baseline.
|
|
550
|
-
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
between shows as `new`, which blocks rather than passes.
|
|
577
|
+
- A story with no baseline always blocks. `new` and `no baseline yet` only tell the reviewer
|
|
578
|
+
whether the pull request changed it, measured against the default branch's newest complete
|
|
579
|
+
capture, which may be a few merges older than the pull request's base.
|
|
554
580
|
- Every baseline PNG, and every settings file that excludes a story, that the pull request adds,
|
|
555
581
|
changes or deletes must be named with its new hash in a review record the pull request adds
|
|
556
582
|
under `<baselines>/reviews/`. A baseline PNG is a Git LFS pointer in git, and the gate reads the
|
|
@@ -565,8 +591,9 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
|
|
|
565
591
|
anything running as you (an AI coding agent included) has your GitHub login and signing key and
|
|
566
592
|
could press Accept or call the page's API. Nothing technical prevents that today; tell your
|
|
567
593
|
agents not to, and keep the review to yourself.
|
|
568
|
-
- The projects the gate checks are
|
|
569
|
-
|
|
594
|
+
- The projects the gate checks are every project in the base branch's config and in the pull
|
|
595
|
+
request's config, seeded or not, plus every project with baselines on the base branch. So
|
|
596
|
+
removing a project from the config does not remove it from the gate.
|
|
570
597
|
- The gate is part of a workflow file, which a pull request can edit, and a pull request can
|
|
571
598
|
loosen a story's own `diffThreshold` or `delay`, or a settings file's non-excluding keys,
|
|
572
599
|
without a review item. Read changes to those in code review.
|
|
@@ -584,6 +611,21 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
|
|
|
584
611
|
other `core.hooksPath`), that hook is not installed: call `git lfs pre-push "$@"` from your own
|
|
585
612
|
pre-push hook. `git push --no-verify` skips the upload too; after one that carried baselines,
|
|
586
613
|
run `git lfs push origin <branch>`.
|
|
614
|
+
- **download failed / failed to load: ...; reload the page to retry.** `serve` starts
|
|
615
|
+
downloading every capture as soon as it starts, and retries a gh call that fails on the network
|
|
616
|
+
(DNS, a dropped connection, a GitHub 5xx) three times over about 20 seconds; it logs each failed
|
|
617
|
+
call and each retry to stderr. A project whose download still fails shows "download failed", a
|
|
618
|
+
pull request (or the default branch's run) GitHub would not answer for shows "failed to load",
|
|
619
|
+
or, when it loaded before, keeps what it showed with "could not refresh", and everything else
|
|
620
|
+
loads as usual. Reload the page to try again; captures already downloaded are kept, and a
|
|
621
|
+
damaged one is downloaded again.
|
|
622
|
+
- **A gh or git call hangs.** Every gh and git call `serve` and Finish make is stopped after 10
|
|
623
|
+
minutes (`VISUAL_REVIEW_TIMEOUT_MS` sets another limit, in milliseconds), and git never waits
|
|
624
|
+
for a credential prompt. A stopped Finish names the step it was on and keeps your decisions.
|
|
625
|
+
- **"the server stopped while this Finish was at ..."** The server restarted during a Finish.
|
|
626
|
+
Look at the branch on origin to see whether its commit was pushed before pressing Finish again.
|
|
627
|
+
- **"... was pushed as ..., but opening its pull request failed"** (the seed). Press Finish
|
|
628
|
+
again: it opens the pull request for the branch already pushed.
|
|
587
629
|
- **capture failed / no capture** on a target. The `visual` job produced no results. Open its
|
|
588
630
|
log from the page and re-run the job. **incomplete: N of M stories**: the job stopped part way
|
|
589
631
|
(a timeout); re-run it.
|
package/capture/capture.mjs
CHANGED
|
@@ -154,6 +154,13 @@ export function storySettings(parameters, file) {
|
|
|
154
154
|
name,
|
|
155
155
|
globals: Object.fromEntries(Object.entries(m).filter(([k]) => k !== "disable")),
|
|
156
156
|
}));
|
|
157
|
+
// A mode names files and results.json items, which allow only these: refused before any story
|
|
158
|
+
// is captured, not when results.json is checked at the end.
|
|
159
|
+
for (const { name } of modes) {
|
|
160
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name) || name.length > 50) {
|
|
161
|
+
throw new Error(`Chromatic mode "${name}": a mode name is lowercase letters, digits and "-", at most 50`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
157
164
|
const where = byFile ? "settings file" : "story's parameters";
|
|
158
165
|
return {
|
|
159
166
|
disableSnapshot,
|
|
@@ -506,7 +513,10 @@ async function loadReference(dir) {
|
|
|
506
513
|
if (!results || validateResults(results).length > 0 || !results.complete) {
|
|
507
514
|
return { images, runId: null };
|
|
508
515
|
}
|
|
509
|
-
|
|
516
|
+
// Both statuses mean "captured with no baseline, hash recorded". Reading only `new` made a story
|
|
517
|
+
// alternate: a run that matched its reference says `unseeded`, so the next run found no image
|
|
518
|
+
// for it and said `new` again.
|
|
519
|
+
for (const item of results.items.filter((i) => i.status === "new" || i.status === "unseeded")) {
|
|
510
520
|
const bytes = await readFile(join(dir, item.file)).catch(() => null);
|
|
511
521
|
if (bytes && sha256(bytes) === item.capture) {
|
|
512
522
|
images.set(item.file, bytes);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/visual-review",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
|
|
5
5
|
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
6
|
"type": "module",
|
|
@@ -79,7 +79,7 @@ jobs:
|
|
|
79
79
|
run: ${{ matrix.build }}
|
|
80
80
|
|
|
81
81
|
# A story with no baseline that looks as it does in the default branch's newest capture is
|
|
82
|
-
# "unseeded"
|
|
82
|
+
# "unseeded"; without a reference every one is "new". Both block until accepted or seeded.
|
|
83
83
|
- name: Download the default branch's newest capture
|
|
84
84
|
id: reference
|
|
85
85
|
if: github.event_name == 'pull_request'
|
|
@@ -115,8 +115,9 @@ jobs:
|
|
|
115
115
|
fi
|
|
116
116
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
117
117
|
|
|
118
|
-
# Fails while a project
|
|
119
|
-
#
|
|
118
|
+
# Fails while a project of the base branch's or the pull request's config, or with baselines on
|
|
119
|
+
# the base branch (seeded or not), holds anything but unchanged or excluded items in its newest
|
|
120
|
+
# capture, or a baseline file changed without a review record.
|
|
120
121
|
# It runs the published gate at a pinned version, so a pull request's own dependencies cannot
|
|
121
122
|
# change it. Depth 2: HEAD^1, the merge commit's first parent, is the base branch tip.
|
|
122
123
|
visual-gate:
|
package/trusted/cli.mjs
CHANGED
|
@@ -214,6 +214,7 @@ async function serve(args) {
|
|
|
214
214
|
origin,
|
|
215
215
|
masterRun,
|
|
216
216
|
results: values.results && resolve(values.results),
|
|
217
|
+
warm: true,
|
|
217
218
|
// What the owner types to run this server from their own shell, so Finish signs with
|
|
218
219
|
// their key rather than the environment of whoever started it (an agent, say).
|
|
219
220
|
startCommand:
|
package/trusted/gate.mjs
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* The pull request gate: fails while a project
|
|
4
|
-
* changes the owner has not reviewed.
|
|
3
|
+
* The pull request gate: fails while a project holds visual changes the owner has not reviewed.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* it
|
|
5
|
+
* It fails closed: nothing merges with an image nobody approved. Every project with baselines on the
|
|
6
|
+
* base branch, and every project in the base branch's config or the pull request's, is gated, seeded
|
|
7
|
+
* or not. Every story needs an approved baseline. A story with none blocks: `new` when the pull
|
|
8
|
+
* request adds or changes it, `unseeded` when it looks as in master's newest capture. Either way
|
|
9
|
+
* the owner accepts it (on the pull request, or by seeding it from the default branch), which
|
|
10
|
+
* creates its first baseline.
|
|
10
11
|
*
|
|
11
12
|
* <dir> holds the downloaded `visual-<project>-<attempt>` artifacts of this CI run, every attempt
|
|
12
13
|
* of it. For each project only the highest attempt counts, so re-running failed jobs (which
|
|
13
14
|
* leaves the visual jobs' old attempt as the newest) can neither hide nor resurrect a capture.
|
|
14
15
|
* Which projects exist and are seeded is read from <ref> (the base branch tip, fetched by the
|
|
15
|
-
* caller),
|
|
16
|
-
*
|
|
17
|
-
* request turns the gate off. A
|
|
16
|
+
* caller), and so is visual-review.config.json (the projects and where the baselines live), so
|
|
17
|
+
* neither deleting a project's baselines, nor removing it from the config, nor moving the baselines
|
|
18
|
+
* directory in the pull request turns the gate off. The pull request's config can only add projects. A gated project with no results.json, or an incomplete one, fails: a capture that
|
|
18
19
|
* crashed has shown the owner nothing. An invalid results.json counts as missing.
|
|
19
20
|
*
|
|
20
21
|
* It also fails when a baseline PNG, or a settings file that excludes a story, differs from the
|
|
@@ -40,7 +41,7 @@ import { parseArgs } from "node:util";
|
|
|
40
41
|
import { loadConfigAt, repoRoot } from "./lib/config.mjs";
|
|
41
42
|
import { validateResults } from "./lib/results.mjs";
|
|
42
43
|
|
|
43
|
-
const PASSING = new Set(["unchanged", "excluded"
|
|
44
|
+
const PASSING = new Set(["unchanged", "excluded"]);
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* The newest attempt's results.json of every project in a directory of downloaded artifacts.
|
|
@@ -62,24 +63,41 @@ export function newestResults(dir) {
|
|
|
62
63
|
continue;
|
|
63
64
|
}
|
|
64
65
|
const file = join(dir, name, "results.json");
|
|
65
|
-
|
|
66
|
+
let results = null;
|
|
67
|
+
try {
|
|
68
|
+
results = JSON.parse(readFileSync(file, "utf8"));
|
|
69
|
+
} catch {
|
|
70
|
+
// Missing or not JSON: counted as missing, like any invalid results.json.
|
|
71
|
+
}
|
|
72
|
+
out[project] = { attempt, results };
|
|
66
73
|
}
|
|
67
74
|
return out;
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The projects the gate checks: every project with baselines at the base, and every project of the
|
|
79
|
+
* base's config and of the pull request's.
|
|
80
|
+
* @param {{ projects: Record<string, object> }} config the base branch's config
|
|
81
|
+
* @param {Set<string>} seeded the projects with baselines at the base
|
|
82
|
+
* @param {{ projects: Record<string, object> }} [headConfig] the pull request's config
|
|
83
|
+
* @returns {string[]} the gated project ids
|
|
84
|
+
*/
|
|
85
|
+
export function gatedProjects(config, seeded, headConfig) {
|
|
86
|
+
return [...new Set([...seeded, ...Object.keys(config.projects), ...Object.keys(headConfig?.projects ?? {})])];
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
/**
|
|
71
90
|
* What blocks the pull request.
|
|
72
|
-
* @param {{
|
|
73
|
-
*
|
|
74
|
-
*
|
|
91
|
+
* @param {{ config: { defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> },
|
|
92
|
+
* headConfig: { projects: Record<string, object> } | undefined, seeded: Set<string>,
|
|
93
|
+
* captures: Record<string, { attempt: number, results: object | null }> }} input the base
|
|
94
|
+
* branch's config, the pull request's config (if any), the projects with baselines on the base
|
|
95
|
+
* branch, and the newest capture of each project
|
|
75
96
|
* @returns {string[]} one line per blocked project; empty when the gate passes
|
|
76
97
|
*/
|
|
77
|
-
export function gateProblems({
|
|
98
|
+
export function gateProblems({ config, headConfig, seeded, captures }) {
|
|
78
99
|
const problems = [];
|
|
79
|
-
for (const p of
|
|
80
|
-
if (!seeded.has(p)) {
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
100
|
+
for (const p of gatedProjects(config, seeded, headConfig)) {
|
|
83
101
|
const r = captures[p]?.results;
|
|
84
102
|
if (!r) {
|
|
85
103
|
problems.push(`${p}: no capture results (the visual job failed or uploaded nothing); re-run it`);
|
|
@@ -101,15 +119,36 @@ export function gateProblems({ projects, seeded, captures }) {
|
|
|
101
119
|
for (const s of open) {
|
|
102
120
|
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
103
121
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"(not accepted; a rejected item needs a code change, not another review)",
|
|
107
|
-
);
|
|
122
|
+
const what = [...counts].map(([s, n]) => `${n} ${s}`).join(", ");
|
|
123
|
+
problems.push(`${p}: ${what} ${seeded.has(p) ? NOT_ACCEPTED : notSeeded(config, p)}`);
|
|
108
124
|
}
|
|
109
125
|
}
|
|
110
126
|
return problems;
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
const NOT_ACCEPTED = "(not accepted; a rejected item needs a code change, not another review)";
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Why an unseeded project blocks, and how to seed it.
|
|
133
|
+
* @param {{ defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> }} config the
|
|
134
|
+
* base branch's config
|
|
135
|
+
* @param {string} p the project
|
|
136
|
+
* @returns {string} the rest of the gate's line
|
|
137
|
+
*/
|
|
138
|
+
function notSeeded(config, p) {
|
|
139
|
+
const branch = config.defaultBranch;
|
|
140
|
+
const here = "accept them on this pull request with `visual-review serve`";
|
|
141
|
+
if (config.projects[p]?.seedFromDefaultBranch === false) {
|
|
142
|
+
return `(not accepted; ${p} has no baselines on ${branch} yet, so ${here} to create its first ones)`;
|
|
143
|
+
}
|
|
144
|
+
return (
|
|
145
|
+
`(not accepted; ${p} has no baselines on ${branch} yet. Seed it: capture a known-good commit with ` +
|
|
146
|
+
`\`gh workflow run visual-seed.yml --ref ${branch} -f ref=<sha>\` (or take ${branch}'s newest run), ` +
|
|
147
|
+
`review that run with \`visual-review serve --master-run <run id>\` and merge the seed pull ` +
|
|
148
|
+
`request, then merge ${branch} into this branch; or ${here})`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
113
152
|
const gitOut = (cwd, args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 28 });
|
|
114
153
|
|
|
115
154
|
/**
|
|
@@ -189,8 +228,7 @@ function parseOr(bytes) {
|
|
|
189
228
|
|
|
190
229
|
/**
|
|
191
230
|
* The projects with at least one baseline PNG at a git ref: the directories under the baselines
|
|
192
|
-
* directory at the base tip
|
|
193
|
-
* visual-review.config.json.
|
|
231
|
+
* directory at the base tip. They are gated whatever the config says.
|
|
194
232
|
* @param {string} ref the base branch tip
|
|
195
233
|
* @param {string} [cwd] the repository
|
|
196
234
|
* @param {string} [baselines] the baselines directory
|
|
@@ -245,17 +283,14 @@ export function runGate(args) {
|
|
|
245
283
|
return 2;
|
|
246
284
|
}
|
|
247
285
|
const root = repoRoot();
|
|
248
|
-
const
|
|
249
|
-
const seeded = seededAt(values.base, root, baselines);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
seeded,
|
|
253
|
-
captures: newestResults(values.captures),
|
|
254
|
-
});
|
|
286
|
+
const config = loadConfigAt(values.base, root);
|
|
287
|
+
const seeded = seededAt(values.base, root, config.baselines);
|
|
288
|
+
const headConfig = loadConfigAt(values.head, root);
|
|
289
|
+
const problems = gateProblems({ config, headConfig, seeded, captures: newestResults(values.captures) });
|
|
255
290
|
for (const line of problems) {
|
|
256
291
|
console.log(`::error::visual changes not accepted -- ${line}`);
|
|
257
292
|
}
|
|
258
|
-
const unrecorded = unrecordedChanges(values.base, values.head, root, baselines);
|
|
293
|
+
const unrecorded = unrecordedChanges(values.base, values.head, root, config.baselines);
|
|
259
294
|
for (const line of unrecorded) {
|
|
260
295
|
console.log(`::error::baseline without a review -- ${line}`);
|
|
261
296
|
}
|