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);