Show what the release gate found, run by run

A Testy tab on the status dashboard and an `e2e_runs` collection behind it. The
owner's call to put it here rather than stand up a service: a whole application
to display a table of results would cost more to keep alive than the thing it
displays, and the VPS's disk is already the scarce resource.

The screenshots ride along and live exactly as long as the run does. A result
whose evidence has been deleted is a claim again rather than a record, and
deleting a run takes its pictures with it, which is the retention policy in one
sentence.

Five outcomes on the cards, not two, because the distinction is the whole point
of the gate: "nelze zde" is a target that cannot ask the question, "čeká na
tebe" is something only the owner can unblock, and neither may be allowed to
read as a pass. The verdict line answers the only question a release asks --
whether anything that blocks shipping has failed -- and the counts underneath
say how much of the catalogue is still unwritten, so a green card cannot quietly
mean an empty one.

Superuser-only, like the rest of this dashboard: it names internal ids and
carries screenshots of a signed-in app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-09-10 01:04:54 +02:00
parent 9daf638c49
commit 96f346f775
2 changed files with 213 additions and 0 deletions

View file

@ -0,0 +1,75 @@
/// <reference path="../pb_data/types.d.ts" />
// What the release gate found, run by run.
//
// The catalogue in amber-app (`docs/e2e/catalogue.yaml`) says what a person must
// be able to do before a build goes out; the runner executes it and writes one
// record here per run. The dashboard's Testy tab reads them.
//
// WHY A COLLECTION AND NOT A SERVICE: the owner's call, and the right one. A
// whole application to display a table of results would cost more to keep alive
// than the thing it displays, and the VPS's disk is already the scarce resource
// (old releases eat it, not databases).
//
// **The screenshots live here too, for as long as the run does.** A result whose
// evidence has been deleted is a claim again rather than a record, and they are
// small next to a release artifact. Deleting a run takes its evidence with it,
// which is the retention policy in one sentence.
//
// Superuser-only, like the status dashboard it appears in: this is operator
// data, it names internal ids and it carries screenshots of a signed-in app.
// There is no reason for a family account to be able to read it.
migrate(
(app) => {
const runs = new Collection({
type: "base",
name: "e2e_runs",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
// The run's own stamp, e.g. 2026-09-10T00-31-02Z — the directory name on
// the machine that ran it, so a record can be traced back to its files.
{ type: "text", name: "run", required: true, max: 40 },
// linux | windows | phone | tv | attach
{ type: "text", name: "target", required: true, max: 20 },
// What was under test, not what wrote the record: the app's version and
// the commit the cases came from. Without both, a green run a month old
// says nothing about which build it green-lit.
{ type: "text", name: "appVersion", required: true, max: 40 },
{ type: "text", name: "commit", required: true, max: 40 },
{ type: "number", name: "catalogueVersion", onlyInt: true },
// { pass, fail, blocked, unsupported, unimplemented }
{ type: "json", name: "counts", maxSize: 2000 },
// One entry per case: id, outcome, blocker, how, ms, evidence, error.
// The evidence entries name their screenshot files, which is how the
// dashboard pairs a claim with the picture that proves it.
{ type: "json", name: "cases", maxSize: 2000000 },
{
type: "file",
name: "shots",
maxSelect: 200,
maxSize: 8388608,
mimeTypes: ["image/png"],
},
// Anything the run wants to say for itself — usually why a target could
// not start at all, which is a result worth keeping rather than an
// absence of one.
{ type: "text", name: "note", max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
],
indexes: [
"CREATE INDEX idx_e2e_runs_created ON e2e_runs (created)",
"CREATE INDEX idx_e2e_runs_target ON e2e_runs (target, created)",
],
});
app.save(runs);
},
(app) => {
const c = app.findCollectionByNameOrId("e2e_runs");
if (c) app.delete(c);
},
);

View file

@ -45,6 +45,24 @@
.no{background:rgba(255,107,107,.16);color:var(--bad)}
.wn{background:rgba(240,166,60,.16);color:var(--warn)}
.big{font-size:26px;font-weight:600;font-variant-numeric:tabular-nums}
/* A run is a card with its own verdict line, and its cases are a list under a
<details> — a run has ninety of them and nobody wants ninety rows open. */
.run{border:1px solid var(--line);border-radius:10px;padding:14px 16px;
margin-bottom:10px;background:var(--panel2)}
.run h3{margin:0 0 6px;font-size:15px;display:flex;align-items:center;gap:8px;
flex-wrap:wrap}
.run .when{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
.run details{margin-top:10px}
.run summary{cursor:pointer;color:var(--muted);font-size:12.5px}
.case{display:flex;gap:10px;align-items:baseline;padding:5px 0;
border-bottom:1px solid var(--line);font-size:13px}
.case:last-child{border-bottom:none}
.case .cid{font-family:var(--mono);font-size:12px;min-width:230px}
.case .msg{color:var(--muted)}
.shots{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.shots a{display:block;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.shots img{display:block;width:150px;height:auto}
.shots .cap{font-size:11px;color:var(--muted);padding:3px 5px;font-family:var(--mono)}
.kv{display:flex;gap:18px;flex-wrap:wrap}
.kv div{min-width:78px}
.kv .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em}
@ -160,6 +178,7 @@
<div class="tabs" id="tabs">
<button data-view="overview">Přehled</button>
<button data-view="reports">Hlášení</button>
<button data-view="e2e">Testy</button>
<button data-view="admin">Správa</button>
</div>
<div class="grow"></div>
@ -661,6 +680,118 @@ function viewReports(rows){
"tvrzení než nula.</p></section>";
}
/* ══ Testy ═════════════════════════════════════════════════════════════════════
What the release gate found. The catalogue lives in amber-app
(docs/e2e/catalogue.yaml) and says what a person must be able to do before a
build goes out; the runner executes it and posts one e2e_runs record per run.
Five outcomes, not two, and the distinction is the point: a case that could
not be asked must never read as a pass. "Nelze zde" is a target that cannot
answer (no keychain on the desktop sandbox), "čeká na tebe" is something only
the owner can unblock (a missing PIN), and "nenapsáno" is work. */
function outcomePill(o){
if (o === "pass") return '<span class="pill ok">prošlo</span>';
if (o === "fail") return '<span class="pill no">spadlo</span>';
if (o === "blocked") return '<span class="pill wn">čeká na tebe</span>';
if (o === "unsupported") return '<span class="pill un">nelze zde</span>';
return '<span class="pill un">nenapsáno</span>';
}
function runVerdict(c){
// A build ships when nothing that blocks a release has failed. Cases nobody
// has written yet are not a verdict, they are a gap, and the card says both.
if ((c.fail || 0) > 0) return '<span class="pill no">nevydávat</span>';
if ((c.blocked || 0) > 0) return '<span class="pill wn">něco čeká na tebe</span>';
return '<span class="pill ok">nic nebrání vydání</span>';
}
function shotUrl(r, name){
var hit = (r.shots || []).filter(function(f){ return f.indexOf(name) === 0; })[0];
return hit ? "/api/files/e2e_runs/" + r.id + "/" + hit : null;
}
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 ? '<a href="' + u + '" target="_blank"><img loading="lazy" src="' + u +
'"><div class="cap">' + esc(s.name) + "</div></a>" : "";
}).join("");
var checks = (c.evidence || []).filter(function(e){ return e.kind === "checked"; });
var claim = checks.length
? checks[checks.length - 1].claim
: (c.needs || c.error || "");
return '<div class="case">' + outcomePill(c.outcome) +
'<span class="cid">' + esc(c.id) + "</span>" +
(c.blocker ? '<span class="pill un">blokuje</span>' : "") +
'<span class="msg">' + esc(String(claim).split("\n").pop().substring(0, 160)) +
"</span></div>" + (pics ? '<div class="shots">' + pics + "</div>" : "");
}
function runCard(r){
var c = r.counts || {};
var cases = r.cases || [];
var interesting = cases.filter(function(x){
return x.outcome === "fail" || x.outcome === "blocked";
});
var rest = cases.filter(function(x){ return x.outcome === "pass"; });
var quiet = cases.filter(function(x){
return x.outcome === "unsupported" || x.outcome === "unimplemented";
});
return '<div class="run"><h3>' + runVerdict(c) +
'<span class="pill un">' + esc(r.target) + "</span>" +
"<code>" + esc(r.appVersion) + "</code><code>" + esc(r.commit) + "</code>" +
'<span class="grow"></span><span class="when">' +
esc(String(r.created).substring(0, 16)) + "</span></h3>" +
'<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>" : "") +
(interesting.length
? "<div style=\"margin-top:12px\">" +
interesting.map(function(x){ return caseRow(r, x); }).join("") + "</div>"
: "") +
(rest.length
? "<details><summary>" + rest.length + " prošlých, i s důkazy</summary>" +
rest.map(function(x){ return caseRow(r, x); }).join("") + "</details>"
: "") +
(quiet.length
? "<details><summary>" + quiet.length +
" nelze zde nebo nenapsáno</summary>" +
quiet.map(function(x){ return caseRow(r, x); }).join("") + "</details>"
: "") +
"</div>";
}
function viewE2e(runs){
if (!runs.length) {
return '<section class="wide"><h2>Testy: co brána našla</h2>' +
'<div class="warnbox">Zatím tu není žádný běh. Runner je v amber-app: ' +
"<code>tools/e2e/runner.py run --target linux</code>, a nahraje se sem " +
"přes <code>tools/e2e/publish.py</code>.</div></section>";
}
// 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 +
'<p class="note">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ý — brána, která mlčí, je horší než žádná.</p></section>" +
(older.length
? '<section class="wide"><h2>Dřívější běhy</h2>' +
older.map(runCard).join("") + "</section>"
: "");
}
function viewDevices(rows){
// Group by box, because "which television" was unanswerable until the device
// label carried a model and a per-install id.
@ -1205,6 +1336,13 @@ async function load(){
wire();
return;
}
if (prefs.view === "e2e") {
var rs = await records("e2e_runs", { sort: "-created", perPage: 40 })
.catch(function(){ return { items: [] }; });
el("main").innerHTML = viewE2e(rs.items || []);
wire();
return;
}
if (prefs.view === "reports") {
var rows = await sessions();
el("main").innerHTML = viewReports(rows) + viewDevices(rows);