@graphty/visual-review 0.1.2 → 0.2.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.
- package/README.md +51 -27
- package/capture/capture.mjs +12 -2
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -3
- package/templates/visual-seed.yml +32 -12
- package/trusted/gate.mjs +61 -32
- package/trusted/lib/accept.mjs +1 -1
- package/trusted/lib/config.mjs +3 -0
- package/trusted/lib/serve.mjs +1 -1
- package/trusted/page/index.html +1 -1
- package/trusted/page/review.css +4 -0
- package/trusted/page/review.js +27 -19
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,10 @@ 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, `.`, `_` and `-`.
|
|
150
|
-
|
|
150
|
+
Project ids are letters, digits, `.`, `_` and `-`. Every project is gated; there is no setting
|
|
151
|
+
that turns the gate off, and a config that sets `gate` is refused. The pull request gate reads the
|
|
152
|
+
config as it is on the base branch, so a pull request cannot move `baselines` out from under it or
|
|
153
|
+
drop a project from the gate; a project that a pull request adds to its own config is gated too.
|
|
151
154
|
|
|
152
155
|
## The GitHub Actions workflows
|
|
153
156
|
|
|
@@ -170,16 +173,20 @@ copy the `visual` job and the gate's steps into it, keep those names, and set `w
|
|
|
170
173
|
file.
|
|
171
174
|
|
|
172
175
|
**`visual-seed.yml`** is started by hand to capture an older commit with the default branch's
|
|
173
|
-
tool: `gh workflow run visual-seed.yml --ref main -f ref=<sha>`.
|
|
174
|
-
|
|
176
|
+
tool: `gh workflow run visual-seed.yml --ref main -f ref=<sha>`. It captures every project seeded
|
|
177
|
+
from the default branch; add `-f projects="web charts"` to capture only those. Each project is
|
|
178
|
+
built and captured on its own, so one whose Storybook does not build at that commit fails alone.
|
|
179
|
+
See [Seeding](#seeding-one-story-at-a-time).
|
|
175
180
|
|
|
176
181
|
Both need nothing but the default `GITHUB_TOKEN`: the capture job reads Actions artifacts
|
|
177
182
|
(`actions: read`); nothing in CI writes to the repository.
|
|
178
183
|
|
|
179
184
|
## Your first review: seeding baselines
|
|
180
185
|
|
|
181
|
-
A project has no baselines until you accept some, and
|
|
182
|
-
|
|
186
|
+
A project has no baselines until you accept some, and the gate fails closed until it has them:
|
|
187
|
+
every pull request is blocked by each of its stories (`new` or `no baseline yet`), with a message
|
|
188
|
+
saying to seed the project. After the setup pull request merges, the default branch's push runs
|
|
189
|
+
the capture:
|
|
183
190
|
|
|
184
191
|
1. Find that run's id: `gh run list --workflow visual-review.yml --branch main --limit 1`.
|
|
185
192
|
2. Start the page with `--master-run <run id>` ([Opening the review page](#opening-the-review-page))
|
|
@@ -309,9 +316,10 @@ story is new or looks different from the default branch's newest capture of it),
|
|
|
309
316
|
story no longer exists, lost a mode, or whose story's own parameters now exclude it), `unstable`
|
|
310
317
|
(two captures of the same commit differed), `failed` (did not render, even after one retry).
|
|
311
318
|
|
|
312
|
-
`no baseline yet` items
|
|
313
|
-
|
|
314
|
-
|
|
319
|
+
`no baseline yet` items block the pull request like `new` ones: they count as needing a decision,
|
|
320
|
+
Accept all includes them, and the story screen offers Accept, Reject and Exclude for them. Accepting
|
|
321
|
+
one makes its capture the story's first baseline. The grid also lists them under their own filter.
|
|
322
|
+
Seed them from the default branch (below), or accept them on the pull request.
|
|
315
323
|
|
|
316
324
|
## Keys
|
|
317
325
|
|
|
@@ -422,15 +430,23 @@ names the captured commit, so Finish's seed branch starts from that commit.
|
|
|
422
430
|
A story with no baseline on the default branch is in the "no baseline yet" state. On every pull
|
|
423
431
|
request, CI compares its capture with the default branch's newest capture of that story:
|
|
424
432
|
|
|
425
|
-
- **The pull request does not change it:** `no baseline yet` (`unseeded`).
|
|
426
|
-
not block the pull request, and it is never accepted by Accept all.
|
|
433
|
+
- **The pull request does not change it:** `no baseline yet` (`unseeded`).
|
|
427
434
|
- **The pull request adds the story, or changes how it looks** (for example an agent fixing a
|
|
428
|
-
story you rejected): `new`.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
435
|
+
story you rejected): `new`.
|
|
436
|
+
|
|
437
|
+
Both block the pull request until you decide. Review them there; accepting one creates its first
|
|
438
|
+
baseline in that pull request's accept commit. Seeding never restarts from scratch: each round
|
|
439
|
+
accepts what now looks right, and every story still without a baseline keeps blocking pull
|
|
440
|
+
requests until it is accepted or seeded.
|
|
441
|
+
|
|
442
|
+
The same holds for a project with no baselines at all: the gate fails closed. Every project in the
|
|
443
|
+
config is gated from the start, so every pull request is blocked by the stories of an unseeded
|
|
444
|
+
project, and the gate's message says how to unblock it: seed the project (capture a known-good
|
|
445
|
+
commit with `visual-seed.yml`, or take the default branch's newest run, review it with
|
|
446
|
+
`serve --master-run <run id>`, merge the seed pull request, then merge the default branch into the
|
|
447
|
+
blocked one), or accept the items on that pull request. A project with
|
|
448
|
+
`"seedFromDefaultBranch": false` is told to accept them on the pull request. Seed only from a commit
|
|
449
|
+
whose images a person already reviewed.
|
|
434
450
|
|
|
435
451
|
If the default branch's capture could not be downloaded (its artifacts expired, or no run there has
|
|
436
452
|
finished one), every story without a baseline is `new` on that pull request. Re-run its `visual` job
|
|
@@ -462,6 +478,14 @@ the pull request.
|
|
|
462
478
|
wider. It is never cropped to the content, so a small component sits in the full canvas and
|
|
463
479
|
every capture of a project has the same size unless its story overflows. results.json records the scale as `scale`, and each review record
|
|
464
480
|
copies it into its `subject`.
|
|
481
|
+
- **Why captures rasterize on the CPU.** Chromium runs with `--disable-gpu-rasterization`, so the
|
|
482
|
+
page's text and shapes are drawn by the CPU; WebGL still runs on SwiftShader. Drawn through
|
|
483
|
+
SwiftShader, a glyph that sat on a sub-pixel boundary landed on either side of it from one
|
|
484
|
+
render to the next (a quarter-pixel shift of one letter, in 1 to 7 of 48 renders of the same
|
|
485
|
+
story), so stories with nothing moving read `unstable`, a different few on each run. With the
|
|
486
|
+
switch, 48 of 48 renders matched. The repository owner chose this on 2026-09-30, knowing it
|
|
487
|
+
changes how text is drawn in every story of every project: a baseline captured before it can
|
|
488
|
+
read `changed` once, and is accepted again.
|
|
465
489
|
- **From GitHub Actions to the page.** Each `visual` job uploads `results.json` and the PNGs to
|
|
466
490
|
review as an artifact `visual-<project>-<attempt>`, kept 30 days. The server lists open pull
|
|
467
491
|
requests with `gh`, finds each one's newest run of the capturing workflow, and downloads those
|
|
@@ -533,14 +557,13 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
|
|
|
533
557
|
|
|
534
558
|
## What the gate does and does not guarantee
|
|
535
559
|
|
|
536
|
-
- A pull request cannot pass the gate while its capture of a
|
|
537
|
-
`unchanged
|
|
560
|
+
- A pull request cannot pass the gate while its capture of a gated project holds anything but
|
|
561
|
+
`unchanged` or `excluded` items, including after "Re-run failed jobs" (the
|
|
538
562
|
highest attempt's artifact counts); a missing, unfinished or invalid capture blocks it too. A
|
|
539
563
|
rejected item stays blocking until a code change makes it match the baseline.
|
|
540
|
-
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
between shows as `new`, which blocks rather than passes.
|
|
564
|
+
- A story with no baseline always blocks. `new` and `no baseline yet` only tell the reviewer
|
|
565
|
+
whether the pull request changed it, measured against the default branch's newest complete
|
|
566
|
+
capture, which may be a few merges older than the pull request's base.
|
|
544
567
|
- Every baseline PNG, and every settings file that excludes a story, that the pull request adds,
|
|
545
568
|
changes or deletes must be named with its new hash in a review record the pull request adds
|
|
546
569
|
under `<baselines>/reviews/`. A baseline PNG is a Git LFS pointer in git, and the gate reads the
|
|
@@ -555,8 +578,9 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
|
|
|
555
578
|
anything running as you (an AI coding agent included) has your GitHub login and signing key and
|
|
556
579
|
could press Accept or call the page's API. Nothing technical prevents that today; tell your
|
|
557
580
|
agents not to, and keep the review to yourself.
|
|
558
|
-
- The projects the gate checks are
|
|
559
|
-
|
|
581
|
+
- The projects the gate checks are every project in the base branch's config and in the pull
|
|
582
|
+
request's config, seeded or not, plus every project with baselines on the base branch. So
|
|
583
|
+
removing a project from the config does not remove it from the gate.
|
|
560
584
|
- The gate is part of a workflow file, which a pull request can edit, and a pull request can
|
|
561
585
|
loosen a story's own `diffThreshold` or `delay`, or a settings file's non-excluding keys,
|
|
562
586
|
without a review item. Read changes to those in code review.
|
package/capture/capture.mjs
CHANGED
|
@@ -54,10 +54,17 @@ const VIEWPORT = { width: 1200, height: 900 };
|
|
|
54
54
|
/** Device pixels per CSS pixel, as Chromatic captures; recorded in results.json as `scale`. */
|
|
55
55
|
const SCALE = 2;
|
|
56
56
|
const RENDER_TIMEOUT = 30_000;
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Chromium's switches for every capture. `--disable-gpu-rasterization` draws the page's text and
|
|
59
|
+
* shapes on the CPU: rasterized through SwiftShader, a glyph that sits on a sub-pixel boundary
|
|
60
|
+
* landed on either side of it from one render to the next, so a story with nothing moving read
|
|
61
|
+
* `unstable` (the README, "Why captures rasterize on the CPU"). WebGL still runs on SwiftShader.
|
|
62
|
+
*/
|
|
63
|
+
export const CHROMIUM_ARGS = [
|
|
58
64
|
"--use-gl=angle",
|
|
59
65
|
"--use-angle=swiftshader",
|
|
60
66
|
"--enable-unsafe-swiftshader",
|
|
67
|
+
"--disable-gpu-rasterization",
|
|
61
68
|
"--force-color-profile=srgb",
|
|
62
69
|
"--disable-lcd-text",
|
|
63
70
|
"--font-render-hinting=none",
|
|
@@ -499,7 +506,10 @@ async function loadReference(dir) {
|
|
|
499
506
|
if (!results || validateResults(results).length > 0 || !results.complete) {
|
|
500
507
|
return { images, runId: null };
|
|
501
508
|
}
|
|
502
|
-
|
|
509
|
+
// Both statuses mean "captured with no baseline, hash recorded". Reading only `new` made a story
|
|
510
|
+
// alternate: a run that matched its reference says `unseeded`, so the next run found no image
|
|
511
|
+
// for it and said `new` again.
|
|
512
|
+
for (const item of results.items.filter((i) => i.status === "new" || i.status === "unseeded")) {
|
|
503
513
|
const bytes = await readFile(join(dir, item.file)).catch(() => null);
|
|
504
514
|
if (bytes && sha256(bytes) === item.capture) {
|
|
505
515
|
images.set(item.file, bytes);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/visual-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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:
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
# baselines can be seeded from it: `visual-review serve --master-run <this run's id>` lists the run
|
|
6
6
|
# as the default branch, and Finish opens the seed pull request. Dispatch it from __BRANCH__:
|
|
7
7
|
# gh workflow run visual-seed.yml --ref __BRANCH__ -f ref=<sha>
|
|
8
|
-
# Only the projects whose "seedFromDefaultBranch" is not false are captured
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# Only the projects whose "seedFromDefaultBranch" is not false are captured, or, with
|
|
9
|
+
# `-f projects="a b"`, only those of them named there. Each project is built and captured on its
|
|
10
|
+
# own: one whose Storybook fails to build at that commit fails its own jobs and no other project's.
|
|
11
|
+
# The capture job repeats the "visual" job of the review workflow, except that it has no reference
|
|
12
|
+
# (every story without a baseline is "new") and results.json names the captured commit.
|
|
11
13
|
|
|
12
14
|
name: Visual seed capture
|
|
13
15
|
|
|
@@ -18,6 +20,11 @@ on:
|
|
|
18
20
|
description: "The commit (or branch or tag) whose Storybooks are captured"
|
|
19
21
|
required: true
|
|
20
22
|
type: string
|
|
23
|
+
projects:
|
|
24
|
+
description: "The projects to capture, separated by spaces or commas (default: every one seeded from the default branch)"
|
|
25
|
+
required: false
|
|
26
|
+
type: string
|
|
27
|
+
default: ""
|
|
21
28
|
|
|
22
29
|
permissions:
|
|
23
30
|
contents: read
|
|
@@ -34,13 +41,23 @@ jobs:
|
|
|
34
41
|
sparse-checkout: visual-review.config.json
|
|
35
42
|
sparse-checkout-cone-mode: false
|
|
36
43
|
|
|
44
|
+
# A name that is not a project seeded from the default branch stops the run here, rather
|
|
45
|
+
# than seeding fewer projects than were asked for.
|
|
37
46
|
- name: List the projects
|
|
38
47
|
id: plan
|
|
48
|
+
env:
|
|
49
|
+
WANT: ${{ inputs.projects }}
|
|
39
50
|
run: |
|
|
40
|
-
|
|
51
|
+
projects=$(jq -c --arg want "$WANT" '(.baselines // "visual-baselines") as $b
|
|
52
|
+
| [$want | splits("[ ,]+") | select(. != "")] as $w
|
|
41
53
|
| [.projects | to_entries[] | select(.value.seedFromDefaultBranch != false)
|
|
42
|
-
| {project: .key, storybook: .value.storybook, build: .value.build, baselines: $b}]
|
|
43
|
-
|
|
54
|
+
| {project: .key, storybook: .value.storybook, build: .value.build, baselines: $b}] as $all
|
|
55
|
+
| ($w - [$all[].project]) as $unknown
|
|
56
|
+
| if ($unknown | length) > 0
|
|
57
|
+
then error("not a project seeded from the default branch: \($unknown | join(", "))")
|
|
58
|
+
else [$all[] | select(($w | length) == 0 or (.project | IN($w[])))] end' \
|
|
59
|
+
visual-review.config.json)
|
|
60
|
+
echo "projects=$projects" >> "$GITHUB_OUTPUT"
|
|
44
61
|
|
|
45
62
|
storybook:
|
|
46
63
|
name: Build ${{ matrix.project }} at ${{ inputs.ref }}
|
|
@@ -71,9 +88,12 @@ jobs:
|
|
|
71
88
|
name: storybook-${{ matrix.project }}
|
|
72
89
|
path: ${{ matrix.storybook }}/
|
|
73
90
|
|
|
91
|
+
# Runs even when another project's build failed. A project whose own build failed has no
|
|
92
|
+
# Storybook artifact, so its job stops at the download, before installing anything.
|
|
74
93
|
visual:
|
|
75
94
|
name: visual (${{ matrix.project }})
|
|
76
95
|
needs: [plan, storybook]
|
|
96
|
+
if: ${{ !cancelled() && needs.plan.result == 'success' }}
|
|
77
97
|
runs-on: ubuntu-24.04
|
|
78
98
|
timeout-minutes: 45
|
|
79
99
|
strategy:
|
|
@@ -84,6 +104,12 @@ jobs:
|
|
|
84
104
|
# The default branch's checkout: the capture tool, the config and the baselines come from here.
|
|
85
105
|
- uses: actions/checkout@v4
|
|
86
106
|
|
|
107
|
+
- name: Download Storybook build
|
|
108
|
+
uses: actions/download-artifact@v4
|
|
109
|
+
with:
|
|
110
|
+
name: storybook-${{ matrix.project }}
|
|
111
|
+
path: ${{ matrix.storybook }}/
|
|
112
|
+
|
|
87
113
|
- name: Restore this project's baseline images
|
|
88
114
|
uses: actions/cache@v4
|
|
89
115
|
with:
|
|
@@ -99,12 +125,6 @@ jobs:
|
|
|
99
125
|
- name: Install Chromium
|
|
100
126
|
run: __CLI__ install-browser
|
|
101
127
|
|
|
102
|
-
- name: Download Storybook build
|
|
103
|
-
uses: actions/download-artifact@v4
|
|
104
|
-
with:
|
|
105
|
-
name: storybook-${{ matrix.project }}
|
|
106
|
-
path: ${{ matrix.storybook }}/
|
|
107
|
-
|
|
108
128
|
- name: Capture
|
|
109
129
|
run: __CLI__ capture --project ${{ matrix.project }} --out "$RUNNER_TEMP/visual"
|
|
110
130
|
|
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.
|
|
@@ -67,19 +68,30 @@ export function newestResults(dir) {
|
|
|
67
68
|
return out;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The projects the gate checks: every project with baselines at the base, and every project of the
|
|
73
|
+
* base's config and of the pull request's.
|
|
74
|
+
* @param {{ projects: Record<string, object> }} config the base branch's config
|
|
75
|
+
* @param {Set<string>} seeded the projects with baselines at the base
|
|
76
|
+
* @param {{ projects: Record<string, object> }} [headConfig] the pull request's config
|
|
77
|
+
* @returns {string[]} the gated project ids
|
|
78
|
+
*/
|
|
79
|
+
export function gatedProjects(config, seeded, headConfig) {
|
|
80
|
+
return [...new Set([...seeded, ...Object.keys(config.projects), ...Object.keys(headConfig?.projects ?? {})])];
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
/**
|
|
71
84
|
* What blocks the pull request.
|
|
72
|
-
* @param {{
|
|
73
|
-
*
|
|
74
|
-
*
|
|
85
|
+
* @param {{ config: { defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> },
|
|
86
|
+
* headConfig: { projects: Record<string, object> } | undefined, seeded: Set<string>,
|
|
87
|
+
* captures: Record<string, { attempt: number, results: object | null }> }} input the base
|
|
88
|
+
* branch's config, the pull request's config (if any), the projects with baselines on the base
|
|
89
|
+
* branch, and the newest capture of each project
|
|
75
90
|
* @returns {string[]} one line per blocked project; empty when the gate passes
|
|
76
91
|
*/
|
|
77
|
-
export function gateProblems({
|
|
92
|
+
export function gateProblems({ config, headConfig, seeded, captures }) {
|
|
78
93
|
const problems = [];
|
|
79
|
-
for (const p of
|
|
80
|
-
if (!seeded.has(p)) {
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
94
|
+
for (const p of gatedProjects(config, seeded, headConfig)) {
|
|
83
95
|
const r = captures[p]?.results;
|
|
84
96
|
if (!r) {
|
|
85
97
|
problems.push(`${p}: no capture results (the visual job failed or uploaded nothing); re-run it`);
|
|
@@ -101,15 +113,36 @@ export function gateProblems({ projects, seeded, captures }) {
|
|
|
101
113
|
for (const s of open) {
|
|
102
114
|
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
103
115
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"(not accepted; a rejected item needs a code change, not another review)",
|
|
107
|
-
);
|
|
116
|
+
const what = [...counts].map(([s, n]) => `${n} ${s}`).join(", ");
|
|
117
|
+
problems.push(`${p}: ${what} ${seeded.has(p) ? NOT_ACCEPTED : notSeeded(config, p)}`);
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
return problems;
|
|
111
121
|
}
|
|
112
122
|
|
|
123
|
+
const NOT_ACCEPTED = "(not accepted; a rejected item needs a code change, not another review)";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Why an unseeded project blocks, and how to seed it.
|
|
127
|
+
* @param {{ defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> }} config the
|
|
128
|
+
* base branch's config
|
|
129
|
+
* @param {string} p the project
|
|
130
|
+
* @returns {string} the rest of the gate's line
|
|
131
|
+
*/
|
|
132
|
+
function notSeeded(config, p) {
|
|
133
|
+
const branch = config.defaultBranch;
|
|
134
|
+
const here = "accept them on this pull request with `visual-review serve`";
|
|
135
|
+
if (config.projects[p]?.seedFromDefaultBranch === false) {
|
|
136
|
+
return `(not accepted; ${p} has no baselines on ${branch} yet, so ${here} to create its first ones)`;
|
|
137
|
+
}
|
|
138
|
+
return (
|
|
139
|
+
`(not accepted; ${p} has no baselines on ${branch} yet. Seed it: capture a known-good commit with ` +
|
|
140
|
+
`\`gh workflow run visual-seed.yml --ref ${branch} -f ref=<sha>\` (or take ${branch}'s newest run), ` +
|
|
141
|
+
`review that run with \`visual-review serve --master-run <run id>\` and merge the seed pull ` +
|
|
142
|
+
`request, then merge ${branch} into this branch; or ${here})`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
113
146
|
const gitOut = (cwd, args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 28 });
|
|
114
147
|
|
|
115
148
|
/**
|
|
@@ -189,8 +222,7 @@ function parseOr(bytes) {
|
|
|
189
222
|
|
|
190
223
|
/**
|
|
191
224
|
* 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.
|
|
225
|
+
* directory at the base tip. They are gated whatever the config says.
|
|
194
226
|
* @param {string} ref the base branch tip
|
|
195
227
|
* @param {string} [cwd] the repository
|
|
196
228
|
* @param {string} [baselines] the baselines directory
|
|
@@ -245,17 +277,14 @@ export function runGate(args) {
|
|
|
245
277
|
return 2;
|
|
246
278
|
}
|
|
247
279
|
const root = repoRoot();
|
|
248
|
-
const
|
|
249
|
-
const seeded = seededAt(values.base, root, baselines);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
seeded,
|
|
253
|
-
captures: newestResults(values.captures),
|
|
254
|
-
});
|
|
280
|
+
const config = loadConfigAt(values.base, root);
|
|
281
|
+
const seeded = seededAt(values.base, root, config.baselines);
|
|
282
|
+
const headConfig = loadConfigAt(values.head, root);
|
|
283
|
+
const problems = gateProblems({ config, headConfig, seeded, captures: newestResults(values.captures) });
|
|
255
284
|
for (const line of problems) {
|
|
256
285
|
console.log(`::error::visual changes not accepted -- ${line}`);
|
|
257
286
|
}
|
|
258
|
-
const unrecorded = unrecordedChanges(values.base, values.head, root, baselines);
|
|
287
|
+
const unrecorded = unrecordedChanges(values.base, values.head, root, config.baselines);
|
|
259
288
|
for (const line of unrecorded) {
|
|
260
289
|
console.log(`::error::baseline without a review -- ${line}`);
|
|
261
290
|
}
|
package/trusted/lib/accept.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { isLfsPointer } from "./compare.mjs";
|
|
|
26
26
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
27
27
|
|
|
28
28
|
/** The statuses an item can be accepted or rejected in; unstable and failed are only excluded. */
|
|
29
|
-
const DECIDABLE = new Set(["changed", "moved", "new", "removed"]);
|
|
29
|
+
const DECIDABLE = new Set(["changed", "moved", "new", "unseeded", "removed"]);
|
|
30
30
|
const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
|
|
31
31
|
|
|
32
32
|
/**
|
package/trusted/lib/config.mjs
CHANGED
|
@@ -90,6 +90,9 @@ export function normalizeConfig(input) {
|
|
|
90
90
|
if (p.seedFromDefaultBranch !== undefined && typeof p.seedFromDefaultBranch !== "boolean") {
|
|
91
91
|
fail(`${where}.seedFromDefaultBranch must be true or false`);
|
|
92
92
|
}
|
|
93
|
+
if (p.gate !== undefined) {
|
|
94
|
+
fail(`${where}.gate is not a setting: every project is gated, and every story needs an approved baseline`);
|
|
95
|
+
}
|
|
93
96
|
let waitFor = null;
|
|
94
97
|
if (p.waitFor !== undefined && p.waitFor !== null) {
|
|
95
98
|
const w = p.waitFor;
|
package/trusted/lib/serve.mjs
CHANGED
|
@@ -44,7 +44,7 @@ const HEADERS = {
|
|
|
44
44
|
*/
|
|
45
45
|
const componentOf = (id) => id.split("--")[0];
|
|
46
46
|
|
|
47
|
-
const REVIEWABLE = new Set(["changed", "moved", "new", "removed", "unstable", "failed"]);
|
|
47
|
+
const REVIEWABLE = new Set(["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"]);
|
|
48
48
|
const WRITES = new Set(["decide", "accept-all", "finish"]);
|
|
49
49
|
|
|
50
50
|
/**
|
package/trusted/page/index.html
CHANGED
|
@@ -14,6 +14,6 @@
|
|
|
14
14
|
<span id="status" role="status"></span>
|
|
15
15
|
<button type="button" id="copy-link" title="Copy a link to this screen">Copy link</button>
|
|
16
16
|
</header>
|
|
17
|
-
<main id="app"><p>Loading...</p></main>
|
|
17
|
+
<main id="app" tabindex="-1"><p>Loading...</p></main>
|
|
18
18
|
</body>
|
|
19
19
|
</html>
|
package/trusted/page/review.css
CHANGED
package/trusted/page/review.js
CHANGED
|
@@ -11,9 +11,10 @@ const app = document.getElementById("app");
|
|
|
11
11
|
const crumbs = document.getElementById("crumbs");
|
|
12
12
|
const statusLine = document.getElementById("status");
|
|
13
13
|
|
|
14
|
-
const REVIEWABLE = ["changed", "moved", "new", "removed", "unstable", "failed"];
|
|
15
|
-
const ACCEPTABLE = ["changed", "moved", "new", "removed"];
|
|
16
|
-
// A story with no baseline that this pull request did not change
|
|
14
|
+
const REVIEWABLE = ["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"];
|
|
15
|
+
const ACCEPTABLE = ["changed", "moved", "new", "unseeded", "removed"];
|
|
16
|
+
// A story with no baseline that this pull request did not change. It blocks like `new`: accepting it
|
|
17
|
+
// creates its first baseline.
|
|
17
18
|
const UNSEEDED = "unseeded";
|
|
18
19
|
const NO_BASELINE = "no baseline yet";
|
|
19
20
|
const statusLabel = (status) => (status === UNSEEDED ? NO_BASELINE : status);
|
|
@@ -58,7 +59,7 @@ const state = {
|
|
|
58
59
|
};
|
|
59
60
|
const running = () => state.job?.running === true;
|
|
60
61
|
const VIEWS = ["side", "flash", "highlight", "spotlight"];
|
|
61
|
-
const FILTERS = ["undecided", "all", ...REVIEWABLE,
|
|
62
|
+
const FILTERS = ["undecided", "all", ...REVIEWABLE, ...Object.keys(DECISIONS)];
|
|
62
63
|
let routing = false; // true while the page follows the address (a link opened, Back, Forward)
|
|
63
64
|
const images = new Map();
|
|
64
65
|
const diffs = new Map();
|
|
@@ -182,18 +183,13 @@ function ordered(items) {
|
|
|
182
183
|
|
|
183
184
|
function visibleItems() {
|
|
184
185
|
const text = state.text.trim().toLowerCase();
|
|
185
|
-
let items;
|
|
186
|
-
if (state.filter ===
|
|
187
|
-
items =
|
|
188
|
-
} else {
|
|
189
|
-
items =
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} else if (Object.hasOwn(DECISIONS, state.filter)) {
|
|
193
|
-
items = items.filter((i) => decisionOf(i)?.decision === state.filter);
|
|
194
|
-
} else if (state.filter !== "all") {
|
|
195
|
-
items = items.filter((i) => i.status === state.filter);
|
|
196
|
-
}
|
|
186
|
+
let items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
|
|
187
|
+
if (state.filter === "undecided") {
|
|
188
|
+
items = items.filter((i) => !decisionOf(i));
|
|
189
|
+
} else if (Object.hasOwn(DECISIONS, state.filter)) {
|
|
190
|
+
items = items.filter((i) => decisionOf(i)?.decision === state.filter);
|
|
191
|
+
} else if (state.filter !== "all") {
|
|
192
|
+
items = items.filter((i) => i.status === state.filter);
|
|
197
193
|
}
|
|
198
194
|
return ordered(text ? items.filter((i) => i.file.includes(text)) : items);
|
|
199
195
|
}
|
|
@@ -384,7 +380,7 @@ function targetCard(t) {
|
|
|
384
380
|
el(
|
|
385
381
|
"td",
|
|
386
382
|
{},
|
|
387
|
-
p.reviewable > 0
|
|
383
|
+
p.reviewable > 0
|
|
388
384
|
? el("button", { type: "button", onclick: () => openProject(t, p.project) }, "Review")
|
|
389
385
|
: null,
|
|
390
386
|
),
|
|
@@ -864,7 +860,7 @@ function showStory() {
|
|
|
864
860
|
const view = note ? "side" : state.view;
|
|
865
861
|
const onlyExclude = item.status === "unstable" || item.status === "failed";
|
|
866
862
|
const unseeded = item.status === UNSEEDED;
|
|
867
|
-
const decidable = !isLocal()
|
|
863
|
+
const decidable = !isLocal();
|
|
868
864
|
setCrumbs(
|
|
869
865
|
el("button", { type: "button", class: "link", onclick: showGrid }, `${targetLabel()} / ${state.project}`),
|
|
870
866
|
el("span", {}, itemName(item)),
|
|
@@ -1069,7 +1065,7 @@ function showStory() {
|
|
|
1069
1065
|
"p",
|
|
1070
1066
|
{},
|
|
1071
1067
|
"No baseline yet, and this pull request does not change it: it looks as on master. " +
|
|
1072
|
-
"
|
|
1068
|
+
"Accepting it makes this image its first baseline.",
|
|
1073
1069
|
)
|
|
1074
1070
|
: null,
|
|
1075
1071
|
decidable ? decisionButtons : null,
|
|
@@ -1759,6 +1755,18 @@ function toggleView(view) {
|
|
|
1759
1755
|
showStory();
|
|
1760
1756
|
}
|
|
1761
1757
|
|
|
1758
|
+
// Safari on an iPad sends a hardware keyboard's keys only to a focused element, and tapping an
|
|
1759
|
+
// image or a button focuses nothing, so the shortcuts never arrived. The page itself holds focus
|
|
1760
|
+
// whenever nothing else does.
|
|
1761
|
+
function keepKeys() {
|
|
1762
|
+
if (document.activeElement === null || document.activeElement === document.body) {
|
|
1763
|
+
app.focus({ preventScroll: true });
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
document.addEventListener("pointerup", () => setTimeout(keepKeys));
|
|
1767
|
+
document.addEventListener("focusout", () => setTimeout(keepKeys));
|
|
1768
|
+
keepKeys();
|
|
1769
|
+
|
|
1762
1770
|
document.addEventListener("keydown", (e) => {
|
|
1763
1771
|
if (!["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
|
|
1764
1772
|
return;
|