@intentius/behold 0.29.6 → 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/AGENTS.md +6 -1
- package/README.md +7 -0
- package/dist/cli.js +1263 -770
- package/package.json +1 -1
- package/web/app.js +320 -0
- package/web/index.html +24 -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 +235 -0
- package/web/kiosk-popover.test.js +180 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +199 -0
- package/web/kiosk-roots.test.js +95 -0
- package/web/kiosk-runs.js +365 -23
- 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 +160 -17
- package/web/kiosk.js +536 -59
- package/web/kiosk.test.js +76 -11
- 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,6 +10,9 @@
|
|
|
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, rootPaint, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
14
|
+
import { TOUR, chipWords, docsUrl, jobMarks, runFeatures } from "./kiosk-annotations.js";
|
|
15
|
+
|
|
13
16
|
/** The run, job and step statuses, as a viewer reads them. */
|
|
14
17
|
export const STATUS_WORDS = {
|
|
15
18
|
queued: "queued",
|
|
@@ -182,7 +185,12 @@ function ownSteps(steps) {
|
|
|
182
185
|
|
|
183
186
|
/** How many log lines a job's own steps wrote. */
|
|
184
187
|
function ownLines(steps) {
|
|
185
|
-
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;
|
|
186
194
|
}
|
|
187
195
|
|
|
188
196
|
/**
|
|
@@ -198,8 +206,8 @@ export function defaultStep(steps) {
|
|
|
198
206
|
const i = steps.findIndex((x) => x.status === s);
|
|
199
207
|
if (i >= 0) return i;
|
|
200
208
|
}
|
|
201
|
-
const own = ownSteps(steps).filter(({ s }) => s.log
|
|
202
|
-
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;
|
|
203
211
|
const any = steps.map((s, i) => ({ s, i })).filter(({ s }) => s.log && s.log.length);
|
|
204
212
|
return any.length ? any[any.length - 1].i : 0;
|
|
205
213
|
}
|
|
@@ -287,6 +295,39 @@ export const SETUP_LINES = [
|
|
|
287
295
|
/^Deleting the contents of '/,
|
|
288
296
|
/^Syncing repository: /,
|
|
289
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$/,
|
|
290
331
|
];
|
|
291
332
|
|
|
292
333
|
/** Whether a log line is set-up noise (SETUP_LINES). */
|
|
@@ -300,8 +341,22 @@ export function setupLine(line) {
|
|
|
300
341
|
* opened (`open`), the row heads the whole log in order. Every line keeps
|
|
301
342
|
* its number in the log. A log with no set-up lines is drawn as it came.
|
|
302
343
|
*/
|
|
303
|
-
export function logRows(lines, { open = false, from = 0 } = {}) {
|
|
304
|
-
|
|
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) {
|
|
305
360
|
const n = all.filter((l) => l.setup).length;
|
|
306
361
|
if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
|
|
307
362
|
const head = { setup: n, open };
|
|
@@ -353,11 +408,12 @@ export function statusIcon(status) {
|
|
|
353
408
|
}
|
|
354
409
|
|
|
355
410
|
/** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
|
|
356
|
-
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 } = {}) {
|
|
357
412
|
host.replaceChildren();
|
|
358
413
|
const head = el("div", "r-side-head");
|
|
359
414
|
head.appendChild(el("div", "r-side-title", "Pipeline runs"));
|
|
360
|
-
|
|
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."));
|
|
361
417
|
host.appendChild(head);
|
|
362
418
|
if (!index || index.error || index.absent || !index.doc) {
|
|
363
419
|
host.appendChild(el("p", "r-note", index ? index.absent || index.error : "Reading the runs…"));
|
|
@@ -365,11 +421,15 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
365
421
|
}
|
|
366
422
|
// The selected run as its page draws it, when its own document is newer than the index.
|
|
367
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
|
+
}
|
|
368
428
|
const live = runs[0];
|
|
369
429
|
const named = scenarioTitles(index.doc.runs, titles);
|
|
370
430
|
const list = el("ol", "r-runs");
|
|
371
431
|
runs.forEach((r, i) => {
|
|
372
|
-
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"));
|
|
373
433
|
if (i === 1) list.appendChild(el("li", "r-group", "Recent"));
|
|
374
434
|
const li = el("li", `r-run${r.id === selectedId ? " r-selected" : ""}`);
|
|
375
435
|
li.tabIndex = 0;
|
|
@@ -405,7 +465,14 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
405
465
|
* null for the defaults. Returns what it drew, so the caller can keep a log's
|
|
406
466
|
* scroll.
|
|
407
467
|
*/
|
|
408
|
-
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null } = {}) {
|
|
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 } = {}) {
|
|
469
|
+
// behold#571: a root's name with its colour's dot, where the line names one.
|
|
470
|
+
const textOf = (cls, text) => {
|
|
471
|
+
if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
|
|
472
|
+
const n = el("span", cls);
|
|
473
|
+
n.appendChild(rootText(document, text, roots, { light }));
|
|
474
|
+
return n;
|
|
475
|
+
};
|
|
409
476
|
host.replaceChildren();
|
|
410
477
|
if (!answer || answer.error || answer.absent || !answer.doc) {
|
|
411
478
|
host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
|
|
@@ -440,6 +507,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
440
507
|
li.style.setProperty("--depth", String(j.depth));
|
|
441
508
|
li.appendChild(statusIcon(j.status));
|
|
442
509
|
li.appendChild(el("span", "r-job-name", j.name));
|
|
510
|
+
// behold#571: a wave's job row names the roots it applies, each with its dot, and lights its wave box on hover.
|
|
511
|
+
const wave = waveOfJob(j.name, waves);
|
|
512
|
+
const applies = wave !== null ? rootsOfWave(wave, waves).filter((r) => rootColour(r, roots, { light })) : [];
|
|
513
|
+
if (wave !== null && waves.length) li.dataset.kWave = String(wave);
|
|
514
|
+
if (applies.length) {
|
|
515
|
+
const box = el("span", "r-job-roots");
|
|
516
|
+
for (const r of applies) {
|
|
517
|
+
const name = el("span", "k-root", r);
|
|
518
|
+
name.dataset.kRoot = r;
|
|
519
|
+
name.style.setProperty("--k-root", rootPaint(r, roots, { light }));
|
|
520
|
+
box.appendChild(name);
|
|
521
|
+
}
|
|
522
|
+
li.appendChild(box);
|
|
523
|
+
}
|
|
443
524
|
if (j.needs && j.needs.length) li.appendChild(el("span", "r-job-needs", `after ${j.needs.join(", ")}`));
|
|
444
525
|
li.appendChild(el("span", "r-dur", j.status === "waiting" ? "held at the gate" : durationWords(j.started_at, j.ended_at, now) || STATUS_WORDS[j.status] || ""));
|
|
445
526
|
li.addEventListener("click", () => pickJob(j.name));
|
|
@@ -448,6 +529,31 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
448
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(", ")}`));
|
|
449
530
|
host.appendChild(jobsBox);
|
|
450
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
|
+
|
|
451
557
|
if (!job) return { log: null, steps: null, key: null };
|
|
452
558
|
host.appendChild(el("div", "r-label", "Steps of the job, with its log"));
|
|
453
559
|
const stepsBox = el("div", "r-steps");
|
|
@@ -466,6 +572,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
466
572
|
}
|
|
467
573
|
const open = ui.open !== null && ui.open !== undefined && ui.job === job.name ? ui.open : defaultStep(steps);
|
|
468
574
|
let logEl = null;
|
|
575
|
+
const marks = jobMarks(steps, { hide: hideLine });
|
|
469
576
|
steps.forEach((s, i) => {
|
|
470
577
|
const row = el("div", `r-step${i === open ? " r-open" : ""}`);
|
|
471
578
|
const line = el("div", "r-step-line");
|
|
@@ -476,9 +583,13 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
476
583
|
const pre = el("pre", "r-log");
|
|
477
584
|
if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
|
|
478
585
|
const key = logKey(run.id, job.name, i);
|
|
479
|
-
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
|
+
}
|
|
480
591
|
if (row.setup) {
|
|
481
|
-
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"})`);
|
|
482
593
|
fold.setAttribute("role", "button");
|
|
483
594
|
fold.tabIndex = 0;
|
|
484
595
|
fold.setAttribute("aria-expanded", String(row.open));
|
|
@@ -489,9 +600,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
489
600
|
pre.appendChild(fold);
|
|
490
601
|
continue;
|
|
491
602
|
}
|
|
492
|
-
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);
|
|
493
606
|
const shown = logLineWords(job.status, row.text);
|
|
494
|
-
|
|
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
|
+
}
|
|
495
617
|
pre.appendChild(ln);
|
|
496
618
|
}
|
|
497
619
|
if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
|
|
@@ -505,6 +627,67 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
505
627
|
return { log: logEl, steps: stepsBox, key: logEl ? logKey(run.id, job.name, open) : null, live: LIVE.has(job.status) };
|
|
506
628
|
}
|
|
507
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
|
+
|
|
508
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. */
|
|
509
692
|
export function logKey(runId, job, step) {
|
|
510
693
|
return `${runId}|${job}|${step}`;
|
|
@@ -537,8 +720,30 @@ export const PICK_HOLD_MS = 90_000;
|
|
|
537
720
|
* is live), and redraw. The live run is selected until a viewer picks
|
|
538
721
|
* another, and again {@link PICK_HOLD_MS} after their last pick.
|
|
539
722
|
*/
|
|
540
|
-
export function startRuns(
|
|
541
|
-
|
|
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;
|
|
542
747
|
let follow = true;
|
|
543
748
|
let lastRunKey = "";
|
|
544
749
|
// The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
|
|
@@ -558,17 +763,52 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
558
763
|
const id = selectedId();
|
|
559
764
|
const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) || null : null;
|
|
560
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
|
+
}
|
|
561
782
|
// One merged read of the selected run for both the sidebar and its page.
|
|
562
783
|
const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
|
|
563
|
-
renderSidebar(side, s.index, id, { now:
|
|
784
|
+
renderSidebar(side, s.index, id, { now: shown(), scenarios, titles: titles(), names: names(), pick, merged, replay: replay() });
|
|
564
785
|
// The redraw replaces the scrollers: keep where a viewer left them.
|
|
565
786
|
const old = main.querySelector(".r-log");
|
|
566
787
|
const oldSteps = main.querySelector(".r-steps");
|
|
567
788
|
const prevTop = old ? old.scrollTop : 0;
|
|
568
789
|
const prevStepsTop = oldSteps ? oldSteps.scrollTop : 0;
|
|
569
790
|
const drawn = renderRun(main, ans, s.ui, {
|
|
570
|
-
now:
|
|
791
|
+
now: shown(),
|
|
571
792
|
entry,
|
|
793
|
+
roots: roots(),
|
|
794
|
+
waves: waves(),
|
|
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
|
+
},
|
|
572
812
|
pickJob: (name) => {
|
|
573
813
|
s.ui = { job: name, open: null, setup: null };
|
|
574
814
|
hold();
|
|
@@ -585,6 +825,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
585
825
|
draw();
|
|
586
826
|
},
|
|
587
827
|
});
|
|
828
|
+
fitFeatures(main.querySelector(".r-feats-list"), s.featsOpen);
|
|
588
829
|
logFollow = followsLog(logShown, drawn.key, logFollow);
|
|
589
830
|
const sameLog = drawn.key !== null && drawn.key === logShown;
|
|
590
831
|
logShown = drawn.key;
|
|
@@ -594,7 +835,17 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
594
835
|
}
|
|
595
836
|
const log = drawn.log;
|
|
596
837
|
const steps = drawn.steps;
|
|
597
|
-
|
|
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) {
|
|
598
849
|
log.scrollTop = log.scrollHeight;
|
|
599
850
|
// The steps list scrolls too: bring the log's end into its view.
|
|
600
851
|
steps.scrollTop = sameLog ? prevStepsTop : 0;
|
|
@@ -613,7 +864,70 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
613
864
|
};
|
|
614
865
|
log.addEventListener("scroll", onScroll, { passive: true });
|
|
615
866
|
steps.addEventListener("scroll", onScroll, { passive: true });
|
|
867
|
+
tour(log);
|
|
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;
|
|
616
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
|
+
}
|
|
617
931
|
const hold = () => {
|
|
618
932
|
follow = false;
|
|
619
933
|
s.selected = selectedId() ?? s.selected;
|
|
@@ -627,7 +941,9 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
627
941
|
void readRun();
|
|
628
942
|
};
|
|
629
943
|
const readIndex = async () => {
|
|
630
|
-
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"));
|
|
631
947
|
const runs = s.index && s.index.doc ? s.index.doc.runs : [];
|
|
632
948
|
const live = liveRun(runs);
|
|
633
949
|
onLive(live);
|
|
@@ -635,18 +951,44 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
635
951
|
};
|
|
636
952
|
const readRun = async () => {
|
|
637
953
|
const id = selectedId();
|
|
638
|
-
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
|
+
}
|
|
639
958
|
const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) : null;
|
|
640
959
|
// A run that ended reads once; one still going reads on every tick.
|
|
641
|
-
const
|
|
960
|
+
const src = source();
|
|
961
|
+
const key = `${src ? "replay" : "live"}|${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
|
|
642
962
|
if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
|
|
643
|
-
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 };
|
|
644
964
|
lastRunKey = key;
|
|
645
|
-
s.run = await fetchJson(`/api/terragucci/runs/${id}`);
|
|
965
|
+
s.run = src ? await src.run(id) : await fetchJson(`/api/terragucci/runs/${id}`);
|
|
646
966
|
draw();
|
|
647
967
|
};
|
|
648
968
|
void readIndex();
|
|
649
969
|
setInterval(() => void readIndex(), 3_000);
|
|
650
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
|
+
};
|
|
651
980
|
return s;
|
|
652
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
|
+
});
|