@intentius/behold 0.29.7 → 0.29.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/cli.js +1221 -765
- package/package.json +1 -1
- package/web/app.js +60 -4
- package/web/index.html +2 -0
- package/web/kiosk-annotations.js +528 -0
- package/web/kiosk-annotations.test.js +83 -0
- package/web/kiosk-approval.js +241 -0
- package/web/kiosk-approval.test.js +122 -0
- package/web/kiosk-popover.js +1 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +15 -7
- package/web/kiosk-runs.js +341 -25
- package/web/kiosk-runs.test.js +77 -1
- package/web/kiosk-vendor.js +380 -0
- package/web/kiosk-vendor.test.js +265 -0
- package/web/kiosk.html +157 -17
- package/web/kiosk.js +449 -47
- package/web/kiosk.test.js +73 -8
- package/web/theme.js +112 -0
- package/web/theme.test.js +51 -1
- package/web/vendors/choudoufu-128.png +0 -0
- package/web/vendors/choudoufu-64.png +0 -0
- package/web/view-repo.js +44 -0
package/web/kiosk-runs.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// A job held at a terragucci gate is `waiting`: it waits for an approval, and
|
|
11
11
|
// is never drawn as a failure. Step names are shown as the driver wrote them.
|
|
12
12
|
|
|
13
|
-
import { rootColour, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
13
|
+
import { rootColour, rootPaint, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
14
|
+
import { TOUR, chipWords, docsUrl, jobMarks, runFeatures } from "./kiosk-annotations.js";
|
|
14
15
|
|
|
15
16
|
/** The run, job and step statuses, as a viewer reads them. */
|
|
16
17
|
export const STATUS_WORDS = {
|
|
@@ -184,7 +185,12 @@ function ownSteps(steps) {
|
|
|
184
185
|
|
|
185
186
|
/** How many log lines a job's own steps wrote. */
|
|
186
187
|
function ownLines(steps) {
|
|
187
|
-
return ownSteps(steps).reduce((n, { s }) => n + (s.log
|
|
188
|
+
return ownSteps(steps).reduce((n, { s }) => n + saidLines(s.log), 0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** behold#571: a log's lines that are not set-up or an artifact's copy, the ones a viewer reads. */
|
|
192
|
+
function saidLines(log) {
|
|
193
|
+
return (log || []).filter((l) => !setupLine(l) && typeof l === "string" && l.trim()).length;
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
/**
|
|
@@ -200,8 +206,8 @@ export function defaultStep(steps) {
|
|
|
200
206
|
const i = steps.findIndex((x) => x.status === s);
|
|
201
207
|
if (i >= 0) return i;
|
|
202
208
|
}
|
|
203
|
-
const own = ownSteps(steps).filter(({ s }) => s.log
|
|
204
|
-
if (own.length) return own.reduce((a, b) => (b.s.log
|
|
209
|
+
const own = ownSteps(steps).filter(({ s }) => saidLines(s.log) > 0);
|
|
210
|
+
if (own.length) return own.reduce((a, b) => (saidLines(b.s.log) > saidLines(a.s.log) ? b : a)).i;
|
|
205
211
|
const any = steps.map((s, i) => ({ s, i })).filter(({ s }) => s.log && s.log.length);
|
|
206
212
|
return any.length ? any[any.length - 1].i : 0;
|
|
207
213
|
}
|
|
@@ -289,6 +295,39 @@ export const SETUP_LINES = [
|
|
|
289
295
|
/^Deleting the contents of '/,
|
|
290
296
|
/^Syncing repository: /,
|
|
291
297
|
/^Working directory is '/,
|
|
298
|
+
// behold#571: an artifact's upload or download, as actions/upload-artifact and download-artifact print it.
|
|
299
|
+
/^With the provided path, there will be \d+ files? uploaded$/,
|
|
300
|
+
/^Starting artifact upload$/,
|
|
301
|
+
/^For more detailed logs during the artifact (?:upload|download) process, enable step-debugging: /,
|
|
302
|
+
/^Artifact name is valid!$/,
|
|
303
|
+
/^Container for artifact "[^"]*" successfully created\. Starting upload of file\(s\)$/,
|
|
304
|
+
/^Total size of all the files uploaded is \d+ bytes$/,
|
|
305
|
+
/^File upload process has finished\. Finalizing the artifact upload$/,
|
|
306
|
+
/^Artifact has been finalized\. All files have been successfully uploaded!$/,
|
|
307
|
+
/^The raw size of all the files that were specified for upload is \d+ bytes$/,
|
|
308
|
+
/^The size of all the files that were uploaded is \d+ bytes\. /,
|
|
309
|
+
/^Note: The size of downloaded zips can differ significantly from the reported size\./,
|
|
310
|
+
/^Artifact \S+ has been successfully uploaded!/,
|
|
311
|
+
/^Uploaded bytes \d+$/,
|
|
312
|
+
/^Beginning upload of artifact content to blob storage$/,
|
|
313
|
+
/^Finished uploading artifact content to blob storage!$/,
|
|
314
|
+
/^SHA256 (?:hash|digest) of (?:uploaded )?artifact (?:zip )?is /,
|
|
315
|
+
/^Finalizing artifact upload$/,
|
|
316
|
+
/^Artifact \S+ successfully finalized\. Artifact ID \d+$/,
|
|
317
|
+
/^Artifact download URL: /,
|
|
318
|
+
/^Starting download for \S+$/,
|
|
319
|
+
/^Directory structure has been setup for the artifact$/,
|
|
320
|
+
/^Total number of files that will be downloaded: \d+$/,
|
|
321
|
+
/^Artifact \S+ was downloaded to /,
|
|
322
|
+
/^Artifact download has finished successfully$/,
|
|
323
|
+
/^Downloading single artifact$/,
|
|
324
|
+
/^Preparing to download the following artifacts:$/,
|
|
325
|
+
/^- \S+ \(ID: \d+, Size: \d+/,
|
|
326
|
+
/^Redirecting to blob download url: /,
|
|
327
|
+
/^Starting download of artifact to: /,
|
|
328
|
+
/^Artifact download completed successfully\.$/,
|
|
329
|
+
/^Total of \d+ artifact\(s\) downloaded$/,
|
|
330
|
+
/^Download artifact has finished successfully$/,
|
|
292
331
|
];
|
|
293
332
|
|
|
294
333
|
/** Whether a log line is set-up noise (SETUP_LINES). */
|
|
@@ -302,8 +341,22 @@ export function setupLine(line) {
|
|
|
302
341
|
* opened (`open`), the row heads the whole log in order. Every line keeps
|
|
303
342
|
* its number in the log. A log with no set-up lines is drawn as it came.
|
|
304
343
|
*/
|
|
305
|
-
export function logRows(lines, { open = false, from = 0 } = {}) {
|
|
306
|
-
|
|
344
|
+
export function logRows(lines, { open = false, from = 0, hide = null } = {}) {
|
|
345
|
+
// behold#571: a tool's page leaves out a line naming another tool, and says how many it left out.
|
|
346
|
+
const every = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
|
|
347
|
+
// behold#571: a blank line after a set-up line (the upload's spacing) folds with it.
|
|
348
|
+
let prev = false;
|
|
349
|
+
for (const l of every) {
|
|
350
|
+
if (typeof l.text === "string" && !l.text.trim()) l.setup = prev;
|
|
351
|
+
else prev = l.setup;
|
|
352
|
+
}
|
|
353
|
+
const all = hide ? every.filter((l) => !hide(l.text)) : every;
|
|
354
|
+
const gone = every.length - all.length;
|
|
355
|
+
const rows = logRowsOf(all, open);
|
|
356
|
+
return gone ? [...rows, { hidden: gone }] : rows;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function logRowsOf(all, open) {
|
|
307
360
|
const n = all.filter((l) => l.setup).length;
|
|
308
361
|
if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
|
|
309
362
|
const head = { setup: n, open };
|
|
@@ -355,11 +408,12 @@ export function statusIcon(status) {
|
|
|
355
408
|
}
|
|
356
409
|
|
|
357
410
|
/** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
|
|
358
|
-
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null } = {}) {
|
|
411
|
+
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null, replay = false } = {}) {
|
|
359
412
|
host.replaceChildren();
|
|
360
413
|
const head = el("div", "r-side-head");
|
|
361
414
|
head.appendChild(el("div", "r-side-title", "Pipeline runs"));
|
|
362
|
-
|
|
415
|
+
// behold#571: a tool's page replays an earlier cycle's runs, and says so.
|
|
416
|
+
head.appendChild(el("div", "r-side-note", replay ? "Real CI runs of the replayed cycle, as they stood at this point in it. Newest first." : "Real CI runs of the demo repo, copied from its forge as they go. Newest first."));
|
|
363
417
|
host.appendChild(head);
|
|
364
418
|
if (!index || index.error || index.absent || !index.doc) {
|
|
365
419
|
host.appendChild(el("p", "r-note", index ? index.absent || index.error : "Reading the runs…"));
|
|
@@ -367,11 +421,15 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
367
421
|
}
|
|
368
422
|
// The selected run as its page draws it, when its own document is newer than the index.
|
|
369
423
|
const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
|
|
424
|
+
if (!runs.length) {
|
|
425
|
+
host.appendChild(el("p", "r-note", "No runs to show yet."));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
370
428
|
const live = runs[0];
|
|
371
429
|
const named = scenarioTitles(index.doc.runs, titles);
|
|
372
430
|
const list = el("ol", "r-runs");
|
|
373
431
|
runs.forEach((r, i) => {
|
|
374
|
-
if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
|
|
432
|
+
if (i === 0) list.appendChild(el("li", "r-group", replay ? (LIVE.has(live.status) ? "Running in the replay" : "Latest in the replay") : LIVE.has(live.status) ? "Live" : "Latest"));
|
|
375
433
|
if (i === 1) list.appendChild(el("li", "r-group", "Recent"));
|
|
376
434
|
const li = el("li", `r-run${r.id === selectedId ? " r-selected" : ""}`);
|
|
377
435
|
li.tabIndex = 0;
|
|
@@ -407,7 +465,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
407
465
|
* null for the defaults. Returns what it drew, so the caller can keep a log's
|
|
408
466
|
* scroll.
|
|
409
467
|
*/
|
|
410
|
-
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null, roots = [], waves = [], light = false } = {}) {
|
|
468
|
+
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], light = false, hideLine = null } = {}) {
|
|
411
469
|
// behold#571: a root's name with its colour's dot, where the line names one.
|
|
412
470
|
const textOf = (cls, text) => {
|
|
413
471
|
if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
|
|
@@ -458,7 +516,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
458
516
|
for (const r of applies) {
|
|
459
517
|
const name = el("span", "k-root", r);
|
|
460
518
|
name.dataset.kRoot = r;
|
|
461
|
-
name.style.setProperty("--k-root",
|
|
519
|
+
name.style.setProperty("--k-root", rootPaint(r, roots, { light }));
|
|
462
520
|
box.appendChild(name);
|
|
463
521
|
}
|
|
464
522
|
li.appendChild(box);
|
|
@@ -471,6 +529,31 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
471
529
|
if (skipped.length) jobsBox.appendChild(el("li", "r-job r-job-skipped", `${skipped.length} skipped, not for this event: ${skipped.map((j) => j.name).join(", ")}`));
|
|
472
530
|
host.appendChild(jobsBox);
|
|
473
531
|
|
|
532
|
+
// behold#571: the features this run's logs show, by tool, each jumping to its first line.
|
|
533
|
+
// One wrapping list (a tool's name, then its features); folded, it shows whole lines only (fitFeatures).
|
|
534
|
+
const feats = runFeatures(shown, { hide: hideLine });
|
|
535
|
+
if (feats.length) {
|
|
536
|
+
const box = el("div", `r-feats${featsOpen ? " r-feats-open" : ""}`);
|
|
537
|
+
box.appendChild(el("div", "r-label r-feats-label", "Features in this run"));
|
|
538
|
+
const list = el("div", "r-feats-list");
|
|
539
|
+
for (const g of feats) {
|
|
540
|
+
list.appendChild(el("span", `r-feats-product r-ann-${g.product}`, g.product));
|
|
541
|
+
for (const f of g.features) {
|
|
542
|
+
const b = el("button", "r-feat", f.feature);
|
|
543
|
+
b.type = "button";
|
|
544
|
+
b.title = `${f.a.note} (${f.job}, line ${f.n})`;
|
|
545
|
+
b.addEventListener("click", () => jump(f.job, f.step, f.n));
|
|
546
|
+
list.appendChild(b);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const more = el("button", "r-feats-more", featsOpen ? "Show fewer" : "");
|
|
550
|
+
more.type = "button";
|
|
551
|
+
more.addEventListener("click", () => toggleFeats());
|
|
552
|
+
list.appendChild(more);
|
|
553
|
+
box.appendChild(list);
|
|
554
|
+
host.appendChild(box);
|
|
555
|
+
}
|
|
556
|
+
|
|
474
557
|
if (!job) return { log: null, steps: null, key: null };
|
|
475
558
|
host.appendChild(el("div", "r-label", "Steps of the job, with its log"));
|
|
476
559
|
const stepsBox = el("div", "r-steps");
|
|
@@ -489,6 +572,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
489
572
|
}
|
|
490
573
|
const open = ui.open !== null && ui.open !== undefined && ui.job === job.name ? ui.open : defaultStep(steps);
|
|
491
574
|
let logEl = null;
|
|
575
|
+
const marks = jobMarks(steps, { hide: hideLine });
|
|
492
576
|
steps.forEach((s, i) => {
|
|
493
577
|
const row = el("div", `r-step${i === open ? " r-open" : ""}`);
|
|
494
578
|
const line = el("div", "r-step-line");
|
|
@@ -499,9 +583,13 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
499
583
|
const pre = el("pre", "r-log");
|
|
500
584
|
if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
|
|
501
585
|
const key = logKey(run.id, job.name, i);
|
|
502
|
-
for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0 })) {
|
|
586
|
+
for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0, hide: hideLine })) {
|
|
587
|
+
if (row.hidden) {
|
|
588
|
+
pre.appendChild(el("div", "r-log-cut", `${row.hidden} line${row.hidden === 1 ? "" : "s"} of this log left out on this page`));
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
503
591
|
if (row.setup) {
|
|
504
|
-
const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"}
|
|
592
|
+
const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} runner set-up and file copies (${row.setup} line${row.setup === 1 ? "" : "s"})`);
|
|
505
593
|
fold.setAttribute("role", "button");
|
|
506
594
|
fold.tabIndex = 0;
|
|
507
595
|
fold.setAttribute("aria-expanded", String(row.open));
|
|
@@ -512,9 +600,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
512
600
|
pre.appendChild(fold);
|
|
513
601
|
continue;
|
|
514
602
|
}
|
|
515
|
-
const
|
|
603
|
+
const lineKey = `${key}|${row.n}`;
|
|
604
|
+
const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}${flash === lineKey ? " r-flash" : ""}`);
|
|
605
|
+
ln.dataset.n = String(row.n);
|
|
516
606
|
const shown = logLineWords(job.status, row.text);
|
|
517
|
-
|
|
607
|
+
const text = row.quiet ? el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text) : textOf(shown.held ? "r-lt r-wait" : "r-lt", shown.text);
|
|
608
|
+
ln.append(el("span", "r-ln", String(row.n)), text);
|
|
609
|
+
// behold#571: the line's feature, as a chip on its first line in the job and a dot after.
|
|
610
|
+
const mark = marks.get(`${i}:${row.n}`);
|
|
611
|
+
if (mark) {
|
|
612
|
+
ln.classList.add("r-ann-line");
|
|
613
|
+
ln.dataset.annKey = lineKey;
|
|
614
|
+
text.append(" ", annMark(mark.a, { first: mark.first, tour: mark.first && TOUR.includes(mark.a.id) }));
|
|
615
|
+
if (note && note.key === lineKey) ln.appendChild(annNote(mark.a, note));
|
|
616
|
+
}
|
|
518
617
|
pre.appendChild(ln);
|
|
519
618
|
}
|
|
520
619
|
if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
|
|
@@ -528,6 +627,67 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
528
627
|
return { log: logEl, steps: stepsBox, key: logEl ? logKey(run.id, job.name, open) : null, live: LIVE.has(job.status) };
|
|
529
628
|
}
|
|
530
629
|
|
|
630
|
+
/** behold#571: a line's feature mark: an outlined chip (the tool and the feature), or a faint dot on a later line of it. */
|
|
631
|
+
export function annMark(a, { first = true, tour = false } = {}) {
|
|
632
|
+
const b = el("button", first ? `r-ann r-ann-${a.product}${tour ? " r-ann-tour" : ""}` : `r-ann-dot r-ann-${a.product}`, first ? chipWords(a) : "");
|
|
633
|
+
b.type = "button";
|
|
634
|
+
b.dataset.ann = a.id;
|
|
635
|
+
b.setAttribute("aria-label", `${chipWords(a)}: ${a.note}`);
|
|
636
|
+
if (!first) b.title = chipWords(a);
|
|
637
|
+
return b;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** behold#571: a feature's note, opened under its line: the sentence, and the docs in a new tab. */
|
|
641
|
+
export function annNote(a, { auto = false, pinned = false } = {}) {
|
|
642
|
+
const box = el("div", `r-ann-note${auto ? " r-ann-auto" : ""}${pinned ? " r-ann-pinned" : ""}`);
|
|
643
|
+
box.setAttribute("role", "note");
|
|
644
|
+
box.appendChild(el("div", `r-ann-note-head r-ann-${a.product}`, chipWords(a)));
|
|
645
|
+
box.appendChild(el("p", "r-ann-note-text", a.note));
|
|
646
|
+
const link = el("a", "r-ann-more", "Read more");
|
|
647
|
+
link.href = docsUrl(a);
|
|
648
|
+
link.target = "_blank";
|
|
649
|
+
link.rel = "noopener";
|
|
650
|
+
box.appendChild(link);
|
|
651
|
+
return box;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** behold#571: how many whole lines the folded features strip shows. */
|
|
655
|
+
export const FEATURE_LINES = 3;
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* behold#571: fold the features strip to FEATURE_LINES whole lines: what
|
|
659
|
+
* would wrap past them is hidden, never cut through, and a "+N more" button
|
|
660
|
+
* on the last line opens the rest. A tool's name left last on a line with
|
|
661
|
+
* none of its features after it goes too. Measures the drawn list.
|
|
662
|
+
*/
|
|
663
|
+
export function fitFeatures(list, open, lines = FEATURE_LINES) {
|
|
664
|
+
if (!list) return;
|
|
665
|
+
const more = list.querySelector(".r-feats-more");
|
|
666
|
+
const items = [...list.children].filter((c) => c !== more);
|
|
667
|
+
for (const i of items) i.hidden = false;
|
|
668
|
+
if (open) {
|
|
669
|
+
more.hidden = false;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
more.hidden = true;
|
|
673
|
+
const tops = [...new Set(items.map((i) => i.offsetTop))].sort((a, b) => a - b);
|
|
674
|
+
if (tops.length <= lines) return;
|
|
675
|
+
const last = tops[lines - 1];
|
|
676
|
+
const shown = items.filter((i) => i.offsetTop <= last);
|
|
677
|
+
for (const i of items) if (i.offsetTop > last) i.hidden = true;
|
|
678
|
+
more.hidden = false;
|
|
679
|
+
const count = () => items.filter((i) => i.hidden && i.classList.contains("r-feat")).length;
|
|
680
|
+
more.textContent = `+${count()} more`;
|
|
681
|
+
// The button sits on the last line: hide features from its end until it fits there.
|
|
682
|
+
while (more.offsetTop > last && shown.length) {
|
|
683
|
+
shown.pop().hidden = true;
|
|
684
|
+
more.textContent = `+${count()} more`;
|
|
685
|
+
}
|
|
686
|
+
// A tool's name with none of its features shown after it.
|
|
687
|
+
const vis = items.filter((i) => !i.hidden);
|
|
688
|
+
if (vis.length && vis[vis.length - 1].classList.contains("r-feats-product")) vis[vis.length - 1].hidden = true;
|
|
689
|
+
}
|
|
690
|
+
|
|
531
691
|
/** Which log is open: the run, the job and the step. A new key means a different log, which the pane follows from its end. */
|
|
532
692
|
export function logKey(runId, job, step) {
|
|
533
693
|
return `${runId}|${job}|${step}`;
|
|
@@ -560,8 +720,30 @@ export const PICK_HOLD_MS = 90_000;
|
|
|
560
720
|
* is live), and redraw. The live run is selected until a viewer picks
|
|
561
721
|
* another, and again {@link PICK_HOLD_MS} after their last pick.
|
|
562
722
|
*/
|
|
563
|
-
export function startRuns(
|
|
564
|
-
|
|
723
|
+
export function startRuns(
|
|
724
|
+
{ side, main },
|
|
725
|
+
{
|
|
726
|
+
fetchJson,
|
|
727
|
+
now = () => new Date(),
|
|
728
|
+
shown = now,
|
|
729
|
+
scenarios = {},
|
|
730
|
+
titles = () => ({}),
|
|
731
|
+
names = () => ({}),
|
|
732
|
+
onLive = () => {},
|
|
733
|
+
roots = () => [],
|
|
734
|
+
waves = () => [],
|
|
735
|
+
light = () => false,
|
|
736
|
+
source = () => null,
|
|
737
|
+
filter = (index) => index,
|
|
738
|
+
replay = () => false,
|
|
739
|
+
hideLine = null,
|
|
740
|
+
} = {},
|
|
741
|
+
) {
|
|
742
|
+
const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null }, note: null, flash: null, scrollTo: null, pending: null, featsOpen: false };
|
|
743
|
+
// behold#571: the notes a kiosk with nobody at it has opened by itself, and when it last had a pointer.
|
|
744
|
+
const toured = new Set();
|
|
745
|
+
let lastPointer = -Infinity;
|
|
746
|
+
let tourRest = 0;
|
|
565
747
|
let follow = true;
|
|
566
748
|
let lastRunKey = "";
|
|
567
749
|
// The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
|
|
@@ -581,20 +763,52 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
581
763
|
const id = selectedId();
|
|
582
764
|
const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) || null : null;
|
|
583
765
|
const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
|
|
766
|
+
// behold#571: a line asked for from elsewhere (the approval panel), once its run is read.
|
|
767
|
+
if (s.pending && ans && ans.doc && ans.doc.id === s.pending.run) {
|
|
768
|
+
const p = s.pending;
|
|
769
|
+
s.pending = null;
|
|
770
|
+
s.ui = { job: p.job, open: p.step, setup: null };
|
|
771
|
+
const k = `${logKey(p.run, p.job, p.step)}|${p.n}`;
|
|
772
|
+
s.scrollTo = k;
|
|
773
|
+
s.flash = { key: k, until: now().getTime() + FLASH_MS };
|
|
774
|
+
s.note = p.note === false ? s.note : { key: k, pinned: true, auto: false };
|
|
775
|
+
}
|
|
776
|
+
const t = now().getTime();
|
|
777
|
+
if (s.flash && t > s.flash.until) s.flash = null;
|
|
778
|
+
if (s.note && s.note.auto && t > s.note.until) {
|
|
779
|
+
s.note = null;
|
|
780
|
+
tourRest = t + TOUR_REST_MS;
|
|
781
|
+
}
|
|
584
782
|
// One merged read of the selected run for both the sidebar and its page.
|
|
585
783
|
const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
|
|
586
|
-
renderSidebar(side, s.index, id, { now:
|
|
784
|
+
renderSidebar(side, s.index, id, { now: shown(), scenarios, titles: titles(), names: names(), pick, merged, replay: replay() });
|
|
587
785
|
// The redraw replaces the scrollers: keep where a viewer left them.
|
|
588
786
|
const old = main.querySelector(".r-log");
|
|
589
787
|
const oldSteps = main.querySelector(".r-steps");
|
|
590
788
|
const prevTop = old ? old.scrollTop : 0;
|
|
591
789
|
const prevStepsTop = oldSteps ? oldSteps.scrollTop : 0;
|
|
592
790
|
const drawn = renderRun(main, ans, s.ui, {
|
|
593
|
-
now:
|
|
791
|
+
now: shown(),
|
|
594
792
|
entry,
|
|
595
793
|
roots: roots(),
|
|
596
794
|
waves: waves(),
|
|
597
795
|
light: light(),
|
|
796
|
+
hideLine,
|
|
797
|
+
note: s.note,
|
|
798
|
+
flash: s.flash ? s.flash.key : null,
|
|
799
|
+
featsOpen: s.featsOpen,
|
|
800
|
+
toggleFeats: () => {
|
|
801
|
+
s.featsOpen = !s.featsOpen;
|
|
802
|
+
hold();
|
|
803
|
+
draw();
|
|
804
|
+
},
|
|
805
|
+
jump: (job, step, n) => {
|
|
806
|
+
const runId = ans && ans.doc ? ans.doc.id : null;
|
|
807
|
+
if (runId === null) return;
|
|
808
|
+
s.pending = { run: runId, job, step, n };
|
|
809
|
+
hold();
|
|
810
|
+
draw();
|
|
811
|
+
},
|
|
598
812
|
pickJob: (name) => {
|
|
599
813
|
s.ui = { job: name, open: null, setup: null };
|
|
600
814
|
hold();
|
|
@@ -611,6 +825,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
611
825
|
draw();
|
|
612
826
|
},
|
|
613
827
|
});
|
|
828
|
+
fitFeatures(main.querySelector(".r-feats-list"), s.featsOpen);
|
|
614
829
|
logFollow = followsLog(logShown, drawn.key, logFollow);
|
|
615
830
|
const sameLog = drawn.key !== null && drawn.key === logShown;
|
|
616
831
|
logShown = drawn.key;
|
|
@@ -620,7 +835,17 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
620
835
|
}
|
|
621
836
|
const log = drawn.log;
|
|
622
837
|
const steps = drawn.steps;
|
|
623
|
-
|
|
838
|
+
const target = s.scrollTo && s.scrollTo.startsWith(`${drawn.key}|`) ? log.querySelector(`.r-log-line[data-n="${s.scrollTo.slice(drawn.key.length + 1)}"]`) : null;
|
|
839
|
+
if (target) {
|
|
840
|
+
// A line asked for: in the middle of the log's view, the log in the steps' view; the pane stops following.
|
|
841
|
+
s.scrollTo = null;
|
|
842
|
+
logFollow = false;
|
|
843
|
+
log.scrollTop = Math.max(0, target.offsetTop - log.clientHeight / 3);
|
|
844
|
+
steps.scrollTop = 0;
|
|
845
|
+
const below = log.getBoundingClientRect().bottom - steps.getBoundingClientRect().bottom;
|
|
846
|
+
if (below > 0) steps.scrollTop += below + 8;
|
|
847
|
+
if (typeof target.scrollIntoView === "function" && main.closest && main.closest(".k-runs-open")) target.scrollIntoView({ block: "center" });
|
|
848
|
+
} else if (logFollow) {
|
|
624
849
|
log.scrollTop = log.scrollHeight;
|
|
625
850
|
// The steps list scrolls too: bring the log's end into its view.
|
|
626
851
|
steps.scrollTop = sameLog ? prevStepsTop : 0;
|
|
@@ -639,7 +864,70 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
639
864
|
};
|
|
640
865
|
log.addEventListener("scroll", onScroll, { passive: true });
|
|
641
866
|
steps.addEventListener("scroll", onScroll, { passive: true });
|
|
867
|
+
tour(log);
|
|
642
868
|
};
|
|
869
|
+
/**
|
|
870
|
+
* behold#571: with nobody at the screen (no pointer or touch for a while,
|
|
871
|
+
* and not a touch screen), the tour's chips open their notes one at a
|
|
872
|
+
* time, each for a few seconds, as their line scrolls into the log's view.
|
|
873
|
+
*/
|
|
874
|
+
const tour = (log) => {
|
|
875
|
+
const t = now().getTime();
|
|
876
|
+
if (s.note || t < tourRest || t - lastPointer < IDLE_MS || !tourable()) return;
|
|
877
|
+
const view = log.getBoundingClientRect();
|
|
878
|
+
if (!view.height) return;
|
|
879
|
+
for (const chip of log.querySelectorAll(".r-ann-tour")) {
|
|
880
|
+
const line = chip.closest(".r-ann-line");
|
|
881
|
+
const key = line && line.dataset.annKey;
|
|
882
|
+
if (!key || toured.has(key)) continue;
|
|
883
|
+
const r = line.getBoundingClientRect();
|
|
884
|
+
if (r.top < view.top || r.bottom > view.bottom - view.height / 4) continue;
|
|
885
|
+
toured.add(key);
|
|
886
|
+
s.note = { key, pinned: false, auto: true, until: t + TOUR_NOTE_MS };
|
|
887
|
+
draw();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
// behold#571: pointing at a chip or a dot opens its note, a click or a tap keeps it open; a click elsewhere closes it.
|
|
892
|
+
const annKeyAt = (target) => {
|
|
893
|
+
const mark = target && target.closest ? target.closest(".r-ann, .r-ann-dot, .r-ann-note") : null;
|
|
894
|
+
const line = mark && mark.closest(".r-ann-line");
|
|
895
|
+
return line ? line.dataset.annKey : null;
|
|
896
|
+
};
|
|
897
|
+
if (main.addEventListener) {
|
|
898
|
+
const seen = () => (lastPointer = now().getTime());
|
|
899
|
+
for (const ev of ["pointermove", "pointerdown", "touchstart", "wheel", "keydown"]) document.addEventListener(ev, seen, { passive: true });
|
|
900
|
+
main.addEventListener("pointerover", (e) => {
|
|
901
|
+
if (e.pointerType === "touch") return;
|
|
902
|
+
const key = annKeyAt(e.target);
|
|
903
|
+
if (s.note && (s.note.pinned || s.note.key === key)) return;
|
|
904
|
+
if (!key && !s.note) return;
|
|
905
|
+
s.note = key ? { key, pinned: false, auto: false } : null;
|
|
906
|
+
draw();
|
|
907
|
+
});
|
|
908
|
+
document.addEventListener("click", (e) => {
|
|
909
|
+
// The approval panel's chips and the features strip show their line, which opens its note.
|
|
910
|
+
if (e.target && e.target.closest && e.target.closest("[data-ann-jump], .r-feat")) return;
|
|
911
|
+
const key = annKeyAt(e.target);
|
|
912
|
+
const onMark = e.target && e.target.closest && e.target.closest(".r-ann, .r-ann-dot");
|
|
913
|
+
if (onMark && key) {
|
|
914
|
+
s.note = s.note && s.note.key === key && s.note.pinned ? null : { key, pinned: true, auto: false };
|
|
915
|
+
hold();
|
|
916
|
+
draw();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (s.note && !key) {
|
|
920
|
+
s.note = null;
|
|
921
|
+
draw();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
document.addEventListener("keydown", (e) => {
|
|
925
|
+
if (e.key === "Escape" && s.note) {
|
|
926
|
+
s.note = null;
|
|
927
|
+
draw();
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
}
|
|
643
931
|
const hold = () => {
|
|
644
932
|
follow = false;
|
|
645
933
|
s.selected = selectedId() ?? s.selected;
|
|
@@ -653,7 +941,9 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
653
941
|
void readRun();
|
|
654
942
|
};
|
|
655
943
|
const readIndex = async () => {
|
|
656
|
-
s
|
|
944
|
+
// behold#571: a tool's page reads its replay's runs from `source`, and keeps its own repo's alone.
|
|
945
|
+
const src = source();
|
|
946
|
+
s.index = filter(src ? await src.index() : await fetchJson("/api/terragucci/runs"));
|
|
657
947
|
const runs = s.index && s.index.doc ? s.index.doc.runs : [];
|
|
658
948
|
const live = liveRun(runs);
|
|
659
949
|
onLive(live);
|
|
@@ -661,18 +951,44 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
661
951
|
};
|
|
662
952
|
const readRun = async () => {
|
|
663
953
|
const id = selectedId();
|
|
664
|
-
if (id === null)
|
|
954
|
+
if (id === null) {
|
|
955
|
+
if (s.index && s.index.doc) s.run = { doc: null, absent: "No run to show yet." };
|
|
956
|
+
return draw();
|
|
957
|
+
}
|
|
665
958
|
const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) : null;
|
|
666
959
|
// A run that ended reads once; one still going reads on every tick.
|
|
667
|
-
const
|
|
960
|
+
const src = source();
|
|
961
|
+
const key = `${src ? "replay" : "live"}|${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
|
|
668
962
|
if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
|
|
669
|
-
if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null, setup: null };
|
|
963
|
+
if (s.run && s.run.doc && s.run.doc.id !== id && !s.pending) s.ui = { job: null, open: null, setup: null };
|
|
670
964
|
lastRunKey = key;
|
|
671
|
-
s.run = await fetchJson(`/api/terragucci/runs/${id}`);
|
|
965
|
+
s.run = src ? await src.run(id) : await fetchJson(`/api/terragucci/runs/${id}`);
|
|
672
966
|
draw();
|
|
673
967
|
};
|
|
674
968
|
void readIndex();
|
|
675
969
|
setInterval(() => void readIndex(), 3_000);
|
|
676
970
|
setInterval(draw, 1_000);
|
|
971
|
+
/** behold#571: show a run's log line from elsewhere (the approval panel's chips): its job, its step open, the line in view and lit, its note open. */
|
|
972
|
+
s.show = (run, job, step, n) => {
|
|
973
|
+
s.selected = run;
|
|
974
|
+
follow = false;
|
|
975
|
+
s.picked = now().getTime();
|
|
976
|
+
s.pending = { run, job, step, n };
|
|
977
|
+
if (s.run && s.run.doc && s.run.doc.id === run) draw();
|
|
978
|
+
else void readRun();
|
|
979
|
+
};
|
|
677
980
|
return s;
|
|
678
981
|
}
|
|
982
|
+
|
|
983
|
+
/** How long a line asked for stays lit. */
|
|
984
|
+
export const FLASH_MS = 2_500;
|
|
985
|
+
/** How long a tour's note stays open, and the rest before the next. */
|
|
986
|
+
export const TOUR_NOTE_MS = 6_000;
|
|
987
|
+
export const TOUR_REST_MS = 2_000;
|
|
988
|
+
/** How long without a pointer or a touch before the kiosk counts as unattended. */
|
|
989
|
+
export const IDLE_MS = 45_000;
|
|
990
|
+
|
|
991
|
+
/** Whether this screen takes a tour: not a touch screen, where a finger opens the notes. */
|
|
992
|
+
function tourable() {
|
|
993
|
+
return !(typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
|
|
994
|
+
}
|
package/web/kiosk-runs.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, codeish, logLineWords, logRows, placeWords, scenarioTitles, setupLine, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
4
|
+
import { FEATURE_LINES, fitFeatures, FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, codeish, logLineWords, logRows, placeWords, scenarioTitles, setupLine, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
5
5
|
|
|
6
6
|
const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
|
|
7
7
|
const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
|
|
@@ -183,3 +183,79 @@ describe("the runs for a viewer new to the demo (behold#571)", () => {
|
|
|
183
183
|
expect(logRows(own.log)).toEqual(own.log.map((text, i) => ({ n: i + 1, text })));
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
|
|
187
|
+
describe("an artifact's upload and download fold with the set-up (behold#571)", () => {
|
|
188
|
+
it("knows upload-artifact's and download-artifact's lines", () => {
|
|
189
|
+
for (const l of [
|
|
190
|
+
"With the provided path, there will be 9 files uploaded",
|
|
191
|
+
"Starting artifact upload",
|
|
192
|
+
"For more detailed logs during the artifact upload process, enable step-debugging: https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging#enabling-step-debug-logging",
|
|
193
|
+
"Artifact name is valid!",
|
|
194
|
+
'Container for artifact "terragucci-report-apply-wave-1" successfully created. Starting upload of file(s)',
|
|
195
|
+
"Total size of all the files uploaded is 10011 bytes",
|
|
196
|
+
"File upload process has finished. Finalizing the artifact upload",
|
|
197
|
+
"Artifact has been finalized. All files have been successfully uploaded!",
|
|
198
|
+
"The raw size of all the files that were specified for upload is 38546 bytes",
|
|
199
|
+
"The size of all the files that were uploaded is 10011 bytes. This takes into account any gzip compression used to reduce the upload size, time and storage",
|
|
200
|
+
"Note: The size of downloaded zips can differ significantly from the reported size. For more information see: https://github.com/actions/upload-artifact#zipped-artifact-downloads ",
|
|
201
|
+
"Artifact terragucci-report-apply-wave-1 has been successfully uploaded!",
|
|
202
|
+
"Starting download for terragucci-report",
|
|
203
|
+
"Directory structure has been setup for the artifact",
|
|
204
|
+
"Total number of files that will be downloaded: 16",
|
|
205
|
+
"Artifact terragucci-report was downloaded to /workspace/terragucci-admin/demo/terragucci-report",
|
|
206
|
+
"Artifact download has finished successfully",
|
|
207
|
+
]) expect(setupLine(l), l).toBe(true);
|
|
208
|
+
expect(setupLine("wave 2: report copied to demo/forgejo:3000/x/tf-apply-wave-2")).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("folds a whole upload step, its blank lines with it, into the one row", () => {
|
|
212
|
+
const keep = run(444).jobs.find((j) => j.name === "apply-wave-1").steps.find((s) => s.name === "Keep the apply report");
|
|
213
|
+
const rows = logRows(keep.log);
|
|
214
|
+
expect(rows).toEqual([{ setup: keep.log.length, open: false }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("keeps a blank line that follows terragucci's own lines", () => {
|
|
218
|
+
expect(logRows(["3 members: 3 groups, 0 destroys or replacements.", "", "Group b6b888372b35: 1 member (outlier), change:"]).map((r) => r.n)).toEqual([1, 2, 3]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("never opens an upload step by default, even when it has the most lines", () => {
|
|
222
|
+
const check = run(444).jobs.find((j) => j.name === "check").steps;
|
|
223
|
+
expect(check[defaultStep(check)].name).toBe("Format check and validate, every root");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("the features strip, folded to whole lines (behold#571)", () => {
|
|
228
|
+
// A drawn list just big enough: each item where it wrapped to, and the "+N more" button.
|
|
229
|
+
const item = (cls, top) => ({ hidden: false, offsetTop: top, classList: { contains: (c) => c === cls } });
|
|
230
|
+
const list = (items, moreTop) => {
|
|
231
|
+
const more = { hidden: false, offsetTop: moreTop, textContent: "", classList: { contains: () => false } };
|
|
232
|
+
return { more, items, el: { children: [...items, more], querySelector: () => more } };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
it("hides what wraps past the last line it shows, never part of a line, and counts the features hidden", () => {
|
|
236
|
+
expect(FEATURE_LINES).toBe(3);
|
|
237
|
+
const items = [item("r-feats-product", 0), item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feats-product", 60), item("r-feat", 60), item("r-feat", 80)];
|
|
238
|
+
const l = list(items, 40);
|
|
239
|
+
fitFeatures(l.el, false);
|
|
240
|
+
expect(items.map((i) => i.hidden)).toEqual([false, false, false, false, true, true, true]);
|
|
241
|
+
expect(l.more).toMatchObject({ hidden: false, textContent: "+2 more" });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("shows everything, and no button, when it fits; everything with the button when opened", () => {
|
|
245
|
+
const fits = list([item("r-feat", 0), item("r-feat", 20)], 20);
|
|
246
|
+
fitFeatures(fits.el, false);
|
|
247
|
+
expect(fits.items.every((i) => !i.hidden)).toBe(true);
|
|
248
|
+
expect(fits.more.hidden).toBe(true);
|
|
249
|
+
const open = list([item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feat", 60)], 60);
|
|
250
|
+
fitFeatures(open.el, true);
|
|
251
|
+
expect(open.items.every((i) => !i.hidden)).toBe(true);
|
|
252
|
+
expect(open.more.hidden).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("drops a tool's name left last on a line with none of its features after it", () => {
|
|
256
|
+
const items = [item("r-feat", 0), item("r-feat", 20), item("r-feats-product", 40), item("r-feat", 60)];
|
|
257
|
+
const l = list(items, 40);
|
|
258
|
+
fitFeatures(l.el, false);
|
|
259
|
+
expect(items.map((i) => i.hidden)).toEqual([false, false, true, true]);
|
|
260
|
+
});
|
|
261
|
+
});
|