@graphty/visual-review 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +548 -4
- package/capture/capture.mjs +669 -0
- package/package.json +70 -11
- package/templates/visual-review.yml +141 -0
- package/templates/visual-seed.yml +144 -0
- package/trusted/cli.mjs +334 -0
- package/trusted/gate.mjs +272 -0
- package/trusted/lib/accept.mjs +526 -0
- package/trusted/lib/compare.mjs +195 -0
- package/trusted/lib/config.mjs +168 -0
- package/trusted/lib/github.mjs +228 -0
- package/trusted/lib/init.mjs +196 -0
- package/trusted/lib/results.mjs +147 -0
- package/trusted/lib/serve.mjs +663 -0
- package/trusted/page/index.html +19 -0
- package/trusted/page/review.css +496 -0
- package/trusted/page/review.js +1762 -0
- package/trusted/vendor/pixelmatch.mjs +336 -0
|
@@ -0,0 +1,1762 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The review page. Plain JavaScript on purpose: this page is where the owner approves images, so
|
|
3
|
+
* it must stay small enough to read. Every text that came from a pull request (titles, reasons,
|
|
4
|
+
* console lines) is set with textContent, never parsed as HTML.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import pixelmatch from "/pixelmatch.mjs";
|
|
8
|
+
|
|
9
|
+
const token = new URLSearchParams(location.hash.slice(1)).get("token") ?? "";
|
|
10
|
+
const app = document.getElementById("app");
|
|
11
|
+
const crumbs = document.getElementById("crumbs");
|
|
12
|
+
const statusLine = document.getElementById("status");
|
|
13
|
+
|
|
14
|
+
const REVIEWABLE = ["changed", "new", "removed", "unstable", "failed"];
|
|
15
|
+
const ACCEPTABLE = ["changed", "new", "removed"];
|
|
16
|
+
// A story with no baseline that this pull request did not change: shown, never a decision here.
|
|
17
|
+
const UNSEEDED = "unseeded";
|
|
18
|
+
const NO_BASELINE = "no baseline yet";
|
|
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 };
|
|
22
|
+
const FLASH_MS = 333; // one image each third of a second: about 1.5 full cycles a second
|
|
23
|
+
// "fit" (the default) shows the whole of both images in their panes, at one scale, never above real
|
|
24
|
+
// size; 1 is real size: one CSS pixel per CSS pixel the story was drawn at, scrolling when larger.
|
|
25
|
+
const ZOOMS = ["fit", 1, 2, 4, 8];
|
|
26
|
+
const CRISP_FROM = 4; // from this zoom on, pixels are drawn as hard squares
|
|
27
|
+
const GROW = 10; // image pixels the spotlight and the changed boxes grow each changed pixel by
|
|
28
|
+
const SPOT_ALPHA = 190; // the spotlight's dimming, out of 255, as Chromatic's focus mask
|
|
29
|
+
const RE_REVIEW = "re-review: your earlier accept was replaced by master's baseline";
|
|
30
|
+
// The grid's decision filters, and how a decision reads on a tile.
|
|
31
|
+
const DECISIONS = { accept: "Accepted", reject: "Rejected", exclude: "Excluded" };
|
|
32
|
+
|
|
33
|
+
const state = {
|
|
34
|
+
targets: [],
|
|
35
|
+
target: null, // summary of the pull request (or master) being reviewed
|
|
36
|
+
project: null,
|
|
37
|
+
data: null, // GET /api/pr/:id/:project
|
|
38
|
+
filter: "undecided", // the grid opens on what still needs a decision
|
|
39
|
+
text: "", // the grid's text filter
|
|
40
|
+
index: 0, // the item shown, in `sequence`
|
|
41
|
+
// The files of this pass through the stories, frozen when a story is opened from the grid:
|
|
42
|
+
// deciding one never drops it, so Previous goes back to it and its Undo.
|
|
43
|
+
sequence: [],
|
|
44
|
+
lastFile: null, // the item last opened, highlighted when the grid comes back
|
|
45
|
+
view: "side", // side | flash | highlight | spotlight
|
|
46
|
+
zoom: "fit",
|
|
47
|
+
box: 0, // which changed box "next changed box" is on
|
|
48
|
+
held: null, // the view to return to when Space is released
|
|
49
|
+
pending: "reject", // what Enter in the reason box does
|
|
50
|
+
screen: "targets",
|
|
51
|
+
job: null, // the newest Finish, from GET /api/finish-status
|
|
52
|
+
armed: null, // the bulk Undo button pressed once: its second press undoes
|
|
53
|
+
};
|
|
54
|
+
const running = () => state.job?.running === true;
|
|
55
|
+
const VIEWS = ["side", "flash", "highlight", "spotlight"];
|
|
56
|
+
const FILTERS = ["undecided", "all", ...REVIEWABLE, UNSEEDED, ...Object.keys(DECISIONS)];
|
|
57
|
+
let routing = false; // true while the page follows the address (a link opened, Back, Forward)
|
|
58
|
+
const images = new Map();
|
|
59
|
+
const diffs = new Map();
|
|
60
|
+
let flashTimer = null;
|
|
61
|
+
let factor = 1; // CSS pixels per image pixel of the pictures on the stage
|
|
62
|
+
let shownBoxes = []; // the changed boxes of the item on the stage
|
|
63
|
+
let stageKey = null; // "file|zoom" of what is on the stage, to keep its scroll across view changes
|
|
64
|
+
// At fit, the stage refits when the window (or an iPad's orientation) changes its size.
|
|
65
|
+
const refit = new ResizeObserver(() => {
|
|
66
|
+
const stage = document.getElementById("stage");
|
|
67
|
+
if (state.screen === "story" && state.zoom === "fit" && stage?.querySelector(".sheet")) {
|
|
68
|
+
fit(stage);
|
|
69
|
+
showBox(stage, shownBoxes, false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------- helpers
|
|
74
|
+
|
|
75
|
+
function el(tag, attrs = {}, ...children) {
|
|
76
|
+
const node = document.createElement(tag);
|
|
77
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
78
|
+
if (key.startsWith("on")) {
|
|
79
|
+
node.addEventListener(key.slice(2), value);
|
|
80
|
+
} else if (value !== false && value !== null && value !== undefined) {
|
|
81
|
+
node.setAttribute(key, value === true ? "" : value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
node.append(...children.flat().filter((c) => c !== null && c !== undefined && c !== false));
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Like replaceChildren, but a null child (a section that does not apply) is left out.
|
|
89
|
+
function render(...children) {
|
|
90
|
+
app.replaceChildren(...children.filter((c) => c !== null && c !== undefined && c !== false));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Clearing the status line during a Finish shows the Finish's step instead, so a screen that
|
|
94
|
+
// opens while it runs (a reload, Visual review, a link) never blanks its progress.
|
|
95
|
+
function say(text, isError = false) {
|
|
96
|
+
statusLine.textContent = text === "" && running() ? finishing() : text;
|
|
97
|
+
statusLine.className = isError ? "error" : "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function api(path, body) {
|
|
101
|
+
const res = await fetch(path, {
|
|
102
|
+
method: body ? "POST" : "GET",
|
|
103
|
+
headers: { "x-review-token": token, ...(body ? { "content-type": "application/json" } : {}) },
|
|
104
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
105
|
+
});
|
|
106
|
+
const json = await res.json().catch(() => ({}));
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw new Error(json.error ?? `${res.status} ${res.statusText}`);
|
|
109
|
+
}
|
|
110
|
+
return json;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Loads an image through the API (it needs the token header) and returns an object URL.
|
|
114
|
+
function image(kind, file) {
|
|
115
|
+
const key = `${state.target.id}/${state.project}/${kind}/${file}`;
|
|
116
|
+
if (!images.has(key)) {
|
|
117
|
+
const path = ["img", state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
|
|
118
|
+
images.set(
|
|
119
|
+
key,
|
|
120
|
+
fetch(`/api/${path}`, { headers: { "x-review-token": token } }).then(async (res) => {
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
throw new Error(`${file}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
|
|
123
|
+
}
|
|
124
|
+
return URL.createObjectURL(await res.blob());
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return images.get(key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function loaded(url) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const img = new Image();
|
|
134
|
+
img.onload = () => resolve(img);
|
|
135
|
+
img.onerror = reject;
|
|
136
|
+
img.src = url;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const componentOf = (id) => id.split("--")[0];
|
|
141
|
+
const itemName = (item) => (item.mode ? `${item.id} (${item.mode})` : item.id);
|
|
142
|
+
const short = (sha) => (sha ? sha.slice(0, 10) : "none");
|
|
143
|
+
const decisionOf = (item) => state.data?.decisions[item.file] ?? null;
|
|
144
|
+
const isLocal = () => state.target?.local === true;
|
|
145
|
+
// Device pixels per CSS pixel of this capture: 2 today, 1 for captures made before it was recorded.
|
|
146
|
+
const scale = () => state.data?.results.scale ?? 1;
|
|
147
|
+
|
|
148
|
+
// Errors first, then components by their most urgent item, stories the same way within one, and
|
|
149
|
+
// the modes of a story together. Tile numbers and the story's "N of M" both follow this order.
|
|
150
|
+
function ordered(items) {
|
|
151
|
+
const best = new Map();
|
|
152
|
+
const keep = (key, item) => best.set(key, Math.min(best.get(key) ?? 9, RANK[item.status] ?? 9));
|
|
153
|
+
for (const item of items) {
|
|
154
|
+
keep(`c:${componentOf(item.id)}`, item);
|
|
155
|
+
keep(`s:${item.id}`, item);
|
|
156
|
+
}
|
|
157
|
+
const key = (i) => [
|
|
158
|
+
i.status === "failed" ? 0 : 1,
|
|
159
|
+
best.get(`c:${componentOf(i.id)}`),
|
|
160
|
+
componentOf(i.id),
|
|
161
|
+
best.get(`s:${i.id}`),
|
|
162
|
+
i.id,
|
|
163
|
+
i.mode ?? "",
|
|
164
|
+
];
|
|
165
|
+
return [...items].sort((a, b) => {
|
|
166
|
+
const [ka, kb] = [key(a), key(b)];
|
|
167
|
+
for (let n = 0; n < ka.length; n++) {
|
|
168
|
+
if (ka[n] !== kb[n]) {
|
|
169
|
+
return ka[n] < kb[n] ? -1 : 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function visibleItems() {
|
|
177
|
+
const text = state.text.trim().toLowerCase();
|
|
178
|
+
let items;
|
|
179
|
+
if (state.filter === UNSEEDED) {
|
|
180
|
+
items = state.data.items.filter((i) => i.status === UNSEEDED);
|
|
181
|
+
} else {
|
|
182
|
+
items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
|
|
183
|
+
if (state.filter === "undecided") {
|
|
184
|
+
items = items.filter((i) => !decisionOf(i));
|
|
185
|
+
} else if (Object.hasOwn(DECISIONS, state.filter)) {
|
|
186
|
+
items = items.filter((i) => decisionOf(i)?.decision === state.filter);
|
|
187
|
+
} else if (state.filter !== "all") {
|
|
188
|
+
items = items.filter((i) => i.status === state.filter);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return ordered(text ? items.filter((i) => i.file.includes(text)) : items);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The items of the frozen pass, in its order; an item gone from a reloaded project is left out.
|
|
195
|
+
function passItems() {
|
|
196
|
+
const byFile = new Map(state.data.items.map((i) => [i.file, i]));
|
|
197
|
+
return state.sequence.map((f) => byFile.get(f)).filter(Boolean);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const current = () => passItems()[state.index];
|
|
201
|
+
|
|
202
|
+
function progress() {
|
|
203
|
+
const items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
|
|
204
|
+
return `${items.filter(decisionOf).length} / ${items.length} reviewed`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function setCrumbs(...parts) {
|
|
208
|
+
crumbs.replaceChildren(...parts.flatMap((p, i) => (i === 0 ? [p] : [" / ", p])));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function stopFlash() {
|
|
212
|
+
clearInterval(flashTimer);
|
|
213
|
+
flashTimer = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------- screen: targets
|
|
217
|
+
|
|
218
|
+
async function loadTargets() {
|
|
219
|
+
say("Loading pull requests and captures...");
|
|
220
|
+
try {
|
|
221
|
+
const [prs, status] = await Promise.all([api("/api/prs"), api("/api/finish-status")]);
|
|
222
|
+
state.targets = prs.targets;
|
|
223
|
+
state.job = status.job;
|
|
224
|
+
say("");
|
|
225
|
+
if (running()) {
|
|
226
|
+
// A reload during a Finish, on any screen: follow the running one, never offer a second.
|
|
227
|
+
watchFinish();
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
say(err.message, true);
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function showTargets(notice = "") {
|
|
237
|
+
stopFlash();
|
|
238
|
+
state.screen = "targets";
|
|
239
|
+
setCrumbs();
|
|
240
|
+
if (!(await loadTargets())) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
remember();
|
|
244
|
+
say(notice);
|
|
245
|
+
if (state.targets.length === 0) {
|
|
246
|
+
render(finishOutcome(), el("p", {}, "No open pull request has a CI run, and no master run was given."));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const finishable = state.targets.find((t) => !t.local);
|
|
250
|
+
render(finishOutcome(), finishable ? signerBlock(finishable) : null, ...state.targets.map(targetCard));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The key Finish signs with comes from the server's environment, which is an agent's when an
|
|
254
|
+
// agent started the server.
|
|
255
|
+
function signerLine(s) {
|
|
256
|
+
if (!s) {
|
|
257
|
+
return "Finish's signing key is unknown.";
|
|
258
|
+
}
|
|
259
|
+
if (!s.signs) {
|
|
260
|
+
return "Finish commits are NOT signed (commit.gpgsign is off).";
|
|
261
|
+
}
|
|
262
|
+
const key = s.key ?? "git's default key for the committer's email";
|
|
263
|
+
const from = s.keyFrom ? `, set in ${s.keyFrom}` : "";
|
|
264
|
+
const who = s.author ? ` as ${s.author}` : "";
|
|
265
|
+
const env = s.fromEnv
|
|
266
|
+
? " This comes from the environment of whoever started this server (for example an agent through" +
|
|
267
|
+
" servherd), not from your own git config."
|
|
268
|
+
: "";
|
|
269
|
+
return `Finish commits are signed with ${s.format} key ${key}${from}${who}.${env}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function signerBlock(t) {
|
|
273
|
+
return el(
|
|
274
|
+
"section",
|
|
275
|
+
{ class: "card signer" },
|
|
276
|
+
el("p", {}, signerLine(t.signer)),
|
|
277
|
+
t.startCommand
|
|
278
|
+
? el(
|
|
279
|
+
"p",
|
|
280
|
+
{},
|
|
281
|
+
"To sign as yourself, stop this server and start it from your own shell, in your own terminal:",
|
|
282
|
+
)
|
|
283
|
+
: null,
|
|
284
|
+
t.startCommand ? el("pre", { class: "console" }, t.startCommand) : null,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function targetCard(t) {
|
|
289
|
+
const decided = t.projects.reduce((n, p) => n + p.decided, 0);
|
|
290
|
+
const heading = t.local ? "Local preview " : t.pr === null ? "master (seed) " : `#${t.pr} `;
|
|
291
|
+
return el(
|
|
292
|
+
"section",
|
|
293
|
+
{ class: "card" },
|
|
294
|
+
el(
|
|
295
|
+
"h2",
|
|
296
|
+
{},
|
|
297
|
+
heading,
|
|
298
|
+
t.url
|
|
299
|
+
? el("a", { href: t.url, target: "_blank", rel: "noreferrer" }, t.title)
|
|
300
|
+
: t.pr === null
|
|
301
|
+
? null
|
|
302
|
+
: t.title,
|
|
303
|
+
),
|
|
304
|
+
t.local
|
|
305
|
+
? el("p", { class: "meta" }, t.title)
|
|
306
|
+
: el(
|
|
307
|
+
"p",
|
|
308
|
+
{ class: "meta" },
|
|
309
|
+
`CI run ${t.runId ?? "none"}, attempt ${t.runAttempt ?? "-"}; captured ${short(t.pr === null ? t.commit : t.headSha)}`,
|
|
310
|
+
t.branch ? ` on ${t.branch}` : "",
|
|
311
|
+
" ",
|
|
312
|
+
t.runUrl ? el("a", { href: t.runUrl, target: "_blank", rel: "noreferrer" }, "run") : null,
|
|
313
|
+
),
|
|
314
|
+
el(
|
|
315
|
+
"p",
|
|
316
|
+
{ class: "badges" },
|
|
317
|
+
t.mergeMasterFirst ? el("span", { class: "badge warn" }, "merge master first") : null,
|
|
318
|
+
t.local
|
|
319
|
+
? el(
|
|
320
|
+
"span",
|
|
321
|
+
{ class: "badge warn" },
|
|
322
|
+
"local preview: look only; only a CI capture of a pushed commit can be decided",
|
|
323
|
+
)
|
|
324
|
+
: null,
|
|
325
|
+
),
|
|
326
|
+
el(
|
|
327
|
+
"table",
|
|
328
|
+
{},
|
|
329
|
+
el("tr", {}, el("th", {}, "Project"), el("th", {}, "Found"), el("th", {}, "Reviewed"), el("th", {})),
|
|
330
|
+
t.projects.map((p) =>
|
|
331
|
+
el(
|
|
332
|
+
"tr",
|
|
333
|
+
{},
|
|
334
|
+
el(
|
|
335
|
+
"td",
|
|
336
|
+
{},
|
|
337
|
+
p.project,
|
|
338
|
+
p.acceptable || t.local ? null : el("span", { class: "badge" }, "not seeded from master"),
|
|
339
|
+
),
|
|
340
|
+
el(
|
|
341
|
+
"td",
|
|
342
|
+
{},
|
|
343
|
+
p.problem ? el("span", { class: "badge warn" }, p.problem) : null,
|
|
344
|
+
p.problem && p.logUrl
|
|
345
|
+
? el("a", { href: p.logUrl, target: "_blank", rel: "noreferrer" }, " job log")
|
|
346
|
+
: null,
|
|
347
|
+
" ",
|
|
348
|
+
Object.entries(p.counts)
|
|
349
|
+
.map(([s, n]) => `${n} ${statusLabel(s)}`)
|
|
350
|
+
.join(", "),
|
|
351
|
+
),
|
|
352
|
+
el("td", {}, t.local ? "-" : `${p.decided} / ${p.reviewable}`),
|
|
353
|
+
el(
|
|
354
|
+
"td",
|
|
355
|
+
{},
|
|
356
|
+
p.reviewable > 0 || p.counts[UNSEEDED]
|
|
357
|
+
? el("button", { type: "button", onclick: () => openProject(t, p.project) }, "Review")
|
|
358
|
+
: null,
|
|
359
|
+
),
|
|
360
|
+
),
|
|
361
|
+
),
|
|
362
|
+
),
|
|
363
|
+
t.local
|
|
364
|
+
? null
|
|
365
|
+
: running() && state.job.target === t.id
|
|
366
|
+
? el("p", { class: "finish-running" }, `Finish is running: ${state.job.step}...`)
|
|
367
|
+
: el(
|
|
368
|
+
"p",
|
|
369
|
+
{},
|
|
370
|
+
el(
|
|
371
|
+
"button",
|
|
372
|
+
{
|
|
373
|
+
type: "button",
|
|
374
|
+
class: "primary",
|
|
375
|
+
disabled: decided === 0 || running(),
|
|
376
|
+
onclick: () => finishTarget(t),
|
|
377
|
+
},
|
|
378
|
+
`Finish ${t.pr === null ? "seed" : `#${t.pr}`} (${decided} decisions)`,
|
|
379
|
+
),
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------- screen: project grid
|
|
385
|
+
|
|
386
|
+
async function openProject(target, project) {
|
|
387
|
+
state.target = target;
|
|
388
|
+
state.project = project;
|
|
389
|
+
state.filter = "undecided";
|
|
390
|
+
state.text = "";
|
|
391
|
+
state.lastFile = null;
|
|
392
|
+
state.sequence = [];
|
|
393
|
+
say("Loading...");
|
|
394
|
+
try {
|
|
395
|
+
await reload();
|
|
396
|
+
say("");
|
|
397
|
+
} catch (err) {
|
|
398
|
+
say(err.message, true);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Nothing left to decide (or a local preview): show everything instead of an empty grid.
|
|
402
|
+
if (visibleItems().length === 0) {
|
|
403
|
+
state.filter = "all";
|
|
404
|
+
}
|
|
405
|
+
showGrid();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function reload() {
|
|
409
|
+
state.data = await api(`/api/pr/${encodeURIComponent(state.target.id)}/${encodeURIComponent(state.project)}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function targetLabel() {
|
|
413
|
+
return state.target.local ? "local preview" : state.target.pr === null ? "master" : `#${state.target.pr}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const thumbs = new IntersectionObserver((entries) => {
|
|
417
|
+
for (const e of entries) {
|
|
418
|
+
if (e.isIntersecting) {
|
|
419
|
+
thumbs.unobserve(e.target);
|
|
420
|
+
const { kind, file } = e.target.dataset;
|
|
421
|
+
image(kind, file).then(
|
|
422
|
+
(url) => (e.target.src = url),
|
|
423
|
+
(err) => (e.target.alt = err.message),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Opens item `index` of the grid, and freezes what the grid shows as the pass Next and Previous
|
|
430
|
+
// walk through.
|
|
431
|
+
function openItem(index) {
|
|
432
|
+
say("");
|
|
433
|
+
state.sequence = visibleItems().map((i) => i.file);
|
|
434
|
+
state.index = index;
|
|
435
|
+
state.box = 0;
|
|
436
|
+
showStory();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function tile(item, number) {
|
|
440
|
+
const kind = item.capture ? "capture" : item.baseline ? "baseline" : null;
|
|
441
|
+
const img = kind
|
|
442
|
+
? el("img", { "data-kind": kind, "data-file": item.file, alt: itemName(item) })
|
|
443
|
+
: el("div", { class: "noimage" }, item.status);
|
|
444
|
+
if (kind) {
|
|
445
|
+
thumbs.observe(img);
|
|
446
|
+
}
|
|
447
|
+
const d = decisionOf(item);
|
|
448
|
+
return el(
|
|
449
|
+
"div",
|
|
450
|
+
{ class: "tile-box" },
|
|
451
|
+
el(
|
|
452
|
+
"button",
|
|
453
|
+
{
|
|
454
|
+
type: "button",
|
|
455
|
+
class: `tile ${d ? `decided ${d.decision}` : ""} ${item.file === state.lastFile ? "current" : ""}`,
|
|
456
|
+
"data-file": item.file,
|
|
457
|
+
onclick: () => openItem(number - 1),
|
|
458
|
+
},
|
|
459
|
+
img,
|
|
460
|
+
el("span", { class: "name" }, el("span", { class: "number" }, `${number}`), " ", item.mode ?? ""),
|
|
461
|
+
el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
|
|
462
|
+
item.reReview ? el("span", { class: "badge warn", title: RE_REVIEW }, "re-review") : null,
|
|
463
|
+
),
|
|
464
|
+
decisionLine(item),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// An item's decision, with its reason, and an Undo that clears it without opening the story. A
|
|
469
|
+
// reject an earlier Finish already posted stays: it is shown, never undone from the grid.
|
|
470
|
+
function decisionLine(item) {
|
|
471
|
+
const d = decisionOf(item);
|
|
472
|
+
if (!d) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
const text = `${DECISIONS[d.decision]}${d.bulk ? " (not opened)" : ""}${d.reason ? `: ${d.reason}` : ""}`;
|
|
476
|
+
return el(
|
|
477
|
+
"div",
|
|
478
|
+
{ class: `decision ${d.decision}`, "data-file": item.file },
|
|
479
|
+
el("span", { class: "what", title: text }, text),
|
|
480
|
+
d.posted
|
|
481
|
+
? el("span", { class: "meta" }, "Posted by Finish: stays")
|
|
482
|
+
: el(
|
|
483
|
+
"button",
|
|
484
|
+
{
|
|
485
|
+
type: "button",
|
|
486
|
+
title: `Undo the ${d.decision} of ${itemName(item)}`,
|
|
487
|
+
onclick: () => undo([item.file], itemName(item), true),
|
|
488
|
+
},
|
|
489
|
+
"Undo",
|
|
490
|
+
),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// A failed capture: its reason, and the console and stack output, in the errors list.
|
|
495
|
+
function errorRow(item, number) {
|
|
496
|
+
return el(
|
|
497
|
+
"li",
|
|
498
|
+
{ class: item.file === state.lastFile ? "current" : null, "data-file": item.file },
|
|
499
|
+
el(
|
|
500
|
+
"button",
|
|
501
|
+
{ type: "button", class: "link", onclick: () => openItem(number - 1) },
|
|
502
|
+
el("span", { class: "number" }, `${number}`),
|
|
503
|
+
` ${itemName(item)}`,
|
|
504
|
+
),
|
|
505
|
+
decisionLine(item),
|
|
506
|
+
el("div", { class: "reason" }, item.reason ?? "no reason recorded"),
|
|
507
|
+
item.console.length > 0
|
|
508
|
+
? el(
|
|
509
|
+
"details",
|
|
510
|
+
{},
|
|
511
|
+
el("summary", {}, `console and stack (${item.console.length} lines)`),
|
|
512
|
+
el("pre", { class: "console" }, item.console.join("\n")),
|
|
513
|
+
)
|
|
514
|
+
: null,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// How many items of `component` Accept would take without opening them.
|
|
519
|
+
const undecidedIn = (component) =>
|
|
520
|
+
state.data.items.filter(
|
|
521
|
+
(i) => ACCEPTABLE.includes(i.status) && !decisionOf(i) && (!component || componentOf(i.id) === component),
|
|
522
|
+
).length;
|
|
523
|
+
|
|
524
|
+
// The files whose decisions a bulk Undo in `component` (or the whole project) clears: every
|
|
525
|
+
// decision not yet posted by Finish.
|
|
526
|
+
const undoableIn = (component) =>
|
|
527
|
+
state.data.items
|
|
528
|
+
.filter((i) => decisionOf(i) && !decisionOf(i).posted && (!component || componentOf(i.id) === component))
|
|
529
|
+
.map((i) => i.file);
|
|
530
|
+
|
|
531
|
+
// A bulk Undo asks by a second press: the first arms it, and any other redraw of the grid disarms it.
|
|
532
|
+
function undoButton(component, label) {
|
|
533
|
+
const files = undoableIn(component);
|
|
534
|
+
if (isLocal() || files.length === 0) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
const key = component ?? "";
|
|
538
|
+
const where = component ? `the component ${component}` : state.project;
|
|
539
|
+
const armed = state.armed === key;
|
|
540
|
+
return el(
|
|
541
|
+
"button",
|
|
542
|
+
{
|
|
543
|
+
type: "button",
|
|
544
|
+
class: `undo-all ${armed ? "reject" : ""}`,
|
|
545
|
+
"data-armed": String(armed),
|
|
546
|
+
onclick: () => {
|
|
547
|
+
if (armed) {
|
|
548
|
+
undo(files, where);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
state.armed = key;
|
|
552
|
+
showGrid();
|
|
553
|
+
say(`Press Confirm to undo the decisions of ${where}, or Escape to cancel.`);
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
armed ? `Confirm: undo ${files.length} ${files.length === 1 ? "decision" : "decisions"}` : label(files.length),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Clears decisions one by one through the same request as the story screen's U.
|
|
561
|
+
// ponytail: one request per item; a bulk endpoint if a project ever holds thousands of decisions.
|
|
562
|
+
async function undo(files, where, one = false) {
|
|
563
|
+
state.armed = null;
|
|
564
|
+
let done = 0;
|
|
565
|
+
try {
|
|
566
|
+
for (const file of files) {
|
|
567
|
+
await api("/api/decide", {
|
|
568
|
+
id: state.target.id,
|
|
569
|
+
project: state.project,
|
|
570
|
+
file,
|
|
571
|
+
decision: null,
|
|
572
|
+
reason: null,
|
|
573
|
+
});
|
|
574
|
+
done++;
|
|
575
|
+
}
|
|
576
|
+
} catch (err) {
|
|
577
|
+
say(`Undid ${done} of ${files.length}, then: ${err.message}`, true);
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
await reload();
|
|
581
|
+
} catch (err) {
|
|
582
|
+
say(err.message, true);
|
|
583
|
+
}
|
|
584
|
+
if (done === files.length) {
|
|
585
|
+
say(
|
|
586
|
+
one
|
|
587
|
+
? `${where}: undone, undecided again`
|
|
588
|
+
: `Undid ${done} ${done === 1 ? "decision" : "decisions"} of ${where}.`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
showGrid();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function showGrid() {
|
|
595
|
+
stopFlash();
|
|
596
|
+
state.screen = "grid";
|
|
597
|
+
setCrumbs(el("span", {}, targetLabel()), el("span", {}, state.project));
|
|
598
|
+
const items = visibleItems();
|
|
599
|
+
const counts = {};
|
|
600
|
+
for (const i of state.data.items) {
|
|
601
|
+
counts[i.status] = (counts[i.status] ?? 0) + 1;
|
|
602
|
+
}
|
|
603
|
+
const filterButton = (f, label) =>
|
|
604
|
+
el(
|
|
605
|
+
"button",
|
|
606
|
+
{
|
|
607
|
+
type: "button",
|
|
608
|
+
"aria-pressed": String(state.filter === f),
|
|
609
|
+
onclick: () => {
|
|
610
|
+
state.filter = f;
|
|
611
|
+
showGrid();
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
label,
|
|
615
|
+
);
|
|
616
|
+
const errors = [];
|
|
617
|
+
const groups = new Map(); // component -> story id -> [[item, number]]
|
|
618
|
+
items.forEach((item, i) => {
|
|
619
|
+
if (item.status === "failed") {
|
|
620
|
+
errors.push(errorRow(item, i + 1));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const c = componentOf(item.id);
|
|
624
|
+
if (!groups.has(c)) {
|
|
625
|
+
groups.set(c, new Map());
|
|
626
|
+
}
|
|
627
|
+
const stories = groups.get(c);
|
|
628
|
+
if (!stories.has(item.id)) {
|
|
629
|
+
stories.set(item.id, []);
|
|
630
|
+
}
|
|
631
|
+
stories.get(item.id).push(tile(item, i + 1));
|
|
632
|
+
});
|
|
633
|
+
const acceptable = state.data.acceptable && !isLocal();
|
|
634
|
+
const sections = [...groups].map(([component, stories]) => {
|
|
635
|
+
const n = undecidedIn(component);
|
|
636
|
+
return el(
|
|
637
|
+
"section",
|
|
638
|
+
{ class: "component", "data-component": component },
|
|
639
|
+
el(
|
|
640
|
+
"h3",
|
|
641
|
+
{},
|
|
642
|
+
component,
|
|
643
|
+
acceptable && n > 0
|
|
644
|
+
? el(
|
|
645
|
+
"button",
|
|
646
|
+
{ type: "button", class: "accept", onclick: () => acceptAll(component) },
|
|
647
|
+
`Accept ${n} undecided`,
|
|
648
|
+
)
|
|
649
|
+
: null,
|
|
650
|
+
undoButton(component, (k) => `Undo ${k} ${k === 1 ? "decision" : "decisions"}`),
|
|
651
|
+
),
|
|
652
|
+
el(
|
|
653
|
+
"div",
|
|
654
|
+
{ class: "stories" },
|
|
655
|
+
[...stories].map(([id, tiles]) =>
|
|
656
|
+
el(
|
|
657
|
+
"div",
|
|
658
|
+
{ class: "story" },
|
|
659
|
+
el("div", { class: "story-name" }, id.slice(component.length + 2) || id),
|
|
660
|
+
el("div", { class: "modes" }, tiles),
|
|
661
|
+
),
|
|
662
|
+
),
|
|
663
|
+
),
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
const filterBox = el("input", {
|
|
667
|
+
id: "filter",
|
|
668
|
+
type: "search",
|
|
669
|
+
placeholder: "Filter by story id",
|
|
670
|
+
value: state.text,
|
|
671
|
+
oninput: (e) => {
|
|
672
|
+
state.text = e.target.value;
|
|
673
|
+
const at = e.target.selectionStart;
|
|
674
|
+
showGrid();
|
|
675
|
+
const box = document.getElementById("filter");
|
|
676
|
+
box.focus();
|
|
677
|
+
box.setSelectionRange(at, at);
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
const goto = el("input", {
|
|
681
|
+
id: "goto",
|
|
682
|
+
type: "text",
|
|
683
|
+
placeholder: "Go to: number or story id",
|
|
684
|
+
onkeydown: (e) => {
|
|
685
|
+
if (e.key === "Enter") {
|
|
686
|
+
goTo(e.target.value);
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
render(
|
|
691
|
+
el(
|
|
692
|
+
"div",
|
|
693
|
+
{ class: "toolbar" },
|
|
694
|
+
el("strong", { id: "progress" }, progress()),
|
|
695
|
+
filterButton(
|
|
696
|
+
"undecided",
|
|
697
|
+
`Needs a decision (${state.data.items.filter((i) => REVIEWABLE.includes(i.status) && !decisionOf(i)).length})`,
|
|
698
|
+
),
|
|
699
|
+
filterButton("all", `All (${state.data.items.filter((i) => REVIEWABLE.includes(i.status)).length})`),
|
|
700
|
+
["changed", "new", "unstable", "removed", "failed"]
|
|
701
|
+
.filter((s) => counts[s])
|
|
702
|
+
.map((s) => filterButton(s, `${s} (${counts[s]})`)),
|
|
703
|
+
counts[UNSEEDED] ? filterButton(UNSEEDED, `${NO_BASELINE} (${counts[UNSEEDED]})`) : null,
|
|
704
|
+
isLocal()
|
|
705
|
+
? null
|
|
706
|
+
: Object.entries(DECISIONS).map(([k, label]) =>
|
|
707
|
+
filterButton(
|
|
708
|
+
k,
|
|
709
|
+
`${label} (${state.data.items.filter((i) => REVIEWABLE.includes(i.status) && decisionOf(i)?.decision === k).length})`,
|
|
710
|
+
),
|
|
711
|
+
),
|
|
712
|
+
el("span", { class: "spacer" }),
|
|
713
|
+
filterBox,
|
|
714
|
+
goto,
|
|
715
|
+
acceptable
|
|
716
|
+
? el(
|
|
717
|
+
"button",
|
|
718
|
+
{ type: "button", class: "accept", onclick: () => acceptAll(null), title: "Shift+A" },
|
|
719
|
+
"Accept all",
|
|
720
|
+
)
|
|
721
|
+
: null,
|
|
722
|
+
undoButton(null, () => "Undo all decisions"),
|
|
723
|
+
finishButton(),
|
|
724
|
+
),
|
|
725
|
+
isLocal()
|
|
726
|
+
? el(
|
|
727
|
+
"p",
|
|
728
|
+
{ class: "badge warn" },
|
|
729
|
+
"Local preview: look only. Nothing can be decided here; only a CI capture of a pushed commit can.",
|
|
730
|
+
)
|
|
731
|
+
: null,
|
|
732
|
+
items.length > 0
|
|
733
|
+
? el(
|
|
734
|
+
"p",
|
|
735
|
+
{ id: "shown", class: "meta" },
|
|
736
|
+
`Showing ${items.length}: ${errors.length} ${errors.length === 1 ? "error" : "errors"} ` +
|
|
737
|
+
`(listed first, never accepted) and ${items.length - errors.length} ` +
|
|
738
|
+
`${items.length - errors.length === 1 ? "image" : "images"} to compare.`,
|
|
739
|
+
)
|
|
740
|
+
: null,
|
|
741
|
+
errors.length > 0
|
|
742
|
+
? el(
|
|
743
|
+
"section",
|
|
744
|
+
{ class: "errors" },
|
|
745
|
+
el(
|
|
746
|
+
"h3",
|
|
747
|
+
{},
|
|
748
|
+
`Errors: ${errors.length} ${errors.length === 1 ? "story" : "stories"} failed to capture`,
|
|
749
|
+
),
|
|
750
|
+
el(
|
|
751
|
+
"p",
|
|
752
|
+
{ class: "meta" },
|
|
753
|
+
"An error is never accepted. Fix the story, re-run the visual job for a one-off timeout, " +
|
|
754
|
+
"or exclude the story with a reason.",
|
|
755
|
+
),
|
|
756
|
+
el("ol", { class: "error-list" }, errors),
|
|
757
|
+
)
|
|
758
|
+
: null,
|
|
759
|
+
...(items.length === 0 ? [el("p", {}, "Nothing here.")] : sections),
|
|
760
|
+
);
|
|
761
|
+
// An armed bulk Undo lasts until the next redraw: this render showed it, the next one does not.
|
|
762
|
+
state.armed = null;
|
|
763
|
+
remember();
|
|
764
|
+
// Back from a story: show where it is in the grid.
|
|
765
|
+
const current = app.querySelector(".current");
|
|
766
|
+
current?.scrollIntoView({ block: "center" });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// A number opens that item; anything else opens the first item whose id contains it.
|
|
770
|
+
function goTo(value) {
|
|
771
|
+
const v = value.trim().toLowerCase();
|
|
772
|
+
if (v === "") {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
let items = visibleItems();
|
|
776
|
+
let at = /^\d+$/.test(v) ? Number(v) - 1 : items.findIndex((i) => i.file.includes(v));
|
|
777
|
+
if (at < 0 || at >= items.length) {
|
|
778
|
+
if (/^\d+$/.test(v)) {
|
|
779
|
+
say(`There is no item ${v}: the list has ${items.length}.`, true);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
// Not in this filter: look in everything reviewable.
|
|
783
|
+
state.filter = "all";
|
|
784
|
+
state.text = "";
|
|
785
|
+
items = visibleItems();
|
|
786
|
+
at = items.findIndex((i) => i.file.includes(v));
|
|
787
|
+
if (at < 0) {
|
|
788
|
+
say(`No story id contains "${value.trim()}".`, true);
|
|
789
|
+
showGrid();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
openItem(at);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ---------------------------------------------------------------- screen: story
|
|
797
|
+
|
|
798
|
+
// Why Flash, Highlight and Spotlight are off: they need two images.
|
|
799
|
+
function singleImageNote(item) {
|
|
800
|
+
if (item.baseline && item.capture) {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
if (item.status === "removed") {
|
|
804
|
+
return "Only one image: this story was removed, so there is only its baseline.";
|
|
805
|
+
}
|
|
806
|
+
if (item.status === "failed") {
|
|
807
|
+
return "No image: the capture failed.";
|
|
808
|
+
}
|
|
809
|
+
return "New story, no baseline: there is only the new image.";
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function showStory() {
|
|
813
|
+
stopFlash();
|
|
814
|
+
state.screen = "story";
|
|
815
|
+
const items = passItems();
|
|
816
|
+
if (items.length === 0) {
|
|
817
|
+
showGrid();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
state.index = Math.max(0, Math.min(state.index, items.length - 1));
|
|
821
|
+
const item = items[state.index];
|
|
822
|
+
state.lastFile = item.file;
|
|
823
|
+
const d = decisionOf(item);
|
|
824
|
+
if (d?.bulk && !isLocal()) {
|
|
825
|
+
// Opening an item Accept all decided counts it as opened.
|
|
826
|
+
delete d.bulk;
|
|
827
|
+
api("/api/decide", { id: state.target.id, project: state.project, file: item.file, ...d }).catch((err) =>
|
|
828
|
+
say(err.message, true),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
const note = singleImageNote(item);
|
|
832
|
+
const view = note ? "side" : state.view;
|
|
833
|
+
const onlyExclude = item.status === "unstable" || item.status === "failed";
|
|
834
|
+
const unseeded = item.status === UNSEEDED;
|
|
835
|
+
const decidable = !isLocal() && !unseeded;
|
|
836
|
+
setCrumbs(
|
|
837
|
+
el("button", { type: "button", class: "link", onclick: showGrid }, `${targetLabel()} / ${state.project}`),
|
|
838
|
+
el("span", {}, itemName(item)),
|
|
839
|
+
);
|
|
840
|
+
const sizeChanged =
|
|
841
|
+
item.size &&
|
|
842
|
+
item.baselineSize &&
|
|
843
|
+
(item.size[0] !== item.baselineSize[0] || item.size[1] !== item.baselineSize[1]);
|
|
844
|
+
const viewButton = (v, label, key) =>
|
|
845
|
+
el(
|
|
846
|
+
"button",
|
|
847
|
+
{
|
|
848
|
+
type: "button",
|
|
849
|
+
"aria-pressed": String(view === v),
|
|
850
|
+
disabled: Boolean(note) && v !== "side",
|
|
851
|
+
title: note && v !== "side" ? note : key,
|
|
852
|
+
onclick: () => {
|
|
853
|
+
state.view = v;
|
|
854
|
+
showStory();
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
label,
|
|
858
|
+
);
|
|
859
|
+
const zoomButton = (z) =>
|
|
860
|
+
el(
|
|
861
|
+
"button",
|
|
862
|
+
{
|
|
863
|
+
type: "button",
|
|
864
|
+
"aria-pressed": String(state.zoom === z),
|
|
865
|
+
title: "Z cycles the zoom",
|
|
866
|
+
onclick: () => {
|
|
867
|
+
state.zoom = z;
|
|
868
|
+
showStory();
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
z === "fit" ? "Fit to screen" : z === 1 ? "Real size (1x)" : `${z}x`,
|
|
872
|
+
);
|
|
873
|
+
const reason = el("input", {
|
|
874
|
+
id: "reason",
|
|
875
|
+
type: "text",
|
|
876
|
+
placeholder: "Reason (needed to reject or exclude)",
|
|
877
|
+
value: d?.reason ?? "",
|
|
878
|
+
maxlength: "2000",
|
|
879
|
+
onkeydown: (e) => {
|
|
880
|
+
if (e.key === "Enter") {
|
|
881
|
+
decide(state.pending);
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
const decisionButtons = d
|
|
886
|
+
? [
|
|
887
|
+
el("span", { class: "meta" }, `Decided: ${d.decision}. To change it, undo it first.`),
|
|
888
|
+
el("button", { type: "button", onclick: () => decide(null), title: "U" }, "Undo"),
|
|
889
|
+
]
|
|
890
|
+
: [
|
|
891
|
+
state.data.acceptable && !onlyExclude
|
|
892
|
+
? el(
|
|
893
|
+
"button",
|
|
894
|
+
{ type: "button", class: "accept", onclick: () => decide("accept"), title: "A" },
|
|
895
|
+
"Accept",
|
|
896
|
+
)
|
|
897
|
+
: null,
|
|
898
|
+
!onlyExclude
|
|
899
|
+
? el(
|
|
900
|
+
"button",
|
|
901
|
+
{ type: "button", class: "reject", onclick: () => decide("reject"), title: "R" },
|
|
902
|
+
"Reject",
|
|
903
|
+
)
|
|
904
|
+
: null,
|
|
905
|
+
state.data.acceptable
|
|
906
|
+
? el(
|
|
907
|
+
"button",
|
|
908
|
+
{
|
|
909
|
+
type: "button",
|
|
910
|
+
onclick: () => decide("exclude"),
|
|
911
|
+
title: "E: stop capturing every mode of this story",
|
|
912
|
+
},
|
|
913
|
+
"Exclude",
|
|
914
|
+
)
|
|
915
|
+
: null,
|
|
916
|
+
reason,
|
|
917
|
+
];
|
|
918
|
+
// Keep the panes' scroll when only the view changes (F, H, S, Space) on the same item and zoom.
|
|
919
|
+
const oldFrame = document.querySelector("#stage .sheet")?.parentElement;
|
|
920
|
+
const keep =
|
|
921
|
+
oldFrame && stageKey === `${item.file}|${state.zoom}` ? [oldFrame.scrollLeft, oldFrame.scrollTop] : null;
|
|
922
|
+
// One screen: the controls, then the two panes taking all the height left, then the decisions.
|
|
923
|
+
render(
|
|
924
|
+
el(
|
|
925
|
+
"div",
|
|
926
|
+
{ class: "story-view" },
|
|
927
|
+
el(
|
|
928
|
+
"div",
|
|
929
|
+
{ class: "toolbar" },
|
|
930
|
+
el("button", { type: "button", onclick: () => move(-1), title: "K" }, "Previous"),
|
|
931
|
+
el("strong", { id: "position" }, `${state.index + 1} of ${items.length}`),
|
|
932
|
+
el("button", { type: "button", onclick: () => move(1), title: "J" }, "Next"),
|
|
933
|
+
el("strong", { id: "progress" }, progress()),
|
|
934
|
+
el("span", { class: "spacer" }),
|
|
935
|
+
el("button", { type: "button", onclick: showGrid, title: "Escape" }, "Back to the grid"),
|
|
936
|
+
finishButton(),
|
|
937
|
+
),
|
|
938
|
+
el(
|
|
939
|
+
"h2",
|
|
940
|
+
{},
|
|
941
|
+
itemName(item),
|
|
942
|
+
" ",
|
|
943
|
+
el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
|
|
944
|
+
d
|
|
945
|
+
? el("span", { class: `badge ${d.decision}` }, `${d.decision}${d.reason ? `: ${d.reason}` : ""}`)
|
|
946
|
+
: null,
|
|
947
|
+
sizeChanged
|
|
948
|
+
? el(
|
|
949
|
+
"span",
|
|
950
|
+
{ class: "badge warn" },
|
|
951
|
+
`size changed ${item.baselineSize.join("x")} -> ${item.size.join("x")} image pixels`,
|
|
952
|
+
)
|
|
953
|
+
: null,
|
|
954
|
+
item.flaky ? el("span", { class: "badge" }, "flaky") : null,
|
|
955
|
+
item.reReview ? el("span", { class: "badge warn" }, RE_REVIEW) : null,
|
|
956
|
+
),
|
|
957
|
+
el(
|
|
958
|
+
"p",
|
|
959
|
+
{ class: "meta" },
|
|
960
|
+
item.changedPixels !== null ? `${item.changedPixels} changed image pixels` : "",
|
|
961
|
+
item.bbox ? ` in [${item.bbox.join(", ")}]` : "",
|
|
962
|
+
` at threshold ${item.threshold}; captured at ${scale()} image pixels per CSS pixel`,
|
|
963
|
+
),
|
|
964
|
+
el(
|
|
965
|
+
"div",
|
|
966
|
+
{ class: "toolbar" },
|
|
967
|
+
viewButton("side", "Side by side", ""),
|
|
968
|
+
viewButton("flash", "Flash", "F, or hold Space"),
|
|
969
|
+
viewButton("highlight", "Highlight", "H"),
|
|
970
|
+
viewButton("spotlight", "Spotlight", "S"),
|
|
971
|
+
note ? el("span", { id: "single-note", class: "meta" }, note) : null,
|
|
972
|
+
el("span", { class: "spacer" }),
|
|
973
|
+
ZOOMS.map(zoomButton),
|
|
974
|
+
el(
|
|
975
|
+
"button",
|
|
976
|
+
{
|
|
977
|
+
type: "button",
|
|
978
|
+
id: "next-box",
|
|
979
|
+
onclick: nextBox,
|
|
980
|
+
title: "N",
|
|
981
|
+
disabled: !item.baseline || !item.capture,
|
|
982
|
+
},
|
|
983
|
+
"Next changed box",
|
|
984
|
+
),
|
|
985
|
+
el("span", { id: "box-count", class: "meta" }),
|
|
986
|
+
),
|
|
987
|
+
el("div", { id: "stage", class: `stage ${view} zoom-${state.zoom}` }),
|
|
988
|
+
item.console.length > 0
|
|
989
|
+
? el(
|
|
990
|
+
"details",
|
|
991
|
+
{
|
|
992
|
+
class: `console-block ${item.status === "failed" ? "errors" : ""}`,
|
|
993
|
+
open: item.status === "failed",
|
|
994
|
+
},
|
|
995
|
+
el(
|
|
996
|
+
"summary",
|
|
997
|
+
{},
|
|
998
|
+
item.status === "failed" ? `Error: ${item.reason ?? "capture failed"}` : "Console",
|
|
999
|
+
` (${item.console.length} lines)`,
|
|
1000
|
+
),
|
|
1001
|
+
el("pre", { class: "console" }, item.console.join("\n")),
|
|
1002
|
+
)
|
|
1003
|
+
: null,
|
|
1004
|
+
el(
|
|
1005
|
+
"div",
|
|
1006
|
+
{ class: "actions" },
|
|
1007
|
+
isLocal()
|
|
1008
|
+
? el("p", {}, "Local preview: look only. Only a CI capture of a pushed commit can be decided.")
|
|
1009
|
+
: null,
|
|
1010
|
+
unseeded
|
|
1011
|
+
? el(
|
|
1012
|
+
"p",
|
|
1013
|
+
{},
|
|
1014
|
+
"No baseline yet, and this pull request does not change it: it looks as on master. " +
|
|
1015
|
+
"Seed it from master's capture, or accept it on the pull request that changes it.",
|
|
1016
|
+
)
|
|
1017
|
+
: null,
|
|
1018
|
+
decidable ? decisionButtons : null,
|
|
1019
|
+
),
|
|
1020
|
+
),
|
|
1021
|
+
);
|
|
1022
|
+
renderStage(item, view, keep);
|
|
1023
|
+
remember();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// The changed pixels of an item, padded top-left to the larger size, grown by GROW pixels, and the
|
|
1027
|
+
// boxes around each separate grown region. Computed once per item.
|
|
1028
|
+
async function diffOf(item) {
|
|
1029
|
+
if (!diffs.has(item.file)) {
|
|
1030
|
+
diffs.set(
|
|
1031
|
+
item.file,
|
|
1032
|
+
(async () => {
|
|
1033
|
+
const [a, b] = await Promise.all([
|
|
1034
|
+
loaded(await image("baseline", item.file)),
|
|
1035
|
+
loaded(await image("capture", item.file)),
|
|
1036
|
+
]);
|
|
1037
|
+
const w = Math.max(a.naturalWidth, b.naturalWidth);
|
|
1038
|
+
const h = Math.max(a.naturalHeight, b.naturalHeight);
|
|
1039
|
+
const pixels = (img) => {
|
|
1040
|
+
const ctx = new OffscreenCanvas(w, h).getContext("2d");
|
|
1041
|
+
ctx.drawImage(img, 0, 0);
|
|
1042
|
+
return ctx.getImageData(0, 0, w, h).data;
|
|
1043
|
+
};
|
|
1044
|
+
const [pa, pb] = [pixels(a), pixels(b)];
|
|
1045
|
+
const mask = new Uint8ClampedArray(w * h * 4);
|
|
1046
|
+
pixelmatch(pa, pb, mask, w, h, {
|
|
1047
|
+
threshold: item.threshold,
|
|
1048
|
+
includeAA: item.includeAA,
|
|
1049
|
+
diffMask: true,
|
|
1050
|
+
});
|
|
1051
|
+
const grown = grow(mask, w, h);
|
|
1052
|
+
return { w, h, a: pa, b: pb, grown, boxes: regions(grown, w, h) };
|
|
1053
|
+
})(),
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
return diffs.get(item.file);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// A square dilation by GROW pixels, as two passes (rows, then columns) of a sliding window.
|
|
1060
|
+
function grow(mask, w, h) {
|
|
1061
|
+
const on = new Uint8Array(w * h);
|
|
1062
|
+
for (let i = 0; i < w * h; i++) {
|
|
1063
|
+
on[i] = mask[i * 4 + 3] !== 0 ? 1 : 0;
|
|
1064
|
+
}
|
|
1065
|
+
const pass = (src, count, length, at) => {
|
|
1066
|
+
const out = new Uint8Array(w * h);
|
|
1067
|
+
for (let line = 0; line < count; line++) {
|
|
1068
|
+
let last = -Infinity;
|
|
1069
|
+
// Forward then backward: distance to the nearest set pixel along the line.
|
|
1070
|
+
for (let k = 0; k < length; k++) {
|
|
1071
|
+
if (src[at(line, k)]) {
|
|
1072
|
+
last = k;
|
|
1073
|
+
}
|
|
1074
|
+
if (k - last <= GROW) {
|
|
1075
|
+
out[at(line, k)] = 1;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
last = Infinity;
|
|
1079
|
+
for (let k = length - 1; k >= 0; k--) {
|
|
1080
|
+
if (src[at(line, k)]) {
|
|
1081
|
+
last = k;
|
|
1082
|
+
}
|
|
1083
|
+
if (last - k <= GROW) {
|
|
1084
|
+
out[at(line, k)] = 1;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return out;
|
|
1089
|
+
};
|
|
1090
|
+
const rows = pass(on, h, w, (y, x) => y * w + x);
|
|
1091
|
+
return pass(rows, w, h, (x, y) => y * w + x);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// The bounding boxes [x, y, width, height] of the separate regions of a grown mask, largest first.
|
|
1095
|
+
function regions(grown, w, h) {
|
|
1096
|
+
const seen = new Uint8Array(w * h);
|
|
1097
|
+
const boxes = [];
|
|
1098
|
+
const stack = [];
|
|
1099
|
+
for (let start = 0; start < w * h; start++) {
|
|
1100
|
+
if (!grown[start] || seen[start]) {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
let [x0, y0, x1, y1] = [w, h, -1, -1];
|
|
1104
|
+
seen[start] = 1;
|
|
1105
|
+
stack.push(start);
|
|
1106
|
+
while (stack.length > 0) {
|
|
1107
|
+
const p = stack.pop();
|
|
1108
|
+
const x = p % w;
|
|
1109
|
+
const y = (p - x) / w;
|
|
1110
|
+
x0 = Math.min(x0, x);
|
|
1111
|
+
x1 = Math.max(x1, x);
|
|
1112
|
+
y0 = Math.min(y0, y);
|
|
1113
|
+
y1 = Math.max(y1, y);
|
|
1114
|
+
for (const q of [x > 0 ? p - 1 : -1, x < w - 1 ? p + 1 : -1, p - w, p + w]) {
|
|
1115
|
+
if (q >= 0 && q < w * h && grown[q] && !seen[q]) {
|
|
1116
|
+
seen[q] = 1;
|
|
1117
|
+
stack.push(q);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
boxes.push([x0, y0, x1 - x0 + 1, y1 - y0 + 1]);
|
|
1122
|
+
}
|
|
1123
|
+
return boxes.sort((p, q) => q[2] * q[3] - p[2] * p[3]);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Sizes every picture on the stage at one scale, so equal-size images line up pixel for pixel in
|
|
1127
|
+
// their panes. At "fit" the largest of them fits its pane both ways (never above real size, one CSS
|
|
1128
|
+
// pixel per CSS pixel the story was drawn at); zoomed, it is real size times the zoom and the
|
|
1129
|
+
// panes scroll. Each pane's sheet is as large as the largest picture, so both scroll alike.
|
|
1130
|
+
function fit(stage) {
|
|
1131
|
+
const pics = [...stage.querySelectorAll(".sheet > img, .sheet > canvas")];
|
|
1132
|
+
const natural = (p) => [p.naturalWidth ?? p.width, p.naturalHeight ?? p.height];
|
|
1133
|
+
let [w, h] = [1, 1];
|
|
1134
|
+
for (const p of pics) {
|
|
1135
|
+
const [x, y] = natural(p);
|
|
1136
|
+
[w, h] = [Math.max(w, x), Math.max(h, y)];
|
|
1137
|
+
}
|
|
1138
|
+
const frame = stage.querySelector(".frame");
|
|
1139
|
+
factor =
|
|
1140
|
+
state.zoom === "fit"
|
|
1141
|
+
? Math.min(1 / scale(), frame.clientWidth / w, frame.clientHeight / h)
|
|
1142
|
+
: state.zoom / scale();
|
|
1143
|
+
for (const p of pics) {
|
|
1144
|
+
const [x, y] = natural(p);
|
|
1145
|
+
p.style.width = `${x * factor}px`;
|
|
1146
|
+
p.style.height = `${y * factor}px`;
|
|
1147
|
+
p.classList.toggle("crisp", factor * scale() >= CRISP_FROM);
|
|
1148
|
+
}
|
|
1149
|
+
for (const sheet of stage.querySelectorAll(".sheet")) {
|
|
1150
|
+
sheet.style.width = `${w * factor}px`;
|
|
1151
|
+
sheet.style.height = `${h * factor}px`;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Two panes, always: the baseline on the left and the new image (or the view's picture) on the
|
|
1156
|
+
// right. A missing image leaves its pane empty, the same size, so the other one never moves.
|
|
1157
|
+
async function renderStage(item, view, keep) {
|
|
1158
|
+
const stage = document.getElementById("stage");
|
|
1159
|
+
const pane = (text, ...pics) =>
|
|
1160
|
+
el(
|
|
1161
|
+
"figure",
|
|
1162
|
+
{},
|
|
1163
|
+
el("div", { class: "label", title: text }, text),
|
|
1164
|
+
el(
|
|
1165
|
+
"div",
|
|
1166
|
+
{ class: pics.length ? "frame" : "frame empty" },
|
|
1167
|
+
pics.length ? el("div", { class: "sheet" }, pics) : null,
|
|
1168
|
+
),
|
|
1169
|
+
);
|
|
1170
|
+
const imgOf = async (kind) => {
|
|
1171
|
+
const img = await loaded(await image(kind, item.file));
|
|
1172
|
+
img.alt = `${kind} of ${itemName(item)}`;
|
|
1173
|
+
return img;
|
|
1174
|
+
};
|
|
1175
|
+
try {
|
|
1176
|
+
const diff = item.baseline && item.capture ? await diffOf(item) : null;
|
|
1177
|
+
const left = item.baseline ? pane("Baseline", await imgOf("baseline")) : pane("No baseline");
|
|
1178
|
+
let right;
|
|
1179
|
+
if (!item.capture) {
|
|
1180
|
+
right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
|
|
1181
|
+
} else if (view === "side") {
|
|
1182
|
+
right = pane(item.baseline ? "New" : "New (no baseline)", await imgOf("capture"));
|
|
1183
|
+
} else if (view === "flash") {
|
|
1184
|
+
// The two images themselves, one after the other in the same place: no overlay.
|
|
1185
|
+
const [base, next] = [await imgOf("baseline"), await imgOf("capture")];
|
|
1186
|
+
next.style.visibility = "hidden";
|
|
1187
|
+
right = pane("Flash: baseline", base, next);
|
|
1188
|
+
right.classList.add("flashing");
|
|
1189
|
+
const tag = right.querySelector(".label");
|
|
1190
|
+
let showingNew = false;
|
|
1191
|
+
flashTimer = setInterval(() => {
|
|
1192
|
+
showingNew = !showingNew;
|
|
1193
|
+
base.style.visibility = showingNew ? "hidden" : "visible";
|
|
1194
|
+
next.style.visibility = showingNew ? "visible" : "hidden";
|
|
1195
|
+
tag.textContent = showingNew ? "Flash: new" : "Flash: baseline";
|
|
1196
|
+
}, FLASH_MS);
|
|
1197
|
+
} else if (view === "highlight") {
|
|
1198
|
+
right = pane("Changed pixels in red over the dimmed baseline", highlight(item, diff));
|
|
1199
|
+
} else {
|
|
1200
|
+
right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
|
|
1201
|
+
}
|
|
1202
|
+
stage.replaceChildren(left, right);
|
|
1203
|
+
const frames = [...stage.querySelectorAll(".frame")];
|
|
1204
|
+
// Zoomed, scrolling one pane scrolls the other to the same place.
|
|
1205
|
+
for (const f of frames) {
|
|
1206
|
+
f.addEventListener("scroll", () => {
|
|
1207
|
+
for (const o of frames) {
|
|
1208
|
+
if (o !== f && (o.scrollLeft !== f.scrollLeft || o.scrollTop !== f.scrollTop)) {
|
|
1209
|
+
o.scrollLeft = f.scrollLeft;
|
|
1210
|
+
o.scrollTop = f.scrollTop;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
fit(stage);
|
|
1216
|
+
refit.disconnect();
|
|
1217
|
+
refit.observe(stage);
|
|
1218
|
+
shownBoxes = diff ? diff.boxes : [];
|
|
1219
|
+
stageKey = `${item.file}|${state.zoom}`;
|
|
1220
|
+
const count = document.getElementById("box-count");
|
|
1221
|
+
if (diff) {
|
|
1222
|
+
count.textContent =
|
|
1223
|
+
diff.boxes.length === 0
|
|
1224
|
+
? "no changed box at this threshold"
|
|
1225
|
+
: `box ${Math.min(state.box, diff.boxes.length - 1) + 1} of ${diff.boxes.length}`;
|
|
1226
|
+
showBox(stage, diff.boxes, false);
|
|
1227
|
+
}
|
|
1228
|
+
if (keep) {
|
|
1229
|
+
for (const f of frames) {
|
|
1230
|
+
[f.scrollLeft, f.scrollTop] = keep;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
stage.replaceChildren(el("p", { class: "error" }, err.message));
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Outlines the current changed box in every pane, and scrolls it into view. A pane opens at the
|
|
1239
|
+
// top left of its image; on opening (`jump` false) it scrolls only when the box is smaller than
|
|
1240
|
+
// the pane and out of sight. Next changed box (`jump` true) always brings the box into view, its
|
|
1241
|
+
// top left first when it is larger than the pane.
|
|
1242
|
+
function showBox(stage, boxes, jump) {
|
|
1243
|
+
if (boxes.length === 0) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
state.box = Math.min(state.box, boxes.length - 1);
|
|
1247
|
+
const [x, y, w, h] = boxes[state.box];
|
|
1248
|
+
const PAD = 16;
|
|
1249
|
+
for (const frame of stage.querySelectorAll(".frame")) {
|
|
1250
|
+
const sheet = frame.querySelector(".sheet");
|
|
1251
|
+
if (!sheet) {
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
const [sw, sh] = [sheet.offsetWidth, sheet.offsetHeight];
|
|
1255
|
+
// The outline is drawn inside the image, so a box at an edge keeps all four sides.
|
|
1256
|
+
const [left, top] = [Math.max(0, x * factor - 2), Math.max(0, y * factor - 2)];
|
|
1257
|
+
const [right, bottom] = [Math.min(sw, (x + w) * factor + 2), Math.min(sh, (y + h) * factor + 2)];
|
|
1258
|
+
sheet.querySelector(".boxmark")?.remove();
|
|
1259
|
+
const mark = el("div", { class: "boxmark" });
|
|
1260
|
+
Object.assign(mark.style, {
|
|
1261
|
+
left: `${left}px`,
|
|
1262
|
+
top: `${top}px`,
|
|
1263
|
+
width: `${right - left}px`,
|
|
1264
|
+
height: `${bottom - top}px`,
|
|
1265
|
+
});
|
|
1266
|
+
sheet.append(mark);
|
|
1267
|
+
if (!jump) {
|
|
1268
|
+
frame.scrollLeft = 0;
|
|
1269
|
+
frame.scrollTop = 0;
|
|
1270
|
+
}
|
|
1271
|
+
const fits = right - left <= frame.clientWidth && bottom - top <= frame.clientHeight;
|
|
1272
|
+
if (!jump && !fits) {
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
const reveal = (start, end, scroll, view) => {
|
|
1276
|
+
if (end - start > view || start < scroll) {
|
|
1277
|
+
return Math.max(0, start - PAD);
|
|
1278
|
+
}
|
|
1279
|
+
return end > scroll + view ? end - view + PAD : scroll;
|
|
1280
|
+
};
|
|
1281
|
+
const [ox, oy] = [sheet.offsetLeft, sheet.offsetTop];
|
|
1282
|
+
frame.scrollLeft = reveal(ox + left, ox + right, frame.scrollLeft, frame.clientWidth);
|
|
1283
|
+
frame.scrollTop = reveal(oy + top, oy + bottom, frame.scrollTop, frame.clientHeight);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function nextBox() {
|
|
1288
|
+
const item = current();
|
|
1289
|
+
if (!item?.baseline || !item?.capture) {
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const { boxes } = await diffOf(item);
|
|
1293
|
+
if (boxes.length === 0) {
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
state.box = (state.box + 1) % boxes.length;
|
|
1297
|
+
document.getElementById("box-count").textContent = `box ${state.box + 1} of ${boxes.length}`;
|
|
1298
|
+
showBox(document.getElementById("stage"), boxes, true);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// pixelmatch's own picture: the changed pixels in red over the dimmed baseline.
|
|
1302
|
+
function highlight(item, diff) {
|
|
1303
|
+
const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
|
|
1304
|
+
const ctx = canvas.getContext("2d");
|
|
1305
|
+
const out = ctx.createImageData(diff.w, diff.h);
|
|
1306
|
+
pixelmatch(diff.a, diff.b, out.data, diff.w, diff.h, {
|
|
1307
|
+
threshold: item.threshold,
|
|
1308
|
+
includeAA: item.includeAA,
|
|
1309
|
+
alpha: 0.2,
|
|
1310
|
+
});
|
|
1311
|
+
ctx.putImageData(out, 0, 0);
|
|
1312
|
+
return canvas;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// The new image with everything dimmed except the changed pixels grown by GROW pixels.
|
|
1316
|
+
function spotlight(diff) {
|
|
1317
|
+
const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
|
|
1318
|
+
const ctx = canvas.getContext("2d");
|
|
1319
|
+
const out = ctx.createImageData(diff.w, diff.h);
|
|
1320
|
+
const keep = 1 - SPOT_ALPHA / 255;
|
|
1321
|
+
for (let i = 0; i < diff.w * diff.h; i++) {
|
|
1322
|
+
const lit = diff.grown[i] === 1;
|
|
1323
|
+
for (let c = 0; c < 3; c++) {
|
|
1324
|
+
out.data[i * 4 + c] = lit ? diff.b[i * 4 + c] : diff.b[i * 4 + c] * keep;
|
|
1325
|
+
}
|
|
1326
|
+
out.data[i * 4 + 3] = lit ? diff.b[i * 4 + 3] : Math.max(diff.b[i * 4 + 3], SPOT_ALPHA);
|
|
1327
|
+
}
|
|
1328
|
+
ctx.putImageData(out, 0, 0);
|
|
1329
|
+
return canvas;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
async function acceptAll(component) {
|
|
1333
|
+
if (!state.data.acceptable || isLocal()) {
|
|
1334
|
+
say(`${state.project} cannot be accepted here`, true);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const n = undecidedIn(component);
|
|
1338
|
+
const where = component ? `the component ${component}` : state.project;
|
|
1339
|
+
if (n === 0) {
|
|
1340
|
+
say(`Nothing undecided to accept in ${where}.`);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (!confirm(`Accept ${n} undecided items of ${where} without opening them?`)) {
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
await api("/api/accept-all", {
|
|
1348
|
+
id: state.target.id,
|
|
1349
|
+
project: state.project,
|
|
1350
|
+
...(component ? { component } : {}),
|
|
1351
|
+
});
|
|
1352
|
+
await reload();
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
say(err.message, true);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
say(`Accepted ${n} items in ${where}.`);
|
|
1358
|
+
(state.screen === "story" ? showStory : showGrid)();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function move(step) {
|
|
1362
|
+
say("");
|
|
1363
|
+
const count = passItems().length;
|
|
1364
|
+
state.index = (state.index + step + count) % count;
|
|
1365
|
+
state.box = 0;
|
|
1366
|
+
showStory();
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
async function decide(decision) {
|
|
1370
|
+
if (isLocal()) {
|
|
1371
|
+
say("A local preview is only looked at: nothing is decided on it.", true);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const items = passItems();
|
|
1375
|
+
const item = items[state.index];
|
|
1376
|
+
const before = decisionOf(item);
|
|
1377
|
+
if (decision === null && !before) {
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
// No key reverses a decision: changing one is an explicit Undo first.
|
|
1381
|
+
if (decision !== null && before) {
|
|
1382
|
+
const done = { accept: "accepted", reject: "rejected", exclude: "excluded" }[before.decision];
|
|
1383
|
+
say(`${itemName(item)} is already ${done}. Press U (Undo) first to change it.`, true);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const reasonBox = document.getElementById("reason");
|
|
1387
|
+
const reason = reasonBox?.value.trim() ?? "";
|
|
1388
|
+
if ((decision === "reject" || decision === "exclude") && reason === "") {
|
|
1389
|
+
state.pending = decision;
|
|
1390
|
+
reasonBox?.focus();
|
|
1391
|
+
say(`A ${decision} needs a reason: type it, then press Enter.`);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if (
|
|
1395
|
+
decision === "exclude" &&
|
|
1396
|
+
!confirm(
|
|
1397
|
+
`Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
|
|
1398
|
+
"until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
|
|
1399
|
+
)
|
|
1400
|
+
) {
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
await api("/api/decide", {
|
|
1405
|
+
id: state.target.id,
|
|
1406
|
+
project: state.project,
|
|
1407
|
+
file: item.file,
|
|
1408
|
+
decision,
|
|
1409
|
+
reason: reason === "" ? null : reason,
|
|
1410
|
+
});
|
|
1411
|
+
} catch (err) {
|
|
1412
|
+
say(err.message, true);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (decision === null) {
|
|
1416
|
+
delete state.data.decisions[item.file];
|
|
1417
|
+
} else {
|
|
1418
|
+
state.data.decisions[item.file] = { decision, reason: reason === "" ? null : reason };
|
|
1419
|
+
}
|
|
1420
|
+
state.pending = "reject";
|
|
1421
|
+
say(`${itemName(item)}: ${decision ?? "undone, undecided again"}`);
|
|
1422
|
+
// The pass is frozen, so a decided item stays in it: a decision moves on to the next one, and
|
|
1423
|
+
// Previous comes back to it. Undo stays on the item.
|
|
1424
|
+
if (decision !== null) {
|
|
1425
|
+
state.index = Math.min(state.index + 1, items.length - 1);
|
|
1426
|
+
}
|
|
1427
|
+
state.box = 0;
|
|
1428
|
+
showStory();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ---------------------------------------------------------------- Finish
|
|
1432
|
+
|
|
1433
|
+
function finishButton() {
|
|
1434
|
+
if (isLocal()) {
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
return el(
|
|
1438
|
+
"button",
|
|
1439
|
+
{ type: "button", class: "primary", disabled: running(), onclick: () => finishTarget(state.target) },
|
|
1440
|
+
`Finish ${targetLabel()}`,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function finishTarget(target) {
|
|
1445
|
+
const what =
|
|
1446
|
+
target.pr === null ? "push a seed branch and open its pull request" : `commit and push to ${target.branch}`;
|
|
1447
|
+
let fresh;
|
|
1448
|
+
try {
|
|
1449
|
+
fresh = await api(`/api/target/${encodeURIComponent(target.id)}`);
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
say(err.message, true);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const lines = [
|
|
1455
|
+
`Finish ${target.pr === null ? "the master seed" : `#${target.pr}`}: ${what}, across every project?`,
|
|
1456
|
+
];
|
|
1457
|
+
const notOpened = fresh.projects.reduce((n, p) => n + p.notOpened, 0);
|
|
1458
|
+
if (notOpened > 0) {
|
|
1459
|
+
lines.push(`${notOpened} accepted without being opened.`);
|
|
1460
|
+
}
|
|
1461
|
+
const left = fresh.projects.filter((p) => p.undecided > 0);
|
|
1462
|
+
if (left.length > 0) {
|
|
1463
|
+
lines.push(
|
|
1464
|
+
`Warning: still undecided, left for a later round: ${left.map((p) => `${p.project}: ${p.undecided} undecided`).join(", ")}.`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
lines.push(signerLine(fresh.signer));
|
|
1468
|
+
if (fresh.startCommand) {
|
|
1469
|
+
lines.push(
|
|
1470
|
+
`To sign as yourself instead, cancel and start the server from your own shell:\n${fresh.startCommand}`,
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
lines.push("One commit status is posted when Finish completes.");
|
|
1474
|
+
if (!confirm(lines.join("\n\n"))) {
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
// Finish runs on the server and can take minutes; the page only starts it and then asks how it
|
|
1478
|
+
// is going, so a dropped connection or a reload loses nothing.
|
|
1479
|
+
say("Starting Finish...");
|
|
1480
|
+
try {
|
|
1481
|
+
state.job = (await api("/api/finish", { id: target.id })).job;
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
// The request may have been lost after the server started it: ask before calling it failed.
|
|
1484
|
+
state.job = await api("/api/finish-status")
|
|
1485
|
+
.then((s) => s.job)
|
|
1486
|
+
.catch(() => null);
|
|
1487
|
+
if (!running() || state.job.target !== target.id) {
|
|
1488
|
+
say("Finish failed; your decisions are kept. Fix the cause and press Finish again.", true);
|
|
1489
|
+
app.prepend(el("pre", { class: "error" }, err.message));
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
await watchFinish();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const finishing = () =>
|
|
1497
|
+
`Finishing ${state.job.pr === null ? "the master seed" : `#${state.job.pr}`}: ${state.job.step}...`;
|
|
1498
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1499
|
+
let watching = false;
|
|
1500
|
+
|
|
1501
|
+
// Follows the running Finish to its end, then shows the targets with its outcome.
|
|
1502
|
+
async function watchFinish() {
|
|
1503
|
+
if (watching) {
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
watching = true;
|
|
1507
|
+
try {
|
|
1508
|
+
while (running()) {
|
|
1509
|
+
say(finishing());
|
|
1510
|
+
await sleep(1000);
|
|
1511
|
+
try {
|
|
1512
|
+
state.job = (await api("/api/finish-status")).job;
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
say(`Lost contact with the server (${err.message}); Finish goes on there. Retrying...`, true);
|
|
1515
|
+
await sleep(2000);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
} finally {
|
|
1519
|
+
watching = false;
|
|
1520
|
+
}
|
|
1521
|
+
await showTargets();
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// What the newest Finish did, shown above the targets until the server restarts.
|
|
1525
|
+
function finishOutcome() {
|
|
1526
|
+
const job = state.job;
|
|
1527
|
+
if (!job || job.running) {
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
const label = job.pr === null ? "the master seed" : `#${job.pr}`;
|
|
1531
|
+
if (job.error !== null) {
|
|
1532
|
+
return el(
|
|
1533
|
+
"section",
|
|
1534
|
+
{ class: "card finish-outcome" },
|
|
1535
|
+
el(
|
|
1536
|
+
"p",
|
|
1537
|
+
{ class: "error" },
|
|
1538
|
+
`Finish of ${label} failed; your decisions are kept. Fix the cause and press Finish again.`,
|
|
1539
|
+
),
|
|
1540
|
+
el("pre", { class: "error" }, job.error),
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
const out = job.result;
|
|
1544
|
+
const parts = [out.commit ? `Committed ${short(out.commit)} to ${out.branch}.` : "No commit (nothing accepted)."];
|
|
1545
|
+
if (out.rejects > 0) {
|
|
1546
|
+
parts.push(`${out.rejects} rejects posted.`);
|
|
1547
|
+
}
|
|
1548
|
+
if (out.issue) {
|
|
1549
|
+
parts.push(`Issue: ${out.issue}`);
|
|
1550
|
+
}
|
|
1551
|
+
if (out.pullRequest) {
|
|
1552
|
+
parts.push(`Pull request: ${out.pullRequest}`);
|
|
1553
|
+
}
|
|
1554
|
+
parts.push(out.statusError ? `The commit status failed: ${out.statusError}` : `Status: ${out.status}.`);
|
|
1555
|
+
return el(
|
|
1556
|
+
"section",
|
|
1557
|
+
{ class: "card finish-outcome" },
|
|
1558
|
+
el("p", { class: out.statusError ? "error" : "" }, `Finish of ${label} done. `, parts.join(" ")),
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// ---------------------------------------------------------------- the address
|
|
1563
|
+
|
|
1564
|
+
// Every screen is in the address, after the session token, so a copied link opens it again:
|
|
1565
|
+
// #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit
|
|
1566
|
+
// Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
|
|
1567
|
+
function hashFor() {
|
|
1568
|
+
const p = new URLSearchParams({ token });
|
|
1569
|
+
if (state.screen !== "targets") {
|
|
1570
|
+
p.set("target", state.target.id);
|
|
1571
|
+
p.set("project", state.project);
|
|
1572
|
+
p.set("filter", state.filter);
|
|
1573
|
+
if (state.text) {
|
|
1574
|
+
p.set("q", state.text);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (state.screen === "story") {
|
|
1578
|
+
p.set("item", current().file);
|
|
1579
|
+
p.set("view", state.held ?? state.view);
|
|
1580
|
+
p.set("zoom", String(state.zoom));
|
|
1581
|
+
}
|
|
1582
|
+
return `#${p}`;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// Writes the screen into the address: a new history entry when the screen changes (so Back
|
|
1586
|
+
// returns to the one before), in place when only the item, view, zoom or filter does.
|
|
1587
|
+
function remember() {
|
|
1588
|
+
const hash = hashFor();
|
|
1589
|
+
if (hash === location.hash) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const was = new URLSearchParams(location.hash.slice(1));
|
|
1593
|
+
const screen = was.has("item") ? "story" : was.has("target") ? "grid" : "targets";
|
|
1594
|
+
const moved =
|
|
1595
|
+
screen !== state.screen ||
|
|
1596
|
+
(screen !== "targets" &&
|
|
1597
|
+
(was.get("target") !== String(state.target.id) || was.get("project") !== state.project));
|
|
1598
|
+
const push = !routing && moved;
|
|
1599
|
+
history[push ? "pushState" : "replaceState"](null, "", hash);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Shows the screen the address names; what no longer exists (a closed pull request, a story gone
|
|
1603
|
+
// from a new CI run) lands on the nearest screen that does, with a line saying so.
|
|
1604
|
+
async function route() {
|
|
1605
|
+
const p = new URLSearchParams(location.hash.slice(1));
|
|
1606
|
+
routing = true;
|
|
1607
|
+
try {
|
|
1608
|
+
const id = p.get("target");
|
|
1609
|
+
if (!id) {
|
|
1610
|
+
await showTargets();
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (!(await loadTargets())) {
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const target = state.targets.find((t) => String(t.id) === id);
|
|
1617
|
+
const project = p.get("project");
|
|
1618
|
+
if (!target || !target.projects.some((x) => x.project === project)) {
|
|
1619
|
+
const what = target ? `${project} is not a project of ${id}` : `${id} is no longer listed`;
|
|
1620
|
+
await showTargets(`${what}: showing every target.`);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (state.data === null || state.target?.id !== target.id || state.project !== project) {
|
|
1624
|
+
state.sequence = [];
|
|
1625
|
+
state.target = target;
|
|
1626
|
+
state.project = project;
|
|
1627
|
+
try {
|
|
1628
|
+
await reload();
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
say(err.message, true);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
state.target = target;
|
|
1635
|
+
state.filter = FILTERS.includes(p.get("filter")) ? p.get("filter") : "undecided";
|
|
1636
|
+
state.text = p.get("q") ?? "";
|
|
1637
|
+
const file = p.get("item");
|
|
1638
|
+
if (!file) {
|
|
1639
|
+
showGrid();
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const item = state.data.items.find((i) => i.file === file);
|
|
1643
|
+
if (!item) {
|
|
1644
|
+
showGrid();
|
|
1645
|
+
say(`${file} is not in this CI run any more: showing the grid.`);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
// Back into the pass it came from keeps that pass; otherwise the grid's items, with this one.
|
|
1649
|
+
if (!state.sequence.includes(file)) {
|
|
1650
|
+
const shown = visibleItems();
|
|
1651
|
+
state.sequence = (shown.includes(item) ? shown : ordered([...shown, item])).map((i) => i.file);
|
|
1652
|
+
}
|
|
1653
|
+
state.index = state.sequence.indexOf(file);
|
|
1654
|
+
state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
|
|
1655
|
+
const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
|
|
1656
|
+
state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
|
|
1657
|
+
state.box = 0;
|
|
1658
|
+
say("");
|
|
1659
|
+
showStory();
|
|
1660
|
+
} finally {
|
|
1661
|
+
routing = false;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ---------------------------------------------------------------- keys and start
|
|
1666
|
+
|
|
1667
|
+
// F, H and S switch to that view, or back to side by side when it is already shown.
|
|
1668
|
+
function toggleView(view) {
|
|
1669
|
+
state.view = state.view === view ? "side" : view;
|
|
1670
|
+
showStory();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
document.addEventListener("keydown", (e) => {
|
|
1674
|
+
if (!["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const inInput = e.target instanceof HTMLInputElement;
|
|
1678
|
+
if (e.key === "Escape") {
|
|
1679
|
+
// From a story, wherever focus is (the reason box included), back to the grid.
|
|
1680
|
+
e.preventDefault();
|
|
1681
|
+
if (inInput) {
|
|
1682
|
+
e.target.blur();
|
|
1683
|
+
}
|
|
1684
|
+
if (state.screen === "story" || app.querySelector('[data-armed="true"]')) {
|
|
1685
|
+
// From a story back to the grid; on the grid, it disarms a bulk Undo pressed once.
|
|
1686
|
+
say("");
|
|
1687
|
+
showGrid();
|
|
1688
|
+
}
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (inInput) {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
|
1695
|
+
if (e.shiftKey && key === "a") {
|
|
1696
|
+
e.preventDefault();
|
|
1697
|
+
acceptAll(null);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
if (state.screen !== "story") {
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (key === " ") {
|
|
1704
|
+
// Held: flash until released, then back to the view it came from.
|
|
1705
|
+
e.preventDefault();
|
|
1706
|
+
const item = current();
|
|
1707
|
+
if (!e.repeat && state.held === null && item?.baseline && item?.capture) {
|
|
1708
|
+
state.held = state.view;
|
|
1709
|
+
state.view = "flash";
|
|
1710
|
+
showStory();
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const keys = {
|
|
1715
|
+
j: () => move(1),
|
|
1716
|
+
k: () => move(-1),
|
|
1717
|
+
a: () => decide("accept"),
|
|
1718
|
+
r: () => decide("reject"),
|
|
1719
|
+
e: () => decide("exclude"),
|
|
1720
|
+
u: () => decide(null),
|
|
1721
|
+
f: () => toggleView("flash"),
|
|
1722
|
+
h: () => toggleView("highlight"),
|
|
1723
|
+
s: () => toggleView("spotlight"),
|
|
1724
|
+
n: nextBox,
|
|
1725
|
+
z: () => {
|
|
1726
|
+
state.zoom = ZOOMS[(ZOOMS.indexOf(state.zoom) + 1) % ZOOMS.length];
|
|
1727
|
+
showStory();
|
|
1728
|
+
},
|
|
1729
|
+
};
|
|
1730
|
+
const action = keys[key];
|
|
1731
|
+
if (action) {
|
|
1732
|
+
e.preventDefault();
|
|
1733
|
+
action();
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
document.addEventListener("keyup", (e) => {
|
|
1737
|
+
if (e.key === " " && state.held !== null) {
|
|
1738
|
+
e.preventDefault();
|
|
1739
|
+
state.view = state.held;
|
|
1740
|
+
state.held = null;
|
|
1741
|
+
if (state.screen === "story") {
|
|
1742
|
+
showStory();
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
document.getElementById("home").addEventListener("click", () => showTargets());
|
|
1747
|
+
document.getElementById("copy-link").addEventListener("click", async () => {
|
|
1748
|
+
try {
|
|
1749
|
+
await navigator.clipboard.writeText(location.href);
|
|
1750
|
+
say("Link copied. It carries your session token: it opens this screen on any device.");
|
|
1751
|
+
} catch {
|
|
1752
|
+
// No clipboard (a page served over plain http to another device): the browser's own box.
|
|
1753
|
+
prompt("Copy this link:", location.href);
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
window.addEventListener("popstate", () => route());
|
|
1757
|
+
|
|
1758
|
+
if (token === "") {
|
|
1759
|
+
render(el("p", { class: "error" }, "No session token: open the URL that visual-review serve printed."));
|
|
1760
|
+
} else {
|
|
1761
|
+
route();
|
|
1762
|
+
}
|