@giamme/forge 0.11.1 → 0.12.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/CHANGELOG.md +34 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +44 -11
- package/docs/commands.md +45 -7
- package/docs/decomposed-runs.md +43 -2
- package/docs/forge-ui.md +27 -6
- package/docs/reviews.md +31 -6
- package/package.json +1 -1
- package/references/dataset.md +3 -1
- package/references/decompose.md +159 -17
- package/references/harnesses.md +13 -1
- package/references/ui.md +14 -4
- package/scripts/forge-control.py +268 -25
- package/scripts/forge-dispatch.sh +80 -3
- package/scripts/forge-guard.py +156 -15
- package/scripts/forge-lib-plans.sh +9 -3
- package/scripts/forge-lib-review.sh +22 -3
- package/scripts/forge-lib-task.sh +51 -4
- package/scripts/forge-lib-verify.sh +137 -6
- package/scripts/forge-parallel-options.sh +59 -0
- package/scripts/forge-parallel.sh +212 -28
- package/scripts/forge-ripwire.py +1 -1
- package/scripts/forge-route.py +157 -5
- package/scripts/forge-schedule.py +687 -50
- package/scripts/forge-verify.py +878 -0
- package/scripts/forge_dataset/forecast.py +29 -4
- package/scripts/forge_dataset/harvest.py +10 -4
- package/scripts/forge_verify_reports.py +225 -0
- package/ui/README.md +18 -3
- package/ui/docs/api.md +126 -17
- package/ui/forge_ui/control.py +12 -2
- package/ui/forge_ui/export.py +20 -7
- package/ui/forge_ui/reader.py +307 -11
- package/ui/forge_ui/server.py +16 -0
- package/ui/forge_ui/static/app.js +2 -1
- package/ui/forge_ui/static/controls.js +217 -33
- package/ui/forge_ui/static/gantt.js +14 -9
- package/ui/forge_ui/static/index.html +1 -0
- package/ui/forge_ui/static/panel.js +2 -1
- package/ui/forge_ui/static/runmodel.js +92 -11
- package/ui/forge_ui/static/scene3d.js +9 -5
- package/ui/forge_ui/static/style.css +60 -0
- package/ui/forge_ui/static/verifyview.js +324 -0
|
@@ -289,7 +289,11 @@ export async function createStage(host, cb = {}) {
|
|
|
289
289
|
function finishInfo(id) {
|
|
290
290
|
const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
|
|
291
291
|
if (id === 'f:integration') return { title: 'INTEGRATION', sub: `${F.integration.merged}/${F.integration.total} merged`, status: F.integration.status, line: F.integration.accepted ? 'at ' + String(F.integration.accepted).slice(0, 10) : 'into forge/' + (run.run_id || '…') + '-integration' };
|
|
292
|
-
if (id === 'f:verification')
|
|
292
|
+
if (id === 'f:verification') {
|
|
293
|
+
const V = F.verification, n = V.lanes.length, plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`;
|
|
294
|
+
const line = V.mode === 'lanes' ? `${plural(n, 'lane')} · ${plural(V.reruns, 'rerun')}` : V.runs ? plural(V.runs, 'run') : 'not run yet';
|
|
295
|
+
return { title: 'VERIFICATION', sub: V.command || (V.mode === 'lanes' ? V.lanes.map(l => l.name).join(' · ') : 'no command recorded'), status: V.status, line };
|
|
296
|
+
}
|
|
293
297
|
const status = finishStatus(id, { status: F.final.status });
|
|
294
298
|
const src = status === 'OUTDATED' && st.replay !== null ? (RM.reviewAt(run, st.replay) || F.final) : F.final;
|
|
295
299
|
const f = F.final.findings;
|
|
@@ -313,7 +317,7 @@ export async function createStage(host, cb = {}) {
|
|
|
313
317
|
}, 'cover', { x: x + 12, y: y + 8, z: h });
|
|
314
318
|
if (m.id === 'f:final') { leds(el, 4, x + 18, y + d - 13, h + .16, { step: 11, s: 3 }); }
|
|
315
319
|
else if (m.id === 'f:integration') leds(el, Math.min(40, list(st.run.tasks).length), x + 17, y + d - 13, h + .16, { step: 8, s: 2.4, perRow: 22 });
|
|
316
|
-
else leds(el, Math.max(1, Math.min(12,
|
|
320
|
+
else leds(el, Math.max(1, Math.min(12, RM.verifyLeds(st.run).length)), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
|
|
317
321
|
finishLeds(el);
|
|
318
322
|
display(el, e => finishInfo(e.id).line || '', { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
|
|
319
323
|
halo(el);
|
|
@@ -326,7 +330,7 @@ export async function createStage(host, cb = {}) {
|
|
|
326
330
|
function finishLeds(el) {
|
|
327
331
|
if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
|
|
328
332
|
else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => { const s = taskStatus(t); return s === 'MERGED' || s === 'ACCEPTED' ? s : null; }));
|
|
329
|
-
else setLeds(el,
|
|
333
|
+
else setLeds(el, RM.verifyLeds(st.run, st.replay !== null ? st.replayState : null));
|
|
330
334
|
}
|
|
331
335
|
function buildPlan(g) {
|
|
332
336
|
const P = RM.planModel(st.run); st.plan = P;
|
|
@@ -512,7 +516,7 @@ export async function createStage(host, cb = {}) {
|
|
|
512
516
|
const lw = Math.max(10, it.w - 10);
|
|
513
517
|
el.roof = label(el, lw, it.d - 14, 4, (cx, s, C, e) => {
|
|
514
518
|
const item = e.data.item, sel = e.cur.sel > .5;
|
|
515
|
-
const title = item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
|
|
519
|
+
const title = item.vlane ? 'verify · ' + item.vlane : item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
|
|
516
520
|
const f = Math.min(11, Math.max(4, (lw - 4) / Math.max(4, title.length * E.CW)));
|
|
517
521
|
E.font(cx, f, s, 600); cx.fillStyle = sel ? C.accHi : item.ghost ? C.ink : C.inkHi; cx.fillText(E.ellipsis(title, Math.floor((lw - 2) / (f * E.CW))), 1 * s, (f + 1) * s);
|
|
518
522
|
// segments: when the dwarf and the review ran inside this attempt
|
|
@@ -1135,7 +1139,7 @@ export async function createStage(host, cb = {}) {
|
|
|
1135
1139
|
if (el.kind === 'finish') return { kind: el.id === 'f:final' ? 'final' : el.id === 'f:verification' ? 'verification' : 'integration' };
|
|
1136
1140
|
const it = el.data.item; if (!it) return null;
|
|
1137
1141
|
if (it.kind === 'review') return { kind: 'final', name: it.review };
|
|
1138
|
-
if (it.kind === 'verification') return { kind: 'execution', task: null, exec: it.id };
|
|
1142
|
+
if (it.kind === 'verification') return it.vlane || it.approx ? { kind: 'verification' } : { kind: 'execution', task: null, exec: it.id };
|
|
1139
1143
|
if (it.ghost || String(it.id).startsWith('span:')) return { kind: 'task', id: it.task };
|
|
1140
1144
|
return { kind: 'execution', task: it.task, id: it.task, exec: it.id };
|
|
1141
1145
|
}
|
|
@@ -99,6 +99,9 @@
|
|
|
99
99
|
.st-fail, .st-error, .st-conflict, .st-infra, .st-timeout, .st-blocked { --st: var(--lamp-red); --st-lit: var(--lit-red); }
|
|
100
100
|
[class^="st-"], [class*=" st-"] { --st-fill: var(--st); --st-ring: var(--lamp-edge); }
|
|
101
101
|
.st-accepted, .st-nochanges, .st-interrupted, .st-infra, .st-timeout, .st-blocked, .st-outdated { --st-fill: transparent; --st-ring: var(--st); }
|
|
102
|
+
/* verification lanes: flaky passed on a rerun (amber, hollow), known passed with only baseline failures (green, hollow) */
|
|
103
|
+
.st-flaky { --st: var(--lamp-amber); --st-lit: var(--lit-amber); --st-fill: transparent; --st-ring: var(--st); }
|
|
104
|
+
.st-known { --st: var(--lamp-green); --st-lit: var(--lit-green); --st-fill: transparent; --st-ring: var(--st); }
|
|
102
105
|
/* review severity is a shape (P0 spike, P1 diamond, P2 cone, P3 pin) on a ramp of its own, never a status lamp */
|
|
103
106
|
.sev-P0 { --sev: var(--sev-p0); --sev-shape: polygon(50% 0, 100% 100%, 0 100%); }
|
|
104
107
|
.sev-P1 { --sev: var(--sev-p1); --sev-shape: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
|
|
@@ -563,6 +566,51 @@ summary:hover { color: var(--ink-2); }
|
|
|
563
566
|
ul.plain { list-style: none; padding: 0; margin: 4px 0; display: grid; gap: 4px; font-size: var(--t-sm); }
|
|
564
567
|
ul.plain li.current { font-weight: 650; }
|
|
565
568
|
pre.cmd { max-height: none; }
|
|
569
|
+
/* the verification inspector (verifyview.js): live state, failing tests, lanes and their logs */
|
|
570
|
+
.vfy-num { font-variant-numeric: tabular-nums; }
|
|
571
|
+
.vfy-live { margin: var(--s-3) 0 0; padding: var(--s-3); border-radius: var(--r-key); background: var(--module-2); box-shadow: inset 0 0 0 1px var(--rule); }
|
|
572
|
+
.vfy-now { margin: 0; font-size: var(--t-sm); }
|
|
573
|
+
.vfy-step { font-weight: 600; }
|
|
574
|
+
.vfy-last { margin: var(--s-2) 0 0; font-size: var(--t-xs); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
575
|
+
.vfy-idle { margin: var(--s-2) 0 0; padding: 2px var(--s-2); border-radius: var(--r-small); background: var(--warn-wash); font-size: var(--t-xs); width: fit-content; }
|
|
576
|
+
.vfy-group { margin: var(--s-3) 0 0; }
|
|
577
|
+
.vfy-group-head { display: flex; gap: var(--s-2); align-items: baseline; margin: 0 0 var(--s-1); font-size: var(--t-sm); }
|
|
578
|
+
.vfy-tests { gap: var(--s-2); }
|
|
579
|
+
.vfy-test { padding: var(--s-2) var(--s-3); border-radius: var(--r-key); background: var(--module-2); box-shadow: inset 3px 0 0 var(--lamp-red); }
|
|
580
|
+
.vfy-test.vfy-known { box-shadow: inset 3px 0 0 var(--lamp-off); }
|
|
581
|
+
.vfy-test-head { display: flex; flex-wrap: wrap; gap: var(--s-2); align-items: baseline; }
|
|
582
|
+
.vfy-test-name { overflow-wrap: anywhere; font-size: var(--t-xs); }
|
|
583
|
+
.vfy-badge { padding: 0 6px; border-radius: var(--r-small); font-size: var(--t-xs); font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
|
|
584
|
+
.vfy-badge-new { background: var(--lamp-red); color: var(--plate-ink); }
|
|
585
|
+
.vfy-badge-known { box-shadow: inset 0 0 0 1px var(--rule-2); color: var(--muted); }
|
|
586
|
+
.vfy-message { margin: var(--s-1) 0 0; font-size: var(--t-sm); overflow-wrap: anywhere; }
|
|
587
|
+
.vfy-excerpt summary { cursor: pointer; font-size: var(--t-xs); color: var(--muted); }
|
|
588
|
+
/* a link-like button: none of the key's plate type, face or press */
|
|
589
|
+
.vfy-link { display: inline; min-height: 0; padding: 0; border: 0; background: none; box-shadow: none; color: var(--ink); text-decoration: underline; text-underline-offset: 2px;
|
|
590
|
+
font-family: var(--font-ui); font-weight: 400; font-size: var(--t-xs); letter-spacing: normal; text-transform: none; line-height: inherit; white-space: normal; text-align: left; }
|
|
591
|
+
.vfy-link.mono { font-family: var(--mono); }
|
|
592
|
+
.vfy-link:hover:not(:disabled) { background: none; color: var(--ink-2); }
|
|
593
|
+
.vfy-section-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); margin: 0 0 var(--s-2); }
|
|
594
|
+
.vfy-section-head h3 { margin: 0; }
|
|
595
|
+
.vfy-nowrap { white-space: nowrap; }
|
|
596
|
+
.vfy-table-wrap { overflow-x: auto; }
|
|
597
|
+
.vfy-table { width: 100%; border-collapse: collapse; font-size: var(--t-sm); }
|
|
598
|
+
.vfy-table th { text-align: left; font-weight: 600; color: var(--muted); font-size: var(--t-xs); padding: 0 var(--s-2) var(--s-1) 0; }
|
|
599
|
+
.vfy-table td { padding: var(--s-1) var(--s-2) var(--s-1) 0; border-top: 1px solid var(--rule); vertical-align: top; }
|
|
600
|
+
.vfy-table tr.vfy-followed td { background: var(--module-2); }
|
|
601
|
+
.vfy-lane .vfy-link { font-size: var(--t-sm); }
|
|
602
|
+
.vfy-label { margin-left: var(--s-1); font-size: var(--t-xs); }
|
|
603
|
+
.vfy-lane-now { display: grid; gap: 2px; margin-top: 2px; max-width: 28ch; }
|
|
604
|
+
.vfy-lane-now .vfy-last { margin: 0; }
|
|
605
|
+
.vfy-log { display: grid; padding: var(--s-2) 0; font-family: var(--mono); font-size: var(--t-xs); line-height: 1.5; white-space: pre; overflow: auto; }
|
|
606
|
+
.vfy-line { display: flex; gap: var(--s-3); padding: 0 var(--s-3); }
|
|
607
|
+
.vfy-line.vfy-hl { background: var(--warn-wash); color: var(--warn-ink); }
|
|
608
|
+
.vfy-ln { flex: none; min-width: 5ch; text-align: right; color: var(--glass-dim); font-variant-numeric: tabular-nums; user-select: none; }
|
|
609
|
+
.vfy-line.vfy-hl .vfy-ln { color: inherit; }
|
|
610
|
+
.vfy-lt { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
611
|
+
.vfy-note { font-size: var(--t-xs); margin: var(--s-1) 0 0; }
|
|
612
|
+
.vfy-summary, .vfy-endstep, .vfy-mutated { margin: var(--s-3) 0 0; font-size: var(--t-sm); }
|
|
613
|
+
.vfy-lanes + .fields, .vfy-tail + .fields { margin-top: var(--s-4); }
|
|
566
614
|
.phase-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin: 6px 0; background: var(--module-2); }
|
|
567
615
|
.phase { min-width: 2px; }
|
|
568
616
|
.phase-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: var(--t-xs); color: var(--muted); }
|
|
@@ -624,6 +672,17 @@ p.ctl-why::before { content: ''; width: 7px; height: 7px; border-radius: 50%; ba
|
|
|
624
672
|
.ctl-pin-now { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
|
|
625
673
|
p.ctl-stop { display: flex; align-items: center; gap: 7px; margin: 0; padding: 4px var(--s-2); border-radius: var(--r-small); background: var(--warn-wash); color: var(--warn-ink); font-weight: 600; }
|
|
626
674
|
.ctl-log { margin-top: var(--s-1); }
|
|
675
|
+
/* the run's keys (drain, resume, stop), its drained or stopping state, pauses and a queued retry's answer */
|
|
676
|
+
.ctl-keys { display: flex; flex-wrap: wrap; gap: var(--s-2); }
|
|
677
|
+
.ctl-state { font-variant-numeric: tabular-nums; }
|
|
678
|
+
.ctl-stopping, .ctl-drained { background: var(--warn-wash); color: var(--warn-ink); border-color: transparent; }
|
|
679
|
+
.ctl-pauses { display: grid; gap: var(--s-1); }
|
|
680
|
+
.ctl-pause-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: var(--t-xs); }
|
|
681
|
+
.ctl-pause { display: flex; align-items: baseline; gap: 7px; }
|
|
682
|
+
.ctl-pause-text { font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
683
|
+
.ctl-exhausted .ctl-pause-text { color: var(--warn-ink); font-weight: 600; }
|
|
684
|
+
p.ctl-request { display: flex; align-items: center; gap: 7px; margin: 0; font-size: var(--t-sm); }
|
|
685
|
+
p.ctl-request-refused { color: var(--warn-ink); font-weight: 600; }
|
|
627
686
|
.ctl-changes { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 4px; font-size: var(--t-xs); }
|
|
628
687
|
.ctl-changes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; }
|
|
629
688
|
.ctl-what { color: var(--ink); font-weight: 600; }
|
|
@@ -702,6 +761,7 @@ th:hover .th-sort, .th-sort.on { color: var(--ink); }
|
|
|
702
761
|
.gt-item.gt-sel .gt-box { stroke: var(--route); stroke-width: 2.5; }
|
|
703
762
|
.gt-item.gt-crit .gt-box { stroke-width: 2.4; }
|
|
704
763
|
.gt-ghost .gt-box { fill-opacity: .04; stroke-dasharray: 4 3; }
|
|
764
|
+
.gt-item.gt-approx .gt-box { stroke-dasharray: 2 2; } /* a verification run placed from durations, not from its own times */
|
|
705
765
|
.gt-future { opacity: .3; }
|
|
706
766
|
.gt-seg { fill: var(--st, var(--lamp-off)); }
|
|
707
767
|
.gt-seg.gt-dwarf { fill-opacity: .55; }
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/* forge-ui verification view: what the verification runs now, its lanes, and which tests failed
|
|
2
|
+
(window.ForgeVerify, and a CommonJS module under node:test). panel.js's verification inspector delegates here.
|
|
3
|
+
The pure helpers build a view model from RunDetail.verification (ui/docs/api.md) and are tested in
|
|
4
|
+
ui/tests/js/verifyview.test.js; render(host, ctx) is thin DOM over them. Test names, messages, excerpts and log
|
|
5
|
+
lines are untrusted: they only ever enter the page as text.
|
|
6
|
+
ctx = { run, now (epoch seconds), fetchJSON(path), rerender(), hash(sel), onSelect(sel) } */
|
|
7
|
+
(function (root, factory) {
|
|
8
|
+
'use strict';
|
|
9
|
+
const api = factory(root);
|
|
10
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
11
|
+
else root.ForgeVerify = api;
|
|
12
|
+
})(typeof self !== 'undefined' ? self : this, function (root) {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const IDLE_WARN_S = 300; // silence past this is flagged
|
|
16
|
+
const CONTEXT_LINES = 10; // lines shown above an opened log line
|
|
17
|
+
const list = v => Array.isArray(v) ? v : [];
|
|
18
|
+
const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
19
|
+
const STEP = /^::forge step name=(\S+) state=(start|end)(?: exit=(-?\d+))?$/;
|
|
20
|
+
const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
21
|
+
const LANE_CHIP = { waiting: 'pending', running: 'running', pass: 'pass', fail: 'fail', flaky: 'flaky', known: 'known',
|
|
22
|
+
skipped: 'none', interrupted: 'interrupted' };
|
|
23
|
+
|
|
24
|
+
/* ---------- pure ---------- */
|
|
25
|
+
function dur(s) {
|
|
26
|
+
s = num(s); if (s === null) return '—';
|
|
27
|
+
s = Math.max(0, Math.round(s));
|
|
28
|
+
if (s < 60) return s + 's';
|
|
29
|
+
if (s < 3600) return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
|
|
30
|
+
return Math.floor(s / 3600) + 'h' + String(Math.floor(s % 3600 / 60)).padStart(2, '0') + 'm';
|
|
31
|
+
}
|
|
32
|
+
const since = (start, now) => num(start) !== null && num(now) !== null ? Math.max(0, now - start) : null;
|
|
33
|
+
// "no output for N min" past five minutes of silence, else null
|
|
34
|
+
function idleWarning(idle) {
|
|
35
|
+
idle = num(idle);
|
|
36
|
+
return idle !== null && idle > IDLE_WARN_S ? `no output for ${Math.floor(idle / 60)} min` : null;
|
|
37
|
+
}
|
|
38
|
+
// The last step a log started and no later line ended (steps nest), or null.
|
|
39
|
+
function openStep(text) {
|
|
40
|
+
const open = [];
|
|
41
|
+
for (const raw of String(text || '').split('\n')) {
|
|
42
|
+
const m = STEP.exec(raw.replace(ANSI, '').trim());
|
|
43
|
+
if (!m) continue;
|
|
44
|
+
if (m[2] === 'start') open.push(m[1]);
|
|
45
|
+
else { const i = open.lastIndexOf(m[1]); if (i >= 0) open.splice(i, 1); }
|
|
46
|
+
}
|
|
47
|
+
return open.length ? open[open.length - 1] : null;
|
|
48
|
+
}
|
|
49
|
+
function countsText(tests) {
|
|
50
|
+
if (!tests || typeof tests !== 'object') return null;
|
|
51
|
+
const n = k => num(tests[k]) || 0;
|
|
52
|
+
const parts = [`${n('pass')} passed`, `${n('fail') + n('error')} failed`];
|
|
53
|
+
if (n('skip')) parts.push(`${n('skip')} skipped`);
|
|
54
|
+
return parts.join(' · ');
|
|
55
|
+
}
|
|
56
|
+
// "rerun 1 of 2" once a lane ran again (of its own budget, when the lane set one)
|
|
57
|
+
function runsText(lane) {
|
|
58
|
+
const reruns = list(lane && lane.runs).filter(r => r && r.kind === 'rerun').length;
|
|
59
|
+
if (!reruns) return '';
|
|
60
|
+
return `rerun ${reruns}${num(lane.retries) !== null ? ' of ' + lane.retries : ''}`;
|
|
61
|
+
}
|
|
62
|
+
function laneRow(lane, now) {
|
|
63
|
+
const running = lane.state === 'running';
|
|
64
|
+
const took = running && num(lane.started) !== null && num(now) !== null ? since(lane.started, now) : num(lane.duration_s);
|
|
65
|
+
return {
|
|
66
|
+
name: String(lane.name || ''), state: lane.state || 'unknown', chip: LANE_CHIP[lane.state] || 'unknown',
|
|
67
|
+
time: took === null ? '' : dur(took), running,
|
|
68
|
+
runs: runsText(lane), counts: countsText(lane.tests), hasTests: !!(lane.tests && typeof lane.tests === 'object'),
|
|
69
|
+
step: running && lane.step && lane.step.name ? lane.step.name : null,
|
|
70
|
+
last_line: running && lane.last_line ? String(lane.last_line) : null,
|
|
71
|
+
idle: running ? idleWarning(lane.idle_s) : null,
|
|
72
|
+
labels: [lane.first ? 'first' : null, lane.group ? 'group ' + lane.group : null].filter(Boolean),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// failing tests in lane order: [{lane, entries: [{name, badge: new|known, message, excerpt, log_line}], fresh, known}]
|
|
76
|
+
function groupFailing(failing) {
|
|
77
|
+
const groups = new Map();
|
|
78
|
+
for (const f of list(failing)) {
|
|
79
|
+
if (!f || typeof f !== 'object') continue;
|
|
80
|
+
const lane = String(f.lane || '');
|
|
81
|
+
if (!groups.has(lane)) groups.set(lane, { lane, entries: [], fresh: 0, known: 0 });
|
|
82
|
+
const g = groups.get(lane), known = f.known === true;
|
|
83
|
+
g.entries.push({ name: String(f.name || '?'), badge: known ? 'known' : 'new', message: f.message || '', excerpt: f.excerpt || '',
|
|
84
|
+
log_line: num(f.log_line) !== null && f.log_line > 0 ? f.log_line : null, suite: f.suite || null, file: f.file || null });
|
|
85
|
+
if (known) g.known++; else g.fresh++;
|
|
86
|
+
}
|
|
87
|
+
return Array.from(groups.values());
|
|
88
|
+
}
|
|
89
|
+
// The lane whose log the live tail follows: the chosen one if it still exists, else the running lane that printed
|
|
90
|
+
// last, else the first running, failed or listed lane. null in single mode.
|
|
91
|
+
function tailLane(v, chosen) {
|
|
92
|
+
const lanes = list(v && v.lanes);
|
|
93
|
+
if (!lanes.length) return null;
|
|
94
|
+
if (chosen && lanes.some(l => l.name === chosen)) return chosen;
|
|
95
|
+
const running = lanes.filter(l => l.state === 'running');
|
|
96
|
+
const printed = running.filter(l => num(l.last_output_at) !== null).sort((a, b) => b.last_output_at - a.last_output_at);
|
|
97
|
+
const pick = printed[0] || running[0] || lanes.find(l => l.state === 'fail') || lanes[0];
|
|
98
|
+
return pick.name;
|
|
99
|
+
}
|
|
100
|
+
function model(v, now) {
|
|
101
|
+
if (!v) return null;
|
|
102
|
+
const running = v.state === 'running', status = String(v.status || '').toUpperCase();
|
|
103
|
+
const lanes = list(v.lanes).filter(l => l && typeof l === 'object').map(l => laneRow(l, now));
|
|
104
|
+
const failed = !running && (status === 'FAIL' || lanes.some(l => l.state === 'fail'));
|
|
105
|
+
const live = running && v.live ? {
|
|
106
|
+
elapsed: dur(since(v.live.started, now)),
|
|
107
|
+
step: v.live.step && v.live.step.name ? v.live.step.name : null,
|
|
108
|
+
stepElapsed: v.live.step && num(v.live.step.started) !== null ? dur(since(v.live.step.started, now)) : null,
|
|
109
|
+
last_line: v.live.last_line || null,
|
|
110
|
+
idle: idleWarning(v.live.idle_s),
|
|
111
|
+
} : null;
|
|
112
|
+
return {
|
|
113
|
+
running, failed, passed: !running && !failed && status === 'PASS', mode: v.mode === 'lanes' ? 'lanes' : 'single',
|
|
114
|
+
live, lanes, groups: groupFailing(v.failing), mutated: v.mutated === true,
|
|
115
|
+
reportless: failed ? lanes.filter(l => l.state === 'fail' && !l.hasTests).map(l => l.name) : [],
|
|
116
|
+
flaky: lanes.filter(l => l.state === 'flaky').map(l => l.name),
|
|
117
|
+
known: lanes.filter(l => l.state === 'known').map(l => l.name),
|
|
118
|
+
// a single command that ended: the step it was in, from its output's step markers
|
|
119
|
+
endStep: !running && v.mode !== 'lanes' && v.state ? openStep(v.log_tail) : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const runsBase = key => `/api/runs/${encodeURIComponent(key)}/verification`;
|
|
123
|
+
// the log endpoint of the single command (lane null; RETRY for its rerun's log), a lane, or one run k of a lane
|
|
124
|
+
const RETRY = { retry: true };
|
|
125
|
+
function logPath(key, lane, k) {
|
|
126
|
+
if (lane === RETRY) return runsBase(key) + '/retry/log';
|
|
127
|
+
if (!lane) return runsBase(key) + '/log';
|
|
128
|
+
return `${runsBase(key)}/lanes/${encodeURIComponent(lane)}${num(k) !== null ? '/runs/' + k : ''}/log`;
|
|
129
|
+
}
|
|
130
|
+
// the range that shows `line` with a few lines above it
|
|
131
|
+
const linesPath = (key, lane, line) => `${logPath(key, lane)}/lines/${Math.max(1, line - CONTEXT_LINES)}`;
|
|
132
|
+
// lines of a tail as [{n, text}], numbered from tail_first_line when the server knew it
|
|
133
|
+
function tailLines(body) {
|
|
134
|
+
const text = String(body && body.tail || ''), first = num(body && body.tail_first_line);
|
|
135
|
+
const lines = text.endsWith('\n') ? text.slice(0, -1).split('\n') : text.split('\n');
|
|
136
|
+
return (text ? lines : []).map((t, i) => ({ n: first === null ? null : first + i, text: t }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ---------- DOM ---------- */
|
|
140
|
+
const views = new Map(); // run key → { lane (followed by the tail), open: {lane, line} | null }
|
|
141
|
+
const cache = new Map(); // path → { data, error, pending, fresh }
|
|
142
|
+
function el(tag, text, className) {
|
|
143
|
+
const node = document.createElement(tag);
|
|
144
|
+
if (text !== undefined && text !== null) node.textContent = String(text);
|
|
145
|
+
if (className) node.className = className;
|
|
146
|
+
return node;
|
|
147
|
+
}
|
|
148
|
+
const chip = (text, cls) => el('span', text || '—', 'chip st-' + cls);
|
|
149
|
+
function section(parent, title, cls) {
|
|
150
|
+
const s = el('section', undefined, 'insp-section' + (cls ? ' ' + cls : ''));
|
|
151
|
+
s.append(el('h3', title)); parent.append(s); return s;
|
|
152
|
+
}
|
|
153
|
+
function button(text, onClick, cls) {
|
|
154
|
+
const b = el('button', text, cls || 'vfy-link'); b.type = 'button';
|
|
155
|
+
b.addEventListener('click', onClick); return b;
|
|
156
|
+
}
|
|
157
|
+
// a fetch that lands once per `fresh` token and re-renders the view when it does
|
|
158
|
+
function load(ctx, path, fresh) {
|
|
159
|
+
const c = cache.get(path);
|
|
160
|
+
if (c && (c.fresh === fresh || c.pending)) return c;
|
|
161
|
+
const entry = { pending: true, fresh, data: c ? c.data : null, error: null };
|
|
162
|
+
cache.set(path, entry);
|
|
163
|
+
ctx.fetchJSON(path).then(data => { entry.data = data; entry.error = null; }, err => { entry.error = err; })
|
|
164
|
+
.finally(() => { entry.pending = false; ctx.rerender(); });
|
|
165
|
+
return entry;
|
|
166
|
+
}
|
|
167
|
+
function logBlock(parent, rows, mark, scrollKey) {
|
|
168
|
+
const pre = el('pre', undefined, 'vfy-log'); pre.dataset.scroll = scrollKey;
|
|
169
|
+
for (const r of rows) {
|
|
170
|
+
const line = el('span', undefined, 'vfy-line' + (mark !== null && r.n === mark ? ' vfy-hl' : ''));
|
|
171
|
+
if (r.n !== null) line.append(el('span', r.n, 'vfy-ln'));
|
|
172
|
+
line.append(el('span', r.text, 'vfy-lt'));
|
|
173
|
+
pre.append(line);
|
|
174
|
+
}
|
|
175
|
+
if (!rows.length) pre.append(el('span', '(empty)', 'vfy-lt'));
|
|
176
|
+
parent.append(pre);
|
|
177
|
+
return pre;
|
|
178
|
+
}
|
|
179
|
+
function tail(parent, ctx, lane, fresh, scrollKey) {
|
|
180
|
+
const entry = load(ctx, logPath(ctx.run.key, lane), fresh);
|
|
181
|
+
if (!entry.data) {
|
|
182
|
+
const why = !entry.error ? 'Reading the log…' : entry.error.snapshot ? 'This export holds no logs.'
|
|
183
|
+
: entry.error.status === 404 ? 'No log to show yet.' : 'Could not read the log.';
|
|
184
|
+
parent.append(el('p', why, 'muted'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (entry.data.truncated) parent.append(el('p', `the last ${Math.round(String(entry.data.tail || '').length / 1024)} KB`, 'muted vfy-note'));
|
|
188
|
+
logBlock(parent, tailLines(entry.data), null, scrollKey);
|
|
189
|
+
}
|
|
190
|
+
function openedLog(parent, ctx, open) {
|
|
191
|
+
const s = section(parent, `Log of ${open.lane} at line ${open.line}`, 'vfy-opened');
|
|
192
|
+
const head = el('div', undefined, 'vfy-section-head');
|
|
193
|
+
head.append(s.firstChild, button('Close the log', () => { views.get(ctx.run.key).open = null; ctx.rerender(); }, 'vfy-close'));
|
|
194
|
+
s.prepend(head);
|
|
195
|
+
const entry = load(ctx, linesPath(ctx.run.key, open.lane, open.line), 'once');
|
|
196
|
+
if (entry.error) {
|
|
197
|
+
// an export holds no line ranges: the tail, with the line marked when it is in it
|
|
198
|
+
const back = load(ctx, logPath(ctx.run.key, open.lane), 'once');
|
|
199
|
+
if (!back.data) { s.append(el('p', back.error ? 'This log is not available here.' : 'Reading the log…', 'muted')); return; }
|
|
200
|
+
const rows = tailLines(back.data);
|
|
201
|
+
if (!rows.some(r => r.n === open.line)) s.append(el('p', `Line ${open.line} is not in this export; this is the end of the log.`, 'muted vfy-note'));
|
|
202
|
+
reveal(open, logBlock(s, rows, open.line, 'vfy-opened-log'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!entry.data) { s.append(el('p', 'Reading the log…', 'muted')); return; }
|
|
206
|
+
const first = num(entry.data.first_line) || 1;
|
|
207
|
+
const pre = logBlock(s, list(entry.data.lines).map((t, i) => ({ n: first + i, text: t })), open.line, 'vfy-opened-log');
|
|
208
|
+
if (entry.data.truncated) s.append(el('p', 'The log goes on past these lines.', 'muted vfy-note'));
|
|
209
|
+
reveal(open, pre);
|
|
210
|
+
}
|
|
211
|
+
// Bring the marked line into view once, after the inspector put its own scroll back.
|
|
212
|
+
function reveal(open, pre) {
|
|
213
|
+
if (open.revealed || !pre.querySelector('.vfy-hl')) return;
|
|
214
|
+
open.revealed = true;
|
|
215
|
+
setTimeout(() => { if (pre.isConnected) pre.querySelector('.vfy-hl')?.scrollIntoView({ block: 'center' }); }, 0);
|
|
216
|
+
}
|
|
217
|
+
function liveBlock(parent, m) {
|
|
218
|
+
const box = el('div', undefined, 'vfy-live');
|
|
219
|
+
const row = el('p', undefined, 'vfy-now');
|
|
220
|
+
row.append(el('span', 'running for ', 'muted'), el('span', m.live.elapsed, 'vfy-num'));
|
|
221
|
+
if (m.live.step) {
|
|
222
|
+
row.append(el('span', ' · step ', 'muted'), el('span', m.live.step, 'mono vfy-step'));
|
|
223
|
+
if (m.live.stepElapsed) row.append(el('span', ' for ', 'muted'), el('span', m.live.stepElapsed, 'vfy-num'));
|
|
224
|
+
}
|
|
225
|
+
box.append(row);
|
|
226
|
+
if (m.live.last_line) box.append(el('p', m.live.last_line, 'mono vfy-last'));
|
|
227
|
+
if (m.live.idle) box.append(el('p', m.live.idle, 'warn vfy-idle'));
|
|
228
|
+
parent.append(box);
|
|
229
|
+
}
|
|
230
|
+
function failingBlock(parent, ctx, m, view) {
|
|
231
|
+
const total = m.groups.reduce((n, g) => n + g.entries.length, 0);
|
|
232
|
+
const s = section(parent, `Failing tests (${total})`, 'vfy-failing');
|
|
233
|
+
for (const g of m.groups) {
|
|
234
|
+
const box = el('div', undefined, 'vfy-group');
|
|
235
|
+
const h = el('h4', undefined, 'vfy-group-head');
|
|
236
|
+
h.append(el('span', g.lane || 'the command', 'mono'), el('span', [g.fresh ? `${g.fresh} new` : '', g.known ? `${g.known} known` : ''].filter(Boolean).join(' · '), 'muted vfy-num'));
|
|
237
|
+
box.append(h);
|
|
238
|
+
const ul = el('ul', undefined, 'plain vfy-tests');
|
|
239
|
+
for (const e of g.entries) {
|
|
240
|
+
const li = el('li', undefined, 'vfy-test vfy-' + e.badge);
|
|
241
|
+
const top = el('div', undefined, 'vfy-test-head');
|
|
242
|
+
top.append(el('span', e.badge, 'vfy-badge vfy-badge-' + e.badge), el('span', e.name, 'mono vfy-test-name'));
|
|
243
|
+
if (e.log_line !== null && g.lane) {
|
|
244
|
+
top.append(button(`open in log (line ${e.log_line})`, () => { view.open = { lane: g.lane, line: e.log_line }; ctx.rerender(); }));
|
|
245
|
+
}
|
|
246
|
+
li.append(top);
|
|
247
|
+
if (e.message) li.append(el('p', e.message, 'vfy-message'));
|
|
248
|
+
if (e.excerpt) {
|
|
249
|
+
const d = el('details', undefined, 'vfy-excerpt'); d.dataset.keep = `vfy-${g.lane}-${e.name}`;
|
|
250
|
+
d.append(el('summary', 'excerpt'), el('pre', e.excerpt));
|
|
251
|
+
li.append(d);
|
|
252
|
+
}
|
|
253
|
+
ul.append(li);
|
|
254
|
+
}
|
|
255
|
+
box.append(ul);
|
|
256
|
+
s.append(box);
|
|
257
|
+
}
|
|
258
|
+
return s;
|
|
259
|
+
}
|
|
260
|
+
function lanesTable(parent, ctx, m, view, followed) {
|
|
261
|
+
const s = section(parent, `Lanes (${m.lanes.length})`, 'vfy-lanes');
|
|
262
|
+
const wrap = el('div', undefined, 'vfy-table-wrap'); wrap.dataset.scroll = 'vfy-lanes';
|
|
263
|
+
const table = el('table', undefined, 'vfy-table');
|
|
264
|
+
const head = el('tr');
|
|
265
|
+
for (const h of ['Lane', 'State', 'Time', 'Runs', 'Tests']) head.append(el('th', h));
|
|
266
|
+
const thead = el('thead'); thead.append(head);
|
|
267
|
+
const body = el('tbody');
|
|
268
|
+
for (const l of m.lanes) {
|
|
269
|
+
const tr = el('tr', undefined, l.name === followed ? 'vfy-followed' : '');
|
|
270
|
+
const name = el('td', undefined, 'vfy-lane');
|
|
271
|
+
name.append(button(l.name, () => { view.lane = l.name; ctx.rerender(); }, 'vfy-link mono'));
|
|
272
|
+
for (const label of l.labels) name.append(el('span', label, 'plate vfy-label'));
|
|
273
|
+
if (l.step || l.last_line) {
|
|
274
|
+
const now = el('div', undefined, 'vfy-lane-now');
|
|
275
|
+
if (l.step) now.append(el('span', 'step ' + l.step, 'mono vfy-step'));
|
|
276
|
+
if (l.last_line) now.append(el('span', l.last_line, 'mono vfy-last'));
|
|
277
|
+
name.append(now);
|
|
278
|
+
}
|
|
279
|
+
if (l.idle) name.append(el('div', l.idle, 'warn vfy-idle'));
|
|
280
|
+
const state = el('td'); state.append(chip(l.state, l.chip));
|
|
281
|
+
tr.append(name, state, el('td', l.time, 'vfy-num vfy-nowrap'), el('td', l.runs, 'vfy-num vfy-nowrap'), el('td', l.counts || '', 'vfy-num'));
|
|
282
|
+
body.append(tr);
|
|
283
|
+
}
|
|
284
|
+
table.append(thead, body); wrap.append(table); s.append(wrap);
|
|
285
|
+
}
|
|
286
|
+
function render(host, ctx) {
|
|
287
|
+
const run = ctx.run, v = run.verification, m = model(v, num(ctx.now) !== null ? ctx.now : Date.now() / 1000);
|
|
288
|
+
if (!m) return;
|
|
289
|
+
if (!views.has(run.key)) views.set(run.key, { lane: null, open: null });
|
|
290
|
+
const view = views.get(run.key);
|
|
291
|
+
// logs are read again when the run's files moved: every poll while the verification prints
|
|
292
|
+
const fresh = `${run.updated}|${v.live ? v.live.last_output_at : ''}`;
|
|
293
|
+
if (m.running && m.live) liveBlock(host, m);
|
|
294
|
+
if (m.failed && m.groups.length) failingBlock(host, ctx, m, view);
|
|
295
|
+
if (view.open && list(v.lanes).some(l => l.name === view.open.lane)) openedLog(host, ctx, view.open);
|
|
296
|
+
if (m.mutated) host.append(el('p', 'A lane changed the checkout, so no lane was rerun after it.', 'warn vfy-mutated'));
|
|
297
|
+
for (const name of m.reportless) {
|
|
298
|
+
const s = section(host, `${name}: no test report; see the log`, 'vfy-reportless');
|
|
299
|
+
tail(s, ctx, name, fresh, 'vfy-tail-' + name);
|
|
300
|
+
}
|
|
301
|
+
if (m.endStep) host.append(el('p', `It ended in step ${m.endStep}.`, 'vfy-endstep'));
|
|
302
|
+
if (m.passed && m.lanes.length) {
|
|
303
|
+
const p = el('p', undefined, 'vfy-summary');
|
|
304
|
+
p.append(el('span', `${m.lanes.length} lane${m.lanes.length === 1 ? '' : 's'} passed`));
|
|
305
|
+
if (m.flaky.length) p.append(el('span', ` · flaky (passed on a rerun): ${m.flaky.join(', ')}`, 'warn'));
|
|
306
|
+
if (m.known.length) p.append(el('span', ` · known failures only: ${m.known.join(', ')}`, 'muted'));
|
|
307
|
+
host.append(p);
|
|
308
|
+
}
|
|
309
|
+
const followed = m.running ? tailLane(v, view.lane) : view.lane;
|
|
310
|
+
if (m.lanes.length) lanesTable(host, ctx, m, view, followed);
|
|
311
|
+
if (m.running) {
|
|
312
|
+
const s = section(host, m.mode === 'lanes' && followed ? `Live output: ${followed}` : 'Live output', 'vfy-tail');
|
|
313
|
+
// a single command's rerun writes its own log: the live output follows the one that moved last
|
|
314
|
+
const single = v.live && v.live.log === 'verification.retry.log' ? RETRY : null;
|
|
315
|
+
tail(s, ctx, m.mode === 'lanes' && followed ? followed : single, fresh, 'vfy-live-tail');
|
|
316
|
+
} else if (m.mode === 'lanes' && view.lane) {
|
|
317
|
+
const s = section(host, `Output: ${view.lane}`, 'vfy-tail');
|
|
318
|
+
tail(s, ctx, view.lane, fresh, 'vfy-lane-tail');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { render, model, laneRow, groupFailing, idleWarning, openStep, countsText, runsText, tailLane, logPath, linesPath, RETRY,
|
|
323
|
+
tailLines, dur, IDLE_WARN_S };
|
|
324
|
+
});
|