From 96f346f775499dd8e064e9e1abcd3020e8abf3fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 01:04:54 +0200 Subject: [PATCH] Show what the release gate found, run by run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pb_migrations/1796000000_e2e_runs.js | 75 +++++++++++++++ pb_public/status.html | 138 +++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 pb_migrations/1796000000_e2e_runs.js diff --git a/pb_migrations/1796000000_e2e_runs.js b/pb_migrations/1796000000_e2e_runs.js new file mode 100644 index 0000000..b608f1d --- /dev/null +++ b/pb_migrations/1796000000_e2e_runs.js @@ -0,0 +1,75 @@ +/// + +// 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); + }, +); diff --git a/pb_public/status.html b/pb_public/status.html index 97a8a74..46acd4c 100644 --- a/pb_public/status.html +++ b/pb_public/status.html @@ -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 +
— 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 @@
+
@@ -661,6 +680,118 @@ function viewReports(rows){ "tvrzení než nula.

"; } +/* ══ 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 'prošlo'; + if (o === "fail") return 'spadlo'; + if (o === "blocked") return 'čeká na tebe'; + if (o === "unsupported") return 'nelze zde'; + return 'nenapsáno'; +} + +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 'nevydávat'; + if ((c.blocked || 0) > 0) return 'něco čeká na tebe'; + return 'nic nebrání vydání'; +} + +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 ? '
' + esc(s.name) + "
" : ""; + }).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 '
' + outcomePill(c.outcome) + + '' + esc(c.id) + "" + + (c.blocker ? 'blokuje' : "") + + '' + esc(String(claim).split("\n").pop().substring(0, 160)) + + "
" + (pics ? '
' + 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"; + }); + var rest = cases.filter(function(x){ return x.outcome === "pass"; }); + var quiet = cases.filter(function(x){ + return x.outcome === "unsupported" || x.outcome === "unimplemented"; + }); + return '

' + runVerdict(c) + + '' + esc(r.target) + "" + + "" + esc(r.appVersion) + "" + esc(r.commit) + "" + + '' + + esc(String(r.created).substring(0, 16)) + "

" + + '
' + + '
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("") + "
" + : "") + + "
"; +} + +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; }); + + 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 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);