diff --git a/pb_public/status.html b/pb_public/status.html
index 20804c5..4cd9b61 100644
--- a/pb_public/status.html
+++ b/pb_public/status.html
@@ -163,6 +163,31 @@
.accts td:last-child{white-space:nowrap;text-align:right}
.accts button{white-space:nowrap}
@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}
@@ -763,33 +788,96 @@ function normName(s){
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
// 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í.
";
+}
+
+// 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){
return x.outcome === "unsupported" || x.outcome === "unimplemented";
});
- return '' + runVerdict(c, r) +
- '' + esc(r.target) + "" +
- "" + esc(r.appVersion) + "" + esc(r.commit) + "" +
- '' +
- esc(String(r.created).substring(0, 16)) + "
" +
+ var k = "run/" + r.id;
+ var row = function(x){ return caseRow(r, x); };
+ return '
' +
+ runVerdict(c, r) + '' + esc(targetName(r.target)) + "" +
+ "" + esc(r.appVersion || "?") + "" + esc(r.commit || "?") + "" +
+ '' + tally(c) + "" + failsSpan(r) +
+ '' + esc(when(r)) + "
" +
progressBar(r) +
- '' +
- '
prošlo
' + (c.pass || 0) + "
" +
- '
spadlo
' + (c.fail || 0) + "
" +
- '
čeká na tebe
' + (c.blocked || 0) + "
" +
- '
nelze zde
' + (c.unsupported || 0) + "
" +
- '
nenapsáno
' + (c.unimplemented || 0) + "
" +
- "
" +
(r.note ? '' + esc(r.note) + "
" : "") +
- (interesting.length
- ? "" +
- interesting.map(function(x){ return caseRow(r, x); }).join("") + "
"
- : "") +
- (rest.length
- ? "" + rest.length + " prošlých, i s důkazy
" +
- rest.map(function(x){ return caseRow(r, x); }).join("") + " "
- : "") +
- (quiet.length
- ? "" + quiet.length +
- " nelze zde nebo nenapsáno
" +
- quiet.map(function(x){ return caseRow(r, x); }).join("") + " "
- : "") +
+ windowsNote(r) +
+ bad.map(row).join("") +
+ (passed.length ? '' +
+ passed.length + " prošlých, i s důkazy
" + passed.map(row).join("") +
+ " " : "") +
+ (quiet.length ? '' +
+ quiet.length + " nelze zde nebo nenapsáno
" + quiet.map(row).join("") +
+ " " : "") +
+ "";
+}
+
+// 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 '
' +
+ runVerdict(c, r) + '' + esc(targetName(r.target)) + "" +
+ "" + esc(r.appVersion || "?") + "" + esc(r.commit || "?") + "" +
+ '' + tally(c) + "" + failsSpan(r) +
+ 'od ' + esc(when(r)) + "
" +
+ progressBar(r) +
+ (cases.length ? '
dosud ' +
+ cz(cases.length, "případ", "případy", "případů") + "
" +
+ ordered.map(function(x){ return caseRow(r, x); }).join("") + "" : "") +
"
";
}
-function viewE2e(runs){
- if (!runs.length) {
- return '
Testy: co brána našla
' +
- 'Zatím tu není žádný běh. Runner je v amber-app: ' +
- "tools/e2e/runner.py run --target linux, a nahraje se sem " +
- "přes tools/e2e/publish.py.
";
- }
- // 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; });
+function buildGroups(runs){
+ var groups = [], byKey = {};
+ runs.forEach(function(r){
+ var key = (r.appVersion || "?") + " · " + (r.commit || "?");
+ if (!byKey[key]) { byKey[key] = { key: key, runs: [] }; groups.push(byKey[key]); }
+ byKey[key].runs.push(r);
+ });
+ return groups;
+}
- return '
Testy: kde to stojí teď
' + heads +
- 'Katalog je v amber-app: docs/e2e/catalogue.yaml. ' +
- "Případ, na který se tady nedá zeptat, se hlásí jako „nelze zde“, ne jako " +
- "prošlý — brána, která mlčí, je horší než žádná.
" +
- (older.length
- ? '
Dřívější běhy
' +
- older.map(runCard).join("") + ""
- : "");
+function miniPill(r){
+ var c = countsOf(r);
+ var cls = isStalled(r) ? "wn" : (c.fail || 0) > 0 ? "no" : (c.blocked || 0) > 0 ? "wn" : "ok";
+ return '
' + esc(targetName(r.target)) + "";
+}
+
+function groupBlock(g){
+ return '
' +
+ "" + esc(g.key) + "" + g.runs.slice().sort(byTarget).map(miniPill).join("") +
+ '' + cz(g.runs.length, "běh", "běhy", "běhů") + "" +
+ '' + esc(when(g.runs[0])) + "
" +
+ g.runs.map(runRow).join("") + "";
+}
+
+function e2eSkeleton(){
+ return '
Testy: kde to stojí teď
' +
+ '' +
+ '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: " +
+ "docs/e2e/catalogue.yaml. Případ, na který se tady nedá zeptat, " +
+ "se hlásí jako „nelze zde“, ne jako prošlý.
" +
+ '
Dřívější běhy, podle buildu
' +
+ '';
+}
+
+// ── 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
carries a
+// `data-k`, and whatever the reader opened is opened again after a redraw.
+var e2eOpen = {}; // data-k of every 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 '' + i[1] + "
";
+ }).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 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", 'Zatím tu není žádný běh. ' +
+ "Runner je v amber-app: tools/e2e/runner.py run --target linux --publish." +
+ "
"]]);
+ 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){
@@ -1420,14 +1649,11 @@ async function load(){
if (prefs.view === "e2e") {
var rs = await records("e2e_runs", { sort: "-created", perPage: 40 })
.catch(function(){ return { items: [] }; });
- liveRun = (rs.items || []).some(isLive);
- fileTok = "";
- try {
- var ft = await api("/api/files/token", { method: "POST" });
- fileTok = ft.token || "";
- } catch (_) { /* the cards still render; the pictures will not */ }
- el("main").innerHTML = viewE2e(rs.items || []);
- wire();
+ var runs = rs.items || [];
+ liveRun = runs.some(isLive);
+ // Built once, then patched: see `patch`.
+ if (!el("e2e-latest")) { el("main").innerHTML = e2eSkeleton(); wire(); }
+ renderE2e(runs);
return;
}
if (prefs.view === "reports") {