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 // 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 -// every screenshot on the page is a broken icon. -var fileTok = ""; +// browser sends no Authorization header with an image request. The token is +// fetched when a picture is about to be shown and reused for ninety seconds. It +// 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 // the ordinary minute. Nothing else on this dashboard changes that fast. 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 hit = (r.shots || []).filter(function(f){ return normName(f).indexOf(want) === 0; })[0]; - if (!hit) return null; - return "/api/files/e2e_runs/" + r.id + "/" + hit + - (fileTok ? "?token=" + encodeURIComponent(fileTok) : ""); + return hit ? "/api/files/e2e_runs/" + r.id + "/" + hit : null; +} + +// 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 '' + esc(ids.slice(0, 3).join(", ") + + (ids.length > 3 ? " a " + (ids.length - 3) + " další" : "")) + ""; } function caseRow(r, c){ var shots = (c.evidence || []).filter(function(e){ return e.kind === "screenshot"; }); - var pics = shots.map(function(s){ - var u = shotUrl(r, c.id, s.name); - return u ? '
' + esc(s.name) + "
" : ""; + var shown = shots.filter(function(e){ return honestShot(r, e); }); + var pics = shown.map(function(s){ + var p = shotPath(r, c.id, s.name); + return p ? '
' + esc(s.name) + "
" : ""; }).join(""); // 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 @@ -802,75 +890,216 @@ function caseRow(r, c){ var claim = c.outcome === "blocked" ? (c.needs || "") : c.outcome === "fail" ? ((broke && broke.claim) || c.error || "") : (checks.length ? checks[checks.length - 1].claim : ""); - return '
' + outcomePill(c.outcome) + + var head = outcomePill(c.outcome) + '' + esc(c.id) + "" + (c.blocker ? 'blokuje' : "") + - '' + esc(String(claim).split("\n").pop().substring(0, 160)) + - "
" + (pics ? '
' + pics + "
" : ""); + '' + esc(String(claim).split("\n").pop().substring(0, 160)) + "" + + (pics ? '' + cz(shown.length, "snímek", "snímky", "snímků") + "" : ""); + if (!pics) return '
' + head + "
"; + return '
' + + head + '
' + pics + "
"; } -function runCard(r){ - var c = r.counts || {}; - var cases = r.cases || []; - var interesting = cases.filter(function(x){ - return x.outcome === "fail" || x.outcome === "blocked"; +function windowsNote(r){ + if (r.target !== "windows") return ""; + var any = (r.cases || []).some(function(c){ + return (c.evidence || []).some(function(e){ + return e.kind === "screenshot" && honestShot(r, e); + }); }); - var rest = cases.filter(function(x){ return x.outcome === "pass"; }); + if (any) return ""; + return '
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ý.

" + + ''; +} + +// ── 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") {