@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 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), `new` (no baseline, and on a pull request the
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 &lt;old id&gt;" 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
@@ -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 ids = storyIds(JSON.parse(await readFile(join(storybook, "index.json"), "utf8"))).filter(
489
- (id) => !stories || stories.some((p) => id.startsWith(p)),
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
- jobs.push({ ...common, url: base + storyUrl(id, mode.globals), delay: s.delay });
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 ? [] : [...existing].filter((f) => !planned.has(f) && BASELINE_NAME.test(f));
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, ...common } = job;
580
- const baseline = await readBaseline(join(baselines, job.file));
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
- if (["changed", "new", "unseeded", "unstable"].includes(status)) {
606
- await writeFile(join(out, job.file), first.png);
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
- if (baseline && (status === "changed" || status === "unstable")) {
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 = status === "unchanged" ? [] : clip([...first.console, ...(second?.console ?? [])]);
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.0",
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",
@@ -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
- * the record item
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
- return { path, from: w.item.baseline, to: w.item.capture, reason: w.reason };
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.
@@ -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 `first` is null
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" || second === null) {
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 { ...vsSecond, flaky: true };
191
+ return matched(vsSecond, true);
184
192
  }
185
193
  return { ...vsFirst, status: agree() ? "changed" : "unstable", flaky: false };
186
194
  }
@@ -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
- const { workflow_runs: runs } = await api(
163
- gh,
164
- `repos/{owner}/{repo}/actions/workflows/${encodeURIComponent(workflow)}/runs?branch=` +
165
- `${encodeURIComponent(defaultBranch)}&event=push&per_page=10`,
166
- );
167
- for (const run of runs.map(toRun)) {
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 {
@@ -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];
@@ -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
- const bytes = await readFile(kind === "capture" ? join(p.dir, file) : join(p.dir, "baselines", file)).catch(
438
- () => null,
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
  }
@@ -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
+ }
@@ -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, new: 2, unstable: 3, removed: 4, unseeded: 5 };
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 ? pane("Baseline", await imgOf("baseline")) : pane("No 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");