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