Testy: patch the page instead of redrawing it, one line per device

While a run was going, the tab refreshed every five seconds and replaced
the whole page each time. Whatever was open closed, the reader was thrown
back to the top, and every screenshot downloaded again, because the file
token was written into the HTML. The tab was unusable for as long as a
run lasted.

Now each block is drawn to a string, and only a block whose string
changed replaces its element. Every <details> carries a key, and what
the reader opened is opened again after a redraw. Pictures load when
their case is opened, with a token fetched at that moment.

Each device is one collapsed line with its latest finished run: verdict,
build, tally, and the first failures by name. A live run sits above them
with its progress bar. Older runs are grouped by build and collapsed.

Windows pictures are no longer shown. The virtual machine's console
shows its own lock screen, never Amber, and seventeen of those had
passed for evidence under a green verdict. On that target only a picture
the app drew of itself would count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-09-10 15:32:30 +02:00
parent 886e2643cf
commit 4c8e02f48b

View file

@ -163,6 +163,31 @@
.accts td:last-child{white-space:nowrap;text-align:right} .accts td:last-child{white-space:nowrap;text-align:right}
.accts button{white-space:nowrap} .accts button{white-space:nowrap}
@media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} } @media (max-width:560px){ main{padding:12px;gap:12px} section{padding:12px} }
/* Testy: one line per run until somebody opens it, and nothing redrawn
under the reader. */
details.run{padding:10px 14px;margin:0 0 8px}
details.run > summary, details.build > summary{display:flex;align-items:center;gap:8px;
flex-wrap:wrap;cursor:pointer;list-style:none;color:var(--text);font-size:13.5px}
details.run > summary::-webkit-details-marker, details.build > summary::-webkit-details-marker,
details.case > summary::-webkit-details-marker{display:none}
details.run > summary::before, details.build > summary::before{content:"▸";
color:var(--muted);font-size:11px;width:10px}
details.run[open] > summary::before, details.build[open] > summary::before{content:"▾"}
details.run[open] > summary{margin-bottom:8px}
.tgt{font-weight:600}
.tally{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
.fails{font-family:var(--mono);font-size:12px;color:var(--bad)}
details.case{display:block;padding:0;margin-top:0}
details.case > summary{display:flex;gap:10px;align-items:baseline;cursor:pointer;
list-style:none;padding:5px 0;color:var(--text);font-size:inherit}
details.case .shots{margin:4px 0 10px}
.nshots{color:var(--muted);font-size:11.5px;white-space:nowrap;margin-left:auto}
.liverun{border:1px solid var(--accent);border-radius:10px;padding:12px 14px;margin:0 0 10px}
.liverun .lhead{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}
.liverun summary{cursor:pointer;color:var(--muted);font-size:12.5px;margin-top:8px}
details.build{border:1px solid var(--line);border-radius:10px;padding:10px 14px;margin:0 0 8px}
details.build[open] > summary{margin-bottom:10px}
#e2e-older-sec[hidden]{display:none}
</style> </style>
</head> </head>
<body> <body>
@ -763,33 +788,96 @@ function normName(s){
return String(s).toLowerCase().replace(/\.[a-z0-9]+$/, "").replace(/[^a-z0-9]+/g, "_"); return String(s).toLowerCase().replace(/\.[a-z0-9]+$/, "").replace(/[^a-z0-9]+/g, "_");
} }
// A short-lived token for the protected files, fetched once per render. // A short-lived token for the protected files.
// //
// `e2e_runs` is superuser-only, which makes its files protected, which means an // `e2e_runs` is superuser-only, which makes its files protected, which means an
// <img> tag cannot fetch one on the strength of the page's own session -- the // <img> tag cannot fetch one on the strength of the page's own session -- the
// browser sends no Authorization header with an image request. Without this // browser sends no Authorization header with an image request. The token is
// every screenshot on the page is a broken icon. // fetched when a picture is about to be shown and reused for ninety seconds. It
var fileTok = ""; // used to be written into the HTML, which changed it on every refresh and made
// every picture on the page download again every five seconds.
var fileTok = "", fileTokAt = 0;
async function freshTok(){
if (fileTok && Date.now() - fileTokAt < 90000) return fileTok;
try {
var ft = await api("/api/files/token", { method: "POST" });
fileTok = ft.token || ""; fileTokAt = Date.now();
} catch (_) { fileTok = ""; }
return fileTok;
}
// Set while a run is in flight, so the page follows it instead of waiting out // Set while a run is in flight, so the page follows it instead of waiting out
// the ordinary minute. Nothing else on this dashboard changes that fast. // the ordinary minute. Nothing else on this dashboard changes that fast.
var liveRun = false; var liveRun = false;
function shotUrl(r, caseId, shotName){ // The file a screenshot was uploaded as, without a token (see freshTok).
function shotPath(r, caseId, shotName){
var want = normName(caseId + "_" + shotName); var want = normName(caseId + "_" + shotName);
var hit = (r.shots || []).filter(function(f){ var hit = (r.shots || []).filter(function(f){
return normName(f).indexOf(want) === 0; return normName(f).indexOf(want) === 0;
})[0]; })[0];
if (!hit) return null; return hit ? "/api/files/e2e_runs/" + r.id + "/" + hit : null;
return "/api/files/e2e_runs/" + r.id + "/" + hit + }
(fileTok ? "?token=" + encodeURIComponent(fileTok) : "");
// Windows runs the app in a session the virtual machine's console never shows,
// so a screenshot of that console is a picture of its login screen. Seventeen of
// those passed for evidence under "nic nebrání vydání" before anyone looked. On
// that target only a picture the app drew of itself counts.
function honestShot(r, e){ return r.target !== "windows" || e.source === "app"; }
var TARGETS = { tv: "televize", phone: "telefon", linux: "Linux", windows: "Windows" };
var TARGET_ORDER = ["tv", "phone", "linux", "windows"];
function targetName(t){ return TARGETS[t] || t; }
function byTarget(a, b){
var i = TARGET_ORDER.indexOf(a.target), j = TARGET_ORDER.indexOf(b.target);
return (i < 0 ? 99 : i) - (j < 0 ? 99 : j);
}
function cz(n, one, few, many){
return n + " " + (n === 1 ? one : (n >= 2 && n <= 4) ? few : many);
}
function when(r){
var t = pbDate(r.created);
if (isNaN(t)) return String(r.created || "").substring(0, 16);
return new Date(t).toLocaleString("cs-CZ", {
day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" });
}
function isBad(x){ return x.outcome === "fail" || x.outcome === "blocked"; }
// Counted from the cases rather than read from `counts`, which a run only
// writes when it finishes: a live run's tally would otherwise sit at zero.
function countsOf(r){
var cases = r.cases || [];
if (!cases.length) return r.counts || {};
var c = { pass: 0, fail: 0, blocked: 0, unsupported: 0, unimplemented: 0 };
cases.forEach(function(x){ if (c[x.outcome] != null) c[x.outcome]++; });
return c;
}
function tally(c){
var out = [(c.pass || 0) + " prošlo", (c.fail || 0) + " spadlo"];
if (c.blocked) out.push(c.blocked + " čeká na tebe");
if (c.unsupported) out.push(c.unsupported + " nelze zde");
if (c.unimplemented) out.push(c.unimplemented + " nenapsáno");
return out.join(" · ");
}
function failsSpan(r){
var ids = (r.cases || []).filter(function(x){ return x.outcome === "fail"; })
.map(function(x){ return x.id; });
if (!ids.length) return "";
return '<span class="fails">' + esc(ids.slice(0, 3).join(", ") +
(ids.length > 3 ? " a " + (ids.length - 3) + " další" : "")) + "</span>";
} }
function caseRow(r, c){ function caseRow(r, c){
var shots = (c.evidence || []).filter(function(e){ return e.kind === "screenshot"; }); var shots = (c.evidence || []).filter(function(e){ return e.kind === "screenshot"; });
var pics = shots.map(function(s){ var shown = shots.filter(function(e){ return honestShot(r, e); });
var u = shotUrl(r, c.id, s.name); var pics = shown.map(function(s){
return u ? '<a href="' + u + '" target="_blank"><img loading="lazy" src="' + u + var p = shotPath(r, c.id, s.name);
'"><div class="cap">' + esc(s.name) + "</div></a>" : ""; return p ? '<a data-href="' + p + '" target="_blank"><img data-src="' + p +
'" alt=""><div class="cap">' + esc(s.name) + "</div></a>" : "";
}).join(""); }).join("");
// What to say beside the id, in the order that answers the question the // What to say beside the id, in the order that answers the question the
// outcome raises. A blocked case is asking the owner for something, so its // outcome raises. A blocked case is asking the owner for something, so its
@ -802,75 +890,216 @@ function caseRow(r, c){
var claim = c.outcome === "blocked" ? (c.needs || "") var claim = c.outcome === "blocked" ? (c.needs || "")
: c.outcome === "fail" ? ((broke && broke.claim) || c.error || "") : c.outcome === "fail" ? ((broke && broke.claim) || c.error || "")
: (checks.length ? checks[checks.length - 1].claim : ""); : (checks.length ? checks[checks.length - 1].claim : "");
return '<div class="case">' + outcomePill(c.outcome) + var head = outcomePill(c.outcome) +
'<span class="cid">' + esc(c.id) + "</span>" + '<span class="cid">' + esc(c.id) + "</span>" +
(c.blocker ? '<span class="pill un">blokuje</span>' : "") + (c.blocker ? '<span class="pill un">blokuje</span>' : "") +
'<span class="msg">' + esc(String(claim).split("\n").pop().substring(0, 160)) + '<span class="msg">' + esc(String(claim).split("\n").pop().substring(0, 160)) + "</span>" +
"</span></div>" + (pics ? '<div class="shots">' + pics + "</div>" : ""); (pics ? '<span class="nshots">' + cz(shown.length, "snímek", "snímky", "snímků") + "</span>" : "");
if (!pics) return '<div class="case">' + head + "</div>";
return '<details class="case" data-k="' + esc(r.id + "/" + c.id) + '"><summary>' +
head + '</summary><div class="shots">' + pics + "</div></details>";
} }
function runCard(r){ function windowsNote(r){
var c = r.counts || {}; if (r.target !== "windows") return "";
var cases = r.cases || []; var any = (r.cases || []).some(function(c){
var interesting = cases.filter(function(x){ return (c.evidence || []).some(function(e){
return x.outcome === "fail" || x.outcome === "blocked"; return e.kind === "screenshot" && honestShot(r, e);
}); });
var rest = cases.filter(function(x){ return x.outcome === "pass"; }); });
if (any) return "";
return '<div class="warnbox" style="margin-top:10px">Z Windows tu snímky nejsou. ' +
"Testovací virtuál ukazuje na své obrazovce jen přihlašovací okno, ne Amber, " +
"takže by nic nedokazovaly. Případy tu ověřilo to, co aplikace sama hlásí.</div>";
}
// One line per run until somebody opens it: verdict, device, build, tally, and
// the first failures by name, which is most of what a glance is for.
function runRow(r){
var c = countsOf(r);
var cases = r.cases || [];
var bad = cases.filter(isBad);
var passed = cases.filter(function(x){ return x.outcome === "pass"; });
var quiet = cases.filter(function(x){ var quiet = cases.filter(function(x){
return x.outcome === "unsupported" || x.outcome === "unimplemented"; return x.outcome === "unsupported" || x.outcome === "unimplemented";
}); });
return '<div class="run"><h3>' + runVerdict(c, r) + var k = "run/" + r.id;
'<span class="pill un">' + esc(r.target) + "</span>" + var row = function(x){ return caseRow(r, x); };
"<code>" + esc(r.appVersion) + "</code><code>" + esc(r.commit) + "</code>" + return '<details class="run" data-k="' + esc(k) + '"><summary>' +
'<span class="grow"></span><span class="when">' + runVerdict(c, r) + '<span class="tgt">' + esc(targetName(r.target)) + "</span>" +
esc(String(r.created).substring(0, 16)) + "</span></h3>" + "<code>" + esc(r.appVersion || "?") + "</code><code>" + esc(r.commit || "?") + "</code>" +
'<span class="tally">' + tally(c) + "</span>" + failsSpan(r) +
'<span class="grow"></span><span class="when">' + esc(when(r)) + "</span></summary>" +
progressBar(r) + progressBar(r) +
'<div class="kv">' +
'<div><div class="lbl">prošlo</div><div class="big">' + (c.pass || 0) + "</div></div>" +
'<div><div class="lbl">spadlo</div><div class="big">' + (c.fail || 0) + "</div></div>" +
'<div><div class="lbl">čeká na tebe</div><div class="big">' + (c.blocked || 0) + "</div></div>" +
'<div><div class="lbl">nelze zde</div><div class="big">' + (c.unsupported || 0) + "</div></div>" +
'<div><div class="lbl">nenapsáno</div><div class="big">' + (c.unimplemented || 0) + "</div></div>" +
"</div>" +
(r.note ? '<div class="warnbox" style="margin-top:10px">' + esc(r.note) + "</div>" : "") + (r.note ? '<div class="warnbox" style="margin-top:10px">' + esc(r.note) + "</div>" : "") +
(interesting.length windowsNote(r) +
? "<div style=\"margin-top:12px\">" + bad.map(row).join("") +
interesting.map(function(x){ return caseRow(r, x); }).join("") + "</div>" (passed.length ? '<details data-k="' + esc(k + "/pass") + '"><summary>' +
: "") + passed.length + " prošlých, i s důkazy</summary>" + passed.map(row).join("") +
(rest.length "</details>" : "") +
? "<details><summary>" + rest.length + " prošlých, i s důkazy</summary>" + (quiet.length ? '<details data-k="' + esc(k + "/quiet") + '"><summary>' +
rest.map(function(x){ return caseRow(r, x); }).join("") + "</details>" quiet.length + " nelze zde nebo nenapsáno</summary>" + quiet.map(row).join("") +
: "") + "</details>" : "") +
(quiet.length "</details>";
? "<details><summary>" + quiet.length + }
" nelze zde nebo nenapsáno</summary>" +
quiet.map(function(x){ return caseRow(r, x); }).join("") + "</details>" // A run in flight: the bar stays visible without opening anything, and what it
: "") + // has found so far is one click away.
function liveBlock(r){
var c = countsOf(r);
var cases = r.cases || [];
var ordered = cases.filter(isBad).concat(cases.filter(function(x){ return !isBad(x); }));
return '<div class="liverun"><div class="lhead">' +
runVerdict(c, r) + '<span class="tgt">' + esc(targetName(r.target)) + "</span>" +
"<code>" + esc(r.appVersion || "?") + "</code><code>" + esc(r.commit || "?") + "</code>" +
'<span class="tally">' + tally(c) + "</span>" + failsSpan(r) +
'<span class="grow"></span><span class="when">od ' + esc(when(r)) + "</span></div>" +
progressBar(r) +
(cases.length ? '<details data-k="' + esc("run/" + r.id + "/sofar") + '"><summary>dosud ' +
cz(cases.length, "případ", "případy", "případů") + "</summary>" +
ordered.map(function(x){ return caseRow(r, x); }).join("") + "</details>" : "") +
"</div>"; "</div>";
} }
function viewE2e(runs){ function buildGroups(runs){
if (!runs.length) { var groups = [], byKey = {};
return '<section class="wide"><h2>Testy: co brána našla</h2>' + runs.forEach(function(r){
'<div class="warnbox">Zatím tu není žádný běh. Runner je v amber-app: ' + var key = (r.appVersion || "?") + " · " + (r.commit || "?");
"<code>tools/e2e/runner.py run --target linux</code>, a nahraje se sem " + if (!byKey[key]) { byKey[key] = { key: key, runs: [] }; groups.push(byKey[key]); }
"přes <code>tools/e2e/publish.py</code>.</div></section>"; byKey[key].runs.push(r);
});
return groups;
} }
// Newest first, and the newest per target at the top, because the question is
// always "kde to stojí teď" before "co bylo".
var latest = {};
runs.forEach(function(r){ if (!latest[r.target]) latest[r.target] = r; });
var heads = Object.keys(latest).map(function(k){ return runCard(latest[k]); }).join("");
var older = runs.filter(function(r){ return latest[r.target] !== r; });
return '<section class="wide"><h2>Testy: kde to stojí teď</h2>' + heads + function miniPill(r){
'<p class="note">Katalog je v amber-app: <code>docs/e2e/catalogue.yaml</code>. ' + var c = countsOf(r);
"Případ, na který se tady nedá zeptat, se hlásí jako „nelze zde“, ne jako " + var cls = isStalled(r) ? "wn" : (c.fail || 0) > 0 ? "no" : (c.blocked || 0) > 0 ? "wn" : "ok";
"prošlý — brána, která mlčí, je horší než žádná.</p></section>" + return '<span class="pill ' + cls + '">' + esc(targetName(r.target)) + "</span>";
(older.length }
? '<section class="wide"><h2>Dřívější běhy</h2>' +
older.map(runCard).join("") + "</section>" function groupBlock(g){
: ""); return '<details class="build" data-k="' + esc("build/" + g.key) + '"><summary>' +
"<code>" + esc(g.key) + "</code>" + g.runs.slice().sort(byTarget).map(miniPill).join("") +
'<span class="tally">' + cz(g.runs.length, "běh", "běhy", "běhů") + "</span>" +
'<span class="grow"></span><span class="when">' + esc(when(g.runs[0])) + "</span></summary>" +
g.runs.map(runRow).join("") + "</details>";
}
function e2eSkeleton(){
return '<section class="wide"><h2>Testy: kde to stojí teď</h2>' +
'<div id="e2e-live"></div><div id="e2e-latest"></div>' +
'<p class="note">Jeden řádek na zařízení, s jeho posledním dokončeným během. ' +
"Rozklikni řádek pro případy a případ pro jeho snímky. Katalog je v amber-app: " +
"<code>docs/e2e/catalogue.yaml</code>. Případ, na který se tady nedá zeptat, " +
"se hlásí jako „nelze zde“, ne jako prošlý.</p></section>" +
'<section class="wide" id="e2e-older-sec" hidden><h2>Dřívější běhy, podle buildu</h2>' +
'<div id="e2e-older"></div></section>';
}
// ── Redrawing without disturbing the reader ─────────────────────────────────
//
// The tab refreshes every five seconds while a run is going. It used to replace
// the whole page each time, which closed whatever was open, threw the reader
// back up the page and downloaded every picture again, so the tab was unusable
// for as long as a run lasted. Now each block is drawn to a string and only a
// block whose string changed replaces its element; every <details> carries a
// `data-k`, and whatever the reader opened is opened again after a redraw.
var e2eOpen = {}; // data-k of every <details> the reader has open
var e2eHtml = {}; // block key -> the HTML it was last drawn with
function patch(boxId, items){
var box = el(boxId);
if (!box) return;
var keys = items.map(function(i){ return i[0]; }).join("\n");
if (box.getAttribute("data-keys") !== keys) {
box.innerHTML = items.map(function(i){
return '<div class="blk" data-b="' + esc(i[0]) + '">' + i[1] + "</div>";
}).join("");
box.setAttribute("data-keys", keys);
items.forEach(function(i){ e2eHtml[i[0]] = i[1]; });
settle(box);
return;
}
Array.prototype.forEach.call(box.children, function(b, n){
var i = items[n];
if (e2eHtml[i[0]] === i[1]) return;
b.innerHTML = i[1];
e2eHtml[i[0]] = i[1];
settle(b);
});
}
function settle(root){
Array.prototype.forEach.call(root.querySelectorAll("details[data-k]"), function(d){
if (e2eOpen[d.getAttribute("data-k")]) d.open = true;
});
hydrate(root);
}
// Whether every <details> around a picture is open, i.e. whether anyone can see it.
function visibleImg(img){
for (var d = img.closest("details"); d;
d = d.parentElement ? d.parentElement.closest("details") : null) {
if (!d.open) return false;
}
return true;
}
// Pictures load when they come into view, never before: a run has dozens.
async function hydrate(root){
var imgs = Array.prototype.filter.call(root.querySelectorAll("img[data-src]"), function(i){
return !i.getAttribute("src") && visibleImg(i);
});
if (!imgs.length) return;
var t = await freshTok();
var q = t ? "?token=" + encodeURIComponent(t) : "";
imgs.forEach(function(i){
i.setAttribute("src", i.getAttribute("data-src") + q);
});
}
// `toggle` does not bubble, so this listens in the capture phase.
document.addEventListener("toggle", function(ev){
var d = ev.target;
if (!d || !d.getAttribute || !d.getAttribute("data-k")) return;
if (d.open) { e2eOpen[d.getAttribute("data-k")] = true; hydrate(d); }
else delete e2eOpen[d.getAttribute("data-k")];
}, true);
// A full-size picture opens with the freshest token there is, set as the press
// begins so the link the browser follows is never one that expired a while ago.
document.addEventListener("mousedown", function(ev){
var a = ev.target && ev.target.closest ? ev.target.closest("a[data-href]") : null;
if (a) a.setAttribute("href", a.getAttribute("data-href") +
(fileTok ? "?token=" + encodeURIComponent(fileTok) : ""));
}, true);
function renderE2e(runs){
if (!runs.length) {
patch("e2e-live", []);
patch("e2e-latest", [["empty", '<div class="warnbox">Zatím tu není žádný běh. ' +
"Runner je v amber-app: <code>tools/e2e/runner.py run --target linux --publish</code>." +
"</div>"]]);
patch("e2e-older", []);
el("e2e-older-sec").hidden = true;
return;
}
var live = runs.filter(isLive);
// The newest FINISHED run per device answers "kde to stojí teď"; a run still
// going is progress, shown above them, and a run that died is history.
var latest = {};
runs.forEach(function(r){
if (r.status !== "running" && !latest[r.target]) latest[r.target] = r;
});
var heads = Object.keys(latest).map(function(t){ return latest[t]; }).sort(byTarget);
var older = runs.filter(function(r){ return !isLive(r) && heads.indexOf(r) < 0; });
patch("e2e-live", live.map(function(r){ return ["live/" + r.id, liveBlock(r)]; }));
patch("e2e-latest", heads.map(function(r){ return ["latest/" + r.target, runRow(r)]; }));
patch("e2e-older", buildGroups(older).map(function(g){
return ["build/" + g.key, groupBlock(g)];
}));
el("e2e-older-sec").hidden = !older.length;
freshTok();
} }
function viewDevices(rows){ function viewDevices(rows){
@ -1420,14 +1649,11 @@ async function load(){
if (prefs.view === "e2e") { if (prefs.view === "e2e") {
var rs = await records("e2e_runs", { sort: "-created", perPage: 40 }) var rs = await records("e2e_runs", { sort: "-created", perPage: 40 })
.catch(function(){ return { items: [] }; }); .catch(function(){ return { items: [] }; });
liveRun = (rs.items || []).some(isLive); var runs = rs.items || [];
fileTok = ""; liveRun = runs.some(isLive);
try { // Built once, then patched: see `patch`.
var ft = await api("/api/files/token", { method: "POST" }); if (!el("e2e-latest")) { el("main").innerHTML = e2eSkeleton(); wire(); }
fileTok = ft.token || ""; renderE2e(runs);
} catch (_) { /* the cards still render; the pictures will not */ }
el("main").innerHTML = viewE2e(rs.items || []);
wire();
return; return;
} }
if (prefs.view === "reports") { if (prefs.view === "reports") {