@graphty/visual-review 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +548 -4
- package/capture/capture.mjs +669 -0
- package/package.json +70 -11
- package/templates/visual-review.yml +141 -0
- package/templates/visual-seed.yml +144 -0
- package/trusted/cli.mjs +334 -0
- package/trusted/gate.mjs +272 -0
- package/trusted/lib/accept.mjs +526 -0
- package/trusted/lib/compare.mjs +195 -0
- package/trusted/lib/config.mjs +168 -0
- package/trusted/lib/github.mjs +228 -0
- package/trusted/lib/init.mjs +196 -0
- package/trusted/lib/results.mjs +147 -0
- package/trusted/lib/serve.mjs +663 -0
- package/trusted/page/index.html +19 -0
- package/trusted/page/review.css +496 -0
- package/trusted/page/review.js +1762 -0
- package/trusted/vendor/pixelmatch.mjs +336 -0
package/package.json
CHANGED
|
@@ -1,12 +1,71 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
}
|
|
2
|
+
"name": "@graphty/visual-review",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"visual-review": "./trusted/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"trusted/",
|
|
15
|
+
"capture/",
|
|
16
|
+
"templates/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"provenance": true
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20.0.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/graphty-org/graphty-monorepo.git",
|
|
30
|
+
"directory": "visual-review"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"storybook",
|
|
34
|
+
"visual-regression",
|
|
35
|
+
"visual-testing",
|
|
36
|
+
"screenshot",
|
|
37
|
+
"snapshot",
|
|
38
|
+
"playwright",
|
|
39
|
+
"github-actions",
|
|
40
|
+
"git-lfs",
|
|
41
|
+
"chromatic-alternative",
|
|
42
|
+
"review"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/graphty-org/graphty-monorepo/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://graphty.app/docs/visual-review/",
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"pngjs": "7.0.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"playwright": ">=1.49.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^20.19.9",
|
|
57
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
58
|
+
"eslint": "^9.29.0",
|
|
59
|
+
"playwright": "1.57.0",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"vitest": "4.1.11"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"test": "vitest",
|
|
65
|
+
"test:run": "vitest run",
|
|
66
|
+
"coverage": "vitest run --coverage",
|
|
67
|
+
"lint": "eslint . && npm run typecheck",
|
|
68
|
+
"lint:knip": "cd .. && ./tools/run-knip.sh --workspace visual-review",
|
|
69
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Generated by visual-review init (@graphty/visual-review). `visual-review init --force` rewrites
|
|
2
|
+
# this file; change visual-review.config.json rather than this file where you can.
|
|
3
|
+
#
|
|
4
|
+
# Visual review. On every pull request and every push to __BRANCH__, each Storybook named in
|
|
5
|
+
# visual-review.config.json is built, every story is screenshot and compared with its baseline PNG
|
|
6
|
+
# in git, and the results are uploaded as the artifact "visual-<project>-<attempt>" for
|
|
7
|
+
# `visual-review serve`, which finds this workflow by its file name (the config's "workflow") and
|
|
8
|
+
# the capture jobs by their names, "visual (<project>)". A difference never fails a capture job;
|
|
9
|
+
# on a pull request the "Visual gate" job fails while any difference is not accepted, so make
|
|
10
|
+
# "Visual gate" a required status check (Settings, Branches or Rulesets).
|
|
11
|
+
|
|
12
|
+
name: Visual review
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
pull_request:
|
|
16
|
+
push:
|
|
17
|
+
branches: [__BRANCH__]
|
|
18
|
+
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
# The projects of visual-review.config.json, as the capture job's matrix.
|
|
24
|
+
plan:
|
|
25
|
+
name: Plan
|
|
26
|
+
runs-on: ubuntu-24.04
|
|
27
|
+
outputs:
|
|
28
|
+
projects: ${{ steps.plan.outputs.projects }}
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
with:
|
|
32
|
+
sparse-checkout: visual-review.config.json
|
|
33
|
+
sparse-checkout-cone-mode: false
|
|
34
|
+
|
|
35
|
+
- name: List the projects
|
|
36
|
+
id: plan
|
|
37
|
+
run: |
|
|
38
|
+
echo "projects=$(jq -c '(.baselines // "visual-baselines") as $b
|
|
39
|
+
| [.projects | to_entries[] | {project: .key, storybook: .value.storybook,
|
|
40
|
+
build: .value.build, baselines: $b}]' visual-review.config.json)" >> "$GITHUB_OUTPUT"
|
|
41
|
+
|
|
42
|
+
# continue-on-error: a crash of the tool itself must not fail the run; the gate below judges.
|
|
43
|
+
visual:
|
|
44
|
+
name: visual (${{ matrix.project }})
|
|
45
|
+
needs: plan
|
|
46
|
+
runs-on: ubuntu-24.04
|
|
47
|
+
permissions:
|
|
48
|
+
contents: read
|
|
49
|
+
# To download the default branch's newest capture (the reference step below).
|
|
50
|
+
actions: read
|
|
51
|
+
continue-on-error: true
|
|
52
|
+
timeout-minutes: 45
|
|
53
|
+
strategy:
|
|
54
|
+
fail-fast: false
|
|
55
|
+
matrix:
|
|
56
|
+
include: ${{ fromJSON(needs.plan.outputs.projects) }}
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v4
|
|
59
|
+
|
|
60
|
+
# Baseline PNGs are Git LFS objects. They are cached by the pointer files' hash, so a run
|
|
61
|
+
# downloads from LFS only the images accepted since the cache was saved.
|
|
62
|
+
- name: Restore this project's baseline images
|
|
63
|
+
uses: actions/cache@v4
|
|
64
|
+
with:
|
|
65
|
+
path: .git/lfs/objects
|
|
66
|
+
key: lfs-${{ matrix.project }}-${{ hashFiles(format('{0}/{1}/**/*.png', matrix.baselines, matrix.project)) }}
|
|
67
|
+
restore-keys: lfs-${{ matrix.project }}-
|
|
68
|
+
|
|
69
|
+
- name: Fetch this project's baseline images from Git LFS
|
|
70
|
+
run: git lfs pull --include "${{ matrix.baselines }}/${{ matrix.project }}/**"
|
|
71
|
+
|
|
72
|
+
# __SETUP__
|
|
73
|
+
|
|
74
|
+
- name: Install Chromium
|
|
75
|
+
run: __CLI__ install-browser
|
|
76
|
+
|
|
77
|
+
- name: Build Storybook
|
|
78
|
+
if: matrix.build
|
|
79
|
+
run: ${{ matrix.build }}
|
|
80
|
+
|
|
81
|
+
# A story with no baseline that looks as it does in the default branch's newest capture is
|
|
82
|
+
# "unseeded" and does not block this pull request; without a reference every one is "new".
|
|
83
|
+
- name: Download the default branch's newest capture
|
|
84
|
+
id: reference
|
|
85
|
+
if: github.event_name == 'pull_request'
|
|
86
|
+
continue-on-error: true
|
|
87
|
+
env:
|
|
88
|
+
GH_TOKEN: ${{ github.token }}
|
|
89
|
+
run: echo "dir=$(__CLI__ reference --project ${{ matrix.project }} --out "$RUNNER_TEMP/reference")" >> "$GITHUB_OUTPUT"
|
|
90
|
+
|
|
91
|
+
- name: Capture
|
|
92
|
+
run: __CLI__ capture --project ${{ matrix.project }} --out "$RUNNER_TEMP/visual" --reference "${{ steps.reference.outputs.dir }}"
|
|
93
|
+
|
|
94
|
+
# Kept even when capture crashed, so the reviewer sees the partial results.json.
|
|
95
|
+
- name: Upload captures
|
|
96
|
+
if: always()
|
|
97
|
+
uses: actions/upload-artifact@v4
|
|
98
|
+
with:
|
|
99
|
+
name: visual-${{ matrix.project }}-${{ github.run_attempt }}
|
|
100
|
+
path: ${{ runner.temp }}/visual/
|
|
101
|
+
retention-days: 30
|
|
102
|
+
|
|
103
|
+
- name: Write the counts to the job summary
|
|
104
|
+
if: always()
|
|
105
|
+
run: |
|
|
106
|
+
f="$RUNNER_TEMP/visual/results.json"
|
|
107
|
+
{
|
|
108
|
+
echo "### Visual review (${{ matrix.project }})"
|
|
109
|
+
if [[ -f "$f" ]]; then
|
|
110
|
+
jq -r '"seeded: \(.seeded), complete: \(.complete), \(.items | length) of \(.expected) items, reference run: \(.reference)",
|
|
111
|
+
"", "| status | items |", "|---|---|",
|
|
112
|
+
(.items | group_by(.status)[] | "| \(.[0].status) | \(length) |")' "$f"
|
|
113
|
+
else
|
|
114
|
+
echo "No results.json: capture did not start."
|
|
115
|
+
fi
|
|
116
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
|
117
|
+
|
|
118
|
+
# Fails while a project with baselines on the base branch holds anything but unchanged, excluded
|
|
119
|
+
# or unseeded items in its newest capture, or a baseline file changed without a review record.
|
|
120
|
+
# It runs the published gate at a pinned version, so a pull request's own dependencies cannot
|
|
121
|
+
# change it. Depth 2: HEAD^1, the merge commit's first parent, is the base branch tip.
|
|
122
|
+
visual-gate:
|
|
123
|
+
name: Visual gate
|
|
124
|
+
needs: visual
|
|
125
|
+
if: always() && github.event_name == 'pull_request'
|
|
126
|
+
runs-on: ubuntu-24.04
|
|
127
|
+
steps:
|
|
128
|
+
- uses: actions/checkout@v4
|
|
129
|
+
with:
|
|
130
|
+
fetch-depth: 2
|
|
131
|
+
filter: blob:none
|
|
132
|
+
|
|
133
|
+
- name: Download visual captures
|
|
134
|
+
continue-on-error: true
|
|
135
|
+
uses: actions/download-artifact@v4
|
|
136
|
+
with:
|
|
137
|
+
pattern: visual-*
|
|
138
|
+
path: ${{ runner.temp }}/visual
|
|
139
|
+
|
|
140
|
+
- name: Check visual changes were accepted
|
|
141
|
+
run: npx --yes @graphty/visual-review@__VERSION__ gate --captures "$RUNNER_TEMP/visual" --base HEAD^1 --head HEAD
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Generated by visual-review init (@graphty/visual-review). `visual-review init --force` rewrites
|
|
2
|
+
# this file; change visual-review.config.json rather than this file where you can.
|
|
3
|
+
#
|
|
4
|
+
# Captures the Storybooks of an older, known-good commit with the default branch's capture tool, so
|
|
5
|
+
# baselines can be seeded from it: `visual-review serve --master-run <this run's id>` lists the run
|
|
6
|
+
# as the default branch, and Finish opens the seed pull request. Dispatch it from __BRANCH__:
|
|
7
|
+
# gh workflow run visual-seed.yml --ref __BRANCH__ -f ref=<sha>
|
|
8
|
+
# Only the projects whose "seedFromDefaultBranch" is not false are captured. The capture job
|
|
9
|
+
# repeats the "visual" job of the review workflow, except that it has no reference (every story
|
|
10
|
+
# without a baseline is "new") and results.json names the captured commit.
|
|
11
|
+
|
|
12
|
+
name: Visual seed capture
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
inputs:
|
|
17
|
+
ref:
|
|
18
|
+
description: "The commit (or branch or tag) whose Storybooks are captured"
|
|
19
|
+
required: true
|
|
20
|
+
type: string
|
|
21
|
+
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
plan:
|
|
27
|
+
name: Plan
|
|
28
|
+
runs-on: ubuntu-24.04
|
|
29
|
+
outputs:
|
|
30
|
+
projects: ${{ steps.plan.outputs.projects }}
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
with:
|
|
34
|
+
sparse-checkout: visual-review.config.json
|
|
35
|
+
sparse-checkout-cone-mode: false
|
|
36
|
+
|
|
37
|
+
- name: List the projects
|
|
38
|
+
id: plan
|
|
39
|
+
run: |
|
|
40
|
+
echo "projects=$(jq -c '(.baselines // "visual-baselines") as $b
|
|
41
|
+
| [.projects | to_entries[] | select(.value.seedFromDefaultBranch != false)
|
|
42
|
+
| {project: .key, storybook: .value.storybook, build: .value.build, baselines: $b}]' \
|
|
43
|
+
visual-review.config.json)" >> "$GITHUB_OUTPUT"
|
|
44
|
+
|
|
45
|
+
storybook:
|
|
46
|
+
name: Build ${{ matrix.project }} at ${{ inputs.ref }}
|
|
47
|
+
needs: plan
|
|
48
|
+
runs-on: ubuntu-24.04
|
|
49
|
+
outputs:
|
|
50
|
+
sha: ${{ steps.sha.outputs.sha }}
|
|
51
|
+
strategy:
|
|
52
|
+
fail-fast: false
|
|
53
|
+
matrix:
|
|
54
|
+
include: ${{ fromJSON(needs.plan.outputs.projects) }}
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
with:
|
|
58
|
+
ref: ${{ inputs.ref }}
|
|
59
|
+
|
|
60
|
+
- name: Resolve the commit
|
|
61
|
+
id: sha
|
|
62
|
+
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
63
|
+
|
|
64
|
+
# __SETUP__
|
|
65
|
+
|
|
66
|
+
- name: Build Storybook
|
|
67
|
+
run: ${{ matrix.build }}
|
|
68
|
+
|
|
69
|
+
- uses: actions/upload-artifact@v4
|
|
70
|
+
with:
|
|
71
|
+
name: storybook-${{ matrix.project }}
|
|
72
|
+
path: ${{ matrix.storybook }}/
|
|
73
|
+
|
|
74
|
+
visual:
|
|
75
|
+
name: visual (${{ matrix.project }})
|
|
76
|
+
needs: [plan, storybook]
|
|
77
|
+
runs-on: ubuntu-24.04
|
|
78
|
+
timeout-minutes: 45
|
|
79
|
+
strategy:
|
|
80
|
+
fail-fast: false
|
|
81
|
+
matrix:
|
|
82
|
+
include: ${{ fromJSON(needs.plan.outputs.projects) }}
|
|
83
|
+
steps:
|
|
84
|
+
# The default branch's checkout: the capture tool, the config and the baselines come from here.
|
|
85
|
+
- uses: actions/checkout@v4
|
|
86
|
+
|
|
87
|
+
- name: Restore this project's baseline images
|
|
88
|
+
uses: actions/cache@v4
|
|
89
|
+
with:
|
|
90
|
+
path: .git/lfs/objects
|
|
91
|
+
key: lfs-${{ matrix.project }}-${{ hashFiles(format('{0}/{1}/**/*.png', matrix.baselines, matrix.project)) }}
|
|
92
|
+
restore-keys: lfs-${{ matrix.project }}-
|
|
93
|
+
|
|
94
|
+
- name: Fetch this project's baseline images from Git LFS
|
|
95
|
+
run: git lfs pull --include "${{ matrix.baselines }}/${{ matrix.project }}/**"
|
|
96
|
+
|
|
97
|
+
# __SETUP__
|
|
98
|
+
|
|
99
|
+
- name: Install Chromium
|
|
100
|
+
run: __CLI__ install-browser
|
|
101
|
+
|
|
102
|
+
- name: Download Storybook build
|
|
103
|
+
uses: actions/download-artifact@v4
|
|
104
|
+
with:
|
|
105
|
+
name: storybook-${{ matrix.project }}
|
|
106
|
+
path: ${{ matrix.storybook }}/
|
|
107
|
+
|
|
108
|
+
- name: Capture
|
|
109
|
+
run: __CLI__ capture --project ${{ matrix.project }} --out "$RUNNER_TEMP/visual"
|
|
110
|
+
|
|
111
|
+
# capture takes the commit from GITHUB_SHA, the default branch's here; a seed Finish branches
|
|
112
|
+
# from results.json's commit, so it must name the commit whose Storybook was captured.
|
|
113
|
+
- name: Record the captured commit
|
|
114
|
+
if: always()
|
|
115
|
+
env:
|
|
116
|
+
SHA: ${{ needs.storybook.outputs.sha }}
|
|
117
|
+
run: |
|
|
118
|
+
f="$RUNNER_TEMP/visual/results.json"
|
|
119
|
+
if [[ -f "$f" ]]; then
|
|
120
|
+
jq --arg sha "$SHA" '.commit = $sha' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
- name: Upload captures
|
|
124
|
+
if: always()
|
|
125
|
+
uses: actions/upload-artifact@v4
|
|
126
|
+
with:
|
|
127
|
+
name: visual-${{ matrix.project }}-${{ github.run_attempt }}
|
|
128
|
+
path: ${{ runner.temp }}/visual/
|
|
129
|
+
retention-days: 30
|
|
130
|
+
|
|
131
|
+
- name: Write the counts to the job summary
|
|
132
|
+
if: always()
|
|
133
|
+
run: |
|
|
134
|
+
f="$RUNNER_TEMP/visual/results.json"
|
|
135
|
+
{
|
|
136
|
+
echo "### Visual seed capture (${{ matrix.project }}) at ${{ needs.storybook.outputs.sha }}"
|
|
137
|
+
if [[ -f "$f" ]]; then
|
|
138
|
+
jq -r '"complete: \(.complete), \(.items | length) of \(.expected) items",
|
|
139
|
+
"", "| status | items |", "|---|---|",
|
|
140
|
+
(.items | group_by(.status)[] | "| \(.[0].status) | \(length) |")' "$f"
|
|
141
|
+
else
|
|
142
|
+
echo "No results.json: capture did not start."
|
|
143
|
+
fi
|
|
144
|
+
} >> "$GITHUB_STEP_SUMMARY"
|