@graphty/visual-review 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,10 +147,12 @@ Per project:
147
147
  | `seedFromDefaultBranch` | `true` | `false`: the project's first baselines are accepted on a pull request, not seeded from the default branch |
148
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 |
149
149
 
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.
150
+ Project ids are lowercase letters, digits and `-` (results.json allows no others), and so are
151
+ the names of Chromatic modes, which a capture refuses before it starts. Every project is gated;
152
+ there is no setting that turns the gate off, and a config that sets `gate` is refused. The pull
153
+ request gate reads the config as it is on the base branch, so a pull request cannot move
154
+ `baselines` out from under it or drop a project from the gate; a project that a pull request adds
155
+ to its own config is gated too.
154
156
 
155
157
  ## The GitHub Actions workflows
156
158
 
@@ -222,10 +224,10 @@ token is kept in the work directory, so the URL stays valid across restarts; del
222
224
 
223
225
  The address always names the screen you are on, after the token: the targets list; a pull
224
226
  request (or master) and project with the grid's filter and text; or one story with its view,
225
- zoom, changed box and blink, for example
226
- `#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off`.
227
- A link's `box` and `blink` apply to the page it opens; the choice this browser remembers for B
228
- and L is left as it was.
227
+ zoom, changed box, blink and Spotlight flash, for example
228
+ `#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off&flash=off`.
229
+ A link's `box`, `blink` and `flash` apply to the page it opens; the choice this browser remembers
230
+ for B, L and F in Spotlight is left as it was.
229
231
  Opening that address, in another tab or on another device, opens the same screen. **Copy link**
230
232
  at the top right copies it. The link carries your session token, so it works on your iPad the way
231
233
  the printed URL does; keep it to yourself as you would that URL. All of it sits after `#`, which a
@@ -250,6 +252,10 @@ starts the same server from your own shell.
250
252
  pull request. Merge the default branch into the pull request's branch (by merge, never
251
253
  rebase) and wait for CI.
252
254
  - **capture failed**: the `visual` job produced no results. Re-run that job in GitHub Actions.
255
+ - **CI still running**, **waiting for CI**, **downloading the captures**: there is nothing
256
+ to review yet; reload the page in a moment.
257
+ - **artifact expired**: GitHub deleted the capture after 30 days and it was never
258
+ downloaded here. Re-run the `visual` job.
253
259
  - **incomplete: N of M stories**: the capture stopped part way. Re-run the job.
254
260
  - **not seeded from master**: this project is not reviewed on the default branch
255
261
  (`"seedFromDefaultBranch": false` in the config); its first baselines are accepted on a pull
@@ -295,7 +301,9 @@ starts the same server from your own shell.
295
301
  scroll it was opened at; **Highlight**, the changed pixels in solid red laid over both images
296
302
  themselves, in both panes, where **Blink** (L) flashes the red pixels on and off at Flash's
297
303
  pace (remembered in this browser); and **Spotlight**, the new image dimmed everywhere except around the changed pixels
298
- (each grown by 10 image pixels), which finds a one-pixel change. Flash, Highlight and
304
+ (each grown by 10 image pixels), which finds a one-pixel change, where **Spotlight flash** (F
305
+ in Spotlight) shows the spotlighted baseline and the spotlighted new image one after the other
306
+ at Flash's pace, the pane's label saying which (remembered in this browser). Flash, Highlight and
299
307
  Spotlight need two images; on a new or removed story they are off and the page says why
300
308
  ("New story, no baseline", "Only one image: this story was removed"). Badges here:
301
309
  **size changed** (in image pixels), **flaky** (the two captures differed, then matched), and
@@ -326,11 +334,12 @@ Seed them from the default branch (below), or accept them on the pull request.
326
334
  | Key | Action |
327
335
  | ------------ | ------------------------------------------------------------------------------ |
328
336
  | J / K | Next / previous item of this pass (decided items stay in it) |
329
- | A | Accept an undecided item |
337
+ | A | Accept an undecided item, once both its images are shown |
330
338
  | R | Reject an undecided item (asks for a reason, then Enter) |
331
339
  | E | Exclude an undecided item (asks for a reason, then Enter, then a confirmation) |
332
340
  | U | Undo the item's decision (on the grid: each tile's Undo button) |
333
341
  | F | Flash between baseline and new; F again returns to side by side |
342
+ | F | In Spotlight: flash the spotlighted baseline and new, or stop flashing |
334
343
  | H | Highlight changed pixels; H again returns to side by side |
335
344
  | S | Spotlight the changes; S again returns to side by side |
336
345
  | Z | Next zoom: fit to screen, real size, 2x, 4x, 8x, then fit again |
@@ -344,6 +353,8 @@ Seed them from the default branch (below), or accept them on the pull request.
344
353
 
345
354
  No key reverses a decision. A, R and E do nothing on an item that is already decided, and say
346
355
  so; to change a decision, press U (or the Undo button) first. The same key twice never undoes.
356
+ A held A, R, E or U decides once, and a double click on a decision button decides only the item
357
+ it was clicked on, never the next one.
347
358
 
348
359
  ## What each decision does
349
360
 
@@ -360,7 +371,9 @@ so; to change a decision, press U (or the Undo button) first. The same key twice
360
371
  runner), re-run the `visual` job instead, since the newest attempt replaces the old results.
361
372
  - **Undo** (U, or a tile's Undo on the grid) clears a decision before Finish; it is the only way
362
373
  to change one. The grid also undoes a whole component or project, after a second press.
363
- Decisions are kept across server restarts.
374
+ Decisions are kept across server restarts. A decision applies only to the image it was taken
375
+ on: when a new run or a re-run attempt captures that item differently, it is undecided again
376
+ (the old decision stays saved, and comes back if the image does).
364
377
  - After Finish, accepts and exclusions are cleared; rejects stay, marked as already posted, and
365
378
  still show as rejected on the next CI run while the capture is unchanged. Finish does not post
366
379
  them twice. They live in the work directory's `state/` (the config's `workDir`), not in the
@@ -598,6 +611,21 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
598
611
  other `core.hooksPath`), that hook is not installed: call `git lfs pre-push "$@"` from your own
599
612
  pre-push hook. `git push --no-verify` skips the upload too; after one that carried baselines,
600
613
  run `git lfs push origin <branch>`.
614
+ - **download failed / failed to load: ...; reload the page to retry.** `serve` starts
615
+ downloading every capture as soon as it starts, and retries a gh call that fails on the network
616
+ (DNS, a dropped connection, a GitHub 5xx) three times over about 20 seconds; it logs each failed
617
+ call and each retry to stderr. A project whose download still fails shows "download failed", a
618
+ pull request (or the default branch's run) GitHub would not answer for shows "failed to load",
619
+ or, when it loaded before, keeps what it showed with "could not refresh", and everything else
620
+ loads as usual. Reload the page to try again; captures already downloaded are kept, and a
621
+ damaged one is downloaded again.
622
+ - **A gh or git call hangs.** Every gh and git call `serve` and Finish make is stopped after 10
623
+ minutes (`VISUAL_REVIEW_TIMEOUT_MS` sets another limit, in milliseconds), and git never waits
624
+ for a credential prompt. A stopped Finish names the step it was on and keeps your decisions.
625
+ - **"the server stopped while this Finish was at ..."** The server restarted during a Finish.
626
+ Look at the branch on origin to see whether its commit was pushed before pressing Finish again.
627
+ - **"... was pushed as ..., but opening its pull request failed"** (the seed). Press Finish
628
+ again: it opens the pull request for the branch already pushed.
601
629
  - **capture failed / no capture** on a target. The `visual` job produced no results. Open its
602
630
  log from the page and re-run the job. **incomplete: N of M stories**: the job stopped part way
603
631
  (a timeout); re-run it.
@@ -154,6 +154,13 @@ export function storySettings(parameters, file) {
154
154
  name,
155
155
  globals: Object.fromEntries(Object.entries(m).filter(([k]) => k !== "disable")),
156
156
  }));
157
+ // A mode names files and results.json items, which allow only these: refused before any story
158
+ // is captured, not when results.json is checked at the end.
159
+ for (const { name } of modes) {
160
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name) || name.length > 50) {
161
+ throw new Error(`Chromatic mode "${name}": a mode name is lowercase letters, digits and "-", at most 50`);
162
+ }
163
+ }
157
164
  const where = byFile ? "settings file" : "story's parameters";
158
165
  return {
159
166
  disableSnapshot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/visual-review",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
package/trusted/cli.mjs CHANGED
@@ -214,6 +214,7 @@ async function serve(args) {
214
214
  origin,
215
215
  masterRun,
216
216
  results: values.results && resolve(values.results),
217
+ warm: true,
217
218
  // What the owner types to run this server from their own shell, so Finish signs with
218
219
  // their key rather than the environment of whoever started it (an agent, say).
219
220
  startCommand:
package/trusted/gate.mjs CHANGED
@@ -63,7 +63,13 @@ export function newestResults(dir) {
63
63
  continue;
64
64
  }
65
65
  const file = join(dir, name, "results.json");
66
- out[project] = { attempt, results: existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null };
66
+ let results = null;
67
+ try {
68
+ results = JSON.parse(readFileSync(file, "utf8"));
69
+ } catch {
70
+ // Missing or not JSON: counted as missing, like any invalid results.json.
71
+ }
72
+ out[project] = { attempt, results };
67
73
  }
68
74
  return out;
69
75
  }
@@ -32,10 +32,13 @@ const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
32
32
  /**
33
33
  * Refused decisions and failed git commands; the message is shown to the owner as is.
34
34
  * `committed` is set when the accepts were already pushed and only the reject comment failed,
35
- * so the caller drops the accepts and keeps the rejects for a retry that only comments.
35
+ * so the caller drops the accepts and keeps the rejects for a retry that only comments. On
36
+ * master, `pullRequestMissing` is set too when opening the seed's pull request failed: the caller
37
+ * keeps the accepts, and the retry opens the pull request for the branch already pushed.
36
38
  */
37
39
  export class AcceptError extends Error {
38
40
  committed = null;
41
+ pullRequestMissing = false;
39
42
  }
40
43
 
41
44
  /**
@@ -48,6 +51,7 @@ export class AcceptError extends Error {
48
51
  * with it, so the hooks path points nowhere. Signing is left as the repository configures it, so
49
52
  * the commit carries the owner's identity and signature. GIT_LFS_SKIP_SMUDGE keeps the worktree's
50
53
  * checkout from downloading every baseline image: untouched baselines stay pointer files there.
54
+ * GIT_TERMINAL_PROMPT=0 makes a credential prompt fail instead of waiting on a terminal.
51
55
  * @param {string} cwd the repository or worktree
52
56
  * @param {string[]} args git's arguments
53
57
  * @param {string} [input] stdin
@@ -57,7 +61,7 @@ const git = (cwd, args, input) =>
57
61
  exec("git", ["-c", "core.hooksPath=/dev/null", ...args], {
58
62
  cwd,
59
63
  input,
60
- env: { ...process.env, HUSKY: "0", GIT_LFS_SKIP_SMUDGE: "1" },
64
+ env: { ...process.env, HUSKY: "0", GIT_LFS_SKIP_SMUDGE: "1", GIT_TERMINAL_PROMPT: "0" },
61
65
  });
62
66
 
63
67
  /** Images per `git lfs push --object-id`, so a large seed reports its upload as it goes. */
@@ -185,6 +189,7 @@ function check(projects, decisions) {
185
189
  * @param {{ project: string, file: string, decision: string, reason: string | null }[]} input.decisions
186
190
  * what the owner decided: accept, reject or exclude (checked here)
187
191
  * @param {number} [input.undecided] how many reviewable items are left undecided, for the status
192
+ * @param {string[]} [input.unloaded] the projects whose capture did not load, so nobody reviewed them
188
193
  * @param {Date} [input.now] the review time
189
194
  * @param {(step: string) => void} [input.progress] told each step as it starts, for the page
190
195
  * @param {ReturnType<typeof import("./config.mjs").normalizeConfig>} input.config the settings
@@ -200,6 +205,7 @@ export async function finish({
200
205
  projects,
201
206
  decisions,
202
207
  undecided = 0,
208
+ unloaded = [],
203
209
  now = new Date(),
204
210
  progress = () => {},
205
211
  config,
@@ -220,12 +226,22 @@ export async function finish({
220
226
  ({ commit, branch } = await commitAccepts({ repo, target, accepts, first, now, progress, config }));
221
227
  if (isMaster) {
222
228
  progress("opening the pull request");
223
- pullRequest = await createPullRequest(gh, {
224
- title: `${config.commitPrefix}: seed visual baselines`,
225
- head: branch,
226
- base: config.defaultBranch,
227
- body: seedBody(first, accepts, rejects, config.defaultBranch),
228
- });
229
+ try {
230
+ pullRequest = await createPullRequest(gh, {
231
+ title: `${config.commitPrefix}: seed visual baselines`,
232
+ head: branch,
233
+ base: config.defaultBranch,
234
+ body: seedBody(first, accepts, rejects, config.defaultBranch),
235
+ });
236
+ } catch (err) {
237
+ const e = new AcceptError(
238
+ `${branch} was pushed as ${commit.slice(0, 10)}, but opening its pull request failed: ` +
239
+ `${err.message}. Press Finish again to open it.`,
240
+ );
241
+ e.committed = commit;
242
+ e.pullRequestMissing = true;
243
+ throw e;
244
+ }
229
245
  }
230
246
  }
231
247
  if (rejects.length > 0) {
@@ -259,10 +275,10 @@ export async function finish({
259
275
  // none. A failure here does not undo what was pushed and posted; the page shows it.
260
276
  const accepted = accepts.filter((a) => a.decision === "accept").length;
261
277
  const excluded = accepts.length - accepted;
262
- const state = rejects.length > 0 ? "failure" : undecided > 0 ? "pending" : "success";
278
+ const state = rejects.length > 0 ? "failure" : undecided > 0 || unloaded.length > 0 ? "pending" : "success";
263
279
  const status =
264
280
  `Reviewed: ${accepted} accepted, ${rejects.length} rejected, ${excluded} excluded, ` +
265
- `${undecided} left undecided`;
281
+ `${undecided} left undecided${unloaded.length > 0 ? `, not loaded: ${unloaded.join(", ")}` : ""}`;
266
282
  let statusError = null;
267
283
  progress("posting the status");
268
284
  try {
@@ -287,7 +303,11 @@ export async function finish({
287
303
  */
288
304
  async function commitAccepts({ repo, target, accepts, first, now, progress, config }) {
289
305
  const { baselines, defaultBranch } = config;
290
- const tracking = `refs/remotes/origin/${defaultBranch}`;
306
+ // Finish fetches into refs of its own, never the remote-tracking refs a page reload fetches
307
+ // into at the same time (two fetches of one ref fail on its lock).
308
+ const own = (b) => `refs/visual-review/origin/${b}`;
309
+ const fetch = (b) => git(repo, ["fetch", "-q", "--no-write-fetch-head", "origin", `+refs/heads/${b}:${own(b)}`]);
310
+ const tracking = own(defaultBranch);
291
311
  const isMaster = target.pr === null;
292
312
  const lfs = await lfsProblem(repo);
293
313
  if (lfs) {
@@ -319,19 +339,27 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
319
339
  }
320
340
  }
321
341
 
322
- await git(repo, ["fetch", "-q", "origin", `+refs/heads/${defaultBranch}:${tracking}`]);
342
+ await fetch(defaultBranch);
323
343
  if (isMaster) {
324
344
  if ((await git(repo, ["ls-remote", "--heads", "origin", branch])) !== "") {
345
+ // A seed this tool pushed whose pull request failed to open is used as it is.
346
+ // ponytail: assumes the decisions did not change since that push; compare the trees if
347
+ // they can.
348
+ await fetch(branch);
349
+ const [subject, parent] = (await git(repo, ["log", "-1", "--format=%s%n%P", own(branch)])).split("\n");
350
+ if (subject === `${config.commitPrefix}: seed visual baselines` && parent === base) {
351
+ return { commit: await git(repo, ["rev-parse", own(branch)]), branch };
352
+ }
325
353
  throw new AcceptError(`${branch} already exists on origin: merge or delete it first`);
326
354
  }
327
355
  } else {
328
- await git(repo, ["fetch", "-q", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
329
- if ((await git(repo, ["rev-parse", `refs/remotes/origin/${branch}`])) !== base) {
356
+ await fetch(branch);
357
+ if ((await git(repo, ["rev-parse", own(branch)])) !== base) {
330
358
  throw new AcceptError("capture is stale, wait for CI: the branch has moved past the captured head");
331
359
  }
332
360
  }
333
361
  for (const project of new Set(accepts.map((a) => a.project))) {
334
- if (await behindMaster(repo, base, project, config)) {
362
+ if (await behindMaster(repo, base, project, config, tracking)) {
335
363
  throw new AcceptError(
336
364
  `merge ${defaultBranch} into the branch first: ${defaultBranch} has newer ${project} baselines`,
337
365
  );
@@ -406,12 +434,22 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
406
434
  progress("uploading images to LFS (checking the commit has them all)");
407
435
  await git(tree, ["lfs", "push", "origin", "HEAD"]);
408
436
  progress("pushing");
409
- await git(tree, ["push", "-q", "--no-verify", "origin", `HEAD:refs/heads/${branch}`]);
437
+ await git(tree, ["push", "-q", "--no-verify", "origin", `HEAD:refs/heads/${branch}`]).catch((err) => {
438
+ // git's own advice ("git pull") is wrong here: the capture no longer matches the branch.
439
+ throw /\[rejected\].*\((fetch first|non-fast-forward)\)/.test(err.message)
440
+ ? new AcceptError(
441
+ "capture is stale, wait for CI: the branch moved past the captured head while Finish ran; nothing was pushed",
442
+ )
443
+ : err;
444
+ });
410
445
  return { commit: await git(tree, ["rev-parse", "HEAD"]), branch };
411
446
  } catch (err) {
412
447
  throw err instanceof AcceptError ? err : new AcceptError(err.message);
413
448
  } finally {
414
- await removeWorktree(repo, tree);
449
+ // A cleanup failure must not hide what was pushed; the next Finish removes the tree.
450
+ await removeWorktree(repo, tree).catch((err) =>
451
+ console.error(`visual-review: could not remove the accept worktree ${tree}: ${err.message}`),
452
+ );
415
453
  }
416
454
  }
417
455
 
@@ -421,17 +459,17 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
421
459
  * @param {string} head the captured head
422
460
  * @param {string} project the project id
423
461
  * @param {{ defaultBranch: string, baselines: string }} config the settings
462
+ * @param {string} [tracking] the fetched default branch
424
463
  * @returns {Promise<boolean>} true when the branch must merge the default branch before an accept
425
464
  */
426
- export async function behindMaster(repo, head, project, { defaultBranch, baselines }) {
427
- const newest = await git(repo, [
428
- "log",
429
- "-1",
430
- "--format=%H",
431
- `refs/remotes/origin/${defaultBranch}`,
432
- "--",
433
- `${baselines}/${project}/`,
434
- ]);
465
+ export async function behindMaster(
466
+ repo,
467
+ head,
468
+ project,
469
+ { defaultBranch, baselines },
470
+ tracking = `refs/remotes/origin/${defaultBranch}`,
471
+ ) {
472
+ const newest = await git(repo, ["log", "-1", "--format=%H", tracking, "--", `${baselines}/${project}/`]);
435
473
  return newest !== "" && !(await gitOk(repo, ["merge-base", "--is-ancestor", newest, head]));
436
474
  }
437
475
 
@@ -487,7 +525,8 @@ async function put(path, data) {
487
525
 
488
526
  async function removeWorktree(repo, tree) {
489
527
  if (existsSync(tree)) {
490
- await git(repo, ["worktree", "remove", "--force", tree]);
528
+ // Twice: a server killed during `worktree add` leaves the tree locked ("initializing").
529
+ await git(repo, ["worktree", "remove", "--force", "--force", tree]);
491
530
  }
492
531
  await git(repo, ["worktree", "prune"]);
493
532
  }
@@ -21,8 +21,9 @@ const DEFAULTS = {
21
21
  issueLabels: ["bug"],
22
22
  };
23
23
 
24
- // Project ids name artifacts, jobs, directories and regular expressions, so they stay plain.
25
- const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
24
+ // Project ids name artifacts, jobs, directories and regular expressions, so they stay plain; and
25
+ // results.json allows only these.
26
+ const PROJECT_ID = /^[a-z0-9][a-z0-9-]*$/;
26
27
  // A path inside the repository: relative, no "..", no backslashes.
27
28
  const REPO_PATH = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[^\\]+$/;
28
29
 
@@ -72,7 +73,7 @@ export function normalizeConfig(input) {
72
73
  for (const [id, p] of Object.entries(projects)) {
73
74
  const where = `projects.${id}`;
74
75
  if (!PROJECT_ID.test(id)) {
75
- fail(`${where}: a project id is letters, digits, ".", "_" and "-"`);
76
+ fail(`${where}: a project id is lowercase letters, digits and "-"`);
76
77
  }
77
78
  if (typeof p !== "object" || p === null) {
78
79
  fail(`${where} must be an object`);
@@ -10,22 +10,29 @@
10
10
  */
11
11
 
12
12
  import { execFile } from "node:child_process";
13
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
14
- import { dirname, join } from "node:path";
13
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
14
+ import { basename, dirname, join } from "node:path";
15
15
 
16
16
  import { validateResults } from "./results.mjs";
17
17
 
18
18
  /**
19
- * Runs a program and resolves with its trimmed stdout.
19
+ * Runs a program and resolves with its trimmed stdout. A program that runs longer than
20
+ * VISUAL_REVIEW_TIMEOUT_MS (10 minutes by default) is killed: a gh or git call that stalls on a
21
+ * dead network, or waits on a prompt nobody sees, would otherwise hold the page or Finish forever.
20
22
  * @param {string} cmd the program
21
23
  * @param {string[]} args its arguments
22
24
  * @param {{ cwd?: string, input?: string, env?: object }} [options] stdin and environment
23
25
  * @returns {Promise<string>} stdout; rejects with an Error whose message is the program's stderr
24
26
  */
25
27
  export function exec(cmd, args, { cwd, input, env } = {}) {
28
+ const timeout = Number(process.env.VISUAL_REVIEW_TIMEOUT_MS) || 600000;
26
29
  return new Promise((resolve, reject) => {
27
- const child = execFile(cmd, args, { cwd, env, encoding: "utf8", maxBuffer: 64 << 20 }, (err, out, stderr) => {
28
- if (err) {
30
+ // SIGTERM (the default) lets git remove its lock files as it exits.
31
+ const options = { cwd, env, encoding: /** @type {const} */ ("utf8"), maxBuffer: 64 << 20, timeout };
32
+ const child = execFile(cmd, args, options, (err, out, stderr) => {
33
+ if (err?.killed) {
34
+ reject(new Error(`${cmd} ${args.join(" ")} timed out after ${timeout / 1000} s and was stopped`));
35
+ } else if (err) {
29
36
  reject(new Error(stderr.trim() || err.message));
30
37
  } else {
31
38
  resolve(out.trim());
@@ -42,7 +49,40 @@ export function exec(cmd, args, { cwd, input, env } = {}) {
42
49
  * @param {string} cwd the repository
43
50
  * @returns {(args: string[], input?: string) => Promise<string>} runs gh and returns its stdout
44
51
  */
45
- export const ghRunner = (cwd) => (args, input) => exec("gh", args, { cwd, input });
52
+ export const ghRunner = (cwd) => withRetries((args, input) => exec("gh", args, { cwd, input }));
53
+
54
+ // How gh reports a network that failed (DNS, a dropped or refused connection, a transfer cut
55
+ // short, a call exec stopped) or a GitHub server error. A 4xx, a missing artifact or any other gh
56
+ // error is real and is never retried.
57
+ const TRANSIENT =
58
+ /could not resolve host|no such host|error connecting to|connection (reset|refused|timed out)|i\/o timeout|TLS handshake timeout|HTTP 5\d\d|unexpected EOF|GOAWAY|stream error|context deadline exceeded|timed out after/i;
59
+
60
+ /**
61
+ * Retries a gh runner's calls that failed on the network, after each delay in turn. A write
62
+ * (`--input`) is never retried: GitHub may have applied it before the connection dropped. Every
63
+ * failure and retry is logged to stderr, so the server's log shows what happened.
64
+ * @param {(args: string[], input?: string) => Promise<string>} gh the gh runner
65
+ * @param {number[]} [delays] milliseconds before each retry
66
+ * @returns {(args: string[], input?: string) => Promise<string>} the retrying runner
67
+ */
68
+ export const withRetries =
69
+ (gh, delays = [2000, 5000, 15000]) =>
70
+ async (args, input) => {
71
+ for (let i = 0; ; i++) {
72
+ try {
73
+ return await gh(args, input);
74
+ } catch (err) {
75
+ const retry = i < delays.length && !args.includes("--input") && TRANSIENT.test(err.message);
76
+ // gh's arguments never hold a token (gh keeps its own login), so they are logged whole.
77
+ const next = retry ? `; retrying in ${delays[i] / 1000} s` : "";
78
+ console.error(`visual-review: gh ${args.join(" ")} failed${next}: ${err.message}`);
79
+ if (!retry) {
80
+ throw err;
81
+ }
82
+ await new Promise((resolve) => setTimeout(resolve, delays[i]));
83
+ }
84
+ }
85
+ };
46
86
 
47
87
  const api = async (gh, path) => JSON.parse(await gh(["api", path]));
48
88
 
@@ -108,7 +148,8 @@ export async function visualJobs(gh, run, attempt, projects) {
108
148
  const visual = jobs.filter((j) => /^visual\b/.test(j.name));
109
149
  return Object.fromEntries(
110
150
  projects.map((p) => {
111
- const job = visual.find((j) => j.name.includes(p));
151
+ // The exact name: "visual (graphty-element)" also contains "graphty".
152
+ const job = visual.find((j) => j.name === `visual (${p})`);
112
153
  return [p, job && { conclusion: job.conclusion, url: job.html_url }];
113
154
  }),
114
155
  );
@@ -129,14 +170,28 @@ const downloading = new Map();
129
170
  * @returns {Promise<void>} settles when `dir` is complete, or the artifact had no results.json
130
171
  */
131
172
  function download(gh, runId, name, dir) {
132
- if (existsSync(join(dir, "results.json"))) {
133
- return Promise.resolve();
173
+ if (!downloading.has(dir) && existsSync(join(dir, "results.json"))) {
174
+ try {
175
+ JSON.parse(readFileSync(join(dir, "results.json"), "utf8"));
176
+ return Promise.resolve();
177
+ } catch (err) {
178
+ // Damaged on this disk (CI uploads only results.json it wrote): download it again.
179
+ console.error(
180
+ `visual-review: ${join(dir, "results.json")} is unreadable, downloading again: ${err.message}`,
181
+ );
182
+ }
134
183
  }
135
184
  if (!downloading.has(dir)) {
136
185
  const done = (async () => {
137
- // A directory without results.json is left over from before downloads were atomic.
186
+ // A directory without results.json is left over from before downloads were atomic,
187
+ // and a .part- sibling from a download a killed server never finished.
138
188
  rmSync(dir, { recursive: true, force: true });
139
189
  mkdirSync(dirname(dir), { recursive: true });
190
+ for (const f of readdirSync(dirname(dir))) {
191
+ if (f.startsWith(`${basename(dir)}.part-`)) {
192
+ rmSync(join(dirname(dir), f), { recursive: true, force: true });
193
+ }
194
+ }
140
195
  const part = mkdtempSync(`${dir}.part-`);
141
196
  try {
142
197
  await gh(["run", "download", String(runId), "-n", name, "-D", part]);
@@ -155,31 +210,51 @@ function download(gh, runId, name, dir) {
155
210
  /**
156
211
  * Downloads each project's capture artifact from the highest attempt that uploaded one, into
157
212
  * `<tmp>/<run>-<attempt>/<project>/`. An artifact already downloaded is not fetched again, and
158
- * concurrent calls for the same one share a single download.
213
+ * concurrent calls for the same one share a single download. A failed download fails only its
214
+ * project, which the next call tries again. An expired artifact is used from disk when it was
215
+ * downloaded before.
159
216
  * @param {Function} gh the gh runner
160
217
  * @param {{ id: number }} run the run
161
218
  * @param {string[]} projects project ids
162
219
  * @param {string} tmp the download root
163
- * @returns {Promise<Record<string, { dir: string, attempt: number } | null>>} null for a project
164
- * with no unexpired artifact
220
+ * @param {string[]} [others] receives the projects the run captured that are not in `projects`
221
+ * @returns {Promise<Record<string, { dir: string | null, attempt: number, error?: string,
222
+ * expired?: true } | null>>} null for a project with no artifact; `error` (and no `dir`) when
223
+ * its download failed; `expired` (and no `dir`) when GitHub deleted it and it is not on disk
165
224
  */
166
- export async function downloadCaptures(gh, run, projects, tmp) {
225
+ export async function downloadCaptures(gh, run, projects, tmp, others = []) {
167
226
  const { artifacts } = await api(gh, `repos/{owner}/{repo}/actions/runs/${run.id}/artifacts?per_page=100`);
168
- /** @type {Record<string, { dir: string, attempt: number } | null>} */
227
+ for (const a of artifacts) {
228
+ const p = /^visual-(.+)-\d+$/.exec(a.name)?.[1];
229
+ if (p && !projects.includes(p) && !others.includes(p)) {
230
+ others.push(p);
231
+ }
232
+ }
233
+ /** @type {Record<string, { dir: string | null, attempt: number, error?: string, expired?: true } | null>} */
169
234
  const out = {};
170
235
  for (const project of projects) {
171
236
  const pattern = new RegExp(`^visual-${project}-(\\d+)$`);
172
237
  const newest = artifacts
173
- .filter((a) => !a.expired && pattern.test(a.name))
174
- .map((a) => ({ name: a.name, attempt: Number(pattern.exec(a.name)[1]) }))
238
+ .filter((a) => pattern.test(a.name))
239
+ .map((a) => ({ name: a.name, expired: a.expired, attempt: Number(pattern.exec(a.name)[1]) }))
175
240
  .sort((a, b) => b.attempt - a.attempt)[0];
176
241
  if (!newest) {
177
242
  out[project] = null;
178
243
  continue;
179
244
  }
180
245
  const dir = join(tmp, `${run.id}-${newest.attempt}`, project);
181
- await download(gh, run.id, newest.name, dir);
182
- out[project] = { dir, attempt: newest.attempt };
246
+ if (newest.expired) {
247
+ out[project] = existsSync(join(dir, "results.json"))
248
+ ? { dir, attempt: newest.attempt }
249
+ : { dir: null, attempt: newest.attempt, expired: true };
250
+ continue;
251
+ }
252
+ try {
253
+ await download(gh, run.id, newest.name, dir);
254
+ out[project] = { dir, attempt: newest.attempt };
255
+ } catch (err) {
256
+ out[project] = { dir: null, attempt: newest.attempt, error: err.message };
257
+ }
183
258
  }
184
259
  return out;
185
260
  }
@@ -205,7 +280,12 @@ export async function newestMasterCapture(gh, project, tmp, { workflow, defaultB
205
280
  if (!run) {
206
281
  continue;
207
282
  }
208
- const dir = (await downloadCaptures(gh, run, [project], tmp))[project]?.dir;
283
+ const got = (await downloadCaptures(gh, run, [project], tmp))[project];
284
+ if (got?.error) {
285
+ // Not skipped: an older capture would be compared instead of this one.
286
+ throw new Error(got.error);
287
+ }
288
+ const dir = got?.dir;
209
289
  let results = null;
210
290
  try {
211
291
  results = dir ? JSON.parse(readFileSync(join(dir, "results.json"), "utf8")) : null;