@graphty/visual-review 0.1.0 → 0.1.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 +44 -2
- package/capture/capture.mjs +107 -12
- package/package.json +1 -1
- package/trusted/lib/accept.mjs +18 -7
- package/trusted/lib/compare.mjs +14 -6
- package/trusted/lib/github.mjs +9 -6
- package/trusted/lib/results.mjs +13 -2
- package/trusted/lib/serve.mjs +4 -4
- package/trusted/page/review.css +7 -0
- package/trusted/page/review.js +12 -6
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ your login.
|
|
|
22
22
|
- [Seeding one story at a time](#seeding-one-story-at-a-time)
|
|
23
23
|
- [Iterating on a story before a pull request exists](#iterating-on-a-story-before-a-pull-request-exists)
|
|
24
24
|
- [Story parameters](#story-parameters)
|
|
25
|
+
- [Reorganizing stories: renames](#reorganizing-stories-renames)
|
|
25
26
|
- [What the gate does and does not guarantee](#what-the-gate-does-and-does-not-guarantee)
|
|
26
27
|
- [Troubleshooting](#troubleshooting)
|
|
27
28
|
|
|
@@ -297,7 +298,8 @@ starts the same server from your own shell.
|
|
|
297
298
|
you decided have left it, and the count has gone down; **Accepted**, **Rejected** and
|
|
298
299
|
**Excluded** show them with their decisions.
|
|
299
300
|
|
|
300
|
-
Statuses: `changed` (differs from its baseline), `
|
|
301
|
+
Statuses: `changed` (differs from its baseline), `moved` (a renamed story that looks exactly as
|
|
302
|
+
its old id's baseline; see [renames](#reorganizing-stories-renames)), `new` (no baseline, and on a pull request the
|
|
301
303
|
story is new or looks different from the default branch's newest capture of it), `no baseline yet` (status
|
|
302
304
|
`unseeded`: no baseline, and the pull request does not change it), `removed` (a baseline whose
|
|
303
305
|
story no longer exists, lost a mode, or whose story's own parameters now exclude it), `unstable`
|
|
@@ -332,7 +334,8 @@ so; to change a decision, press U (or the Undo button) first. The same key twice
|
|
|
332
334
|
## What each decision does
|
|
333
335
|
|
|
334
336
|
- **Accept**: the new screenshot becomes the baseline (or, for `removed`, the baseline is
|
|
335
|
-
deleted). Allowed on `changed`, `new` and `removed`.
|
|
337
|
+
deleted). Allowed on `changed`, `moved`, `new` and `removed`. For a renamed story the baseline
|
|
338
|
+
is written under the new id and the old id's baseline is deleted, in the same commit.
|
|
336
339
|
- **Reject**: the difference is a regression. It always needs a reason, which is posted to the pull
|
|
337
340
|
request as a comment with a machine-readable block an agent can read. The pull request stays
|
|
338
341
|
blocked until its code changes so the capture matches the baseline again.
|
|
@@ -483,6 +486,45 @@ Inside a story, `isChromatic()` from `chromatic/isChromatic` is true during capt
|
|
|
483
486
|
`chromatic=true`). A settings file `<baselines>/<project>/<story id>.json` overrides the story's
|
|
484
487
|
parameters; the page's Exclude writes one with `disableSnapshot: true` and your reason.
|
|
485
488
|
|
|
489
|
+
## Reorganizing stories: renames
|
|
490
|
+
|
|
491
|
+
Storybook derives a story's id from its title, so moving stories in the sidebar (a new `title`,
|
|
492
|
+
a new folder) gives every moved story a new id. Without help, each old id's baseline is reported
|
|
493
|
+
`removed` and each new id is `new`, with no before and after to compare. A renames file keeps them
|
|
494
|
+
paired. Add it in the pull request that moves the stories, at `<baselines>/<project>/renames.json`:
|
|
495
|
+
|
|
496
|
+
```json
|
|
497
|
+
[
|
|
498
|
+
{ "from": "building-a-panel-fieldrow--default", "to": "components-panels-and-rows-fieldrow--default" },
|
|
499
|
+
{ "from": "compact-theme-mantine-components-badge--dot", "to": "components-display-badge--dot" }
|
|
500
|
+
]
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Each entry is one story: `from` is its old id, `to` its new one. Modes map on their own, so
|
|
504
|
+
`<from>.dark.png` is compared with the capture of `<to>` in the dark mode, and `<from>.light.png`
|
|
505
|
+
with the light one; a mode the new story no longer has is still reported `removed`.
|
|
506
|
+
|
|
507
|
+
- **Capture** compares the new id's capture with the old id's baseline. It reads `moved` when the
|
|
508
|
+
two look the same (only the name changed) and `changed` when they differ; either way the item
|
|
509
|
+
carries `from`, and the old baseline is not reported `removed`.
|
|
510
|
+
- **The review page** shows each one as a pair, labeled "moved from <old id>" on the tile
|
|
511
|
+
and on the story screen, with the old id's baseline on the left. The grid has a **moved** filter,
|
|
512
|
+
and a component's Accept N undecided takes its moved stories too.
|
|
513
|
+
- **Accepting** writes the baseline under the new id and deletes the old id's baseline in the same
|
|
514
|
+
accept commit. For a `moved` item the image is the same, so git sees a rename and the Git LFS
|
|
515
|
+
pointer does not change. The review record names both paths.
|
|
516
|
+
- **The gate** blocks a `moved` item until it is accepted, like any change: a pull request that
|
|
517
|
+
moves stories cannot land without their baselines moving with them.
|
|
518
|
+
- **A rename whose new id is not a story** in the Storybook is an error: capture reports it as a
|
|
519
|
+
`failed` item under the new id, with the reason, and the page lists it under Errors. Fix the
|
|
520
|
+
entry in `renames.json`. A rename whose old id is still a story is not a move, and does
|
|
521
|
+
nothing.
|
|
522
|
+
- **An entry whose move was accepted** does nothing any more: the old baseline is gone and the new
|
|
523
|
+
id has its own. You can delete the file once the pull request has merged, or leave it.
|
|
524
|
+
|
|
525
|
+
`renames.json` is read from the pull request's own checkout, like the baselines. Only baseline
|
|
526
|
+
PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the same pull request.
|
|
527
|
+
|
|
486
528
|
## What the gate does and does not guarantee
|
|
487
529
|
|
|
488
530
|
- A pull request cannot pass the gate while its capture of a seeded project holds anything but
|
package/capture/capture.mjs
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
* second capture of an unstable one, and `baselines/<file>`: the baseline each changed, unstable
|
|
22
22
|
* and removed item was compared with.
|
|
23
23
|
*
|
|
24
|
+
* A story renamed in the project's `renames.json` (see loadRenames) is compared with the baseline
|
|
25
|
+
* of its old id, mode by mode: `moved` when it looks the same, `changed` otherwise, each carrying
|
|
26
|
+
* `from`; that old baseline is then not reported `removed`. A rename whose new id is not a story
|
|
27
|
+
* is a `failed` item under the new id, so the page lists it; one whose old id is still a story
|
|
28
|
+
* does nothing.
|
|
29
|
+
*
|
|
24
30
|
* With `reference`, a directory holding the default branch's newest capture of the project (CI
|
|
25
31
|
* downloads it on pull requests), a story with no baseline whose capture matches it is `unseeded`, not
|
|
26
32
|
* `new`: seeding is per story, so a story nobody has accepted yet does not block every pull
|
|
@@ -153,6 +159,47 @@ export function storySettings(parameters, file) {
|
|
|
153
159
|
};
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/** A project's renames file, in its baselines directory. */
|
|
163
|
+
const RENAMES = "renames.json";
|
|
164
|
+
|
|
165
|
+
const STORY_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reads a project's renames file, `<dir>/renames.json`: `[{ "from": "<old story id>", "to":
|
|
169
|
+
* "<new story id>" }]`, for stories whose id changed while they stayed the same story.
|
|
170
|
+
* @param {string} dir the project's baselines directory
|
|
171
|
+
* @returns {Promise<Map<string, string>>} the old id by new id; empty when there is no file
|
|
172
|
+
*/
|
|
173
|
+
async function loadRenames(dir) {
|
|
174
|
+
const path = join(dir, RENAMES);
|
|
175
|
+
let list;
|
|
176
|
+
try {
|
|
177
|
+
list = JSON.parse(await readFile(path, "utf8"));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (e.code === "ENOENT") {
|
|
180
|
+
return new Map();
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`${path}: ${e.message}`);
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(list)) {
|
|
185
|
+
throw new Error(`${path}: must be an array of { "from": "<old id>", "to": "<new id>" }`);
|
|
186
|
+
}
|
|
187
|
+
const byTo = new Map();
|
|
188
|
+
const froms = new Set();
|
|
189
|
+
list.forEach((r, i) => {
|
|
190
|
+
const ok = (v) => typeof v === "string" && v.length <= 200 && STORY_ID.test(v);
|
|
191
|
+
if (!ok(r?.from) || !ok(r?.to) || r.from === r.to) {
|
|
192
|
+
throw new Error(`${path}: entry ${i} must be { "from": "<old id>", "to": "<another id>" }`);
|
|
193
|
+
}
|
|
194
|
+
if (byTo.has(r.to) || froms.has(r.from)) {
|
|
195
|
+
throw new Error(`${path}: entry ${i} renames ${r.from} or to ${r.to} a second time`);
|
|
196
|
+
}
|
|
197
|
+
byTo.set(r.to, r.from);
|
|
198
|
+
froms.add(r.from);
|
|
199
|
+
});
|
|
200
|
+
return byTo;
|
|
201
|
+
}
|
|
202
|
+
|
|
156
203
|
async function pngsIn(dir) {
|
|
157
204
|
try {
|
|
158
205
|
return (await readdir(dir)).filter((f) => f.endsWith(".png")).sort();
|
|
@@ -485,9 +532,9 @@ export async function capture({
|
|
|
485
532
|
const started = Date.now();
|
|
486
533
|
await mkdir(join(out, "baselines"), { recursive: true });
|
|
487
534
|
await mkdir(join(out, "second"), { recursive: true });
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
);
|
|
535
|
+
const allIds = storyIds(JSON.parse(await readFile(join(storybook, "index.json"), "utf8")));
|
|
536
|
+
const ids = allIds.filter((id) => !stories || stories.some((p) => id.startsWith(p)));
|
|
537
|
+
const renames = await loadRenames(baselines);
|
|
491
538
|
const refs = await loadReference(reference);
|
|
492
539
|
const [server, base] = await serve(storybook);
|
|
493
540
|
// One browser per worker: every page of a browser shares its one GPU process, so with
|
|
@@ -508,6 +555,35 @@ export async function capture({
|
|
|
508
555
|
// A story whose own parameters exclude it while it still has a baseline is reported as
|
|
509
556
|
// removed, so a pull request cannot drop a story from review without the owner seeing it.
|
|
510
557
|
const newlyExcluded = new Set();
|
|
558
|
+
// Old baselines a rename compares a story with (or reports as a broken rename): not removed.
|
|
559
|
+
const renamed = new Set();
|
|
560
|
+
const renameErrors = [];
|
|
561
|
+
const known = new Set(allIds);
|
|
562
|
+
for (const [to, from] of stories ? [] : renames) {
|
|
563
|
+
if (known.has(to)) {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
// Only a rename with an old baseline left to move is an error; once it is accepted the
|
|
567
|
+
// old baselines are gone, and the entry does nothing.
|
|
568
|
+
for (const file of existing) {
|
|
569
|
+
const [id, mode = null] = file.slice(0, -4).split(".");
|
|
570
|
+
if (id === from && BASELINE_NAME.test(file)) {
|
|
571
|
+
renamed.add(file);
|
|
572
|
+
renameErrors.push({
|
|
573
|
+
id: to,
|
|
574
|
+
mode,
|
|
575
|
+
file: fileName(to, mode),
|
|
576
|
+
from,
|
|
577
|
+
threshold: DEFAULT_THRESHOLD,
|
|
578
|
+
includeAA: false,
|
|
579
|
+
...EMPTY,
|
|
580
|
+
status: "failed",
|
|
581
|
+
reason: `${RENAMES} renames ${from} to ${to}, but the Storybook has no story ${to}: fix ${RENAMES}`,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
items.push(...renameErrors);
|
|
511
587
|
for (const id of ids) {
|
|
512
588
|
const s = storySettings(params[id] ?? {}, await loadSettings(baselines, id));
|
|
513
589
|
for (const mode of s.modes) {
|
|
@@ -521,11 +597,22 @@ export async function capture({
|
|
|
521
597
|
if (s.disableSnapshot) {
|
|
522
598
|
items.push({ ...common, ...EMPTY, status: "excluded", reason: s.reason });
|
|
523
599
|
} else {
|
|
524
|
-
|
|
600
|
+
// Renamed: compared with the old id's baseline of this mode, while the new id
|
|
601
|
+
// has none of its own (after the accept it does, and the rename is done).
|
|
602
|
+
// A rename from an id that is still a story is not a move: it does nothing.
|
|
603
|
+
const old =
|
|
604
|
+
renames.has(id) && !known.has(renames.get(id)) ? fileName(renames.get(id), mode.name) : null;
|
|
605
|
+
const from = old && !existing.has(file) && existing.has(old) ? renames.get(id) : null;
|
|
606
|
+
if (from) {
|
|
607
|
+
renamed.add(old);
|
|
608
|
+
}
|
|
609
|
+
jobs.push({ ...common, from, url: base + storyUrl(id, mode.globals), delay: s.delay });
|
|
525
610
|
}
|
|
526
611
|
}
|
|
527
612
|
}
|
|
528
|
-
const gone = stories
|
|
613
|
+
const gone = stories
|
|
614
|
+
? []
|
|
615
|
+
: [...existing].filter((f) => !planned.has(f) && !renamed.has(f) && BASELINE_NAME.test(f));
|
|
529
616
|
|
|
530
617
|
const emojiFont = hasEmojiFont();
|
|
531
618
|
if (emojiFont === false) {
|
|
@@ -576,10 +663,12 @@ export async function capture({
|
|
|
576
663
|
}
|
|
577
664
|
|
|
578
665
|
const run = async (browser, job) => {
|
|
579
|
-
const { url, delay, ...
|
|
580
|
-
|
|
666
|
+
const { url, delay, from, ...rest } = job;
|
|
667
|
+
// `from` only on a renamed story, so results.json of a project with no renames is as before.
|
|
668
|
+
const common = from ? { ...rest, from } : rest;
|
|
669
|
+
const baseline = await readBaseline(join(baselines, from ? fileName(from, job.mode) : job.file));
|
|
581
670
|
const reference = baseline ? null : (refs.images.get(job.file) ?? null);
|
|
582
|
-
const opts = { threshold: job.threshold, includeAA: job.includeAA, reference };
|
|
671
|
+
const opts = { threshold: job.threshold, includeAA: job.includeAA, reference, moved: from !== null };
|
|
583
672
|
const failed = (shot, prefix = "") => ({
|
|
584
673
|
...common,
|
|
585
674
|
...EMPTY,
|
|
@@ -602,16 +691,22 @@ export async function capture({
|
|
|
602
691
|
result = classify({ baseline, first: first.png, second: second.png, ...opts });
|
|
603
692
|
}
|
|
604
693
|
const { status } = result;
|
|
605
|
-
|
|
606
|
-
|
|
694
|
+
// A moved capture is also its baseline when the bytes are the same: the page reads it.
|
|
695
|
+
if (["changed", "moved", "new", "unseeded", "unstable"].includes(status)) {
|
|
696
|
+
// The capture results.json names: the second one when the first was a flake.
|
|
697
|
+
await writeFile(join(out, job.file), result.flaky ? second.png : first.png);
|
|
607
698
|
}
|
|
608
699
|
if (status === "unstable") {
|
|
609
700
|
await writeFile(join(out, "second", job.file), second.png);
|
|
610
701
|
}
|
|
611
|
-
|
|
702
|
+
const ownBaseline = status === "moved" && result.baseline !== result.capture;
|
|
703
|
+
if (baseline && (status === "changed" || status === "unstable" || ownBaseline)) {
|
|
612
704
|
await writeFile(join(out, "baselines", job.file), baseline);
|
|
613
705
|
}
|
|
614
|
-
const lines =
|
|
706
|
+
const lines =
|
|
707
|
+
status === "unchanged" || status === "moved"
|
|
708
|
+
? []
|
|
709
|
+
: clip([...first.console, ...(second?.console ?? [])]);
|
|
615
710
|
return { ...common, ...result, reason: null, console: lines };
|
|
616
711
|
};
|
|
617
712
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/visual-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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",
|
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", "new", "removed"]);
|
|
29
|
+
const DECIDABLE = new Set(["changed", "moved", "new", "removed"]);
|
|
30
30
|
const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -354,7 +354,7 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
|
|
|
354
354
|
const counts = { accept: 0, exclude: 0, remove: 0 };
|
|
355
355
|
progress(`writing ${writes.length} ${writes.length === 1 ? "file" : "files"}`);
|
|
356
356
|
for (const w of writes) {
|
|
357
|
-
items.push(await write(tree, w, counts, baselines));
|
|
357
|
+
items.push(...(await write(tree, w, counts, baselines)));
|
|
358
358
|
}
|
|
359
359
|
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
|
|
360
360
|
const record = `${baselines}/reviews/${stamp}-${isMaster ? "master" : `pr${target.pr}`}.json`;
|
|
@@ -441,8 +441,9 @@ export async function behindMaster(repo, head, project, { defaultBranch, baselin
|
|
|
441
441
|
* @param {object} w the decision, its results item, and for an accept the verified bytes
|
|
442
442
|
* @param {{ accept: number, exclude: number, remove: number }} counts tallied here
|
|
443
443
|
* @param {string} baselines the baselines directory
|
|
444
|
-
* @returns {Promise<{ path: string, from: string | null, to: string | null, reason: string | null
|
|
445
|
-
*
|
|
444
|
+
* @returns {Promise<{ path: string, from: string | null, to: string | null, reason: string | null,
|
|
445
|
+
* movedFrom?: string, movedTo?: string }[]>} its record items: two for a renamed story, whose
|
|
446
|
+
* baseline moves to its new name
|
|
446
447
|
*/
|
|
447
448
|
async function write(tree, w, counts, baselines) {
|
|
448
449
|
const dir = `${baselines}/${w.project}`;
|
|
@@ -453,17 +454,27 @@ async function write(tree, w, counts, baselines) {
|
|
|
453
454
|
const bytes = `${JSON.stringify(settings, null, 2)}\n`;
|
|
454
455
|
await put(join(tree, path), bytes);
|
|
455
456
|
counts.exclude++;
|
|
456
|
-
return { path, from: old && sha256(old), to: sha256(bytes), reason: `exclude: ${w.reason}` };
|
|
457
|
+
return [{ path, from: old && sha256(old), to: sha256(bytes), reason: `exclude: ${w.reason}` }];
|
|
457
458
|
}
|
|
458
459
|
const path = `${dir}/${w.item.file}`;
|
|
459
460
|
if (w.item.status === "removed") {
|
|
460
461
|
await rm(join(tree, path), { force: true });
|
|
461
462
|
counts.remove++;
|
|
462
|
-
return { path, from: w.item.baseline, to: null, reason: w.reason };
|
|
463
|
+
return [{ path, from: w.item.baseline, to: null, reason: w.reason }];
|
|
463
464
|
}
|
|
464
465
|
await put(join(tree, path), w.bytes);
|
|
465
466
|
counts.accept++;
|
|
466
|
-
|
|
467
|
+
if (!w.item.from) {
|
|
468
|
+
return [{ path, from: w.item.baseline, to: w.item.capture, reason: w.reason }];
|
|
469
|
+
}
|
|
470
|
+
// A rename: the old id's baseline (of this mode) goes, the new one takes its place. For a
|
|
471
|
+
// moved item the bytes are the same, so git sees a rename and the LFS pointer is unchanged.
|
|
472
|
+
const oldPath = `${dir}/${w.item.mode === null ? w.item.from : `${w.item.from}.${w.item.mode}`}.png`;
|
|
473
|
+
await rm(join(tree, oldPath), { force: true });
|
|
474
|
+
return [
|
|
475
|
+
{ path: oldPath, from: w.item.baseline, to: null, reason: w.reason, movedTo: path },
|
|
476
|
+
{ path, from: null, to: w.item.capture, reason: w.reason, movedFrom: oldPath },
|
|
477
|
+
];
|
|
467
478
|
}
|
|
468
479
|
|
|
469
480
|
// Two modes of one story excluded together write one settings file: keep one record item.
|
package/trusted/lib/compare.mjs
CHANGED
|
@@ -154,16 +154,21 @@ function crop(img, w, h) {
|
|
|
154
154
|
* A story with no baseline is `unseeded` ("no baseline yet") when its capture matches
|
|
155
155
|
* `reference`, master's newest capture of it: the pull request did not change it, so it needs no
|
|
156
156
|
* review here. It is `new` when it differs from master's, or master has none (a new story).
|
|
157
|
+
*
|
|
158
|
+
* `moved` says the baseline is another story id's, named in the project's renames.json: a story
|
|
159
|
+
* that only moved (same image, new id) is `moved` rather than `unchanged`, so it still needs an
|
|
160
|
+
* accept, which writes its baseline under the new name.
|
|
157
161
|
* @param {{ baseline: Buffer | null, first: Buffer | null, second?: Buffer | null,
|
|
158
|
-
* reference?: Buffer | null, threshold: number, includeAA: boolean }} input
|
|
159
|
-
* when the story is gone
|
|
160
|
-
* @returns {{ status: "unchanged" | "changed" | "new" | "unseeded" | "removed" | "unstable", flaky: boolean,
|
|
162
|
+
* reference?: Buffer | null, threshold: number, includeAA: boolean, moved?: boolean }} input
|
|
163
|
+
* `first` is null when the story is gone
|
|
164
|
+
* @returns {{ status: "unchanged" | "moved" | "changed" | "new" | "unseeded" | "removed" | "unstable", flaky: boolean,
|
|
161
165
|
* baseline: string | null, capture: string | null, size: number[] | null,
|
|
162
166
|
* baselineSize: number[] | null, changedPixels: number | null, bbox: number[] | null }}
|
|
163
167
|
* `capture` is the hash of the capture the status describes: the second one when flaky
|
|
164
168
|
*/
|
|
165
|
-
export function classify({ baseline, first, second = null, reference = null, threshold, includeAA }) {
|
|
169
|
+
export function classify({ baseline, first, second = null, reference = null, threshold, includeAA, moved = false }) {
|
|
166
170
|
const options = { threshold, includeAA };
|
|
171
|
+
const matched = (r, flaky) => ({ ...r, status: moved ? "moved" : "unchanged", flaky });
|
|
167
172
|
const none = { flaky: false, size: null, baselineSize: null, changedPixels: null, bbox: null };
|
|
168
173
|
if (first === null) {
|
|
169
174
|
return { status: "removed", ...none, baseline: sha256(baseline), capture: null };
|
|
@@ -175,12 +180,15 @@ export function classify({ baseline, first, second = null, reference = null, thr
|
|
|
175
180
|
return { status, ...none, baseline: null, capture: sha256(first), size: pngSize(first) };
|
|
176
181
|
}
|
|
177
182
|
const vsFirst = compareImages(baseline, first, options);
|
|
178
|
-
if (vsFirst.status === "unchanged"
|
|
183
|
+
if (vsFirst.status === "unchanged") {
|
|
184
|
+
return matched(vsFirst, false);
|
|
185
|
+
}
|
|
186
|
+
if (second === null) {
|
|
179
187
|
return { ...vsFirst, flaky: false };
|
|
180
188
|
}
|
|
181
189
|
const vsSecond = compareImages(baseline, second, options);
|
|
182
190
|
if (vsSecond.status === "unchanged") {
|
|
183
|
-
return
|
|
191
|
+
return matched(vsSecond, true);
|
|
184
192
|
}
|
|
185
193
|
return { ...vsFirst, status: agree() ? "changed" : "unstable", flaky: false };
|
|
186
194
|
}
|
package/trusted/lib/github.mjs
CHANGED
|
@@ -159,12 +159,15 @@ export async function downloadCaptures(gh, run, projects, tmp) {
|
|
|
159
159
|
* @returns {Promise<string | null>} the capture's directory, or null when no run has one
|
|
160
160
|
*/
|
|
161
161
|
export async function newestMasterCapture(gh, project, tmp, { workflow, defaultBranch }) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
162
|
+
// The branch's newest commits, then each one's run: GitHub's list of a workflow's runs filtered
|
|
163
|
+
// by branch now and then answers with a stale page (runs from weeks ago), which made a pull
|
|
164
|
+
// request compare with an old capture or none. Commits and a run by head sha answer consistently.
|
|
165
|
+
const commits = await api(gh, `repos/{owner}/{repo}/commits?sha=${encodeURIComponent(defaultBranch)}&per_page=10`);
|
|
166
|
+
for (const { sha } of commits) {
|
|
167
|
+
const run = await newestCiRun(gh, sha, { workflow });
|
|
168
|
+
if (!run) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
168
171
|
const dir = (await downloadCaptures(gh, run, [project], tmp))[project]?.dir;
|
|
169
172
|
let results = null;
|
|
170
173
|
try {
|
package/trusted/lib/results.mjs
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Every status an item can have. `unseeded` ("no baseline yet") is a story with no baseline whose
|
|
11
|
-
* capture matches master's newest capture of it, so the pull request did not change it.
|
|
11
|
+
* capture matches master's newest capture of it, so the pull request did not change it. `moved`
|
|
12
|
+
* is a story that looks exactly as the baseline of the id it was renamed from (`from`, named in
|
|
13
|
+
* the project's renames.json); a renamed story that looks different is `changed` with `from`.
|
|
12
14
|
*/
|
|
13
|
-
const STATUSES = ["unchanged", "changed", "new", "unseeded", "removed", "unstable", "failed", "excluded"];
|
|
15
|
+
const STATUSES = ["unchanged", "moved", "changed", "new", "unseeded", "removed", "unstable", "failed", "excluded"];
|
|
14
16
|
|
|
15
17
|
/** The most items one file may hold (compact-mantine has about 830 today). */
|
|
16
18
|
export const MAX_ITEMS = 5000;
|
|
@@ -26,6 +28,7 @@ const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
|
26
28
|
const HASHES = {
|
|
27
29
|
unchanged: { baseline: true, capture: true },
|
|
28
30
|
changed: { baseline: true, capture: true },
|
|
31
|
+
moved: { baseline: true, capture: true },
|
|
29
32
|
new: { baseline: false, capture: true },
|
|
30
33
|
unseeded: { baseline: false, capture: true },
|
|
31
34
|
removed: { baseline: true, capture: false },
|
|
@@ -113,6 +116,14 @@ export function validateResults(r) {
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
check(STATUSES.includes(item.status), `${at}.status must be one of ${STATUSES.join(", ")}`);
|
|
119
|
+
// The story id a renamed story's baseline was read under (absent when it was not renamed).
|
|
120
|
+
check(
|
|
121
|
+
item.from === undefined ||
|
|
122
|
+
item.from === null ||
|
|
123
|
+
(typeof item.from === "string" && item.from.length <= 200 && NAME.test(item.from)),
|
|
124
|
+
`${at}.from must be a Storybook story id or null`,
|
|
125
|
+
);
|
|
126
|
+
check(item.status !== "moved" || typeof item.from === "string", `${at}.from is required for a moved item`);
|
|
116
127
|
check(typeof item.flaky === "boolean", `${at}.flaky must be a boolean`);
|
|
117
128
|
for (const key of ["baseline", "capture"]) {
|
|
118
129
|
const v = item[key];
|
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", "new", "removed", "unstable", "failed"]);
|
|
47
|
+
const REVIEWABLE = new Set(["changed", "moved", "new", "removed", "unstable", "failed"]);
|
|
48
48
|
const WRITES = new Set(["decide", "accept-all", "finish"]);
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -434,9 +434,9 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
434
434
|
if (!hash) {
|
|
435
435
|
return [404, { error: "no such image" }];
|
|
436
436
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
);
|
|
437
|
+
// A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
|
|
438
|
+
const own = kind === "capture" || item.baseline === item.capture;
|
|
439
|
+
const bytes = await readFile(own ? join(p.dir, file) : join(p.dir, "baselines", file)).catch(() => null);
|
|
440
440
|
if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
441
441
|
return [409, { error: `${file} does not match results.json` }];
|
|
442
442
|
}
|
package/trusted/page/review.css
CHANGED
|
@@ -187,6 +187,7 @@ td {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
.badge.changed,
|
|
190
|
+
.badge.moved,
|
|
190
191
|
.badge.exclude {
|
|
191
192
|
border-color: var(--accent);
|
|
192
193
|
color: var(--accent);
|
|
@@ -494,3 +495,9 @@ td {
|
|
|
494
495
|
background: var(--card);
|
|
495
496
|
color: var(--fg);
|
|
496
497
|
}
|
|
498
|
+
|
|
499
|
+
.tile .moved-from {
|
|
500
|
+
font-size: 12px;
|
|
501
|
+
word-break: break-all;
|
|
502
|
+
color: var(--accent);
|
|
503
|
+
}
|
package/trusted/page/review.js
CHANGED
|
@@ -11,14 +11,14 @@ 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", "new", "removed", "unstable", "failed"];
|
|
15
|
-
const ACCEPTABLE = ["changed", "new", "removed"];
|
|
14
|
+
const REVIEWABLE = ["changed", "moved", "new", "removed", "unstable", "failed"];
|
|
15
|
+
const ACCEPTABLE = ["changed", "moved", "new", "removed"];
|
|
16
16
|
// A story with no baseline that this pull request did not change: shown, never a decision here.
|
|
17
17
|
const UNSEEDED = "unseeded";
|
|
18
18
|
const NO_BASELINE = "no baseline yet";
|
|
19
19
|
const statusLabel = (status) => (status === UNSEEDED ? NO_BASELINE : status);
|
|
20
|
-
// Errors first (their own list), then what changed, then new, unstable and removed stories.
|
|
21
|
-
const RANK = { failed: 0, changed: 1,
|
|
20
|
+
// Errors first (their own list), then what changed, then moved, new, unstable and removed stories.
|
|
21
|
+
const RANK = { failed: 0, changed: 1, moved: 2, new: 3, unstable: 4, removed: 5, unseeded: 6 };
|
|
22
22
|
const FLASH_MS = 333; // one image each third of a second: about 1.5 full cycles a second
|
|
23
23
|
// "fit" (the default) shows the whole of both images in their panes, at one scale, never above real
|
|
24
24
|
// size; 1 is real size: one CSS pixel per CSS pixel the story was drawn at, scrolling when larger.
|
|
@@ -139,6 +139,8 @@ function loaded(url) {
|
|
|
139
139
|
|
|
140
140
|
const componentOf = (id) => id.split("--")[0];
|
|
141
141
|
const itemName = (item) => (item.mode ? `${item.id} (${item.mode})` : item.id);
|
|
142
|
+
// A renamed story (renames.json) is compared with its old id's baseline: say which.
|
|
143
|
+
const movedFrom = (item) => (item.from ? `moved from ${item.from}` : "");
|
|
142
144
|
const short = (sha) => (sha ? sha.slice(0, 10) : "none");
|
|
143
145
|
const decisionOf = (item) => state.data?.decisions[item.file] ?? null;
|
|
144
146
|
const isLocal = () => state.target?.local === true;
|
|
@@ -459,6 +461,7 @@ function tile(item, number) {
|
|
|
459
461
|
img,
|
|
460
462
|
el("span", { class: "name" }, el("span", { class: "number" }, `${number}`), " ", item.mode ?? ""),
|
|
461
463
|
el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
|
|
464
|
+
item.from ? el("span", { class: "moved-from", title: movedFrom(item) }, movedFrom(item)) : null,
|
|
462
465
|
item.reReview ? el("span", { class: "badge warn", title: RE_REVIEW }, "re-review") : null,
|
|
463
466
|
),
|
|
464
467
|
decisionLine(item),
|
|
@@ -697,7 +700,7 @@ function showGrid() {
|
|
|
697
700
|
`Needs a decision (${state.data.items.filter((i) => REVIEWABLE.includes(i.status) && !decisionOf(i)).length})`,
|
|
698
701
|
),
|
|
699
702
|
filterButton("all", `All (${state.data.items.filter((i) => REVIEWABLE.includes(i.status)).length})`),
|
|
700
|
-
["changed", "new", "unstable", "removed", "failed"]
|
|
703
|
+
["changed", "moved", "new", "unstable", "removed", "failed"]
|
|
701
704
|
.filter((s) => counts[s])
|
|
702
705
|
.map((s) => filterButton(s, `${s} (${counts[s]})`)),
|
|
703
706
|
counts[UNSEEDED] ? filterButton(UNSEEDED, `${NO_BASELINE} (${counts[UNSEEDED]})`) : null,
|
|
@@ -941,6 +944,7 @@ function showStory() {
|
|
|
941
944
|
itemName(item),
|
|
942
945
|
" ",
|
|
943
946
|
el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
|
|
947
|
+
item.from ? el("span", { class: "badge moved" }, movedFrom(item)) : null,
|
|
944
948
|
d
|
|
945
949
|
? el("span", { class: `badge ${d.decision}` }, `${d.decision}${d.reason ? `: ${d.reason}` : ""}`)
|
|
946
950
|
: null,
|
|
@@ -1174,7 +1178,9 @@ async function renderStage(item, view, keep) {
|
|
|
1174
1178
|
};
|
|
1175
1179
|
try {
|
|
1176
1180
|
const diff = item.baseline && item.capture ? await diffOf(item) : null;
|
|
1177
|
-
const left = item.baseline
|
|
1181
|
+
const left = item.baseline
|
|
1182
|
+
? pane(item.from ? `Baseline of ${item.from}` : "Baseline", await imgOf("baseline"))
|
|
1183
|
+
: pane("No baseline");
|
|
1178
1184
|
let right;
|
|
1179
1185
|
if (!item.capture) {
|
|
1180
1186
|
right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
|