@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.
@@ -6,21 +6,26 @@
6
6
  * served origin. Images are served only when their file is named by results.json and its bytes
7
7
  * hash to the hash results.json gives, so the page shows exactly what CI compared. Decisions
8
8
  * are kept in `<tmp>/state/<target>.json` until Finish, each with the hash of the image it was
9
- * taken on, so a restart resumes them and a new CI run keeps only those whose image is unchanged.
9
+ * taken on, so a restart resumes them and a new CI run or attempt shows only those whose image is
10
+ * unchanged. The file is the only copy: every request reads it and every change rewrites it, so a
11
+ * failed write changes nothing, and a decision whose item is missing from this run (a project
12
+ * still capturing, a download that failed) is kept for when it comes back.
10
13
  *
11
14
  * Finish runs in the background: a large seed takes minutes, longer than a browser (Safari on an
12
15
  * iPad) keeps one request open. POST /api/finish starts it and GET /api/finish-status reports its
13
- * step, then its result or error, so a reload finds the running Finish.
16
+ * step, then its result or error, so a reload finds the running Finish. The job is also written
17
+ * to `<tmp>/state/finish.json`, so a server restarted during a Finish says it was interrupted.
14
18
  */
15
19
 
16
20
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
22
  import { readFile } from "node:fs/promises";
19
23
  import { dirname, join } from "node:path";
20
24
  import { fileURLToPath } from "node:url";
21
25
 
22
26
  import { AcceptError, behindMaster, cleanReason, decisionProblem, finish } from "./accept.mjs";
23
27
  import { downloadCaptures, exec, getRun, newestCiRun, openPullRequests, visualJobs } from "./github.mjs";
28
+ import { CONFIG_FILE } from "./config.mjs";
24
29
  import { validateResults } from "./results.mjs";
25
30
 
26
31
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -46,6 +51,12 @@ const componentOf = (id) => id.split("--")[0];
46
51
 
47
52
  const REVIEWABLE = new Set(["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"]);
48
53
  const WRITES = new Set(["decide", "accept-all", "finish"]);
54
+ // How long a page load waits for a run's captures to download before it lists the target as
55
+ // downloading; the download goes on, and a reload picks it up.
56
+ const PATIENCE = 1000;
57
+ // An unknown target id refreshes from GitHub at most this often: a closed pull request's open
58
+ // grid asks for dozens of images at once.
59
+ const UNKNOWN_REFRESH = 60000;
49
60
 
50
61
  /**
51
62
  * The image a decision was taken on: the capture, or for a removed item its baseline.
@@ -124,13 +135,27 @@ async function signingIdentity(repo) {
124
135
  */
125
136
  export function sessionToken(stateDir) {
126
137
  const file = join(stateDir, "token");
127
- if (!existsSync(file)) {
138
+ // An empty file (a full disk on the first start) would be an empty token: write a new one.
139
+ if (!existsSync(file) || readFileSync(file, "utf8").trim() === "") {
128
140
  mkdirSync(stateDir, { recursive: true });
129
141
  writeFileSync(file, randomBytes(32).toString("base64url"), { mode: 0o600 });
130
142
  }
131
143
  return readFileSync(file, "utf8").trim();
132
144
  }
133
145
 
146
+ /**
147
+ * Writes a JSON file through a sibling renamed into place, so a kill or a full disk mid-write
148
+ * never leaves half a file.
149
+ * @param {string} file the file
150
+ * @param {unknown} value what to write
151
+ */
152
+ function writeJson(file, value) {
153
+ mkdirSync(dirname(file), { recursive: true });
154
+ const part = `${file}.tmp`;
155
+ writeFileSync(part, JSON.stringify(value, null, 2));
156
+ renameSync(part, file);
157
+ }
158
+
134
159
  /**
135
160
  * Reads and validates one project's results.json.
136
161
  * @param {string} dir the capture directory
@@ -167,29 +192,59 @@ async function loadResults(dir) {
167
192
  * a preview to look at, with no decisions and no Finish
168
193
  * @param {string} [options.startCommand] the shell command that starts this server, shown so the
169
194
  * owner can restart it from their own shell and sign Finish with their own key
195
+ * @param {boolean} [options.warm] start downloading every target's captures right away
170
196
  * @returns {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void}
171
197
  * the handler, for node:https in the CLI and node:http in the tests
172
198
  */
173
- export function createApp({ repo, gh, config, tmp, token, origin, masterRun, results, startCommand = null }) {
199
+ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, results, startCommand = null, warm }) {
174
200
  const stateDir = join(tmp, "state");
175
201
  const { projects, defaultBranch } = config;
176
202
  const names = Object.keys(projects);
177
203
  /** @type {Map<string, object>} targets by id: a pull request number, or "master" */
178
204
  let targets = new Map();
179
- /**
180
- * By target and run, then by `<project>/<file>`; `bulk` marks an accept from Accept all that
181
- * was never opened one by one, `posted` a reject an earlier Finish already commented.
182
- * @type {Map<string, Map<string, { decision: string, reason: string | null, bulk?: true,
183
- * posted?: true }>>}
184
- */
185
- const decisions = new Map();
205
+ /** What the last refresh could not read from GitHub (the pull request list, a git fetch). */
206
+ let listWarnings = [];
207
+ /** Why a target's saved decisions were set aside, by target id. */
208
+ const stateProblems = new Map();
186
209
  let finishing = false;
210
+ const jobFile = join(stateDir, "finish.json");
187
211
  /**
188
212
  * The newest Finish, kept after it ends so a reload still shows its result.
189
- * @type {{ id: number, target: string, pr: number | null, running: boolean, step: string | null,
190
- * result: object | null, error: string | null } | null}
213
+ * @type {{ id: number, target: string, pr: number | null, branch: string | null, running: boolean,
214
+ * step: string | null, result: object | null, error: string | null, warnings: string[],
215
+ * interrupted?: true } | null}
191
216
  */
192
217
  let job = null;
218
+ try {
219
+ const saved = JSON.parse(readFileSync(jobFile, "utf8"));
220
+ if (saved?.running) {
221
+ const where = saved.branch ?? "the visual/seed-* branch";
222
+ job = {
223
+ ...saved,
224
+ running: false,
225
+ step: null,
226
+ interrupted: true,
227
+ warnings: saved.warnings ?? [],
228
+ error:
229
+ `the server stopped while this Finish was at "${saved.step}", so it never ended: check ` +
230
+ `whether ${where} on origin has its commit before pressing Finish again`,
231
+ };
232
+ console.error(`visual-review: ${job.error}`);
233
+ }
234
+ } catch {
235
+ // No Finish ran yet.
236
+ }
237
+ const persist = (j) => {
238
+ try {
239
+ writeJson(jobFile, j);
240
+ } catch (err) {
241
+ const warning = `could not save the Finish job (a restart would not report it): ${err.message}`;
242
+ if (!j.warnings.includes(warning)) {
243
+ j.warnings.push(warning);
244
+ console.error(`visual-review: ${warning}`);
245
+ }
246
+ }
247
+ };
193
248
  let signer = null;
194
249
  const busy = (t) => finishing && job?.target === t.id;
195
250
  const BUSY = "a Finish is running on this target: wait for it to end";
@@ -200,35 +255,87 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
200
255
  return p?.results?.items.find((i) => i.file === key.slice(at + 1));
201
256
  };
202
257
  const stateFile = (t) => join(stateDir, `${t.id}.json`);
258
+ const warned = new Set();
259
+ const warnOnce = (message) => {
260
+ if (!warned.has(message)) {
261
+ warned.add(message);
262
+ console.error(`visual-review: ${message}`);
263
+ }
264
+ };
203
265
 
204
- const decisionsOf = (t) => {
205
- const key = `${t.id}@${t.runId}`;
206
- if (!decisions.has(key)) {
207
- let saved = {};
208
- try {
209
- saved = JSON.parse(readFileSync(stateFile(t), "utf8"));
210
- } catch {
211
- // No state yet, or a broken file: start empty.
212
- }
213
- const mine = new Map();
214
- for (const [k, { hash, ...d }] of Object.entries(saved)) {
215
- const item = itemOf(t, k);
216
- if (item && imageHash(item) === hash) {
217
- mine.set(k, d);
218
- }
266
+ /**
267
+ * A target's saved decisions by `<project>/<file>`, each with the hash of the image it was
268
+ * taken on; `bulk` marks an accept from Accept all that was never opened one by one, `posted`
269
+ * a reject an earlier Finish already commented. A file that is not a decisions file is moved
270
+ * aside, never overwritten, so the owner can still recover what it held.
271
+ * @param {object} t the target
272
+ * @returns {Record<string, { decision: string, reason: string | null, hash: string | null,
273
+ * bulk?: true, posted?: true }>} the decisions
274
+ */
275
+ const readState = (t) => {
276
+ const file = stateFile(t);
277
+ let saved;
278
+ try {
279
+ saved = JSON.parse(readFileSync(file, "utf8"));
280
+ } catch (err) {
281
+ if (err.code === "ENOENT") {
282
+ return {};
283
+ }
284
+ if (!(err instanceof SyntaxError)) {
285
+ throw err;
286
+ }
287
+ }
288
+ if (typeof saved !== "object" || saved === null || Array.isArray(saved)) {
289
+ const aside = `${file}.unreadable-${Date.now()}`;
290
+ renameSync(file, aside);
291
+ stateProblems.set(t.id, `the saved decisions were unreadable; the file was moved to ${aside}`);
292
+ console.error(`visual-review: ${file} is not a decisions file: moved it to ${aside}`);
293
+ return {};
294
+ }
295
+ for (const [k, d] of Object.entries(saved)) {
296
+ if (typeof d !== "object" || d === null || typeof d.decision !== "string") {
297
+ warnOnce(`${file}: ignoring the malformed entry ${JSON.stringify(k)}`);
298
+ delete saved[k];
219
299
  }
220
- decisions.set(key, mine);
221
300
  }
222
- return decisions.get(key);
301
+ return saved;
302
+ };
303
+
304
+ /**
305
+ * Changes a target's saved decisions. The file is read, changed and written in one go, so a
306
+ * failed write changes nothing and a second server on the same directory is not overwritten.
307
+ * ponytail: no lock between processes; two writes in the same millisecond can still race.
308
+ * @param {object} t the target
309
+ * @param {(saved: Record<string, object>) => void} change edits the decisions in place
310
+ */
311
+ const update = (t, change) => {
312
+ const saved = readState(t);
313
+ change(saved);
314
+ writeJson(stateFile(t), saved);
223
315
  };
224
316
 
225
- const save = (t) => {
226
- const out = {};
227
- for (const [k, d] of decisionsOf(t)) {
228
- out[k] = { ...d, hash: imageHash(itemOf(t, k)) };
317
+ /**
318
+ * The decisions that apply to this run: those whose item is in it, with the image the decision
319
+ * was taken on, and still decidable that way. The others stay in the file.
320
+ * @param {object} t the target
321
+ * @returns {Map<string, { decision: string, reason: string | null, bulk?: true, posted?: true }>}
322
+ * by `<project>/<file>`
323
+ */
324
+ const decisionsOf = (t) => {
325
+ const mine = new Map();
326
+ for (const [k, { hash, ...d }] of Object.entries(readState(t))) {
327
+ const item = itemOf(t, k);
328
+ const project = k.slice(0, k.indexOf("/"));
329
+ if (
330
+ item &&
331
+ imageHash(item) === hash &&
332
+ !decisionProblem(item, d.decision, d.reason ?? null) &&
333
+ (d.decision === "reject" || acceptable(t, project))
334
+ ) {
335
+ mine.set(k, d);
336
+ }
229
337
  }
230
- mkdirSync(stateDir, { recursive: true });
231
- writeFileSync(stateFile(t), JSON.stringify(out, null, 2));
338
+ return mine;
232
339
  };
233
340
 
234
341
  // `problem` is what CI said (the job failed, or no artifact); results.json can add its own.
@@ -239,23 +346,72 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
239
346
 
240
347
  async function build(info, run) {
241
348
  const jobs = await visualJobs(gh, run, run.attempt, names);
242
- const downloaded = await downloadCaptures(gh, run, names, tmp);
349
+ const others = [];
350
+ const downloads = downloadCaptures(gh, run, names, tmp, others);
351
+ downloads.catch(() => {}); // A download still running after PATIENCE fails on a later refresh.
352
+ const downloaded = await Promise.race([
353
+ downloads,
354
+ new Promise((resolve) => setTimeout(resolve, PATIENCE, null).unref()),
355
+ ]);
356
+ if (!downloaded) {
357
+ return blank(info, "downloading the captures: reload in a moment", run);
358
+ }
243
359
  const list = [];
244
360
  for (const name of names) {
245
361
  const job = jobs[name];
246
- const failed = !downloaded[name] || job?.conclusion === "failure";
247
- const p = await project(
248
- name,
249
- downloaded[name]?.dir,
250
- failed ? (job ? "capture failed" : "no capture") : null,
251
- );
362
+ const got = downloaded[name];
363
+ let problem = null;
364
+ if (got?.error) {
365
+ problem = retryLater("download failed", got.error);
366
+ } else if (got?.expired) {
367
+ problem = "artifact expired: re-run the visual job";
368
+ } else if (!got && run.status !== "completed" && job?.conclusion !== "failure") {
369
+ problem = "CI still running; reload when it finishes";
370
+ } else if (!got || job?.conclusion === "failure") {
371
+ problem = job ? "capture failed" : "no capture";
372
+ }
373
+ const p = await project(name, got?.dir, problem);
252
374
  p.logUrl = job?.url ?? run.url;
253
375
  list.push(p);
254
376
  }
255
- return { ...info, runId: run.id, runAttempt: run.attempt, runUrl: run.url, projects: list };
377
+ const warnings = others.map(
378
+ (p) =>
379
+ `the run captured ${p}, which this server's ${CONFIG_FILE} does not list: serve from a checkout that has it`,
380
+ );
381
+ return { ...info, runId: run.id, runAttempt: run.attempt, runUrl: run.url, projects: list, warnings };
256
382
  }
257
383
 
258
- async function refresh() {
384
+ const retryLater = (what, message) => `${what}: ${message.split("\n")[0]}; reload the page to retry`;
385
+
386
+ // A target with no capture to show: GitHub would not give it to us (after gh's retries), CI has
387
+ // not run yet, or its download is still going. The other targets still load.
388
+ const blank = (info, problem, run = null) => ({
389
+ ...info,
390
+ runId: run?.id ?? null,
391
+ runAttempt: run?.attempt ?? null,
392
+ runUrl: run?.url ?? null,
393
+ projects: names.map((n) => ({ project: n, dir: null, results: null, problem, logUrl: run?.url ?? null })),
394
+ warnings: [],
395
+ });
396
+
397
+ // A target that loaded before keeps what it showed when a later refresh of it fails.
398
+ const keptOr = (info, err) => {
399
+ const old = targets.get(info.id);
400
+ return old?.runId
401
+ ? { ...old, warnings: [retryLater("could not refresh", err.message)] }
402
+ : blank(info, retryLater("failed to load", err.message));
403
+ };
404
+
405
+ // Concurrent callers (the startup refresh, the page's first request) share one refresh.
406
+ let refreshing = null;
407
+ let refreshedAt = 0;
408
+ const refresh = () =>
409
+ (refreshing ??= load().finally(() => {
410
+ refreshing = null;
411
+ refreshedAt = Date.now();
412
+ }));
413
+
414
+ async function load() {
259
415
  const next = new Map();
260
416
  if (results) {
261
417
  const list = await Promise.all(
@@ -282,31 +438,78 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
282
438
  });
283
439
  }
284
440
  } else {
285
- const prs = await openPullRequests(gh);
441
+ // When the list of pull requests cannot be read, the ones listed before stay, and the
442
+ // master seed still loads.
443
+ listWarnings = [];
444
+ const kept = [...targets.values()].filter((t) => t.pr !== null);
445
+ const prs = await openPullRequests(gh).catch((err) => {
446
+ if (!masterRun && kept.length === 0) {
447
+ throw err;
448
+ }
449
+ console.error(`visual-review: pull requests not listed: ${err.message}`);
450
+ const warning = retryLater("could not list the pull requests", err.message);
451
+ listWarnings.push(warning);
452
+ for (const t of kept) {
453
+ next.set(t.id, { ...t, warnings: [warning] });
454
+ }
455
+ return [];
456
+ });
286
457
  // Best effort: the default branch and the pull requests' branches, so the badge below
287
- // sees what accept will see. A fork's branch is not on origin and fails its fetch, so
288
- // the default branch is fetched alone first.
289
- const fetch = (refs) => exec("git", ["fetch", "-q", "origin", ...refs], { cwd: repo }).catch(() => {});
290
- const fetched = fetch([`+refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`]).then(() =>
291
- fetch(prs.map((p) => `+refs/heads/${p.branch}:refs/remotes/origin/${p.branch}`)),
292
- );
458
+ // sees what accept will see. Skipped while a Finish runs, which fetches and pushes too.
459
+ const fetch = (refs) =>
460
+ exec("git", ["fetch", "-q", "origin", ...refs], {
461
+ cwd: repo,
462
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
463
+ });
464
+ const ref = (b) => `+refs/heads/${b}:refs/remotes/origin/${b}`;
465
+ const logFetch = (what) => (err) => {
466
+ console.error(`visual-review: git fetch of ${what} failed: ${err.message}`);
467
+ listWarnings.push(`git fetch of ${what} failed, so "merge master first" may be wrong: ${err.message}`);
468
+ };
469
+ const fetched = (async () => {
470
+ if (finishing) {
471
+ return;
472
+ }
473
+ await fetch([ref(defaultBranch)]).catch(logFetch(defaultBranch));
474
+ // One fetch for every branch; one missing on origin (a fork's, a deleted one) fails
475
+ // them all, so then one at a time.
476
+ if (prs.length > 0) {
477
+ await fetch(prs.map((p) => ref(p.branch))).catch(async () => {
478
+ for (const p of prs) {
479
+ await fetch([ref(p.branch)]).catch(logFetch(`${p.branch} (#${p.number})`));
480
+ }
481
+ });
482
+ }
483
+ })();
293
484
  // Every pull request at once: one after another took about 40 s for 18 of them.
294
485
  const built = await Promise.all(
295
486
  prs.map(async (pr) => {
296
- const run = await newestCiRun(gh, pr.headSha, config);
297
- const id = String(pr.number);
298
- return run
299
- ? build({ id, pr: pr.number, title: pr.title, url: pr.url, branch: pr.branch }, run)
300
- : null;
487
+ const info = {
488
+ id: String(pr.number),
489
+ pr: pr.number,
490
+ title: pr.title,
491
+ url: pr.url,
492
+ branch: pr.branch,
493
+ };
494
+ try {
495
+ const run = await newestCiRun(gh, pr.headSha, config);
496
+ return run
497
+ ? await build(info, run)
498
+ : blank(info, `waiting for CI on ${pr.headSha.slice(0, 10)}`);
499
+ } catch (err) {
500
+ return keptOr(info, err);
501
+ }
301
502
  }),
302
503
  );
303
504
  await fetched;
304
- for (const t of built) if (t) next.set(t.id, t);
505
+ for (const t of built) next.set(t.id, t);
305
506
  if (masterRun) {
306
- const run = await getRun(gh, masterRun);
507
+ const info = { id: "master", pr: null, title: defaultBranch, url: null, branch: null };
307
508
  next.set(
308
509
  "master",
309
- await build({ id: "master", pr: null, title: defaultBranch, url: null, branch: null }, run),
510
+ await getRun(gh, masterRun)
511
+ .then((run) => build(info, run))
512
+ .catch((err) => keptOr(info, err)),
310
513
  );
311
514
  }
312
515
  }
@@ -316,24 +519,56 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
316
519
  t.headSha = first?.headSha ?? null;
317
520
  const base = t.pr === null ? t.commit : t.headSha;
318
521
  t.earlier = await earlierAccepts(repo, t.headSha, t.pr, config.baselines);
522
+ // null: unknown, when the captured head was never fetched or git fails.
523
+ const known =
524
+ base !== null &&
525
+ (await exec("git", ["cat-file", "-e", `${base}^{commit}`], { cwd: repo }).then(
526
+ () => true,
527
+ () => false,
528
+ ));
319
529
  t.mergeMasterFirst = false;
320
530
  for (const p of t.projects) {
321
- if (
322
- !t.local &&
323
- p.results &&
324
- base &&
325
- (await behindMaster(repo, base, p.project, config).catch(() => true))
326
- ) {
327
- t.mergeMasterFirst = true;
531
+ if (!t.local && p.results && base) {
532
+ const behind = known ? await behindMaster(repo, base, p.project, config).catch(() => null) : null;
533
+ if (behind !== false && t.mergeMasterFirst !== true) {
534
+ t.mergeMasterFirst = behind;
535
+ }
328
536
  }
329
537
  }
330
538
  }
331
539
  signer = await signingIdentity(repo);
332
540
  targets = next;
541
+ prune();
542
+ }
543
+
544
+ // Deletes the downloads of runs no target shows any more (never state/). Not while a Finish
545
+ // reads its captures, nor after a refresh that could not load some target.
546
+ function prune() {
547
+ const runs = new Set([...targets.values()].map((t) => t.runId));
548
+ if (results || finishing || runs.has(null) || !existsSync(tmp)) {
549
+ return;
550
+ }
551
+ for (const d of readdirSync(tmp)) {
552
+ const run = /^(\d+)-\d+$/.exec(d)?.[1];
553
+ if (run && !runs.has(Number(run))) {
554
+ rmSync(join(tmp, d), { recursive: true, force: true });
555
+ }
556
+ }
333
557
  }
334
558
 
335
559
  const summary = (t) => {
336
- const decided = decisionsOf(t);
560
+ const warnings = [...(t.warnings ?? [])];
561
+ let decided = new Map();
562
+ try {
563
+ decided = decisionsOf(t);
564
+ } catch (err) {
565
+ // One unreadable state file never fails the whole list.
566
+ console.error(`visual-review: ${stateFile(t)}: ${err.message}`);
567
+ warnings.push(`could not read the saved decisions: ${err.message}`);
568
+ }
569
+ if (stateProblems.has(t.id)) {
570
+ warnings.push(stateProblems.get(t.id));
571
+ }
337
572
  return {
338
573
  id: t.id,
339
574
  pr: t.pr,
@@ -347,6 +582,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
347
582
  commit: t.commit,
348
583
  headSha: t.headSha,
349
584
  mergeMasterFirst: t.mergeMasterFirst,
585
+ warnings,
350
586
  signer,
351
587
  startCommand,
352
588
  projects: t.projects.map((p) => {
@@ -382,7 +618,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
382
618
  const LOCAL = "is a local preview: nothing is decided on it; only CI captures of a pushed commit are";
383
619
 
384
620
  async function targetOf(id) {
385
- if (!targets.has(id)) {
621
+ if (!targets.has(id) && (refreshing || Date.now() - refreshedAt > UNKNOWN_REFRESH)) {
386
622
  await refresh();
387
623
  }
388
624
  return targets.get(id);
@@ -391,21 +627,39 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
391
627
  async function projectOf(id, name) {
392
628
  const t = await targetOf(id);
393
629
  const p = t?.projects.find((x) => x.project === name);
394
- return p?.results ? { t, p } : {};
630
+ return { t, p: p?.results ? p : undefined };
395
631
  }
396
632
 
633
+ const gone = (id) => [
634
+ 404,
635
+ {
636
+ error: `${id === "master" ? defaultBranch : `#${id}`} is not listed any more (closed, or it could not be loaded): reload the list`,
637
+ },
638
+ ];
639
+
640
+ // Whether a write is about the capture the page shows: `hash` (the image), or `runId` and
641
+ // `runAttempt`. A page that sends neither is not checked.
642
+ const sameCapture = (t, body, item) =>
643
+ body.hash !== undefined
644
+ ? body.hash === imageHash(item)
645
+ : body.runId === undefined || (body.runId === t.runId && body.runAttempt === t.runAttempt);
646
+ const CHANGED = "the capture changed since the page loaded it (a new CI run or attempt): reload the page";
647
+
397
648
  const routes = {
398
649
  "GET /api/prs": async () => {
399
650
  await refresh();
400
- return [200, { targets: [...targets.values()].map(summary) }];
651
+ return [200, { targets: [...targets.values()].map(summary), warning: listWarnings.join("\n") || null }];
401
652
  },
402
653
  // One target's counts without refetching from GitHub, for Finish's confirmation.
403
654
  "GET /api/target": async ([id]) => {
404
655
  const t = await targetOf(id);
405
- return t ? [200, summary(t)] : [404, { error: "no such target" }];
656
+ return t ? [200, summary(t)] : gone(id);
406
657
  },
407
658
  "GET /api/pr": async ([id, name]) => {
408
659
  const { t, p } = await projectOf(id, name);
660
+ if (!t) {
661
+ return gone(id);
662
+ }
409
663
  if (!p) {
410
664
  return [404, { error: "no such capture" }];
411
665
  }
@@ -428,7 +682,10 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
428
682
  ];
429
683
  },
430
684
  "GET /api/img": async ([id, name, kind, file]) => {
431
- const { p } = await projectOf(id, name);
685
+ const { t, p } = await projectOf(id, name);
686
+ if (!t) {
687
+ return gone(id);
688
+ }
432
689
  const item = p?.results.items.find((i) => i.file === file);
433
690
  const hash = item && { capture: item.capture, baseline: item.baseline }[kind];
434
691
  if (!hash) {
@@ -436,14 +693,27 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
436
693
  }
437
694
  // A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
438
695
  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);
696
+ const path = own ? join(p.dir, file) : join(p.dir, "baselines", file);
697
+ const bytes = await readFile(path).catch(() => null);
440
698
  if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
441
- return [409, { error: `${file} does not match results.json` }];
699
+ // CI hashed the bytes it uploaded, so the copy on this disk is damaged: drop it,
700
+ // and the next reload downloads it again.
701
+ console.error(
702
+ `visual-review: ${path} does not match results.json: downloading it again on the next reload`,
703
+ );
704
+ rmSync(join(p.dir, "results.json"), { force: true });
705
+ return [
706
+ 409,
707
+ { error: `${file}: the downloaded copy is damaged; reload the page to download it again` },
708
+ ];
442
709
  }
443
710
  return [200, bytes, "image/png"];
444
711
  },
445
712
  "POST /api/decide": async (_, body) => {
446
713
  const { p, t } = await projectOf(String(body.id), body.project);
714
+ if (!t) {
715
+ return gone(String(body.id));
716
+ }
447
717
  const item = p?.results.items.find((i) => i.file === body.file);
448
718
  if (!item) {
449
719
  return [404, { error: "no such item" }];
@@ -456,8 +726,22 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
456
726
  return [403, { error: `${body.project} ${LOCAL}` }];
457
727
  }
458
728
  if (body.decision === null) {
459
- decisionsOf(t).delete(key);
460
- save(t);
729
+ update(t, (saved) => {
730
+ delete saved[key];
731
+ });
732
+ return [200, { ok: true }];
733
+ }
734
+ if (!sameCapture(t, body, item)) {
735
+ return [409, { error: `${body.file}: ${CHANGED}` }];
736
+ }
737
+ if (body.opened === true) {
738
+ // Opening an item Accept all decided marks it opened. It never decides anything,
739
+ // so a stale page cannot bring back a decision undone or finished meanwhile.
740
+ if (decisionsOf(t).get(key)?.bulk) {
741
+ update(t, (saved) => {
742
+ delete saved[key].bulk;
743
+ });
744
+ }
461
745
  return [200, { ok: true }];
462
746
  }
463
747
  if (body.decision !== "reject" && !acceptable(t, body.project)) {
@@ -480,15 +764,22 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
480
764
  const done = { accept: "accepted", reject: "rejected", exclude: "excluded" }[before.decision];
481
765
  return [409, { error: `${body.file} is already ${done}: Undo it first to change it` }];
482
766
  }
483
- decisionsOf(t).set(key, { decision: body.decision, reason });
484
- save(t);
767
+ update(t, (saved) => {
768
+ saved[key] = { decision: body.decision, reason, hash: imageHash(item) };
769
+ });
485
770
  return [200, { ok: true }];
486
771
  },
487
772
  "POST /api/accept-all": async (_, body) => {
488
773
  const { p, t } = await projectOf(String(body.id), body.project);
774
+ if (!t) {
775
+ return gone(String(body.id));
776
+ }
489
777
  if (!p) {
490
778
  return [404, { error: "no such capture" }];
491
779
  }
780
+ if (!sameCapture(t, { runId: body.runId, runAttempt: body.runAttempt }, null)) {
781
+ return [409, { error: CHANGED }];
782
+ }
492
783
  if (busy(t)) {
493
784
  return [409, { error: BUSY }];
494
785
  }
@@ -504,20 +795,21 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
504
795
  const inScope = (item) => typeof body.component !== "string" || componentOf(item.id) === body.component;
505
796
  const mine = decisionsOf(t);
506
797
  let accepted = 0;
507
- for (const item of p.results.items) {
508
- const key = `${body.project}/${item.file}`;
509
- if (inScope(item) && !mine.has(key) && !decisionProblem(item, "accept", null)) {
510
- mine.set(key, { decision: "accept", reason: null, bulk: true });
511
- accepted++;
798
+ update(t, (saved) => {
799
+ for (const item of p.results.items) {
800
+ const key = `${body.project}/${item.file}`;
801
+ if (inScope(item) && !mine.has(key) && !decisionProblem(item, "accept", null)) {
802
+ saved[key] = { decision: "accept", reason: null, bulk: true, hash: imageHash(item) };
803
+ accepted++;
804
+ }
512
805
  }
513
- }
514
- save(t);
806
+ });
515
807
  return [200, { accepted }];
516
808
  },
517
809
  "POST /api/finish": async (_, body) => {
518
810
  const t = await targetOf(String(body.id));
519
811
  if (!t) {
520
- return [404, { error: "no such target" }];
812
+ return gone(String(body.id));
521
813
  }
522
814
  if (t.local) {
523
815
  return [403, { error: "a local preview has no Finish" }];
@@ -542,18 +834,25 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
542
834
  id: (job?.id ?? 0) + 1,
543
835
  target: t.id,
544
836
  pr: t.pr,
837
+ branch: t.branch,
545
838
  running: true,
546
839
  step: "starting",
547
840
  result: null,
548
841
  error: null,
842
+ warnings: [],
549
843
  };
550
- runFinish(job, t, mine, {
844
+ persist(job);
845
+ console.log(
846
+ `visual-review: Finish of ${finishLabel(t)} started: ${list.length} decisions, ${undecided} undecided`,
847
+ );
848
+ runFinish(job, t, list, {
551
849
  repo,
552
850
  gh,
553
851
  target: { pr: t.pr, branch: t.branch },
554
852
  projects: captures,
555
853
  decisions: list,
556
854
  undecided,
855
+ unloaded: t.projects.filter((p) => !p.results).map((p) => p.project),
557
856
  config,
558
857
  });
559
858
  return [202, { job }];
@@ -562,47 +861,74 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
562
861
  };
563
862
 
564
863
  /**
565
- * Runs one Finish to its end, recording its steps and outcome on `j`.
864
+ * Runs one Finish to its end, recording its steps and outcome on `j` (and in the log). It
865
+ * never throws: nothing awaits it.
566
866
  * @param {object} j the job
567
867
  * @param {object} t the target
568
- * @param {Map<string, object>} mine the target's decisions
868
+ * @param {{ project: string, file: string, decision: string }[]} sent the decisions it applies
569
869
  * @param {object} input finish's input
570
870
  */
571
- async function runFinish(j, t, mine, input) {
572
- try {
573
- j.result = await finish({ ...input, progress: (step) => (j.step = step) });
574
- // Rejects stay, keyed by image hash, so an unchanged rejected capture on the next
575
- // CI run still reads as rejected rather than undecided.
576
- for (const [k, v] of mine) {
577
- if (v.decision === "reject") {
578
- v.posted = true;
579
- } else {
580
- mine.delete(k);
871
+ async function runFinish(j, t, sent, input) {
872
+ // Drops the accepts and exclusions Finish pushed; rejects stay, keyed by image hash, so an
873
+ // unchanged rejected capture on the next CI run still reads as rejected, not undecided.
874
+ const clear = (posted) =>
875
+ update(t, (saved) => {
876
+ for (const { project, file, decision } of sent) {
877
+ const k = `${project}/${file}`;
878
+ if (decision !== "reject") {
879
+ delete saved[k];
880
+ } else if (posted && saved[k]) {
881
+ saved[k].posted = true;
882
+ }
581
883
  }
884
+ });
885
+ try {
886
+ j.result = await finish({
887
+ ...input,
888
+ progress: (step) => {
889
+ j.step = step;
890
+ persist(j);
891
+ },
892
+ });
893
+ try {
894
+ clear(true);
895
+ } catch (err) {
896
+ // What was pushed and posted stands; the decisions it applied are still saved.
897
+ j.warnings.push(`pushed and posted, but the decisions could not be cleared: ${err.message}`);
898
+ console.error(`visual-review: Finish of ${finishLabel(t)}: ${j.warnings.at(-1)}`);
582
899
  }
583
- save(t);
900
+ const r = j.result;
901
+ const status = r.statusError ? `; status not posted: ${r.statusError}` : "";
902
+ console.log(
903
+ `visual-review: Finish of ${finishLabel(t)} done: commit ${r.commit ?? "none"}, ${r.rejects} rejects${status}`,
904
+ );
584
905
  } catch (err) {
585
- if (err instanceof AcceptError && err.committed) {
906
+ if (err instanceof AcceptError && err.committed && !err.pullRequestMissing) {
586
907
  // The accepts are on the branch; keep only the rejects, so Finish again only comments.
587
- for (const [k, v] of mine) {
588
- if (v.decision !== "reject") {
589
- mine.delete(k);
590
- }
908
+ try {
909
+ clear(false);
910
+ } catch (e) {
911
+ j.warnings.push(`the accepts pushed could not be cleared from the decisions: ${e.message}`);
912
+ console.error(`visual-review: Finish of ${finishLabel(t)}: ${j.warnings.at(-1)}`);
591
913
  }
592
- save(t);
593
914
  }
594
- j.error = err.message;
915
+ // Refused before anything ran, the message says it all; later, it names the step.
916
+ j.error = ["starting", "checking"].includes(j.step) ? err.message : `${j.step}: ${err.message}`;
917
+ console.error(`visual-review: Finish of ${finishLabel(t)} failed: ${j.error}`);
595
918
  } finally {
596
919
  j.running = false;
597
920
  j.step = null;
598
921
  finishing = false;
922
+ persist(j);
599
923
  }
600
924
  }
601
925
 
926
+ const finishLabel = (t) => (t.pr === null ? "the master seed" : `#${t.pr}`);
927
+
602
928
  const tokenOk = (given) => {
603
929
  const a = Buffer.from(String(given ?? ""));
604
930
  const b = Buffer.from(token);
605
- return a.length === b.length && timingSafeEqual(a, b);
931
+ return b.length > 0 && a.length === b.length && timingSafeEqual(a, b);
606
932
  };
607
933
 
608
934
  function send(res, status, body, type = "application/json") {
@@ -628,6 +954,11 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
628
954
  return body;
629
955
  }
630
956
 
957
+ if (warm) {
958
+ // Download the captures now, so they are on disk before the page first asks.
959
+ refresh().catch((err) => console.error(`visual-review: startup refresh failed: ${err.message}`));
960
+ }
961
+
631
962
  return async (req, res) => {
632
963
  try {
633
964
  const url = new URL(req.url, origin);
@@ -657,6 +988,9 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
657
988
  // A malformed request (bad JSON, bad escape, oversized body) is the client's; the rest,
658
989
  // gh and git failures included, is ours.
659
990
  const client = err instanceof SyntaxError || err instanceof URIError || err.message.startsWith("body ");
991
+ if (!client) {
992
+ console.error(`visual-review: ${req.method} ${req.url} failed: ${err.message}`);
993
+ }
660
994
  return send(res, client ? 400 : 500, { error: err.message });
661
995
  }
662
996
  };