From 9f62f78e1b7d6df1811830fc95ce3a54042b5785 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:36:25 +0200 Subject: [PATCH 01/25] feat(e2e): let the player's colours mean what you need them to mean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colours encoded how an action is DRIVEN; the reader expected pass / fail / implemented. Rather than argue for one answer, the encoding is now a setting: driver, kind, priority, act, cast, flags, expect, run. There is exactly ONE painter. paintTick() writes the geometry class, the hue and a data-bucket, and it paints the timeline ticks, the legend swatches AND the settings previews. The legend's counts are then tallied OFF THE PAINTED TICKS rather than recomputed from the predicates — a second counting pass is a second thing that can disagree, which is how the previous legend came to mislabel all three of its colours. An action matching no bucket lands in a visible `unclassified` row instead of a missing tick, so the primaries always sum to 463. Pass/fail is not in recipe.jsonl, so it is not faked. The settings modal says so and then offers a Playwright JSON report to load (FileReader, no upload), joining on the [] in each test title; with none loaded every tick reads "not in the report". A report with no matching ids is refused with a reason rather than half-believed, and it is deliberately NOT persisted — a status that outlives the run which produced it is a stale green. `expect` mode is the honest neighbour: it colours by what each action ASSERTS, where red means a refusal is correct. `gate` and `todo` are explained where their counts appear, in prose rather than a tooltip, because a reader had to ask what "24 gate / 65 todo" meant: gate self-skips until its RPCs exist and wakes on its own; todo is a free-text note, not a gap and not a failure. Every number in that copy is counted at runtime — a literal 24 goes stale on the next recipe edit with nothing to catch it. Exports hand out the RAW SOURCE LINES, not a re-serialisation: the embedded block is un-escaped and kept per action, so the full export is byte-identical to recipe.jsonl. JSON.stringify would have lost the file's own spacing and diffed against every line. Each button carries its row count, so nobody exports 463 believing they exported 12, and subset filenames record the filter. Modal is min(1880px, 95vw) x min(1200px, 92dvh) with the extra width given to the JSON panel (430px flat -> clamp to 519px measured). Participants are two columns, computed from cast size so three names are never split, with labels under the discs — a left-hanging label landed on the other column's discs. act mode keeps a single geometry: nine ordinal buckets do not map onto a distinguishable extent ladder, and acts are already separated by position on the bar. The mode's own copy says so and the harness exempts it with that reason recorded, rather than silently. Verified by rendering: 697 checks, 0 failures. Per mode, in both themes — legend count == the ticks it describes == counts derived independently from recipe.jsonl, labels as expected, primaries partition 463, swatch colour and extent identical to the ticks they stand for. Full export byte-identical, four subsets exporting exactly what their buttons claimed, keyboard-only mode change and export, the report loader and its refusal path, the pass/fail statement asserted on screen unscrolled, focus traps, XSS still rendered as text with exactly 2 script elements, zero non-file:// requests. --- .../skills/hackathon-e2e/recipe-player.html | 973 ++++++++++++++++-- 1 file changed, 887 insertions(+), 86 deletions(-) diff --git a/.claude/skills/hackathon-e2e/recipe-player.html b/.claude/skills/hackathon-e2e/recipe-player.html index 29dae846..1a89dc62 100644 --- a/.claude/skills/hackathon-e2e/recipe-player.html +++ b/.claude/skills/hackathon-e2e/recipe-player.html @@ -238,7 +238,12 @@ .hlp .legend .ghost i { background: repeating-linear-gradient(90deg, var(--k-vote) 0 3px, transparent 3px 6px); } .hlp .legend .denied i { background: var(--st-crit); } -.hlp .roster { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 12px; } +/* two columns where two columns fit and one where they do not — auto-fit + rather than a hard 1fr 1fr, so a short cast is not split into two stubs and + the rail collapsing to full width on a phone does not leave 130px columns. + 122px, not 134: the rail's own content box is 280px and two 134px tracks plus + the gap come to exactly that, which sub-pixel rounding then loses. */ +.hlp .roster { display: grid; grid-template-columns: repeat(auto-fit, minmax(122px, 1fr)); gap: 3px 12px; } .hlp .person { display: flex; align-items: center; gap: 7px; padding: 2px 0; min-width: 0; } .hlp .person .av { width: 17px; height: 17px; border-radius: 50%; flex: none; @@ -318,26 +323,42 @@ .hlp .seg { position: relative; background: var(--panel); border-radius: 2px; overflow: hidden; min-width: 8px; } /* ── timeline ticks: one per action ─────────────────────────────────── A tick is ~1px wide at 463 across the bar, so a pattern inside one cannot - read and hue alone would be the only signal. The pairing is therefore - GEOMETRY, which survives at 1px, in greyscale and in print: - how the action is DRIVEN → hue + vertical extent - browser (ui.flow/ui.assert) = blue, short bar on the baseline - API (rpc/rpc.race) = yellow, full height - fixture (files.generate) = grey, full height - what the action CARRIES → a cap, and the two caps sit at opposite ends - gate (skips unless its RPCs exist) = red cap at the TOP - todo (an open design note) = violet cap at the BOTTOM */ -.hlp .seg .tick { position: absolute; top: 0; bottom: 0; } -.hlp .seg .tick.api { background: color-mix(in srgb, var(--st-warn) var(--tick-strength), var(--panel)); } -.hlp .seg .tick.ui { top: 45%; background: color-mix(in srgb, var(--k-create) var(--tick-strength), var(--panel)); } -.hlp .seg .tick.fixture { background: color-mix(in srgb, var(--k-check) var(--tick-strength), var(--panel)); } -.hlp .seg .tick.gated::before { + read and hue alone would be the only signal. Every encoding therefore pairs + a hue with GEOMETRY — a vertical extent, which survives at 1px, in + greyscale and in print. + + WHAT the hue means is a SETTING now (the ⚙ Colour coding dialog), because + the default answer ("how the action is driven") is not the one most people + guess. So neither the hue nor the extent may be baked into a class name + here: a mode declares, per bucket, a colour and one of the g-* extents + below, and paintTick() writes both onto the element. The bar ticks and the + legend swatches go through that ONE function, so the legend cannot describe + an encoding different from the one being drawn — which is exactly the bug + the previous hand-written legend had, on all three of its colours. + + The two caps are fixed, because they always mean the same two fields: + gate (skips unless its RPCs exist) = cap at the TOP + todo (a free-text design note) = cap at the BOTTOM */ +.hlp .seg .tick, .hlp .tl-legend .sw i { + background: color-mix(in srgb, var(--tc, var(--muted)) var(--tick-strength), var(--panel)); +} +.hlp .seg .tick { position: absolute; } +.hlp .tl-legend .sw i { position: absolute; left: 0; right: 0; } +/* the extent vocabulary — a mode picks one per bucket. Monotone where the + buckets are ordered (priority, expectation), distinct where they are not. */ +.hlp .g-full { top: 0; bottom: 0; } +.hlp .g-low { top: 45%; bottom: 0; } +.hlp .g-high { top: 0; bottom: 45%; } +.hlp .g-mid { top: 27%; bottom: 27%; } +.hlp .g-lower { top: 70%; bottom: 0; } +.hlp .g-upper { top: 0; bottom: 70%; } +.hlp .cap-top::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 5px; - background: var(--st-crit); + background: var(--ct, var(--st-crit)); } -.hlp .seg .tick.noted::after { +.hlp .cap-bot::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 4px; - background: var(--k-vote); + background: var(--cb, var(--k-vote)); } /* the advancing playhead: a bright bar sweeping left → right over the map */ .hlp .scrub .elapsed { @@ -351,9 +372,10 @@ } /* ── timeline legend: sits directly under the acts strip, never in the rail — - the colours it explains are 20px above it. Counts are written by JS from the - embedded data, so the legend cannot drift from what the ticks encode (the - previous hand-written one did, on all three colours). */ + the colours it explains are 20px above it. Counts are TALLIED off the ticks + that were actually drawn (not off a second predicate pass), so the legend + cannot drift from what the ticks encode — the previous hand-written one did, + on all three colours. */ .hlp .tl-legend { flex: 1 1 100%; display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; margin-top: 7px; @@ -362,6 +384,27 @@ font: 700 8.5px/1 var(--mono); letter-spacing: .11em; text-transform: uppercase; color: var(--muted); } +/* what the colours mean RIGHT NOW, named in the legend itself and clickable + straight through to the setting that changes it */ +.hlp .tl-legend .lmode { + display: inline-flex; align-items: center; gap: 5px; + font: 700 8.5px/1 var(--mono); letter-spacing: .09em; text-transform: uppercase; + padding: 4px 7px; border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--k-create) 55%, transparent); + color: var(--ink); +} +.hlp .tl-legend .lmode:hover { background: color-mix(in srgb, var(--k-create) 16%, transparent); } +.hlp .tl-legend .lmode em { font-style: normal; color: var(--k-create); } +/* the counts used to stand alone: a user asked us what "carries 24 gate / 65 + todo note" meant, which is a legend stating a number with no meaning. The + sentence is part of the legend now, not a tooltip on it. */ +.hlp .lgnote { + flex: 1 1 100%; margin-top: 5px; + font: 400 10px/1.5 var(--sans); color: var(--muted); max-width: 132ch; +} +.hlp .lgnote b { color: var(--ink2); font-weight: 700; } +.hlp .lgnote .gk { color: var(--st-crit); font-weight: 700; } +.hlp .lgnote .tk { color: var(--k-vote); font-weight: 700; } .hlp .tl-legend .litem { display: inline-flex; align-items: center; gap: 5px; font: 400 10px/1.3 var(--sans); color: var(--ink2); white-space: nowrap; @@ -369,17 +412,12 @@ .hlp .tl-legend .litem b { font: 700 10px/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); } -/* each swatch is a 14x12 slice of the real bar, drawn with the same geometry */ +/* each swatch is a 15x13 slice of the real bar, painted by the same + paintTick() that paints the bar — see the tick comment above */ .hlp .tl-legend .sw { position: relative; width: 15px; height: 13px; flex: none; background: var(--panel); border-radius: 2px; overflow: hidden; } -.hlp .tl-legend .sw i { position: absolute; left: 0; right: 0; } -.hlp .tl-legend .sw i.api { top: 0; bottom: 0; background: color-mix(in srgb, var(--st-warn) var(--tick-strength), var(--panel)); } -.hlp .tl-legend .sw i.ui { top: 45%; bottom: 0; background: color-mix(in srgb, var(--k-create) var(--tick-strength), var(--panel)); } -.hlp .tl-legend .sw i.fixture { top: 0; bottom: 0; background: color-mix(in srgb, var(--k-check) var(--tick-strength), var(--panel)); } -.hlp .tl-legend .sw i.gated { top: 0; height: 5px; background: var(--st-crit); } -.hlp .tl-legend .sw i.noted { bottom: 0; height: 4px; background: var(--k-vote); } /* phase names sit directly under the timeline, aligned with their segments */ .hlp .acts { display: flex; gap: 2px; margin-top: 4px; } .hlp .acts button { @@ -450,9 +488,12 @@ /* ── action browser modal ────────────────────────────────────────────── */ .hlp .bmodal { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; } .hlp .bmodal[hidden] { display: none; } +/* the browser takes the screen: 463 rows and a JSON panel beside them is the + one view here that wants every pixel. 95vw/92vh with a max so it does not + sprawl on an ultrawide, and the narrow rules below take over under 760px. */ .hlp .bcard { position: relative; display: flex; flex-direction: column; - width: min(1200px, calc(100vw - 20px)); height: min(880px, calc(100dvh - 20px)); + width: min(1880px, 95vw); height: min(1200px, 92dvh); background: var(--surface); border: 1px solid var(--line-strong); border-radius: 6px; box-shadow: 0 16px 48px var(--shadow); overflow: hidden; } @@ -522,9 +563,11 @@ .hlp .brow .btitle { font: 400 12px/1.35 var(--sans); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hlp .brow .bflags { display: inline-flex; gap: 4px; } .hlp .bempty { padding: 22px 14px; font: 400 12px var(--sans); color: var(--muted); text-align: center; } -/* json side panel */ +/* json side panel — the extra width the bigger card buys goes here: params + blobs are deep and wrapping them at 430px was most of what made this panel + hard to read. Clamped, so it never eats the list on a narrow laptop. */ .hlp .bdetail { - width: 430px; flex: none; border-left: 1px solid var(--line); + width: clamp(430px, 38%, 760px); flex: none; border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; background: var(--bg); } .hlp .bdhead { padding: 11px 13px; border-bottom: 1px solid var(--line); flex: none; } @@ -567,6 +610,115 @@ font: 600 9.5px var(--mono); color: var(--ink2); border: 1px solid var(--line-strong); border-radius: 3px; padding: 1px 4px; } +/* the same sentence as the timeline legend's, next to the two filter + checkboxes that use the words — the hint bar is where a reader of this + dialog is already looking for what a control means */ +.hlp .bhint .fldnote { color: var(--ink2); } +.hlp .bhint .fldnote .gk { color: var(--st-crit); font-weight: 700; } +.hlp .bhint .fldnote .tk { color: var(--k-vote); font-weight: 700; } + +/* ── export buttons (browser head) ──────────────────────────────────── + Both carry their row count in the label: the whole point of a subset export + is that it is NOT 463, and a button that does not say which it is invites + exactly that mistake. */ +.hlp .dlbtn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 10px; border-radius: 4px; white-space: nowrap; + font: 600 10.5px/1 var(--mono); letter-spacing: .03em; + border: 1px solid var(--line-strong); color: var(--ink2); +} +.hlp .dlbtn:hover { border-color: var(--muted); color: var(--ink); } +.hlp .dlbtn b { color: var(--ink); font-variant-numeric: tabular-nums; } +.hlp .dlbtn.primary { + border-color: color-mix(in srgb, var(--k-create) 70%, transparent); + background: color-mix(in srgb, var(--k-create) 14%, transparent); + color: var(--ink); +} +.hlp .dlbtn.primary:hover { background: color-mix(in srgb, var(--k-create) 26%, transparent); } +.hlp .dlbtn[disabled] { opacity: .45; cursor: not-allowed; } +.hlp .dlbtn[disabled]:hover { border-color: var(--line-strong); color: var(--ink2); } + +/* ── settings dialog: what the colours mean ──────────────────────────── */ +.hlp .smodal { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; } +.hlp .smodal[hidden] { display: none; } +.hlp .scard { + position: relative; display: flex; flex-direction: column; + width: min(780px, calc(100vw - 20px)); max-height: 92dvh; + background: var(--surface); border: 1px solid var(--line-strong); border-radius: 6px; + box-shadow: 0 16px 48px var(--shadow); overflow: hidden; +} +.hlp .shead { + display: flex; align-items: center; gap: 12px; + padding: 13px 16px; border-bottom: 1px solid var(--line); flex: none; +} +.hlp .shead h3 { font: 700 13px var(--mono); letter-spacing: .05em; flex: 1; } +.hlp .sbody { + overflow-y: auto; padding: 14px 16px 18px; + display: grid; gap: 16px 22px; align-content: start; +} +/* on anything laptop-sized the two explanation blocks move BESIDE the mode + list. Stacked, "pass/fail is not in the recipe" sat below the fold — which is + the one sentence in here that has to be read, since the whole dialog exists + because someone read the colours as pass/fail. */ +@media (min-width: 900px) { + .hlp .scard { width: min(1120px, 95vw); } + .hlp .sbody { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); } + .hlp .sbody > :first-child { grid-row: span 2; } +} +.hlp .sbody h4 { + font: 700 9.5px var(--mono); letter-spacing: .11em; text-transform: uppercase; + color: var(--muted); margin-bottom: 8px; +} +.hlp .sbody p { font: 400 11.5px/1.6 var(--sans); color: var(--ink2); max-width: 78ch; } +.hlp .sbody p b { color: var(--ink); } +.hlp .sbody p .gk { color: var(--st-crit); font-weight: 700; } +.hlp .sbody p .tk { color: var(--k-vote); font-weight: 700; } +/* one row per mode: the radio, the name, one line of what it colours by, and + a live strip of that mode's own swatches so the choice is previewed */ +.hlp .modes { display: flex; flex-direction: column; gap: 4px; } +.hlp .mode { + display: grid; grid-template-columns: 18px 1fr; gap: 3px 9px; align-items: start; + padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; + background: var(--panel); cursor: pointer; +} +.hlp .mode:hover { border-color: var(--line-strong); } +.hlp .mode.on { + border-color: var(--k-create); + background: color-mix(in srgb, var(--k-create) 12%, var(--panel)); +} +.hlp .mode input { accent-color: var(--k-create); margin-top: 2px; } +.hlp .mode .mname { font: 700 11.5px var(--mono); color: var(--ink); letter-spacing: .03em; } +.hlp .mode .mname span { font: 400 10px var(--sans); color: var(--muted); margin-left: 7px; letter-spacing: 0; } +.hlp .mode .mwhy { grid-column: 2; font: 400 11px/1.5 var(--sans); color: var(--ink2); } +.hlp .mode .mprev { grid-column: 2; display: flex; flex-wrap: wrap; gap: 3px 10px; margin-top: 4px; } +.hlp .mode .mprev .pv { display: inline-flex; align-items: center; gap: 4px; font: 400 9.5px var(--sans); color: var(--muted); } +.hlp .mode .mprev .sw { + position: relative; width: 13px; height: 12px; flex: none; + background: var(--bg); border-radius: 2px; overflow: hidden; +} +.hlp .mode .mprev .sw i { + position: absolute; left: 0; right: 0; + background: color-mix(in srgb, var(--tc, var(--muted)) var(--tick-strength), var(--bg)); +} +/* the run-report input: colouring by observed outcome needs a report, and the + recipe is not one — this block says so whether or not a file is loaded */ +.hlp .repbox { + border: 1px solid var(--line); border-left: 3px solid var(--st-warn); + border-radius: 4px; padding: 11px 12px; display: flex; flex-direction: column; gap: 9px; + background: var(--panel); +} +.hlp .repbox.loaded { border-left-color: var(--st-good); } +.hlp .repbox .repstat { font: 600 11px var(--mono); color: var(--ink); } +.hlp .repbox .repstat.warn { color: var(--st-warn); } +.hlp .repbox .repstat.good { color: var(--st-good); } +.hlp .repbox .repstat.bad { color: var(--st-crit); } +.hlp .repbox .reprow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.hlp .repbox input[type="file"] { + font: 400 11px var(--sans); color: var(--ink2); max-width: 100%; + background: var(--bg); border: 1px solid var(--line-strong); border-radius: 4px; padding: 6px 7px; +} +.hlp .repbox input[type="file"]:focus-visible { outline: 2px solid var(--k-create); outline-offset: 1px; } +.hlp .repbox code { font: 400 10.5px var(--mono); color: var(--ink); word-break: break-all; } /* the side panel needs a story below ~700px: the panes stack, the JSON keeps its own scrollport, and the selected row stays visible above it */ @@ -580,7 +732,11 @@ } .hlp .brow { grid-template-columns: 34px 22px 1fr auto; row-gap: 4px; } .hlp .brow .bkind, .hlp .brow .bactor { display: none; } - .hlp .bhint { display: none; } + /* the keycap hints go (there is no physical Tab on a phone) but the + gate/todo sentence stays — it is the meaning of two controls above it, + and dropping it here is how a count ends up on screen with no meaning */ + .hlp .bhint > span:not(.fldnote) { display: none; } + .hlp .bhead .dlbtn { font-size: 10px; padding: 5px 8px; } } /* narrow screens: stack and let the page scroll naturally */ @@ -612,6 +768,10 @@

Hackathon Lifecycle Player

+ @@ -655,8 +815,9 @@

Action kinds

carries an open note ◌

- These are the beam colours on the stage. The timeline's own colours are - legended under it. + These are the beam colours on the stage, and they are fixed. The + timeline below encodes whatever the ⚙ Colours setting says, + and is legended under itself.

@@ -686,8 +847,11 @@

Event log

- +
+

@@ -726,9 +902,12 @@

Recipe actions

- - - + + + @@ -772,6 +951,82 @@
Full JSON
HomeEnd jump Tab cycles inside this dialog Esc close + + + + + + + + @@ -1261,10 +1516,26 @@
Full JSON
const SVGNS = "http://www.w3.org/2000/svg"; const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; -/* ── parse the embedded recipe ────────────────────────────────────── */ -const EVENTS = $("recipe-data").textContent - .split("\n").map(s => s.trim()).filter(Boolean) - .map(l => JSON.parse(l)).filter(a => a.id); +/* ── parse the embedded recipe ────────────────────────────────────── + The block was spliced in with every `Full JSON ["mei.chen","Mei Chen","MC",841,568,15,"extra"], ["noor.haddad","Noor Haddad","NH",831,618,15,"extra"] ]; +/* ── the crowd, laid out in columns ────────────────────────────────── + The eleven extras used to be one file down the right edge, y 118 → 618: it + ran the full height of the stage, put Noor 500px from Dana for no reason, + and left the right third of the frame as a single thin line. They are a + GROUP — the registration wave — so they are drawn as a block. + + The layout is computed from the cast size rather than written into the table + above, because two columns is only right for a crowd: a cast of three split + across two columns is not a grid, it is a gap. Four or fewer stay in one + column, and the x/y in the table are the fallback the loop overwrites. */ +const EXTRAS = (function layoutExtras(){ + const ex = PEOPLE.filter(p => p[6] === "extra"); + const cols = ex.length > 4 ? 2 : 1; + const rows = Math.ceil(ex.length / cols); + const xs = cols === 2 ? [806, 892] : [858]; + const top = 132, pitch = Math.min(74, Math.floor((596 - top) / Math.max(1, rows))); + ex.forEach((p, i) => { + p[3] = xs[Math.floor(i / rows)]; + p[4] = top + (i % rows) * pitch; + }); + return { cols, rows, xs, top, pitch, + cx: xs.reduce((a, b) => a + b, 0) / xs.length, + bottom: top + (rows - 1) * pitch }; +})(); const ENTITIES = [ ["platform","",480,340],["draft","Winter draft",700,120], ["teamM","Team Matterhorn",348,522],["teamB","Team Bernina",614,522], @@ -1361,10 +1656,11 @@
Full JSON
"stroke-width": 1.5, "stroke-dasharray": group === "anon" ? "3 3" : "none"}, g); el("text", {x:x, y:y+3.5, "text-anchor":"middle", class:"node-label", style:"font-weight:700"}, g).textContent = ini; - const labelX = group === "extra" ? x - r - 6 : x; - const labelY = group === "extra" ? y + 3 : y + r + 13; - const anchor = group === "extra" ? "end" : "middle"; - el("text", {x:labelX, y:labelY, "text-anchor":anchor, class:"node-sub"}, g) + /* extras used to hang their name to the LEFT of the circle, which only works + for a single file — in the second column that text lands on top of the + first column's discs. Under the circle, centred, works for both. */ + const labelY = group === "extra" ? y + r + 11 : y + r + 13; + el("text", {x:x, y:labelY, "text-anchor":"middle", class:"node-sub"}, g) .textContent = group === "extra" ? name.split(" ")[0] : name; const badge = el("circle", {cx:x + r*0.75, cy:y - r*0.75, r:4.5, fill:"var(--muted)", stroke:"var(--bg)", "stroke-width":1.5, opacity:0}, g); @@ -1434,8 +1730,10 @@
Full JSON
}; head(118, 146, "middle", "PRINCIPALS", "browser + API · full sessions", "The four checked-in dev-realm users. They log in through the real Keycloak flow, get Playwright storage states, and every UI outcome is asserted through their browsers. The visitor below is any signed-out session."); - head(905, 78, "end", "EXTRAS — THE CROWD", "API-only · cast.json", - "Ten registrants provisioned into Keycloak by scripts/roster.sh. They act through gRPC with real tokens (full RBAC) but have no browser session — they are the registration wave, the waitlist, the dropout and the churn."); + /* centred over the columns the layout actually produced, not over a hard-coded + x — one column and two put the block in different places */ + head(EXTRAS.cx, EXTRAS.top - 42, "middle", "EXTRAS — THE CROWD", "API-only · cast.json", + "Eleven registrants provisioned into Keycloak by scripts/roster.sh. They act through gRPC with real tokens (full RBAC) but have no browser session — they are the registration wave, the waitlist, the dropout, the walk-in and the churn."); })(); /* ── deterministic state (rebuilt on seek) ────────────────────────── */ @@ -1572,7 +1870,16 @@
Full JSON
and blue was "covered backend + frontend" while quietly including the six rpc.race actions, which are pure grpcurl. The `gate` field — the only one that does mean "skip unless these RPCs exist" — was never read at all. - Each tick now carries two independent facts, and the legend states both. */ + + Naming the axes fixed the lie but not the mismatch: shown three colours on a + test timeline, a reader's first guess is pass / fail / not-implemented, and + it is "how the action is driven". So WHAT the hue means is a setting, and + every mode below is a first-class encoding with its own legend. What must + never come back is a legend describing a different mode than the one drawn, + so there is exactly one painter (paintTick) for both the bar and the legend + swatches, and the legend's counts are TALLIED OFF THE PAINTED TICKS rather + than recomputed from the predicates — a second counting pass is a second + thing that can be wrong. */ function driverOf(a){ if (a.action === "ui.flow" || a.action === "ui.assert") return "ui"; /* a real browser step */ if (a.action === "rpc" || a.action === "rpc.race") return "api"; /* driven over grpcurl */ @@ -1580,8 +1887,193 @@
Full JSON
} const hasTodo = a => !!a.todo; const hasGate = a => !!(a.gate && a.gate.length); -function tickClass(a){ - return "tick " + driverOf(a) + (hasGate(a) ? " gated" : "") + (hasTodo(a) ? " noted" : ""); + +/* localStorage throws outright on some file:// origins, and a remembered + preference is a nicety — so every access is wrapped, never guarded. */ +const LS = { + get(k){ try { return localStorage.getItem(k); } catch (e) { return null; } }, + set(k, v){ try { localStorage.setItem(k, v); } catch (e) {} } +}; + +/* cast groups are read off the PEOPLE table, not listed a second time */ +const CAST_GROUP = {}; PEOPLE.forEach(p => CAST_GROUP[p[0]] = p[6]); +const castMembers = g => PEOPLE.filter(p => p[6] === g).map(p => p[0]); +const castOf = a => (a.actor && CAST_GROUP[a.actor]) || (a.actor ? "other" : "none"); + +/* an optional Playwright JSON report — the ONLY source of pass/fail here, + because recipe.jsonl has none. Never persisted: a status remembered across + reloads would outlive the run that produced it, and a stale green is worse + than no green. */ +let report = null; /* {name, status:{[id]:string}, specs, matched} */ +const runOf = a => (report && report.status[a.id]) || null; + +/* values that exist in THIS file, so a new act, kind or priority needs no edit */ +const ACTS_PRESENT = [...new Set(EVENTS.map(a => +a.act))].sort((x, y) => x - y); +const KINDS_PRESENT = [...new Set(EVENTS.map(a => a.action))].sort(); +const PRIOS_PRESENT = [...new Set(EVENTS.map(a => a.priority || "P?"))].sort(); + +/* per-value hue + extent. The extent is what survives at 1px, in greyscale and + in print, so no mode leans on hue alone; where the buckets are ORDERED + (priority, expectation) the extents shrink monotonically with rank. */ +const KIND_STYLE = { + "rpc": {color:"var(--st-warn)", geom:"g-full"}, + "rpc.race": {color:"var(--k-remove)", geom:"g-upper"}, + "ui.flow": {color:"var(--k-edit)", geom:"g-low"}, + "ui.assert": {color:"var(--k-create)", geom:"g-mid"}, + "files.generate": {color:"var(--k-check)", geom:"g-lower"} +}; +const PRIO_STYLE = { + "P1": {color:"var(--k-create)", geom:"g-full"}, + "P2": {color:"var(--k-vote)", geom:"g-low"}, + "P3": {color:"var(--k-check)", geom:"g-lower"} +}; +const CAST_STYLE = { + admin: {color:"var(--k-create)", geom:"g-full", label:"platform admin"}, + principal: {color:"var(--k-edit)", geom:"g-low", label:"principals · browser sessions"}, + extra: {color:"var(--k-join)", geom:"g-high", label:"the crowd · API only"}, + anon: {color:"var(--k-check)", geom:"g-mid", label:"anonymous visitor"}, + other: {color:"var(--k-remove)", geom:"g-upper", label:"actor outside the cast"}, + none: {color:"var(--muted)", geom:"g-lower", label:"no actor field"} +}; +/* acts get a SEQUENTIAL single-hue ramp, never nine more categorical colours + that could be mistaken for kinds. Mixing toward --ink walks away from + --panel in BOTH themes, so one expression is a legible ramp in each. */ +const actRamp = i => "color-mix(in srgb, var(--k-create) " + (100 - i * 11) + "%, var(--ink))"; +const RUN_STYLE = { + passed: {color:"var(--st-good)", geom:"g-full", label:"passed"}, + failed: {color:"var(--st-crit)", geom:"g-high", label:"failed / timed out"}, + skipped: {color:"var(--k-vote)", geom:"g-mid", label:"skipped"}, + other: {color:"var(--st-warn)", geom:"g-low", label:"flaky / interrupted"}, + unknown: {color:"var(--k-check)", geom:"g-lower", label:"not in the report"} +}; +const RUN_BUCKET = {passed:"passed", failed:"failed", timedOut:"failed", + interrupted:"failed", skipped:"skipped", flaky:"other", + expected:"passed", unexpected:"failed"}; + +const styled = (tab, key, extra) => + Object.assign({key: key, label: key, tip: ""}, tab[key] || {color:"var(--muted)", geom:"g-mid"}, extra || {}); + +const GATE_TIP = "gate — the action names the RPCs it needs and self-skips while scripts/probe.sh finds them missing, so it starts running on its own when the backend lands. Nothing has to be edited."; +const TODO_TIP = "todo — a free-text note on the action: why it is written this way, or what to re-specify when a bug it pins is fixed. NOT a gap and NOT a failure."; +/* prose below quotes these three numbers, so they are counted here rather than + typed — a sentence carrying a literal 24 is a sentence that goes stale on the + next recipe edit with nothing to catch it */ +const N_GATE = EVENTS.filter(hasGate).length; +const N_TODO = EVENTS.filter(hasTodo).length; +const N_DENIED = EVENTS.filter(isDenied).length; + +/* ── the colour modes ────────────────────────────────────────────────── + Each is {items} in first-match order; `caps` says whether the gate/todo caps + are drawn on top of the hue (they are switched off only in the mode where + the hue IS gate/todo, so the two never say the same thing twice). */ +const MODES = [ + { id: "driver", label: "how it is driven", field: "action → browser / gRPC / fixture", + why: "The default. Says which harness executes the action, which is the one thing every action has — but it is not pass/fail, and reading it as pass/fail is why this dialog exists.", + caps: true, + items: [ + {key:"ui", label:"browser step", color:"var(--k-create)", geom:"g-low", + tip:"ui.flow + ui.assert — a real Playwright flow or assertion in a real browser.", + pred: a => driverOf(a) === "ui"}, + {key:"api", label:"gRPC call", color:"var(--st-warn)", geom:"g-full", + tip:"rpc + rpc.race — driven over grpcurl with a real Keycloak token.", + pred: a => driverOf(a) === "api"}, + {key:"fixture", label:"fixture file", color:"var(--k-check)", geom:"g-lower", + tip:"files.generate — writes the deterministic upload bundle.", + pred: a => driverOf(a) === "fixture"} + ] }, + { id: "kind", label: "action kind", field: "the action field, verbatim", + why: "The five literal values of `action`, without the browser/API folding the default does — rpc.race and ui.assert get their own colour.", + caps: true, + items: KINDS_PRESENT.map(k => styled(KIND_STYLE, k, + {label: k, tip: "action: " + k, pred: a => a.action === k})) }, + { id: "prio", label: "priority", field: "P1 / P2 / P3", + why: "How load-bearing the action is. The extent shrinks with rank, so the P1 spine reads as a solid band even in greyscale.", + caps: true, + items: PRIOS_PRESENT.map(p => styled(PRIO_STYLE, p, + {label: p, tip: "priority " + p, pred: a => (a.priority || "P?") === p})) }, + { id: "act", label: "act of the story", field: "act 0 → 8", + why: "A sequential ramp over the nine acts. Position already groups them — every act is its own segment of the bar — so this mostly confirms where an act ends, which is exactly what a scrubber wants.", + caps: true, + items: ACTS_PRESENT.map((n, i) => ({key: "act" + n, label: "act " + n, + color: actRamp(i), geom: "g-full", + tip: "act " + n + (ACT_NAMES[n] ? " — " + ACT_NAMES[n] : ""), + pred: a => +a.act === n})) }, + { id: "cast", label: "actor", field: "grouped by cast role", + why: "Grouped, not one hue per person: seventeen categorical colours are seventeen colours nobody can tell apart. Each row names its members.", + caps: true, + items: ["admin", "principal", "extra", "anon", "other", "none"].map(g => + styled(CAST_STYLE, g, {key: g, label: CAST_STYLE[g].label, + tip: g === "none" ? "the action has no actor field (setup and fixture steps)" + : castMembers(g).join(", ") || "no cast member in this group", + pred: a => castOf(a) === g})) }, + { id: "flags", label: "gate / todo", field: "which of the two fields it carries", + why: "Promotes the two caps to the hue itself, so the " + N_GATE + " gated and " + + N_TODO + " noted actions are findable at a glance instead of read off a 5px cap.", + caps: false, + items: [ + {key:"both", label:"gate + todo", color:"var(--k-remove)", geom:"g-full", + tip: "carries both — " + GATE_TIP, pred: a => hasGate(a) && hasTodo(a)}, + {key:"gate", label:"gate only", color:"var(--st-crit)", geom:"g-high", + tip: GATE_TIP, pred: a => hasGate(a) && !hasTodo(a)}, + {key:"todo", label:"todo only", color:"var(--k-vote)", geom:"g-low", + tip: TODO_TIP, pred: a => hasTodo(a) && !hasGate(a)}, + {key:"plain", label:"neither", color:"var(--k-check)", geom:"g-lower", + tip: "no gate and no todo — runs unconditionally and needs no note.", + pred: a => !hasGate(a) && !hasTodo(a)} + ] }, + { id: "expect", label: "expected outcome", field: "the expect block, as written", + why: "The closest the recipe comes to pass/fail — and it is still intent: each of the " + + N_DENIED + " actions that expects an error is ASSERTING that error, so red here " + + "means “a refusal is the correct answer”, never “this broke”.", + caps: true, + items: [ + {key:"pd", label:"expects PermissionDenied", color:"var(--st-crit)", geom:"g-high", + tip:"expect.error = PermissionDenied — the caller is known and not allowed.", + pred: a => !!(a.expect && a.expect.error === "PermissionDenied")}, + {key:"un", label:"expects Unauthenticated", color:"var(--k-remove)", geom:"g-mid", + tip:"expect.error = Unauthenticated — anonymous callers get 'who are you', never 'not you'.", + pred: a => !!(a.expect && a.expect.error === "Unauthenticated")}, + {key:"err", label:"expects another error", color:"var(--st-warn)", geom:"g-low", + tip:"expect.error is set to some other code (InvalidArgument, FailedPrecondition, NotFound, AlreadyExists).", + pred: a => !!(a.expect && a.expect.error)}, + {key:"ok", label:"expects success", color:"var(--st-good)", geom:"g-full", + tip:"expect.ok (or expect.okOr) — the call is meant to succeed.", + pred: a => !!(a.expect && (a.expect.ok !== undefined || a.expect.okOr !== undefined))}, + {key:"none", label:"no expect block", color:"var(--k-check)", geom:"g-lower", + tip:"no expect field — a ui.flow whose steps carry their own assertions, or a fixture.", + pred: a => !a.expect} + ] }, + { id: "run", label: "run outcome", field: "needs a Playwright JSON report", + why: "The real pass/fail — from a report you load, never from the recipe. With nothing loaded every tick is 'not in the report', which is the honest answer to 'did it pass'.", + caps: true, + items: ["passed", "failed", "skipped", "other", "unknown"].map(k => + styled(RUN_STYLE, k, {key: k, label: RUN_STYLE[k].label, + tip: k === "unknown" + ? "no report loaded, or the report has no spec whose title starts with this action's id." + : "the loaded report's last recorded result for this action was " + k + ".", + pred: a => (RUN_BUCKET[runOf(a)] || "unknown") === k})) } +]; +const MODE_BY_ID = {}; MODES.forEach(m => MODE_BY_ID[m.id] = m); +/* if no bucket claims an action the legend still has to add up to 463, so the + miss is a visible row rather than a silently missing tick */ +const NO_BUCKET = {key:"__none", label:"unclassified", color:"var(--muted)", + geom:"g-mid", tip:"no bucket in this mode matched this action — this row should always be empty."}; + +const MODE_KEY = "hlp-colormode"; +let modeId = MODE_BY_ID[LS.get(MODE_KEY)] ? LS.get(MODE_KEY) : "driver"; +const curMode = () => MODE_BY_ID[modeId]; +function bucketFor(a, mode){ + for (const it of mode.items) if (it.pred(a)) return it; + return NO_BUCKET; +} +/* the one painter. `base` is "tick" on the bar and "" for a legend swatch, so + both get the same geometry class and the same --tc, from the same item. */ +function paintTick(node, base, item, caps){ + node.className = base + " " + item.geom + + (caps && caps.gate ? " cap-top" : "") + + (caps && caps.todo ? " cap-bot" : ""); + node.style.setProperty("--tc", item.color); + node.dataset.bucket = item.key; } function render(){ const s = state; @@ -1980,6 +2472,13 @@
Full JSON
/* an aria-modal dialog owns the keyboard while it is open: Esc closes, Tab cycles inside it, and none of the transport shortcuts fire underneath — Space in the middle of a search term must not start playback */ + /* settings stacks ON TOP of the browser (it is reachable from inside it), so + it is asked about first — the topmost dialog owns Esc and Tab */ + if (settingsOpen()){ + if (e.key === "Escape"){ e.preventDefault(); closeSettings(); return; } + trapTab($("settings").querySelector(".scard"), e); + return; + } if (browserOpen()){ if (e.key === "Escape"){ e.preventDefault(); closeBrowser(); return; } trapTab($("browser").querySelector(".bcard"), e); @@ -1996,10 +2495,14 @@
Full JSON
if (e.code === "ArrowLeft"){ setPlaying(false); seek(cursor - 1); } if (e.key === "r" || e.key === "R"){ setPlaying(false); seek(0, true); } if (e.key === "b" || e.key === "B"){ e.preventDefault(); openBrowser(); } + if (e.key === "c" || e.key === "C"){ e.preventDefault(); openSettings(); } }); /* ── scrubber (segments ∝ actions per act) + acts strip on top ────── */ const segs = [], phaseCells = []; +const tickEls = []; /* tickEls[i] is the tick for EVENTS[i] — kept so a mode + change repaints in place, and so the legend can tally + the ticks that exist rather than re-deriving them */ let playheadEl = null, elapsedEl = null; (function buildScrub(){ const byAct = {}; @@ -2009,13 +2512,15 @@
Full JSON
const idx = byAct[act]; const seg = document.createElement("div"); seg.className = "seg"; seg.style.flexGrow = idx.length; - /* one tick per action: hue+height = how it is driven, caps = todo / gate */ + /* one tick per action; applyMode() paints hue + extent + caps */ idx.forEach((ei, j) => { const tick = document.createElement("div"); - tick.className = tickClass(EVENTS[ei]); + tick.className = "tick"; tick.style.left = (j / idx.length * 100) + "%"; tick.style.width = (100 / idx.length) + "%"; + tick.title = "#" + (ei + 1) + " " + EVENTS[ei].id; seg.appendChild(tick); + tickEls[ei] = tick; }); scrub.appendChild(seg); seg.title = "Act " + act + " — " + ACT_NAMES[act] + " (" + idx.length + " actions)"; @@ -2060,10 +2565,7 @@
Full JSON
so every access is wrapped rather than guarded. */ const THEME_KEY = "hlp-theme"; const lightMQ = matchMedia("(prefers-color-scheme: light)"); -const store = { - get(){ try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; } }, - set(v){ try { localStorage.setItem(THEME_KEY, v); } catch (e) {} } -}; +const store = { get(){ return LS.get(THEME_KEY); }, set(v){ LS.set(THEME_KEY, v); } }; function effectiveTheme(){ return document.documentElement.getAttribute("data-theme") || (lightMQ.matches ? "light" : "dark"); @@ -2087,40 +2589,319 @@
Full JSON
}); /* ── timeline legend ────────────────────────────────────────────────── - Built from EVENTS, never hand-written: the legend it replaces drifted from - the code on all three colours, and a count nobody derives is a count that - goes stale silently. Each swatch redraws the tick geometry at 15x13 so the - height/cap mapping is learnable, and every row names its predicate. */ -(function buildLegend(){ - const box = $("tlLegend"); - const count = pred => EVENTS.filter(pred).length; - const group = txt => { - const s = document.createElement("span"); - s.className = "lgroup"; s.textContent = txt; box.appendChild(s); - }; - const item = (parts, label, n, tip) => { + Rebuilt on every mode change, and every count in it is read back off the + PAINTED TICKS — not recomputed from the predicates. A legend that counts + something other than what it points at is the bug this file already had + once; the only way to make it structurally impossible is to let the legend + count the marks themselves. Each swatch is drawn by paintTick, so its hue, + its extent and its caps come from the same item the bar used. */ +function tallyPainted(){ + const t = {}; let gate = 0, todo = 0; + tickEls.forEach(el => { + const k = el.dataset.bucket; + t[k] = (t[k] || 0) + 1; + if (el.classList.contains("cap-top")) gate++; + if (el.classList.contains("cap-bot")) todo++; + }); + return {t: t, gate: gate, todo: todo}; +} +function legendSwatch(item, caps){ + const sw = document.createElement("span"); sw.className = "sw"; + const i = document.createElement("i"); + paintTick(i, "", item, caps); + sw.appendChild(i); + return sw; +} +function renderLegend(){ + const mode = curMode(), box = $("tlLegend"), tal = tallyPainted(); + box.innerHTML = ""; + /* the mode is named IN the legend, and the name is the button that changes + it — "what do these colours mean" should not need a hunt through a header */ + const modeBtn = document.createElement("button"); + modeBtn.className = "lmode"; modeBtn.id = "tlMode"; + modeBtn.setAttribute("aria-haspopup", "dialog"); + modeBtn.title = "Change what the timeline colours encode"; + modeBtn.innerHTML = "⚙ colour = " + esc(mode.label) + ""; + modeBtn.addEventListener("click", openSettings); + box.appendChild(modeBtn); + + const item = (it, n, caps) => { const s = document.createElement("span"); - s.className = "litem"; s.title = tip; - const sw = document.createElement("span"); sw.className = "sw"; - parts.forEach(p => { const i = document.createElement("i"); i.className = p; sw.appendChild(i); }); + s.className = "litem"; s.dataset.bucket = it.key; + s.title = it.tip || it.label; const b = document.createElement("b"); b.textContent = n; - const t = document.createElement("span"); t.textContent = label; - s.append(sw, b, t); box.appendChild(s); + const t = document.createElement("span"); t.textContent = it.label; + s.append(legendSwatch(it, caps), b, t); box.appendChild(s); }; - group("driven by"); - item(["ui"], "browser step", count(a => driverOf(a) === "ui"), - "ui.flow + ui.assert — a real Playwright flow or assertion. Drawn as a SHORT bar sitting on the baseline."); - item(["api"], "gRPC call", count(a => driverOf(a) === "api"), - "rpc + rpc.race — driven over grpcurl with a real token. Drawn FULL height."); - item(["fixture"], "fixture file", count(a => driverOf(a) === "fixture"), - "files.generate — writes the deterministic upload bundle. Drawn full height in neutral grey."); - group("carries"); - item(["api", "gated"], "gate", count(hasGate), - "gate — the action names the RPCs it needs and skips when probe.sh finds them absent, so it wakes up on its own when the backend lands. Drawn as a cap at the TOP of the tick."); - item(["api", "noted"], "todo note", count(hasTodo), - "todo — a design note on the action: why it is written this way, or what to re-specify when a bug behind it is fixed. NOT a gap in the product. Drawn as a cap at the BOTTOM of the tick."); + mode.items.forEach(it => item(it, tal.t[it.key] || 0, null)); + if (tal.t[NO_BUCKET.key]) item(NO_BUCKET, tal.t[NO_BUCKET.key], null); + if (mode.caps){ + const g = document.createElement("span"); + g.className = "lgroup"; g.textContent = "plus a cap for"; box.appendChild(g); + /* the cap rows sit on a representative primary so the cap is shown where it + is really drawn — on top of a hue, never floating on its own */ + const rep = mode.items[0]; + item({key: "cap-gate", label: "gate", tip: GATE_TIP, color: rep.color, geom: rep.geom}, + tal.gate, {gate: true}); + item({key: "cap-todo", label: "todo note", tip: TODO_TIP, color: rep.color, geom: rep.geom}, + tal.todo, {todo: true}); + } + renderLegendNote(tal); +} +/* the sentence under the counts. A user asked us what "carries 24 gate / 65 + todo note" meant — which is the legend stating two numbers and no meaning. + Both fields are spelled out here, in the legend, not in a tooltip. */ +function renderLegendNote(tal){ + const mode = curMode(); + let s = "One tick per action, " + EVENTS.length + " of them in file order, " + + "grouped into the nine acts. Hue and height = " + esc(mode.label) + "."; + if (mode.caps){ + s += " Caps: gate (" + tal.gate + ") — the action " + + "names the RPCs it needs and self-skips until they exist, so it " + + "starts running by itself when the backend lands. " + + "todo (" + tal.todo + ") — a free-text " + + "design note on the action, not a gap and not a failure."; + } + if (mode.id === "run"){ + s += report + ? " Coloured from " + esc(report.name) + ": " + report.matched + " of " + + EVENTS.length + " actions matched a spec in it." + : " No run report loaded, so every tick is “not in the report”. " + + "recipe.jsonl has no status field — load a Playwright JSON report from " + + "⚙ Colours to see what actually happened."; + } else { + s += " None of this is pass/fail: the recipe records intent, not " + + "results — for outcomes, load a run report from ⚙ Colours."; + } + $("tlNote").innerHTML = s; + /* the browse dialog's two filter checkboxes use the same two words, so they + get the same two numbers from the same tally */ + const full = tallyByPred(MODE_BY_ID.flags); + $("bFieldNote").innerHTML = + "gate = self-skips until its RPCs exist (" + + ((full.gate || 0) + (full.both || 0)) + ") · " + + "todo = a design note, not a gap (" + + ((full.todo || 0) + (full.both || 0)) + ")"; +} +/* predicate tally — used only where no ticks are painted for that mode: the + settings previews, and the two numbers in the browse hint */ +function tallyByPred(mode){ + const t = {}; + EVENTS.forEach(a => { const k = bucketFor(a, mode).key; t[k] = (t[k] || 0) + 1; }); + return t; +} + +/* ── applying a mode ─────────────────────────────────────────────────── */ +function applyMode(id, persist){ + if (MODE_BY_ID[id]) modeId = id; + const mode = curMode(); + if (persist) LS.set(MODE_KEY, modeId); + tickEls.forEach((el, i) => { + const a = EVENTS[i]; + paintTick(el, "tick", bucketFor(a, mode), + mode.caps ? {gate: hasGate(a), todo: hasTodo(a)} : null); + }); + renderLegend(); + $("btnSettingsMode").textContent = mode.label; + $("btnSettings").title = "Timeline colours currently encode: " + mode.label + + " — click to change"; + /* the browse rows carry the same encoding on their left edge, so the setting + is felt where the actions are actually read. Mixed down, because 463 rows + of a saturated hue is a wall. */ + rowEls.forEach((row, i) => { + row.style.borderLeftColor = "color-mix(in srgb, " + + bucketFor(EVENTS[i], mode).color + " 62%, var(--line-strong))"; + }); + MODES.forEach(m => { + const r = modeRows[m.id]; if (!r) return; + r.lab.classList.toggle("on", m.id === modeId); + r.radio.checked = m.id === modeId; + r.prev.innerHTML = ""; r.prev.appendChild(previewStrip(m)); + }); + if (selIdx >= 0) select(selIdx, true); +} +function previewStrip(mode){ + const frag = document.createDocumentFragment(); + const t = tallyByPred(mode); + mode.items.slice(0, 7).forEach(it => { + const pv = document.createElement("span"); pv.className = "pv"; pv.title = it.tip || ""; + const sw = document.createElement("span"); sw.className = "sw"; + const i = document.createElement("i"); + paintTick(i, "", it, null); + sw.appendChild(i); + const tx = document.createElement("span"); + tx.textContent = it.label + " " + (t[it.key] || 0); + pv.append(sw, tx); frag.appendChild(pv); + }); + if (mode.items.length > 7){ + const more = document.createElement("span"); + more.className = "pv"; more.textContent = "+" + (mode.items.length - 7) + " more"; + frag.appendChild(more); + } + return frag; +} + +/* ── settings dialog ─────────────────────────────────────────────────── */ +const modeRows = {}; +(function buildSettings(){ + const box = $("sModes"); + MODES.forEach(m => { + const lab = document.createElement("label"); + lab.className = "mode"; lab.dataset.mode = m.id; + const r = document.createElement("input"); + r.type = "radio"; r.name = "hlp-colour-mode"; r.value = m.id; + r.setAttribute("aria-label", m.label + " — " + m.field); + r.addEventListener("change", () => { if (r.checked) applyMode(m.id, true); }); + const nm = document.createElement("span"); nm.className = "mname"; + nm.textContent = m.label; + const fld = document.createElement("span"); fld.textContent = m.field; + nm.appendChild(fld); + const why = document.createElement("span"); why.className = "mwhy"; why.textContent = m.why; + const prev = document.createElement("span"); prev.className = "mprev"; + lab.append(r, nm, why, prev); + box.appendChild(lab); + modeRows[m.id] = {lab: lab, radio: r, prev: prev}; + }); + $("sDeniedN").textContent = N_DENIED; })(); +let lastFocusSettings = null; +function settingsOpen(){ return !$("settings").hidden; } +function openSettings(){ + setPlaying(false); + lastFocusSettings = document.activeElement; + $("settings").hidden = false; + const on = modeRows[modeId]; + (on ? on.radio : $("sClose")).focus(); +} +function closeSettings(){ + $("settings").hidden = true; + if (lastFocusSettings && lastFocusSettings.focus) lastFocusSettings.focus(); +} +$("btnSettings").addEventListener("click", openSettings); +$("bSettings").addEventListener("click", openSettings); +$("sClose").addEventListener("click", closeSettings); +$("sback").addEventListener("click", closeSettings); + +/* ── an optional Playwright JSON report ─────────────────────────────── + The recipe cannot answer "did it pass" — nothing in it records a result — so + the only honest way to offer that colouring is to read a real report. + recipe.spec.ts titles every test `[] `, which is the join key. + Read with FileReader: no upload, no fetch, works from file://. */ +function setReportStatus(kind, text, detail){ + const st = $("sRepStat"); + st.className = "repstat " + kind; + st.textContent = text; + $("sRepBox").classList.toggle("loaded", kind === "good"); + const d = $("sRepDetail"); + if (detail){ d.hidden = false; d.textContent = detail; } else { d.hidden = true; d.textContent = ""; } + $("sRepClear").disabled = !report; +} +function noReportYet(){ + setReportStatus("warn", + "No run report loaded — “run outcome” colours everything as unknown.", ""); +} +function readReport(file){ + const fr = new FileReader(); + fr.onerror = () => { report = null; noReportYet(); + setReportStatus("bad", "Could not read that file.", ""); applyMode(modeId); }; + fr.onload = () => { + let json; + try { json = JSON.parse(String(fr.result)); } + catch (err){ + report = null; + setReportStatus("bad", "That file is not JSON — expected a Playwright JSON report.", ""); + applyMode(modeId); return; + } + const status = {}; + let specs = 0; + (function walk(node){ + if (!node || typeof node !== "object") return; + if (Array.isArray(node.specs)) node.specs.forEach(sp => { + specs++; + const m = /^\[([^\]]+)\]/.exec(String(sp.title || "")); + if (!m) return; + const t = (sp.tests || [])[0] || {}; + const res = t.results || []; + status[m[1]] = String((res[res.length - 1] || {}).status || t.status || "unknown"); + }); + if (Array.isArray(node.suites)) node.suites.forEach(walk); + })(json); + const matched = EVENTS.filter(a => status[a.id]).length; + if (!matched){ + report = null; + setReportStatus("bad", + "Read " + specs + " specs, but none of their titles start with an action id " + + "— is this a journey report?", ""); + applyMode(modeId); return; + } + report = {name: file.name, status: status, specs: specs, matched: matched}; + const t = tallyByPred(MODE_BY_ID.run); + setReportStatus("good", + "Loaded " + file.name + " — " + matched + " of " + EVENTS.length + " actions matched.", + "passed " + (t.passed || 0) + " · failed " + (t.failed || 0) + + " · skipped " + (t.skipped || 0) + " · flaky/interrupted " + (t.other || 0) + + " · not in the report " + (t.unknown || 0) + + ". Read from " + specs + " specs in the file; not remembered across reloads."); + /* loading a report is an unambiguous request to see outcomes */ + applyMode("run", true); + }; + fr.readAsText(file); +} +$("sRepFile").addEventListener("change", e => { + const f = e.target.files && e.target.files[0]; + if (f) readReport(f); +}); +$("sRepClear").addEventListener("click", () => { + report = null; $("sRepFile").value = ""; + noReportYet(); applyMode(modeId); +}); +noReportYet(); + +/* ── downloading JSONL ─────────────────────────────────────────────── + The bytes handed out are the SOURCE LINES kept in RAW_LINE, so the full + export is recipe.jsonl exactly — key order, `{"id": "…"}` spacing and the + act-banner comment lines included. Re-serialising with JSON.stringify would + produce a file that parses the same and diffs against every line of the + original, which is not an export of anything. + Blob + createObjectURL, because a data: URI download is size-capped in some + browsers and this file must work from file:// with no network at all. */ +function downloadText(text, filename){ + const url = URL.createObjectURL(new Blob([text], {type: "application/x-ndjson"})); + const a = document.createElement("a"); + a.href = url; a.download = filename; a.rel = "noopener"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); +} +/* a name that records the filter, so two subsets in a Downloads folder are + still tellable apart a week later */ +function subsetName(n){ + const bits = []; + const v = id => $(id).value; + if (v("fAct") !== "") bits.push("act" + v("fAct")); + if (v("fKind")) bits.push(v("fKind").replace(/\./g, "-")); + if (v("fActor")) bits.push(v("fActor").replace(/[^a-z0-9]+/gi, "-")); + if (v("fPrio")) bits.push(v("fPrio")); + if ($("fTodo").checked) bits.push("todo"); + if ($("fGate").checked) bits.push("gate"); + if ($("fDenied").checked) bits.push("denied"); + const q = v("fText").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + if (q) bits.push("q-" + q.slice(0, 24)); + if (!bits.length) bits.push("all"); + return "recipe-" + bits.join("-").slice(0, 90) + "-" + n + "-actions.jsonl"; +} +$("dlAll").addEventListener("click", () => downloadText(RECIPE_TEXT, "recipe.jsonl")); +$("dlSub").addEventListener("click", () => { + if (!shown.length) return; + /* exactly the rows the header claims, one compact object per line, trailing + newline — the same shape the loader in helpers/recipe.ts reads */ + downloadText(shown.map(i => RAW_LINE[i]).join("\n") + "\n", subsetName(shown.length)); +}); +$("dlAllN").textContent = EVENTS.length; + /* ── action browser ─────────────────────────────────────────────────── All EVENTS.length actions, filterable, with the selected one's formatted JSON in the side panel. Rows are built once and shown/hidden by filter — @@ -2276,6 +3057,13 @@ <h5>Full JSON</h5> bCount.innerHTML = "<b>" + shown.length + "</b> of " + EVENTS.length + " actions"; bCount.dataset.shown = shown.length; bCount.dataset.total = EVENTS.length; + /* the export button carries the count it will actually write — a bare + "download subset" is how someone ships 463 lines believing they shipped 12 */ + $("dlSubN").textContent = shown.length; + $("dlSub").disabled = !shown.length; + $("dlSub").title = shown.length + ? "Download these " + shown.length + " actions as JSONL (" + subsetName(shown.length) + ")" + : "Nothing matches the filters — nothing to export"; let empty = $("bEmpty"); if (!shown.length){ if (!empty){ @@ -2324,6 +3112,15 @@ <h5>Full JSON</h5> if (a.t) meta.appendChild(badge("pbadge", a.t, "when in the story")); meta.appendChild(badge("pbadge", driverOf(a) === "ui" ? "BROWSER" : driverOf(a) === "api" ? "gRPC" : "FIXTURE", "how the action is driven")); + /* which timeline colour this row is currently painted in, named — the row's + left edge carries the hue and a hue with no name is a riddle */ + { + const b = bucketFor(a, curMode()); + const chip = badge("pbadge", "◧ " + b.label, + "timeline colour (" + curMode().label + "): " + (b.tip || b.label)); + chip.style.borderColor = "color-mix(in srgb, " + b.color + " 70%, transparent)"; + meta.appendChild(chip); + } if (isDenied(a)) meta.appendChild(badge("flag denied", "✕ DENIED", (a.expect && a.expect.error) || "expects an error")); if (hasGate(a)) meta.appendChild(badge("flag gate", "⌃ GATE", "gated on RPCs")); @@ -2423,7 +3220,11 @@ <h5>Full JSON</h5> $("bback").addEventListener("click", closeBrowser); $("btnBrowseN").textContent = EVENTS.length; +/* order matters: applyFilters builds `shown` (which the subset export names and + counts), and applyMode paints the ticks, the legend and the row edges — it + must run after buildScrub and buildRows have both produced their elements. */ applyFilters(); +applyMode(modeId); seek(0, true); })(); </script> From f82c45a661df91871bc9570e6f6186ad0332ba01 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:32:26 +0200 Subject: [PATCH 02/25] feat(e2e): drag the diagram, name its regions, and bake in a real run report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The legend note `max-width: 132ch` was wrong in both directions at once: as a block it left half a 2000px screen empty, and as a measure it still ran ~180 characters to the line. One number cannot fix both, because a character cap can only make a block smaller. The block now fills the footer (capped in px) and the text flows into columns, so the used measure lands between 62ch and ~124ch. Measured: 1440 → 1404px / 88 chars, 2000 → 1560 / 98, 2560 → 1560 / 98 out of 2524 available. ## Dragging, and why the drag wins Pointer events (mouse + touch, screen→user via the SVG's own CTM), keyboard-equal: every item is focusable with arrows to nudge, Shift for coarse, Backspace to reset one, and a DRAWN focus ring rather than `outline`, which engines disagree about painting on SVG groups. Positions clamp to the frame and persist; `Reset layout` is the way back. Dragging beats the replay by construction, not arbitration: nothing in the replay writes a position, and `animateBeam()` reads the live `pos[]` table which a drag updates ALONGSIDE the transform. Move only the transform and the beams keep arriving where the item used to be. Proven end to end — drag the Visitor, step to an action whose actor IS the Visitor, assert the beam's path starts at the moved coordinates, then nine more actions do not move it back. ## Seven dim regions, derived not invented Labels come off the data: cast groups from PEOPLE's own group column, the platform ring from every entity key starting with `sec`, the bottom band from the team/submission/asset keys, the draft from ENTITY_NAME.draft. The check that keeps it honest is a PARTITION — all 33 stage items fall in exactly one region, so an invented region leaves something in two or in none. Rects are computed from the items' own boxes plus a pad, both layers are pointer-events:none so they cannot eat the drags, and the washes share ONE opacity group because two regions genuinely interpenetrate and per-rect alpha would paint that overlap twice as a visible stripe. ## The embedded report The documented command would have failed twice: `--reporter=json` hit run.sh's catch-all and exited 2, and `> report.json` captures the Nix/devenv banner ahead of the JSON. playwright.config.ts already writes .artifacts/results.json on every run, so that is the source; a `--reporter=*` pass-through is added anyway so the flag works as written. journey 467 passed / 0 failed / 0 skipped. Reduced to id + outcome + duration: 498.3 KiB → 16.0 KiB, 463 of 463 actions matched. Shown as a DATED SNAPSHOT wherever it colours, because a status outliving its run is a stale green. A loaded file overrides it, `clear` steps back one layer at a time, and a junk file is refused while leaving the previous colouring intact. Note the file now holds THREE literal close-script tags (recipe, run-report, program), not two. The guard is updated with its reason in the harness, SKILL.md and CLAUDE.md; `</` is escaped in both data blocks; the report block sits AFTER the recipe block so the splice's terminator search is unaffected — verified idempotent by byte-identical output across two runs. ## The help modal Asked for after "gate" had to be explained twice, which is the evidence that a distant explanation does not work. Every count and example is derived from the embedded recipe at open time, so nothing goes stale on the next edit, and short glosses now sit BESIDE the jargon: "has gate — waits for its RPCs", "has todo — a design note", "expects denial — the refusal is the pass". Verified: 911 render checks (was 697), 0 failures, both themes at three widths, zero non-file:// requests, zero console errors. --- .claude/CLAUDE.md | 38 +- .claude/skills/hackathon-e2e/SKILL.md | 9 +- .../skills/hackathon-e2e/recipe-player.html | 902 +++++++++++++++++- .../scripts/embed-run-report.mjs | 136 +++ .claude/skills/hackathon-e2e/scripts/run.sh | 27 +- 5 files changed, 1060 insertions(+), 52 deletions(-) create mode 100644 .claude/skills/hackathon-e2e/scripts/embed-run-report.mjs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7713693e..fbab9c3a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -28,7 +28,7 @@ voice) → post-event (winners, gallery uploads, wrap-up blog, profile churn). Executed in order by `tests/journey/recipe.spec.ts` via `helpers/recipe.ts`. Each action carries: `priority` (P1 323 / P2 131 / P3 9), `outcome` -(human-readable expectation), an optional `todo` (placeholder note, 64 +(human-readable expectation), an optional `todo` (placeholder note, 65 actions) and an optional `gate` (24 actions — skip until the listed RPCs exist, capability-probed at runtime by `scripts/probe.sh`, so actions wake up automatically as the backend lands). `implement: false` meant "deliberately @@ -66,6 +66,42 @@ any browser). Rebuild after recipe edits with `<script id="recipe-data">` markers, applies the `</` → `<\/` escape, and verifies the embedded action count against the file. +**The file holds THREE inline script blocks now** — the recipe, a real journey +run, and the program — and the count is asserted, because a fourth appearing by +accident means a data block truncated the document. The run is what `run +outcome` colours from on open: `scripts/embed-run-report.mjs` reduces +`.artifacts/results.json` (written by the json reporter on EVERY run — do not +redirect a run's stdout, this container prints Nix and quitsh banners ahead of +it) to the three things the mode joins on, id + outcome + duration, 498 KiB → +16 KiB. It is a SNAPSHOT and every surface using it says so with its date; a +report you load by hand overrides it, and "clear" steps back one layer at a +time — loaded file → built-in snapshot → nothing at all. The `run-report` block +sits AFTER the recipe block on purpose: `splice-player.mjs` finds its +terminator with the first close tag past the recipe's opening marker, so a data +block in front of the recipe would be overwritten by the next splice. + +The diagram is arrangeable — drag anything, or Tab to it and nudge with the +arrow keys — and **the drag wins over the replay by construction**: nothing in +the animation writes a position (`render()` touches opacity, text and badges +only) and `animateBeam()` reads the live `pos[]` table, which the drag updates +along with the transform. Move only the transform and the beams keep arriving +at the coordinates the item used to occupy. Positions persist through the same +wrapped `localStorage` the theme uses, and `⤺ Reset layout` in the header is the +way back. Behind the items are seven dim labelled regions whose membership is +DERIVED from the cast table and the entity keys and forms a partition of all 33 +items on the stage — an invented region leaves something in two zones or in +none, which the render harness asserts. Their washes share ONE opacity group: +two regions genuinely interpenetrate (a principal's name label reaches x=164, +the upload bundle starts at x=156) and per-rect alpha would paint that overlap +twice as a visible stripe. + +`? What do these mean` opens the vocabulary in plain language — the kinds, +`gate`, `todo`, `expect`, `priority`, `actor`, `save` — with every count and +every example read off the embedded recipe at open time rather than typed, and +the browse dialog's filter chips carry a one-line gloss beside the jargon. Two +people had asked what "has gate" meant, which is what a tooltip plus a +paragraph two panels away earns. + **Act 0 — platform setup** runs before any hackathon exists: the admin drafts the About page, the draft stays invisible to the public, an organizer is denied (site pages need the *global* Admin role), publish makes it diff --git a/.claude/skills/hackathon-e2e/SKILL.md b/.claude/skills/hackathon-e2e/SKILL.md index 4e49e108..a1f26356 100644 --- a/.claude/skills/hackathon-e2e/SKILL.md +++ b/.claude/skills/hackathon-e2e/SKILL.md @@ -315,7 +315,14 @@ recipe.jsonl THE SCREENPLAY: one action per line, full lifecycle in each with priority/outcome/gate triage fields recipe-player.html self-contained animated replay of the recipe (also published as an artifact); rebuild after recipe edits by re-splicing the - JSONL between the <script id="recipe-data"> markers + JSONL between the <script id="recipe-data"> markers. + Holds THREE inline script blocks — the recipe, a reduced + journey report (run-report), and the program — and every `</` + in the two data blocks is escaped as `<\/`, because a block + ends at the first LITERAL close tag even inside a JSON string +scripts/splice-player.mjs re-splice recipe.jsonl into the player, count-checked +scripts/embed-run-report.mjs reduce .artifacts/results.json (id + outcome + duration) + and splice it in, so `run outcome` colours on open personas.ts principals + seed matrix + JOURNEY_CAST constants cast.json the 11 extras (incl. the walk-in) (names, emails, shared dev password) playwright.config.ts Firefox-only; setup/smoke/journey/mobile/docs/tunnel/openreplay projects diff --git a/.claude/skills/hackathon-e2e/recipe-player.html b/.claude/skills/hackathon-e2e/recipe-player.html index 1a89dc62..a69d713f 100644 --- a/.claude/skills/hackathon-e2e/recipe-player.html +++ b/.claude/skills/hackathon-e2e/recipe-player.html @@ -47,6 +47,14 @@ --elapsed: rgba(255,255,255,.10); --playhead-glow: rgba(255,255,255,.5); --shadow: rgba(0,0,0,.5); + /* the dim labelled regions behind the diagram. Three tokens, not one, because + the wash, the outline and the label each need their own strength: a wash + faint enough not to compete with the beams leaves a 1px border invisible, + and a border strong enough to read would be a loud wash. */ + --zone-fill: #ffffff; /* washed over the background at --zone-alpha */ + --zone-alpha: .055; + --zone-line: #2e2e2b; /* drawn at full opacity, so it is dim by value */ + --zone-ink: #7e7c76; /* the region's name — quieter than a node label */ color-scheme: dark; } /* LIGHT column — applied when the OS asks for it and nothing overrides, or @@ -87,6 +95,14 @@ --elapsed: rgba(0,0,0,.07); --playhead-glow: rgba(0,0,0,.35); --shadow: rgba(0,0,0,.18); + /* re-picked, not reused: white-at-5.5% lifts #0d0d0d and does nothing at + all to #f6f6f3, so the light wash is INK at its own strength, and the + outline is a value near --line rather than the dark column's #2e2e2b + (which would read as a hard black box on paper). */ + --zone-fill: #14140e; + --zone-alpha: .05; + --zone-line: #dcdad2; + --zone-ink: #6f6d66; color-scheme: light; } } @@ -123,6 +139,11 @@ --elapsed: rgba(0,0,0,.07); --playhead-glow: rgba(0,0,0,.35); --shadow: rgba(0,0,0,.18); + /* same re-pick as the media-query column above */ + --zone-fill: #14140e; + --zone-alpha: .05; + --zone-line: #dcdad2; + --zone-ink: #6f6d66; color-scheme: light; } /* kill the browser's default body margin — no white frame around the app */ @@ -201,6 +222,48 @@ } .hlp .caption .cflag { font: 700 10px/1 var(--mono); letter-spacing: .08em; white-space: nowrap; } +/* ── zones: dim labelled regions BEHIND the diagram ─────────────────── + Each names what that area of the stage IS. Membership is derived from the + cast table and the entity keys (see ZONES in the script), so the six regions + are a partition of the 33 things on the stage — a made-up region would show + up immediately as a node in two zones or in none. + + The washes live in ONE group with a group opacity rather than per-rect + alpha, and that is load-bearing: two regions genuinely interpenetrate (the + principals' name labels reach x=164 and the upload bundle starts at x=156), + so with per-rect alpha the overlap would paint twice and draw a visible + stripe where there is nothing to see. Inside a single group the same colour + over itself is the same colour, and the opacity is applied once at the end. + + The outlines and the labels sit in a SECOND group at full opacity — a 1px + border inside a 5% group is not a border — and both layers are + pointer-events:none: a zone must never eat a click or a drag aimed at the + item drawn on top of it. */ +.hlp .zones, .hlp .zonemarks { pointer-events: none; } +.hlp .zones { opacity: var(--zone-alpha); } +.hlp .zones rect { fill: var(--zone-fill); stroke: none; } +.hlp .zonemarks rect { fill: none; stroke: var(--zone-line); stroke-width: 1; stroke-dasharray: 4 4; } +.hlp .zonemarks text { + font: 600 8.5px var(--sans); letter-spacing: .09em; text-transform: uppercase; + fill: var(--zone-ink); +} + +/* ── draggable stage items ──────────────────────────────────────────── + Pointer events, not HTML5 drag-and-drop: the latter has no SVG story at all + and no touch story either. Keyboard is a first-class path (arrows nudge the + focused item, Shift for a bigger step), so the ring below is what a keyboard + user steers by — it is drawn as an element rather than left to `outline`, + which several engines still refuse to paint on an SVG group. */ +.hlp #stage .dnode { cursor: grab; touch-action: none; outline: none; } +.hlp #stage .dnode.dragging { cursor: grabbing; } +.hlp #stage .dfocus { + fill: none; stroke: var(--k-create); stroke-width: 2; stroke-dasharray: none; + opacity: 0; pointer-events: none; +} +.hlp #stage .dnode:focus .dfocus, .hlp #stage .dnode:focus-visible .dfocus { opacity: 1; } +.hlp #stage .dnode.moved .dhandle { opacity: 1; } +.hlp #stage .dhandle { fill: var(--k-create); opacity: 0; pointer-events: none; } + /* stage svg pieces (classes used from JS) */ .hlp .node-label { font: 500 10px var(--mono); fill: var(--ink2); } .hlp .node-sub { font: 400 9px var(--mono); fill: var(--muted); } @@ -398,9 +461,25 @@ /* the counts used to stand alone: a user asked us what "carries 24 gate / 65 todo note" meant, which is a legend stating a number with no meaning. The sentence is part of the legend now, not a tooltip on it. */ +/* The block was `max-width: 132ch` at 10px, which was wrong in both directions + at once: on a 2000px screen it left half the footer empty (too NARROW as a + block) while still running ~180 characters to the line (too WIDE as a + measure). Those are different problems and one number cannot fix both — a + cap in characters can only ever make the block smaller. + + So the block fills the footer, capped in px so an ultrawide gets whitespace + rather than one endless line, and the text flows into COLUMNS. `column-width` + is a minimum and a browser only adds a column once two of them fit, so the + used measure stays between 62ch and ~124ch — measured 88 characters at 1440 + and 98 at 2000/2560, i.e. inside the comfortable band at every width from a + phone up. */ .hlp .lgnote { - flex: 1 1 100%; margin-top: 5px; - font: 400 10px/1.5 var(--sans); color: var(--muted); max-width: 132ch; + flex: 1 1 100%; margin-top: 6px; + font: 400 10.5px/1.62 var(--sans); color: var(--muted); + max-width: min(100%, 1560px); + column-width: 62ch; + column-gap: 30px; + column-rule: 1px solid var(--line); } .hlp .lgnote b { color: var(--ink2); font-weight: 700; } .hlp .lgnote .gk { color: var(--st-crit); font-weight: 700; } @@ -445,6 +524,15 @@ } .hlp .hbtn:hover { border-color: var(--muted); color: var(--ink); } .hlp .hbtn b { color: var(--ink); font-variant-numeric: tabular-nums; } +.hlp .hbtn[disabled] { opacity: .42; cursor: default; } +.hlp .hbtn[disabled]:hover { border-color: var(--line-strong); color: var(--ink2); } +/* the help button is the one piece of chrome that answers a question rather + than changing something, so it is marked rather than just present */ +.hlp .hbtn.ask { + border-color: color-mix(in srgb, var(--k-create) 55%, transparent); + color: var(--ink); +} +.hlp .hbtn.ask:hover { background: color-mix(in srgb, var(--k-create) 16%, transparent); } .hlp .hbtn kbd { font: 600 9px/1 var(--mono); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 3px; padding: 2px 4px; @@ -526,6 +614,8 @@ } .hlp .bfilters .fchk { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; } .hlp .bfilters .fchk input { accent-color: var(--k-create); } +/* the one-line meaning, beside the jargon rather than only in a tooltip */ +.hlp .bfilters .fgloss { font: 400 9.5px var(--sans); color: var(--muted); white-space: nowrap; } .hlp .breset { font: 600 10px var(--mono); color: var(--ink2); text-transform: uppercase; letter-spacing: .06em; border: 1px solid var(--line-strong); border-radius: 4px; @@ -719,6 +809,57 @@ } .hlp .repbox input[type="file"]:focus-visible { outline: 2px solid var(--k-create); outline-offset: 1px; } .hlp .repbox code { font: 400 10.5px var(--mono); color: var(--ink); word-break: break-all; } +/* the built-in snapshot's provenance: when it was produced and against how + many actions. A remembered green that outlives its run is the failure mode + this box exists to prevent, so the words "snapshot" and the date are part of + the control, not a footnote. */ +.hlp .repbox.snap { border-left-color: var(--k-vote); } +.hlp .repbox .repwhen { font: 400 10.5px/1.5 var(--mono); color: var(--muted); } +.hlp .repbox .repwhen b { color: var(--ink2); } + +/* ── help dialog: the vocabulary, in plain language ──────────────────── + Same dialog treatment as the settings sheet (focus trap, Esc, focus back to + the opener) and stacked above it, because it is reachable from inside it. + Every count and every example in here is read off the embedded recipe at + runtime — a hard-coded "24 gated actions" goes stale on the next recipe edit + with nothing to catch it, which is the same reason the legend counts are + tallied off the painted ticks. */ +.hlp .hmodal { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; } +.hlp .hmodal[hidden] { display: none; } +/* the help sheet borrows .scard/.shead, but never .sbody — that one is a + two-track grid for the settings layout and would split the prose in half */ +.hlp .dbody { overflow-y: auto; padding: 14px 16px 18px; } +.hlp .doc { display: grid; gap: 15px; align-content: start; } +@media (min-width: 900px) { .hlp .doc { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.hlp .doc section { border-left: 2px solid var(--line-strong); padding-left: 11px; } +.hlp .doc section.wide { grid-column: 1 / -1; } +.hlp .doc h4 { + font: 700 9.5px var(--mono); letter-spacing: .11em; text-transform: uppercase; + color: var(--muted); margin-bottom: 7px; +} +.hlp .doc p { font: 400 11.5px/1.62 var(--sans); color: var(--ink2); max-width: 92ch; } +.hlp .doc p + p { margin-top: 6px; } +.hlp .doc p b { color: var(--ink); } +.hlp .doc dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 7px 12px; } +.hlp .doc dt { + font: 700 10.5px/1.5 var(--mono); color: var(--ink); white-space: nowrap; + display: flex; align-items: baseline; gap: 6px; +} +.hlp .doc dt .n { + font: 700 9.5px var(--mono); font-variant-numeric: tabular-nums; + color: var(--ink2); border: 1px solid var(--line-strong); border-radius: 999px; + padding: 1px 5px; +} +.hlp .doc dd { font: 400 11.5px/1.55 var(--sans); color: var(--ink2); min-width: 0; } +.hlp .doc code, .hlp .doc .ex { + font: 400 10.5px var(--mono); color: var(--ink); + background: var(--panel); border: 1px solid var(--line); border-radius: 3px; + padding: 1px 4px; word-break: break-word; +} +.hlp .doc .ex { display: block; margin-top: 5px; padding: 7px 8px; color: var(--ink2); line-height: 1.5; } +.hlp .doc .ex b { color: var(--ink); } +.hlp .doc .gk { color: var(--st-crit); font-weight: 700; } +.hlp .doc .tk { color: var(--k-vote); font-weight: 700; } /* the side panel needs a story below ~700px: the panes stack, the JSON keeps its own scrollport, and the selected row stays visible above it */ @@ -772,6 +913,14 @@ <h1>Hackathon Lifecycle Player</h1> title="Choose what the timeline colours encode"> ⚙ Colours: <b id="btnSettingsMode">—</b> <kbd>C</kbd> </button> + <button class="hbtn ask" id="btnHelp" aria-haspopup="dialog" + title="What the recipe is, and what every field on an action means — gate, todo, expect, priority, actor, save"> + ? What do these mean <kbd>?</kbd> + </button> + <button class="hbtn" id="btnResetLayout" disabled + title="Drag any item on the diagram to move it; Tab to it and use the arrow keys to nudge it. This puts every moved item back where it started."> + ⤺ Reset layout <b id="btnResetLayoutN">0</b> + </button> <button class="hbtn" id="btnTheme" aria-pressed="false" title="Switch between the dark and light palette"> <span id="themeIcon">◐</span> <span id="themeLabel">Theme</span> </button> @@ -783,7 +932,11 @@ <h1>Hackathon Lifecycle Player</h1> <div class="main"> <div class="stage-wrap"> - <svg id="stage" viewBox="0 0 960 680" role="img" aria-label="Animated map of hackathon actors and actions"></svg> + <!-- role=group, not role=img: an img is a leaf to assistive tech and its + descendants are presentational, which would hide every draggable, + focusable item inside it. --> + <svg id="stage" viewBox="0 0 960 680" role="group" + aria-label="Map of hackathon actors and actions. Every item can be dragged, or focused and nudged with the arrow keys."></svg> <div class="caption" id="caption"> <span class="cid" id="capId">—</span> <span class="ctitle" id="capTitle">Press ▶ to replay the full hackathon: publication, a 13-person registration wave, approvals and dropouts, event days, voting, and the archive.</span> @@ -890,6 +1043,8 @@ <h3 id="bTitle">Recipe actions</h3> title="Download only the actions currently passing the filters, as JSONL"> ⭳ filtered subset · <b id="dlSubN">0</b> </button> + <button class="hbtn ask" id="bHelp" aria-haspopup="dialog" + title="What gate, todo, expect, priority, actor and save mean">? What do these mean</button> <button class="hbtn" id="bSettings" aria-haspopup="dialog" title="Choose what the timeline colours encode">⚙ Colours</button> <button class="hbtn" id="bClose" title="Close (Esc)">✕ Close</button> @@ -902,12 +1057,16 @@ <h3 id="bTitle">Recipe actions</h3> <label>Kind <select id="fKind" aria-label="Filter by action kind"></select></label> <label>Actor <select id="fActor" aria-label="Filter by actor"></select></label> <label>Prio <select id="fPrio" aria-label="Filter by priority"></select></label> + <!-- each chip carries its meaning in the label, not only in the title: + a reader of the words "has gate" should not have to go looking, and + two people asking what it meant is the evidence that a tooltip and + a distant paragraph do not answer it. ? opens the long form. --> <label class="fchk" title="todo — a free-text design note on the action (why it is written this way, or what to re-specify when a bug behind it is fixed). NOT a gap in the product."> - <input type="checkbox" id="fTodo" /> has todo</label> + <input type="checkbox" id="fTodo" /> has todo <span class="fgloss">a design note</span></label> <label class="fchk" title="gate — the action names the RPCs it needs and self-skips until scripts/probe.sh finds them, so it starts running on its own when the backend lands."> - <input type="checkbox" id="fGate" /> has gate</label> + <input type="checkbox" id="fGate" /> has gate <span class="fgloss">waits for its RPCs</span></label> <label class="fchk" title="the action's expect block names an error code — a denial is the asserted outcome, not a failure."> - <input type="checkbox" id="fDenied" /> expects denial</label> + <input type="checkbox" id="fDenied" /> expects denial <span class="fgloss">the refusal is the pass</span></label> <button class="breset" id="fReset">Reset</button> </div> @@ -995,12 +1154,21 @@ <h4>Pass / fail — not in the recipe</h4> </p> <div class="repbox" id="sRepBox" style="margin-top:10px"> <span class="repstat warn" id="sRepStat">No run report loaded — “run outcome” colours everything as unknown.</span> + <!-- provenance of the built-in report, written by JS from the block + embedded at the bottom of this file. A snapshot that does not + say WHEN it was taken is a green that outlives its run. --> + <p class="repwhen" id="sRepWhen" hidden></p> <p> - To colour by what actually happened, load a Playwright JSON report - from a journey run. Nothing is uploaded and no network is used — - the file is read in this tab. + To colour by what actually happened <em>now</em>, load a Playwright + JSON report from your own journey run — it overrides the built-in + snapshot. Nothing is uploaded and no network is used: the file is + read in this tab. </p> - <p><code>bash .claude/skills/hackathon-e2e/scripts/run.sh journey --reporter=json > report.json</code></p> + <p><code>bash .claude/skills/hackathon-e2e/scripts/run.sh journey</code> writes + <code>.artifacts/results.json</code> on every run (the json reporter is in + playwright.config.ts, so no <code>> report.json</code> redirection is + needed — and none should be used: every command in this container + prints Nix and quitsh noise to stdout first, which would not parse).</p> <div class="reprow"> <input type="file" id="sRepFile" accept=".json,application/json" aria-label="Load a Playwright JSON report" /> @@ -1019,6 +1187,11 @@ <h4>The two fields the legend counts</h4> running on its own the moment the RPC lands; nothing has to be edited. Drawn as a cap at the <b>top</b> of its tick. </p> + <p style="margin-top:7px" id="sHelpLink"> + The whole vocabulary — every field an action can carry, in plain + language, with a live example of each — is in + <b>? What do these mean</b> in the header. + </p> <p style="margin-top:7px"> <span class="tk">todo</span> — a <b>free-text note</b> on the action: why it is written the way it is, or what to re-specify when a bug it @@ -1030,6 +1203,25 @@ <h4>The two fields the legend counts</h4> </div> </div> </div> + + <!-- ── help: the vocabulary in plain language ───────────────────────── + Two people have now asked what "has gate" means, which is the evidence + that a tooltip and a paragraph two panels away do not answer it. The + chips and the legend carry a one-line gloss; this is the long form. + Everything inside is written by JS from the embedded recipe, so no count + and no example in here can go stale against the file. ──────────────── --> + <div class="hmodal" id="help" hidden> + <div class="mback" id="hback"></div> + <div class="scard" role="dialog" aria-modal="true" aria-labelledby="hTitle"> + <div class="shead"> + <h3 id="hTitle">What the recipe is, and what every field on an action means</h3> + <button class="hbtn" id="hClose" title="Close (Esc)">✕ Close</button> + </div> + <div class="dbody"> + <div class="doc" id="hDoc"></div> + </div> + </div> + </div> </div> <script id="recipe-data" type="application/jsonl"> @@ -1509,6 +1701,34 @@ <h4>The two fields the legend counts</h4> {"id": "act8.page.cleanup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the outdated webinar page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Delete", "params": {"pageId": "{{var:pageWebinars}}"}, "expect": {"ok": true}} {"id": "act8.draft.delete", "priority": "P2", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the never-announced winter draft event", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Delete", "params": {"hackathonId": "{{var:draftId}}"}, "expect": {"ok": true}, "todo": "TODO: runs once HackathonService.Delete lands — pin cascade semantics (participants/pages/teams of a deleted hackathon) when it does."} </script> + +<!-- ── a real journey run, baked in ───────────────────────────────────── + `run outcome` is the only mode that can answer "did it pass", and until + this block existed it answered "unknown" for everybody until they found a + file to drop on it. This is a REDUCED Playwright report from a real + `scripts/run.sh journey`: one entry per action — the outcome and the + duration in ms — and the run's own totals. The full report is mostly + stdout, attachments and stack frames, none of which this page joins on, + and this file is opened from a disk. + + Two things about the markup, both of which have cost hours before: + + 1. It sits AFTER the recipe-data block on purpose. splice-player.mjs finds + its terminator with indexOf(close, afterOpenMarker), i.e. the FIRST + close tag past the recipe's opening marker — which is still the recipe's + own. Putting this block first would make the splice overwrite it. + 2. Every `</` inside is escaped as `<\/`, exactly as the recipe lines are. + An inline script ends at the first LITERAL close tag even inside a JSON + string, and a Playwright report can carry page snippets in its error + messages. Rebuild it with scripts/embed-run-report.mjs, which applies + that escape; do not paste a report in by hand. + + So this file now holds THREE literal close tags: the recipe, this report + and the program. --> +<script id="run-report" type="application/json"> +{"kind":"hackagon-journey-run","suite":"journey","generatedAt":"2026-08-13T13:06:58.292Z","recipeActions":463,"specs":467,"durationMs":157695,"totals":{"passed":463,"failed":0,"skipped":0,"flaky":0,"other":0},"status":{"act0.about.absent":["passed",1486],"act0.about.create":["passed",1184],"act0.about.draft.hidden":["passed",834],"act0.about.rogue":["passed",73],"act0.about.anon":["passed",15],"act0.about.publish":["passed",2136],"act0.about.live":["passed",922],"act0.about.xss":["passed",75],"act0.about.sanitized":["passed",889],"act0.privacy.create":["passed",34],"act0.terms.create":["passed",30],"act0.slug.dupe":["passed",29],"act0.slug.invalid":["passed",30],"act0.footer.links":["passed",1622],"act0.ghost":["passed",17],"act1.guard":["passed",333],"act1.publish":["passed",54],"act1.logo.presign":["passed",31],"act1.logo.rogue":["passed",79],"act1.logo.anon":["passed",16],"act1.logo.svg":["passed",30],"act1.logo.toobig":["passed",30],"act1.roundtrip":["passed",43],"act1.config.regform":["passed",33],"act1.config.subform":["passed",33],"act1.config.subform.url":["passed",33],"act1.config.voting":["passed",35],"act1.config.emails":["passed",35],"act1.race.emails":["passed",36],"act1.race.emails.check":["passed",35],"act1.race.emails.restore":["passed",33],"act1.config.branding":["passed",33],"act1.config.windows":["passed",30],"act1.window.early":["passed",67],"act1.prizes":["passed",1813],"act1.admin.whoami":["passed",30],"act1.admin.users":["passed",28],"act1.public":["passed",856],"act1.ui.cover":["passed",360],"act1.typo":["passed",37],"act1.typo.check":["passed",35],"act1.typo.fix":["passed",36],"act1.typo.fixed":["passed",36],"act1.reschedule":["passed",37],"act1.venue":["passed",58],"act1.venue.check":["passed",36],"act1.edit.rogue":["passed",31],"act1.draft.create":["passed",39],"act1.draft.hidden":["passed",305],"act1.draft.api":["passed",20],"act1.draft.hub.noreview":["passed",818],"act1.joinable":["passed",280],"act1.rogue":["passed",30],"act1.flow.anon":["passed",940],"act1.flow.bob":["passed",1657],"act1.flow.abandon":["passed",1179],"act1.signin.deeplink":["passed",658],"act1.signin.offsite":["passed",903],"act1.signin.roundtrip":["passed",3095],"act1.flow.wrongpw":["passed",1796],"act1.flow.joinstub":["passed",856],"act1.page.welcome":["passed",35],"act1.page.conduct":["passed",33],"act1.editor.toolbar":["passed",1180],"act1.editor.table.tsv":["passed",607],"act1.editor.table.csv":["passed",628],"act1.editor.table.semicolon":["passed",653],"act1.editor.table.ambiguous":["passed",579],"act1.editor.table.override":["passed",1247],"act1.editor.table.pipe":["passed",629],"act1.editor.table.ragged":["passed",576],"act1.editor.table.noheader":["passed",638],"act1.editor.table.cancel":["passed",586],"act1.editor.typo":["passed",1396],"act1.editor.fix":["passed",755],"act1.editor.readback":["passed",35],"act1.track.ds":["passed",29],"act1.track.rdi":["passed",31],"act2.window.open":["passed",30],"act2.join.alice":["passed",36],"act2.join.bob":["passed",37],"act2.join.charles":["passed",1937],"act2.join.dana":["passed",100],"act2.join.erik":["passed",98],"act2.join.fatima":["passed",100],"act2.join.giulia":["passed",106],"act2.midway":["passed",35],"act2.pause":["passed",31],"act2.pause.ui":["passed",294],"act2.pause.api":["passed",44],"act2.resume":["passed",35],"act2.resume.ui":["passed",295],"act2.join.hiro":["passed",101],"act2.join.ines":["passed",103],"act2.join.jonas":["passed",96],"act2.join.katya":["passed",99],"act2.join.liam":["passed",97],"act2.join.mei":["passed",99],"act2.form.alice":["passed",33],"act2.form.bob":["passed",33],"act2.form.charles":["passed",80],"act2.form.dana":["passed",33],"act2.form.erik":["passed",33],"act2.form.giulia":["passed",66],"act2.form.hiro":["passed",32],"act2.form.katya":["passed",32],"act2.form.mei":["passed",32],"act2.form.missing":["passed",30],"act2.form.unknown":["passed",31],"act2.form.alice.readback":["passed",31],"act2.form.alice.correct":["passed",33],"act2.form.alice.recheck":["passed",30],"act2.form.bob.snoop":["passed",49],"act2.form.admin.read":["passed",31],"act2.idempotent":["passed",31],"act2.anonymous":["passed",16],"act2.anonymous.register":["passed",14],"act2.roster":["passed",39],"act2.users.grown":["passed",30],"act2.flow.admin.users":["passed",2241],"act2.users.rogue":["passed",31],"act2.flow.alice.users":["passed",891],"act2.join.badid":["passed",48],"act2.join.ghost":["passed",56],"act2.whoami.bob":["passed",31],"act2.ui.waitlisted":["passed",309],"act2.ui.locked":["passed",329],"act2.flow.bob":["passed",1730],"act2.flow.anxious":["passed",1510],"act2.cap.create":["passed",43],"act2.cap.set":["passed",36],"act2.cap.join.room":["passed",38],"act2.cap.race":["passed",94],"act2.cap.roster":["passed",37],"act2.cap.join.full":["passed",40],"act2.cap.remove":["passed",50],"act2.cap.nojump":["passed",38],"act2.cap.ui.queued":["passed",292],"act2.cap.noautopromote":["passed",40],"act2.cap.approve.fill":["passed",46],"act2.cap.approve.over":["passed",51],"act2.cap.roster.final":["passed",35],"act2.cap.ui.gauge":["passed",636],"act2.cap.ui.in":["passed",315],"act3.propose.fair":["passed",39],"act3.propose.litdata":["passed",38],"act3.propose.genomelens":["passed",37],"act3.approve.fair":["passed",34],"act3.approve.litdata":["passed",1294],"act3.rogue":["passed",31],"act3.propose.sensor":["passed",34],"act3.withdraw":["passed",35],"act3.edit.fair":["passed",34],"act3.propose.anonymous":["passed",13],"act3.approve.ghost":["passed",30],"act3.propose.waitlisted":["passed",35],"act3.ui.proposals":["passed",929],"act4.pref.bob":["passed",34],"act4.pref.dana":["passed",33],"act4.export":["passed",32],"act4.team.matterhorn":["passed",33],"act4.team.bernina":["passed",29],"act4.team.anon":["passed",14],"act4.assign.bob":["passed",47],"act4.assign.alice":["passed",36],"act4.assign.dana":["passed",35],"act4.assign.erik":["passed",45],"act4.assign.anon":["passed",15],"act4.removeuser.anon":["passed",14],"act4.pref.erik":["passed",32],"act4.pref.update":["passed",36],"act4.window.prefclose":["passed",32],"act4.window.preflate":["passed",31],"act4.team.placeholder":["passed",30],"act4.team.placeholder.delete":["passed",31],"act4.rebalance.add":["passed",48],"act4.rebalance.remove":["passed",1318],"act4.rebalance.final":["passed",39],"act4.assign.hiro":["passed",48],"act4.assign.ines":["passed",52],"act4.assign.fatima":["passed",52],"act4.team.edit":["passed",38],"act4.team.edit.anon":["passed",17],"act4.team.delete.anon":["passed",14],"act4.ui.teams":["passed",553],"act4.webinars":["passed",32],"act5.approve.alice":["passed",32],"act5.approve.bob":["passed",33],"act5.approve.dana":["passed",32],"act5.approve.erik":["passed",51],"act5.approve.fatima":["passed",43],"act5.approve.giulia":["passed",34],"act5.approve.hiro":["passed",32],"act5.approve.ines":["passed",35],"act5.approve.double":["passed",34],"act5.roster.full":["passed",38],"act5.ui.member":["passed",294],"act5.ui.open":["passed",497],"act5.ui.about":["passed",334],"act5.flow.bob":["passed",2093],"act5.flow.admin":["passed",1642],"act5.flow.alice":["passed",1881],"act5.flow.search":["passed",1740],"act5.pref.reopen":["passed",35],"act5.flow.prefer":["passed",1532],"act5.pref.close":["passed",35],"act5.dropout.before":["passed",36],"act5.dropout.remove":["passed",34],"act5.dropout.after":["passed",29],"act5.dropout.team":["passed",37],"act5.backfill":["passed",65],"act5.backfill.access":["passed",35],"act5.backfill.team":["passed",33],"act5.roster.final":["passed",37],"act5.hub.tiles":["passed",615],"act5.hub.rogue":["passed",844],"act5.hub.review.click":["passed",988],"act5.hub.edit.click":["passed",1107],"act5.flow.reach.manage":["passed",1333],"act5.flow.hub.back":["passed",1051],"act5.hub.title.check":["passed",38],"act5.nav.fold":["passed",1188],"act5.pilot.join.offered":["passed",290],"act5.pilot.phase.wrap":["passed",35],"act5.pilot.hub.start":["passed",443],"act5.pilot.phase.day":["passed",33],"act5.pilot.hub.declare":["passed",1065],"act5.pilot.phase.advance":["passed",1019],"act5.pilot.phase.readback":["passed",38],"act5.pilot.cap.close":["passed",36],"act5.pilot.cap.schedule":["passed",36],"act5.pilot.cap.states":["passed",400],"act5.pilot.cap.readback":["passed",40],"act5.pilot.join.closed":["passed",295],"act5.pilot.join.rpc":["passed",32],"act5.pilot.cap.save":["passed",813],"act5.pilot.cap.save.readback":["passed",39],"act5.pilot.cap.plan":["passed",36],"act5.pilot.cap.unmet.bydates":["passed",1011],"act5.pilot.phase.declare":["passed",38],"act5.pilot.phase.declare.applied":["passed",62],"act5.pilot.cap.plan.again":["passed",36],"act5.pilot.cap.unmet":["passed",549],"act5.pilot.cap.unmet.readback":["passed",40],"act5.pilot.cap.refresh":["passed",786],"act5.pilot.cap.refresh.readback":["passed",39],"act5.cap.dupe":["passed",34],"act5.cap.dupe.check":["passed",40],"act5.cap.empty":["passed",37],"act5.cap.unknown":["passed",32],"act5.cap.badid":["passed",29],"act5.cap.ungoverned":["passed",32],"act5.cap.rogue":["passed",33],"act5.cap.anon":["passed",16],"act5.import.template":["passed",717],"act5.import.badmail":["passed",553],"act5.import.badproject":["passed",1150],"act5.import.dupe":["passed",556],"act5.import.waitlisted":["passed",537],"act5.import.halfrow":["passed",560],"act5.import.semicolon":["passed",567],"act5.import.newteam":["passed",556],"act5.import.malformed":["passed",561],"act5.import.bom":["passed",572],"act5.import.accents":["passed",612],"act5.import.apply":["passed",682],"act5.import.revert":["passed",1330],"act5.import.again":["passed",595],"act5.import.readback":["passed",36],"act5.approve.ghost":["passed",30],"act5.remove.ghost":["passed",31],"act5.approve.badid":["passed",28],"act5.window.regclose":["passed",31],"act5.window.regclosed":["passed",33],"act5.ui.charles":["passed",286],"act5.ui.charles.locked":["passed",237],"act5.rogue.approve":["passed",30],"act5.rogue.remove":["passed",28],"act5.owner.rogue":["passed",31],"act5.owner.waitlisted":["passed",32],"act5.owner.promote":["passed",31],"act5.owner.alice.hub":["passed",460],"act5.owner.alice.media":["passed",40],"act5.owner.alice.other.media":["passed",29],"act5.owner.alice.other.cap":["passed",29],"act5.owner.alice.other.hub":["passed",837],"act5.owner.self":["passed",46],"act5.owner.demote":["passed",32],"act5.owner.last":["passed",32],"act5.race.owner.doubleadd":["passed",35],"act5.race.owner.doubleadd.verify":["passed",38],"act5.race.owner.remove":["passed",45],"act5.race.owner.invariant":["passed",41],"act5.race.owner.restore":["passed",34],"act5.race.owner.restore2":["passed",34],"act5.race.owner.final":["passed",40],"act5.forms.roster":["passed",31],"act5.forms.rogue":["passed",29],"act5.forms.anon":["passed",17],"act5.state.facade":["passed",34],"act5.state.rogue":["passed",32],"act5.state.restore":["passed",34],"act5.phase.alias":["passed",36],"act5.audit":["passed",37],"act6.begin":["passed",33],"act6.ui.active":["passed",311],"act6.flow.anon":["passed",917],"act6.list.active":["passed",19],"act6.noshow":["passed",35],"act6.noshow.access":["passed",39],"act6.walkin.signup":["passed",54],"act6.walkin.override":["passed",33],"act6.walkin.join":["passed",35],"act6.walkin.approve":["passed",970],"act6.walkin.form":["passed",35],"act6.walkin.access":["passed",41],"act6.walkin.team":["passed",39],"act6.ui.teams":["passed",363],"act6.roster.walkin":["passed",46],"act6.announce":["passed",60],"act6.announce.ui":["passed",909],"act6.phase.ideation":["passed",33],"act6.phase.hacking":["passed",32],"act6.phase.judging":["passed",31],"act6.phase.rogue":["passed",28],"act6.ui.timeline":["passed",498],"act6.phase.current":["passed",1279],"act6.hub.now":["passed",470],"act6.hub.advance":["passed",997],"act6.hub.marker.readback":["passed",40],"act6.pages.ids":["passed",30],"act6.pages.movedown":["passed",670],"act6.pages.moveup":["passed",666],"act6.pages.reorder":["passed",591],"act6.pages.reorder.readback":["passed",33],"act6.pages.excerpt":["passed",368],"act6.pages.setorder.partial":["passed",34],"act6.pages.setorder.dupe":["passed",30],"act6.pages.setorder.empty":["passed",27],"act6.pages.setorder.ghost":["passed",30],"act6.pages.setorder.rogue":["passed",27],"act6.pages.setorder.anon":["passed",14],"act6.pages.setorder.restore":["passed",34],"act6.pages.order.final":["passed",32],"act6.flow.day1end":["passed",1019],"act6.flow.day2":["passed",2280],"act6.files":["passed",40],"act6.submit.draft":["passed",1215],"act6.submit.draft.id":["passed",34],"act6.submit.final":["passed",1049],"act6.submit.bernina":["passed",35],"act6.submit.bernina.edit":["passed",49],"act6.submit.bernina.final":["passed",39],"act6.submit.abandoned":["passed",35],"act6.logo.refresh":["passed",34],"act6.logo.check":["passed",37],"act6.submit.rogue":["passed",28],"act6.submit.anon":["passed",13],"act6.submit.edit.anon":["passed",15],"act6.submit.final.anon":["passed",15],"act6.submit.invalid":["passed",32],"act6.window.subclose":["passed",34],"act6.window.sublate":["passed",28],"act6.window.override":["passed",31],"act6.submit.grace":["passed",35],"act6.ui.submissions":["passed",324],"act7.cat.impact":["passed",35],"act7.cat.tech":["passed",31],"act7.cat.demo":["passed",32],"act7.cat.ranked":["passed",31],"act7.cat.points":["passed",34],"act7.voting.open":["passed",1154],"act7.monitor.open":["passed",32],"act7.cast.alice":["passed",38],"act7.cast.bob":["passed",1027],"act7.cast.dana":["passed",41],"act7.cast.erik":["passed",37],"act7.cast.giulia":["passed",37],"act7.cast.hiro":["passed",37],"act7.cast.ines":["passed",37],"act7.cast.jonas":["passed",38],"act7.cast.noor":["passed",36],"act7.cast.alice2":["passed",36],"act7.cast.bob2":["passed",38],"act7.cast.ines2":["passed",40],"act7.cast.giulia2":["passed",40],"act7.race.cat":["passed",34],"act7.race.doublevote":["passed",44],"act7.race.check":["passed",33],"act7.ranked.gap":["passed",31],"act7.ranked.dupe":["passed",30],"act7.ranked.bob":["passed",38],"act7.ranked.wrongmethod":["passed",30],"act7.points.over":["passed",29],"act7.points.bob":["passed",38],"act7.points.ines":["passed",38],"act7.cast.admin":["passed",30],"act7.cast.waitlisted":["passed",31],"act7.cast.double":["passed",35],"act7.close":["passed",32],"act7.cast.late":["passed",83],"act7.result.impact":["passed",33],"act7.result.ranked":["passed",36],"act7.result.points":["passed",35],"act7.results":["passed",31],"act7.prizes.finalize":["passed",30],"act8.end":["passed",32],"act8.ui.finished":["passed",304],"act8.latejoin":["passed",30],"act8.flow.anon":["passed",1021],"act8.audit":["passed",42],"act8.thanks":["passed",36],"act8.thanks.ui":["passed",328],"act8.retention.alice":["passed",325],"act8.prizes.edit":["passed",34],"act8.prizes.rogue":["passed",27],"act8.retention":["passed",989],"act8.flow.charles":["passed",1833],"act8.photos":["passed",35],"act8.media.presign":["passed",28],"act8.media.rogue":["passed",29],"act8.media.svg":["passed",28],"act8.media.upload":["passed",242],"act8.media.upload2":["passed",193],"act8.objects.hackathon":["passed",36],"act8.objects.noowner":["passed",28],"act8.objects.badowner":["passed",28],"act8.objects.rogue":["passed",30],"act8.objects.anon":["passed",14],"act8.objects.site.admin":["passed",36],"act8.objects.site.rogue":["passed",26],"act8.objects.all.admin":["passed",438],"act8.objects.all.rogue":["passed",30],"act8.objects.scope.zero":["passed",29],"act8.objects.page.one":["passed",430],"act8.objects.page.two":["passed",441],"act8.objects.pagesize.max":["passed",404],"act8.objects.pagesize.over":["passed",30],"act8.objects.badtoken":["passed",33],"act8.flow.reach.gallery":["passed",1724],"act8.gallery.admin":["passed",972],"act8.gallery.rogue":["passed",872],"act8.picker.halves":["passed",710],"act8.picker.reject":["passed",527],"act8.picker.cancel":["passed",475],"act8.pilot.finished":["passed",41],"act8.pilot.join.finished":["passed",948],"act8.pilot.join.finished.rpc":["passed",43],"act8.flow.bob":["passed",1229],"act8.ui.winners":["passed",593],"act8.blog":["passed",37],"act8.ui.blog":["passed",316],"act8.profile.rename":["passed",38],"act8.profile.sticks":["passed",30],"act8.profile.blank":["passed",29],"act8.menu.alice":["passed",1710],"act8.menu.admin":["passed",877],"act8.form.ui.edit":["passed",924],"act8.account.liam":["passed",48],"act8.account.mei":["passed",43],"act8.account.check":["passed",32],"act8.page.cleanup":["passed",35],"act8.draft.delete":["passed",41]}} +</script> + <script> (() => { "use strict"; @@ -1630,8 +1850,14 @@ <h4>The two fields the legend counts</h4> /* ── build the SVG stage ──────────────────────────────────────────── */ const svg = $("stage"); +/* the four painting layers, in z order. Each carries its own name as a class: + the zone layer inserts itself in FRONT of these (see buildZones), and a + render check that wants to know whether the zones really ended up behind + everything needs to be able to say which group is which. */ const layers = {}; ["beams","entities","people","fx"].forEach(n => { - const g = document.createElementNS(SVGNS,"g"); svg.appendChild(g); layers[n] = g; + const g = document.createElementNS(SVGNS,"g"); + g.setAttribute("class", n); + svg.appendChild(g); layers[n] = g; }); function el(tag, attrs, parent){ const n = document.createElementNS(SVGNS, tag); @@ -1892,7 +2118,8 @@ <h4>The two fields the legend counts</h4> preference is a nicety — so every access is wrapped, never guarded. */ const LS = { get(k){ try { return localStorage.getItem(k); } catch (e) { return null; } }, - set(k, v){ try { localStorage.setItem(k, v); } catch (e) {} } + set(k, v){ try { localStorage.setItem(k, v); } catch (e) {} }, + remove(k){ try { localStorage.removeItem(k); } catch (e) {} } }; /* cast groups are read off the PEOPLE table, not listed a second time */ @@ -1900,12 +2127,57 @@ <h4>The two fields the legend counts</h4> const castMembers = g => PEOPLE.filter(p => p[6] === g).map(p => p[0]); const castOf = a => (a.actor && CAST_GROUP[a.actor]) || (a.actor ? "other" : "none"); -/* an optional Playwright JSON report — the ONLY source of pass/fail here, - because recipe.jsonl has none. Never persisted: a status remembered across - reloads would outlive the run that produced it, and a stale green is worse - than no green. */ -let report = null; /* {name, status:{[id]:string}, specs, matched} */ +/* ── the run report: the ONLY source of pass/fail here ───────────────── + recipe.jsonl has none, so "did it pass" can only ever come from a real run. + Two sources, in this order of authority: + + loaded a Playwright JSON report the reader picks — always wins + embedded the reduced snapshot spliced in below the recipe + + The embedded one exists because `run outcome` used to answer "unknown" for + everybody until someone found a file to drop on it, which made the one mode + that can answer the question useless on open. It is REDUCED to what this mode + joins on — the action id, the outcome and the duration — since a full + Playwright report carries stdout, attachments and stack frames and this file + is opened from a disk. + + It is a SNAPSHOT and every surface that uses it says so, with the date it was + produced and the recipe size it was produced against: a green that outlives + its run is the whole failure mode. A LOADED report is still never persisted, + for the same reason. */ +const EMBEDDED = (function readEmbedded(){ + const node = $("run-report"); + if (!node) return null; + let j; + try { j = JSON.parse(node.textContent.split(ESCAPED_CLOSE).join("</")); } + catch (e) { return null; } + if (!j || !j.status || typeof j.status !== "object") return null; + const status = {}; + for (const id in j.status){ + const v = j.status[id]; + status[id] = String(Array.isArray(v) ? v[0] : v); + } + const matched = EVENTS.filter(a => status[a.id]).length; + if (!matched) return null; /* a snapshot of some other recipe is no snapshot */ + return {name: "the built-in snapshot", status: status, source: "embedded", + specs: j.specs || Object.keys(status).length, matched: matched, + when: String(j.generatedAt || ""), suite: String(j.suite || "journey"), + actions: +j.recipeActions || 0, totals: j.totals || null, + durationMs: +j.durationMs || 0, + dur: id => { const v = j.status[id]; return Array.isArray(v) ? +v[1] || 0 : 0; }}; +})(); +let report = EMBEDDED; /* {name, status:{[id]:string}, specs, matched, source} */ const runOf = a => (report && report.status[a.id]) || null; +/* "2026-08-13T09:12:44.000Z" reads as a wall-clock date, not as an ISO string, + in the one place a reader has to judge whether a green is old */ +function whenText(r){ + if (!r || !r.when) return ""; + const d = new Date(r.when); + if (isNaN(+d)) return r.when; + const p = n => (n < 10 ? "0" : "") + n; + return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + + " " + p(d.getHours()) + ":" + p(d.getMinutes()); +} /* values that exist in THIS file, so a new act, kind or priority needs no edit */ const ACTS_PRESENT = [...new Set(EVENTS.map(a => +a.act))].sort((x, y) => x - y); @@ -2088,9 +2360,13 @@ <h4>The two fields the legend counts</h4> if (!s.status) chip.classList.add("hidden"); $("unlistedChip").classList.toggle("hidden", s.listed); /* entities */ + /* tabindex follows visibility, not only pointer-events: an item that has not + been created yet in the story must not be a stop on the way through the + page with the keyboard either */ const show = (k, on) => { const n = nodeEls[k]; n.g.setAttribute("opacity", on ? 1 : 0); - n.g.style.pointerEvents = on ? "auto" : "none"; }; + n.g.style.pointerEvents = on ? "auto" : "none"; + if (n.g.classList.contains("dnode")) n.g.setAttribute("tabindex", on ? "0" : "-1"); }; show("draft", s.draft); show("placeholder", s.placeholder); show("teamM", s.teamM); show("teamB", s.teamB); show("subM", s.subM); show("subB", s.subB); show("subScratch", s.subScratch); @@ -2417,13 +2693,270 @@ <h4>The two fields the legend counts</h4> } $("mClose").addEventListener("click", closeModal); $("mback").addEventListener("click", closeModal); -function makeClickable(key){ +/* ── moving the items: pointer, keyboard, remembered ────────────────── + WHAT WINS between a drag and the replay: the drag, always — and by + construction rather than by arbitration. Nothing in the animation ever sets a + position. render() changes opacity, text and badges only, and animateBeam() + reads the CURRENT pos[] table each time it fires. So there is no frame on + which an arrangement could be overwritten, and a moved item keeps its place + for the rest of the run. + + That is also why a drag writes pos[] and not only a transform: the transform + moves the disc, and pos[] is where every beam starts and ends. Move one + without the other and the beams keep arriving at the empty coordinates the + item used to occupy. + + Pointer events, not HTML5 drag-and-drop — the latter has no SVG story and no + touch story. Screen→user coordinates go through the SVG's own CTM, so the + letterboxed viewBox scale needs no arithmetic here. Arrow keys do the same + operation without a mouse (Shift for a coarse step, Backspace to put one item + back), and every offset is remembered through the same wrapped localStorage + the theme uses, because file:// can throw on it. */ +const LAYOUT_KEY = "hlp-layout"; +const BASE_POS = {}; /* where the diagram itself puts each item */ +const NODE_BOX = {}; /* its box at rest — used to clamp, to ring, to zone */ +const offsets = {}; /* key → [dx, dy] */ +const DRAG_KEYS = PEOPLE.map(p => p[0]).concat(ENTITIES.map(e => e[0])); +const offOf = k => offsets[k] || [0, 0]; +const movedKeys = () => DRAG_KEYS.filter(k => offsets[k] && (offsets[k][0] || offsets[k][1])); + +/* the group's own geometry, ignoring the ring and the moved-dot this code adds + and ignoring children with no geometry at all — an empty <text> (every + section hexagon starts with an empty count) reports a 0×0 box AT THE ORIGIN, + and unioning that in would stretch a node's box to the top-left corner of the + stage and take the clamp and the zone rects with it. */ +function contentBox(g){ + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const c of g.children){ + if (c.tagName === "title") continue; + if (c.classList && (c.classList.contains("dfocus") || c.classList.contains("dhandle"))) continue; + let b; try { b = c.getBBox(); } catch (e) { continue; } + if (!b || (!b.width && !b.height)) continue; + x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y); + x1 = Math.max(x1, b.x + b.width); y1 = Math.max(y1, b.y + b.height); + } + if (!isFinite(x0)){ const b = g.getBBox(); return {x: b.x, y: b.y, w: b.width, h: b.height}; } + return {x: x0, y: y0, w: x1 - x0, h: y1 - y0}; +} +function userPoint(e){ + const m = svg.getScreenCTM(); + if (!m) return {x: e.clientX, y: e.clientY}; + let p; + if (typeof DOMPoint === "function") p = new DOMPoint(e.clientX, e.clientY); + else { p = svg.createSVGPoint(); p.x = e.clientX; p.y = e.clientY; } + return p.matrixTransform(m.inverse()); +} +/* an item dragged off the frame is an item you cannot get back except through + Reset layout, so the frame is a hard stop rather than a suggestion */ +function clampOffset(key, dx, dy){ + const b = NODE_BOX[key]; + if (!b) return [dx, dy]; + const clamp = (v, lo, hi) => Math.max(Math.min(v, hi), lo); + return [clamp(dx, 2 - b.x, 958 - (b.x + b.w)), clamp(dy, 2 - b.y, 678 - (b.y + b.h))]; +} +function setOffset(key, dx, dy, clamp){ + const n = nodeEls[key]; if (!n || !BASE_POS[key]) return; + if (clamp !== false){ const c = clampOffset(key, dx, dy); dx = c[0]; dy = c[1]; } + offsets[key] = [dx, dy]; + const on = !!(dx || dy); + n.g.setAttribute("transform", on ? "translate(" + dx.toFixed(1) + " " + dy.toFixed(1) + ")" : ""); + n.g.classList.toggle("moved", on); + pos[key].x = BASE_POS[key].x + dx; + pos[key].y = BASE_POS[key].y + dy; +} +function updateResetBtn(){ + const n = movedKeys().length, b = $("btnResetLayout"); + $("btnResetLayoutN").textContent = n; + b.disabled = n === 0; + b.setAttribute("aria-label", n + ? "Reset layout — put the " + n + " moved item" + (n === 1 ? "" : "s") + " back" + : "Reset layout — nothing has been moved yet"); +} +function saveLayout(){ + const o = {}; + movedKeys().forEach(k => { o[k] = [Math.round(offsets[k][0]), Math.round(offsets[k][1])]; }); + if (Object.keys(o).length) LS.set(LAYOUT_KEY, JSON.stringify(o)); + else LS.remove(LAYOUT_KEY); + updateResetBtn(); +} +function resetLayout(){ + DRAG_KEYS.forEach(k => { if (offsets[k]) setOffset(k, 0, 0, false); }); + saveLayout(); +} +function loadLayout(){ + const raw = LS.get(LAYOUT_KEY); if (!raw) return; + let o; try { o = JSON.parse(raw); } catch (e) { return; } + if (!o || typeof o !== "object") return; + for (const k in o){ + if (!nodeEls[k] || !Array.isArray(o[k])) continue; + setOffset(k, +o[k][0] || 0, +o[k][1] || 0); + } +} + +let drag = null, swallowClick = false; +function wireNode(key){ const n = nodeEls[key]; if (!n) return; - n.g.style.cursor = "pointer"; - n.g.addEventListener("click", () => openModal(key)); + const b = contentBox(n.g); + NODE_BOX[key] = b; + BASE_POS[key] = {x: pos[key].x, y: pos[key].y}; + const label = PERSON_NAME[key] || ENTITY_NAME[key] || key; + + /* the focus ring is an ELEMENT, not an `outline`: engines still disagree about + painting an outline on an SVG group, and a keyboard user with no visible + focus has no way to know which item the arrows will move. First child, so + it draws behind the item it rings. */ + const ring = el("rect", {class: "dfocus", rx: 6, + x: (b.x - 5).toFixed(1), y: (b.y - 5).toFixed(1), + width: (b.w + 10).toFixed(1), height: (b.h + 10).toFixed(1)}, n.g); + n.g.insertBefore(ring, n.g.firstChild); + /* a moved item marks itself, so "Reset layout · 3" is checkable against the + picture instead of being a number to trust */ + el("circle", {class: "dhandle", cx: (b.x - 3).toFixed(1), cy: (b.y - 3).toFixed(1), r: 2.6}, n.g); + + n.g.classList.add("dnode"); + n.g.dataset.node = key; + n.g.setAttribute("tabindex", "0"); + n.g.setAttribute("role", "button"); + n.g.setAttribute("aria-label", label + + " — Enter opens its history; the arrow keys move it, Backspace puts it back"); + el("title", {}, n.g).textContent = label + + " — drag to move it, or Tab to it and nudge with the arrow keys. Click for its history."; + + n.g.addEventListener("pointerdown", e => { + if (e.button > 0) return; + swallowClick = false; + const p = userPoint(e), o = offOf(key); + drag = {key: key, px: p.x, py: p.y, dx: o[0], dy: o[1], moved: false}; + n.g.classList.add("dragging"); + try { n.g.setPointerCapture(e.pointerId); } catch (err) {} + e.preventDefault(); + }); + n.g.addEventListener("pointermove", e => { + if (!drag || drag.key !== key) return; + const p = userPoint(e); + const nx = drag.dx + (p.x - drag.px), ny = drag.dy + (p.y - drag.py); + /* a 2px threshold, so a click that trembles still opens the modal */ + if (!drag.moved && Math.hypot(nx - drag.dx, ny - drag.dy) > 2) drag.moved = true; + if (drag.moved) setOffset(key, nx, ny); + }); + const finish = () => { + if (!drag || drag.key !== key) return; + n.g.classList.remove("dragging"); + if (drag.moved){ swallowClick = true; saveLayout(); } + drag = null; + }; + n.g.addEventListener("pointerup", finish); + n.g.addEventListener("pointercancel", finish); + n.g.addEventListener("click", () => { + if (swallowClick){ swallowClick = false; return; } /* that was a drag */ + openModal(key); + }); + n.g.addEventListener("keydown", e => { + const STEP = e.shiftKey ? 16 : 4; + let dx = 0, dy = 0; + if (e.key === "ArrowLeft") dx = -STEP; + else if (e.key === "ArrowRight") dx = STEP; + else if (e.key === "ArrowUp") dy = -STEP; + else if (e.key === "ArrowDown") dy = STEP; + else if (e.key === "Enter" || e.code === "Space"){ + e.preventDefault(); e.stopPropagation(); openModal(key); return; + } else if (e.key === "Backspace" || e.key === "Delete"){ + e.preventDefault(); e.stopPropagation(); + setOffset(key, 0, 0, false); saveLayout(); return; + } else return; + /* the transport listens for arrows on the document: a nudge must not also + step the story, so the focused item keeps the key */ + e.preventDefault(); e.stopPropagation(); + const o = offOf(key); + setOffset(key, o[0] + dx, o[1] + dy); + saveLayout(); + }); } -PEOPLE.forEach(p => makeClickable(p[0])); -ENTITIES.forEach(en => makeClickable(en[0])); +DRAG_KEYS.forEach(wireNode); + +/* ── the regions behind the diagram ─────────────────────────────────── + Six dim, labelled areas saying what each part of the stage IS. Membership is + DERIVED, never listed twice: the cast groups come off the PEOPLE table's own + group column, the platform's ring is every entity key beginning with "sec", + and the bottom band is the teams, their submissions and the upload bundle. + Together they are a PARTITION of all the items on the stage, which is the + property that keeps the taxonomy honest — an invented region would leave a + node in two zones or in none, and the render harness asserts exactly that. + + Rects come from the items' own boxes, so moving a cast member in the table + moves its region with it. They are NOT recomputed after a drag: a zone names + an AREA, so dragging an item out of one is a fact about the item and not + about the area. Two of them overlap slightly and that is real — the + principals' name labels reach x=164 and the upload bundle starts at x=156 — + which is why the washes share one opacity group (see the CSS). */ +const ZONES = [ + {key: "admin", label: "platform admin", side: "t", align: "l", + pad: {t: 8, r: 14, b: 8, l: 14}, + of: PEOPLE.filter(p => p[6] === "admin").map(p => p[0])}, + {key: "draft", label: "private draft", side: "t", align: "l", + pad: {t: 8, r: 16, b: 8, l: 16}, + of: ["draft"]}, + {key: "platform", label: "the platform & its sections", side: "t", align: "l", + pad: {t: 8, r: 8, b: 1, l: 8}, + of: ["platform"].concat(ENTITIES.filter(e => e[0].indexOf("sec") === 0).map(e => e[0]))}, + /* wider pads than the rest, and not for looks: the label has to FIT inside + the box it names, and this column is only as wide as a name label. + The visitor is its own region rather than the fourth member of this one — + it is a signed-OUT session, the cast table gives it its own group and its + own dashed ring, and folding it in here would stretch this box to the + bottom of the frame, where the caption bar covers the label. */ + {key: "principals", label: "principals", side: "b", align: "l", + pad: {t: 8, r: 14, b: 6, l: 14}, + of: PEOPLE.filter(p => p[6] === "principal").map(p => p[0])}, + {key: "visitor", label: "anonymous visitor", side: "t", align: "l", + pad: {t: 8, r: 41, b: 6, l: 41}, + of: PEOPLE.filter(p => p[6] === "anon").map(p => p[0])}, + {key: "crowd", label: "the registration crowd", side: "b", align: "l", + pad: {t: 8, r: 10, b: 6, l: 10}, + of: PEOPLE.filter(p => p[6] === "extra").map(p => p[0])}, + {key: "work", label: "teams, submissions & uploads", side: "b", align: "r", + pad: {t: 0, r: 8, b: 6, l: 0}, + of: ["teamM", "teamB", "placeholder", "subM", "subB", "subScratch", "assets"]} +]; +(function buildZones(){ + const fills = document.createElementNS(SVGNS, "g"); + const marks = document.createElementNS(SVGNS, "g"); + fills.setAttribute("class", "zones"); marks.setAttribute("class", "zonemarks"); + fills.setAttribute("aria-hidden", "true"); marks.setAttribute("aria-hidden", "true"); + const STRIP = 15; /* a clear band for the label, so it never lands on an item */ + ZONES.forEach(z => { + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + z.of.forEach(k => { + const b = NODE_BOX[k]; if (!b) return; + x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y); + x1 = Math.max(x1, b.x + b.w); y1 = Math.max(y1, b.y + b.h); + }); + if (!isFinite(x0)) return; + const p = z.pad; + const X = x0 - p.l, W = (x1 + p.r) - X; + let Y = y0 - p.t, H = (y1 + p.b) - Y; + if (z.side === "t"){ Y -= STRIP; H += STRIP; } else { H += STRIP; } + z.rect = {x: X, y: Y, w: W, h: H}; + [fills, marks].forEach(parent => { + const r = el("rect", {x: X.toFixed(1), y: Y.toFixed(1), + width: W.toFixed(1), height: H.toFixed(1), rx: 7}, parent); + r.setAttribute("data-zone", z.key); + }); + const t = el("text", {class: "zone-label", + x: (z.align === "r" ? X + W - 8 : X + 8).toFixed(1), + y: (z.side === "t" ? Y + 11 : Y + H - 5).toFixed(1)}, marks); + if (z.align === "r") t.setAttribute("text-anchor", "end"); + t.setAttribute("data-zone", z.key); + t.textContent = z.label; + }); + /* first children: behind the beams, the entities and the people */ + svg.insertBefore(marks, svg.firstChild); + svg.insertBefore(fills, svg.firstChild); +})(); + +loadLayout(); +updateResetBtn(); +$("btnResetLayout").addEventListener("click", resetLayout); /* ── transport / engine ───────────────────────────────────────────── */ let cursor = 0, playing = false, timer = null; @@ -2473,7 +3006,13 @@ <h4>The two fields the legend counts</h4> cycles inside it, and none of the transport shortcuts fire underneath — Space in the middle of a search term must not start playback */ /* settings stacks ON TOP of the browser (it is reachable from inside it), so - it is asked about first — the topmost dialog owns Esc and Tab */ + it is asked about first — the topmost dialog owns Esc and Tab. Help stacks + above both, for the same reason: it opens from either of them. */ + if (helpOpen()){ + if (e.key === "Escape"){ e.preventDefault(); closeHelp(); return; } + trapTab($("help").querySelector(".scard"), e); + return; + } if (settingsOpen()){ if (e.key === "Escape"){ e.preventDefault(); closeSettings(); return; } trapTab($("settings").querySelector(".scard"), e); @@ -2496,6 +3035,9 @@ <h4>The two fields the legend counts</h4> if (e.key === "r" || e.key === "R"){ setPlaying(false); seek(0, true); } if (e.key === "b" || e.key === "B"){ e.preventDefault(); openBrowser(); } if (e.key === "c" || e.key === "C"){ e.preventDefault(); openSettings(); } + /* both, because "?" is the conventional key and needs a Shift nobody should + have to find on a phone keyboard or a non-US layout */ + if (e.key === "?" || e.key === "h" || e.key === "H"){ e.preventDefault(); openHelp(); } }); /* ── scrubber (segments ∝ actions per act) + acts strip on top ────── */ @@ -2624,6 +3166,15 @@ <h4>The two fields the legend counts</h4> modeBtn.innerHTML = "⚙ colour = <em>" + esc(mode.label) + "</em>"; modeBtn.addEventListener("click", openSettings); box.appendChild(modeBtn); + /* and the question the words below raise, answered from where they are read: + "gate" and "todo" are in this legend, so the way to what they mean is too */ + const askBtn = document.createElement("button"); + askBtn.className = "lmode"; askBtn.id = "tlHelp"; + askBtn.setAttribute("aria-haspopup", "dialog"); + askBtn.title = "What gate, todo, expect, priority, actor and save mean"; + askBtn.innerHTML = "? <em>what these words mean</em>"; + askBtn.addEventListener("click", openHelp); + box.appendChild(askBtn); const item = (it, n, caps) => { const s = document.createElement("span"); @@ -2664,14 +3215,19 @@ <h4>The two fields the legend counts</h4> } if (mode.id === "run"){ s += report - ? " Coloured from <b>" + esc(report.name) + "</b>: " + report.matched + " of " - + EVENTS.length + " actions matched a spec in it." + ? " Coloured from <b>" + esc(report.name) + "</b>" + + (report.when ? " — the " + esc(whenText(report)) + " " + esc(report.suite) + + " run, a <b>snapshot</b> built into this file rather than a live result" : "") + + ": " + report.matched + " of " + EVENTS.length + " actions matched a spec in it." : " <b>No run report loaded</b>, so every tick is “not in the report”. " + "recipe.jsonl has no status field — load a Playwright JSON report from " + "⚙ Colours to see what actually happened."; } else { s += " None of this is pass/fail: the recipe records <b>intent</b>, not " - + "results — for outcomes, load a run report from ⚙ Colours."; + + "results — for outcomes switch to <b>run outcome</b>" + + (EMBEDDED ? ", coloured from the " + esc(whenText(EMBEDDED)) + " journey run " + + "embedded in this file, or load your own report from ⚙ Colours." + : ", and load a run report from ⚙ Colours."); } $("tlNote").innerHTML = s; /* the browse dialog's two filter checkboxes use the same two words, so they @@ -2783,6 +3339,203 @@ <h4>The two fields the legend counts</h4> $("sClose").addEventListener("click", closeSettings); $("sback").addEventListener("click", closeSettings); +/* ── help dialog: the vocabulary in plain language ───────────────────── + Two people asked what "has gate" meant, so the answer is now a first-class + surface rather than a tooltip and a paragraph two panels away. + + Every number and every example in here is read off the embedded recipe at the + moment the dialog opens. A hard-coded "24 gated actions" is a sentence that + goes stale on the next recipe edit with nothing to catch it — the same reason + the legend's counts are tallied off the ticks they point at. */ +const pickEx = pred => EVENTS.find(pred) || null; +function exBox(a, extra){ + if (!a) return ""; + return "<span class=\"ex\"><b>" + esc(a.id) + "</b> — " + esc(a.title) + + (extra ? "<br>" + extra : "") + "</span>"; +} +function dt(name, n){ + return "<dt><code>" + esc(name) + "</code>" + + (n === undefined ? "" : "<span class=\"n\">" + n + "</span>") + "</dt>"; +} +function buildHelp(){ + const kindN = tallyByPred(MODE_BY_ID.kind); + const prioN = tallyByPred(MODE_BY_ID.prio); + const expN = tallyByPred(MODE_BY_ID.expect); + const castN = tallyByPred(MODE_BY_ID.cast); + const exGate = pickEx(hasGate); + const exTodo = pickEx(a => hasTodo(a) && a.todo.length > 70 && a.todo.length < 240); + const exRace = pickEx(a => a.action === "rpc.race"); + const exFlow = pickEx(a => a.action === "ui.flow" && (a.steps || []).length > 3); + const exAssert = pickEx(a => a.action === "ui.assert"); + const exRpc = pickEx(a => a.action === "rpc" && a.expect && a.expect.ok !== undefined); + const exFiles = pickEx(a => a.action === "files.generate"); + const exPD = pickEx(a => a.expect && a.expect.error === "PermissionDenied"); + const exUN = pickEx(a => a.expect && a.expect.error === "Unauthenticated"); + const exSave = pickEx(a => a.save); + const exVar = pickEx(a => a.id !== (exSave && exSave.id) + && /\{\{var:|\{\{hackathonId\}\}/.test(JSON.stringify(a.params || {}))); + const acts = ACTS_PRESENT.map(n => + "<b>" + n + "</b> " + esc(ACT_NAMES[n] || "") + " (" + EVENTS.filter(a => +a.act === n).length + ")") + .join(" · "); + const firstT = EVENTS[0].t || "", lastT = EVENTS[EVENTS.length - 1].t || ""; + + const S = []; + S.push("<section class=\"wide\"><h4>What you are looking at</h4>" + + "<p><b>recipe.jsonl is the product spec, written as a test.</b> " + + EVENTS.length + " actions, one JSON object per line, executed strictly in " + + "file order by <code>tests/journey/recipe.spec.ts</code> against a freshly " + + "reset stack. This page replays those same lines. Each action says WHO acts, " + + "WHAT they do and WHICH outcome is asserted — and nothing in it records what " + + "happened on any particular run.</p>" + + "<p>The story runs in nine acts, from " + esc(firstT) + " to " + esc(lastT) + + ": " + acts + ". The timeline at the bottom is one tick per action in that " + + "order, and the acts strip under it is clickable.</p></section>"); + + S.push("<section><h4>The kinds of action</h4><dl>" + + dt("rpc", kindN["rpc"] || 0) + + "<dd>A single gRPC call made <b>as a named person</b>, over grpcurl with that " + + "person's real Keycloak token — so the backend's own permission checks decide " + + "the outcome." + exBox(exRpc) + "</dd>" + + dt("ui.flow", kindN["ui.flow"] || 0) + + "<dd>A real browser walks a path step by step — go here, click that, fill this, " + + "expect that — in that person's own signed-in session. Reaching a page by " + + "clicking is a different claim from reaching it by URL." + exBox(exFlow) + "</dd>" + + dt("ui.assert", kindN["ui.assert"] || 0) + + "<dd>A named assertion helper checks a rendered page (the helper's name is the " + + "<code>assert</code> field)." + exBox(exAssert) + "</dd>" + + dt("rpc.race", kindN["rpc.race"] || 0) + + "<dd>Several calls fired <b>simultaneously</b> and judged on the aggregate — how " + + "many succeeded, and which error codes came back as a set. Each one is followed " + + "by a plain read of the END STATE, because “both returned OK” and “there is one " + + "row” are different claims. Two of these reproduced real bugs before their fixes." + + exBox(exRace) + "</dd>" + + dt("files.generate", kindN["files.generate"] || 0) + + "<dd>Writes the deterministic upload bundle the later actions upload." + + exBox(exFiles) + "</dd></dl></section>"); + + S.push("<section><h4><span class=\"gk\">gate</span> — waits for its RPCs</h4>" + + "<p><b>" + N_GATE + " of " + EVENTS.length + " actions carry one.</b> The action " + + "names the RPCs it needs and <b>self-skips while they do not exist</b>. " + + "<code>scripts/probe.sh</code> asks the running backend for them before the suite " + + "starts, so a gated action begins running <b>by itself</b> the moment the RPC " + + "lands — nothing has to be edited, and nothing has to be remembered.</p>" + + "<p>A gate is only useful if somebody probes it: <code>implemented()</code> cannot " + + "tell “the backend said Unimplemented” from “nobody ever asked”, so an action " + + "gated on an unprobed RPC would self-skip forever and look fine. " + + "<code>runRpc</code> therefore <b>throws</b> on a gate that is missing from the " + + "probe list.</p>" + + "<p>On the timeline a gate is drawn as a cap at the <b>top</b> of its tick.</p>" + + exBox(exGate, exGate && exGate.gate + ? "gate: <code>" + esc([].concat(exGate.gate).join(", ")) + "</code>" : "") + + "</section>"); + + S.push("<section><h4><span class=\"tk\">todo</span> — a design note, not a gap</h4>" + + "<p><b>" + N_TODO + " of " + EVENTS.length + " actions carry one.</b> Free text on " + + "the action: why it is written the way it is, what a reader would otherwise get " + + "wrong, or what to re-specify when a bug it pins gets fixed. Several are " + + "justifications for a decision. It is <b>not</b> a count of gaps, <b>not</b> a " + + "count of failures, and <b>not</b> a to-do list for the product.</p>" + + "<p>Drawn as a cap at the <b>bottom</b> of its tick. One as written:</p>" + + exBox(exTodo, exTodo ? "todo: " + esc(exTodo.todo) : "") + + "</section>"); + + S.push("<section><h4><code>expect</code> — the outcome being asserted</h4>" + + "<p><code>{ok: true}</code> means the call is meant to succeed. " + + "<code>{error: \"PermissionDenied\"}</code> means <b>the refusal IS the passing " + + "outcome</b>: " + N_DENIED + " actions assert an error, and a green run is one " + + "where every one of them was refused.</p>" + + "<p>Which refusal matters. An <b>anonymous</b> caller gets " + + "<code>Unauthenticated</code> (" + (expN.un || 0) + ") and a known caller " + + "who may not do it gets <code>PermissionDenied</code> (" + (expN.pd || 0) + + ") — “who are you” and “not you” are different answers, and telling an anonymous " + + "caller <code>NotFound</code> would let them probe which ids exist.</p>" + + exBox(exPD) + exBox(exUN) + + "<p style=\"margin-top:6px\">" + (expN.err || 0) + " assert some other code " + + "(InvalidArgument, FailedPrecondition, NotFound, AlreadyExists), " + (expN.ok || 0) + + " assert success, and " + (expN.none || 0) + " have no <code>expect</code> block at " + + "all — a browser flow whose steps carry their own assertions.</p></section>"); + + S.push("<section><h4><code>priority</code>, <code>act</code>, <code>t</code></h4><dl>" + + dt("priority") + + "<dd>How load-bearing the action is: <b>P1</b> " + (prioN.P1 || 0) + + ", <b>P2</b> " + (prioN.P2 || 0) + ", <b>P3</b> " + (prioN.P3 || 0) + + ". P1 is the spine of the story.</dd>" + + dt("act") + + "<dd>Which of the nine acts it belongs to. Acts are played in order and every " + + "act is its own segment of the timeline.</dd>" + + dt("t") + + "<dd>Where it sits in the event's own calendar — <code>" + esc(firstT) + + "</code> through <code>" + esc(lastT) + "</code>. It is a label, not a clock: " + + "dates in params are written as <code>{{now+120d}}</code> and resolved at run " + + "time.</dd></dl></section>"); + + S.push("<section><h4><code>actor</code> — who is doing it</h4>" + + "<p>" + ["admin", "principal", "extra", "anon"].map(g => "<b>" + + esc(CAST_STYLE[g].label) + "</b> " + (castN[g] || 0)).join(" · ") + + (castN.none ? " · <b>no actor field</b> " + castN.none : "") + ".</p>" + + "<p>The principals log in through the real Keycloak flow and have browser " + + "sessions; the crowd acts over gRPC with real tokens and no browser. Click any " + + "disc on the diagram for that person's own history.</p>" + + "<p><b>The same name means different things in different suites.</b> In the " + + "seeded smoke fixture alice OWNS the event; in this journey the admin creates it, " + + "alice joins it and votes — organisers may not vote. An action written with the " + + "wrong cast in mind gets the right error code for the wrong reason.</p></section>"); + + S.push("<section><h4><code>save</code> and <code>{{vars}}</code> — how one action feeds the next</h4>" + + "<p>An action can save something out of its response under a name, and later " + + "actions substitute it into their own params. That is why the suite is strictly " + + "<b>serial</b>: the ids live in one worker's memory, so a failure early on leaves " + + "everything downstream with nothing to substitute.</p>" + + exBox(exSave, exSave && exSave.save + ? "save: <code>" + esc(JSON.stringify(exSave.save)) + "</code>" : "") + + exBox(exVar, exVar ? "…and a later action substitutes it." : "") + + "</section>"); + + const mode = curMode(); + S.push("<section class=\"wide\"><h4>What the colours mean right now</h4>" + + "<p>The timeline currently colours by <b>" + esc(mode.label) + "</b> — " + + esc(mode.field) + ". That is a <b>setting</b>: <b>⚙ Colours</b> offers " + + MODES.length + " encodings, the legend under the timeline is rebuilt from " + + "whichever is chosen, and every count in it is tallied off the ticks that were " + + "actually drawn — so the legend cannot describe an encoding other than the one " + + "on screen.</p>" + + "<p><b>None of it is pass/fail.</b> The recipe records intent, not results. " + + (EMBEDDED + ? "The <b>run outcome</b> mode is coloured from a real journey run — the <b>" + + esc(whenText(EMBEDDED)) + "</b> " + esc(EMBEDDED.suite) + " run of " + + EMBEDDED.actions + " actions, embedded in this file. It is a <b>snapshot</b>: " + + "it says nothing about the code as it stands today, and loading your own " + + "report from ⚙ Colours overrides it." + : "For outcomes, load a Playwright JSON report from <b>⚙ Colours</b>; with none " + + "loaded, <b>run outcome</b> honestly colours everything “not in the " + + "report”.") + + "</p>" + + "<p>The diagram itself is arrangeable: drag any item, or Tab to it and nudge it " + + "with the arrow keys (Shift for a bigger step, Backspace to put that one back). " + + "The layout is remembered on this machine; <b>⤺ Reset layout</b> in the header " + + "puts everything back. The dim labelled boxes behind the items name what each " + + "region of the stage is.</p></section>"); + + $("hDoc").innerHTML = S.join(""); +} +let lastFocusHelp = null; +function helpOpen(){ return !$("help").hidden; } +function openHelp(){ + setPlaying(false); + lastFocusHelp = document.activeElement; + buildHelp(); + $("help").hidden = false; + $("hClose").focus(); +} +function closeHelp(){ + $("help").hidden = true; + if (lastFocusHelp && lastFocusHelp.focus) lastFocusHelp.focus(); +} +$("btnHelp").addEventListener("click", openHelp); +$("bHelp").addEventListener("click", openHelp); +$("hClose").addEventListener("click", closeHelp); +$("hback").addEventListener("click", closeHelp); + /* ── an optional Playwright JSON report ─────────────────────────────── The recipe cannot answer "did it pass" — nothing in it records a result — so the only honest way to offer that colouring is to read a real report. @@ -2793,24 +3546,76 @@ <h4>The two fields the legend counts</h4> st.className = "repstat " + kind; st.textContent = text; $("sRepBox").classList.toggle("loaded", kind === "good"); + $("sRepBox").classList.toggle("snap", !!(report && report.source === "embedded")); const d = $("sRepDetail"); if (detail){ d.hidden = false; d.textContent = detail; } else { d.hidden = true; d.textContent = ""; } - $("sRepClear").disabled = !report; -} -function noReportYet(){ - setReportStatus("warn", - "No run report loaded — “run outcome” colours everything as unknown.", ""); + describeReport(); +} +/* the outcome mix, off the same predicate tally the ticks are painted from */ +function repDetail(r){ + const t = tallyByPred(MODE_BY_ID.run); + return "passed " + (t.passed || 0) + " · failed " + (t.failed || 0) + + " · skipped " + (t.skipped || 0) + " · flaky/interrupted " + (t.other || 0) + + " · not in the report " + (t.unknown || 0) + + ". Read from " + r.specs + " specs; a loaded report is not remembered across reloads."; +} +/* One control, and its LABEL says what it will do — three states on one button + is otherwise the kind of thing nobody can predict before clicking it. */ +function describeReport(){ + const clear = $("sRepClear"), when = $("sRepWhen"); + const loaded = !!(report && report.source === "loaded"); + const snap = !!(report && report.source === "embedded"); + clear.disabled = !report; + clear.textContent = loaded && EMBEDDED ? "⤺ Back to the built-in snapshot" + : snap ? "✕ Use no report" : "Clear report"; + clear.title = loaded && EMBEDDED + ? "Forget the file you loaded and colour from the snapshot built into this file again" + : loaded ? "Forget the loaded report" + : snap ? "Colour every tick “not in the report” — the honest answer when nothing has been run" + : "Nothing to clear"; + if (!EMBEDDED){ when.hidden = true; return; } + when.hidden = false; + const tot = EMBEDDED.totals; + when.innerHTML = "Built-in <b>snapshot</b>, not a live result: <b>" + esc(EMBEDDED.suite) + + "</b> run of <b>" + esc(whenText(EMBEDDED)) + "</b> against <b>" + EMBEDDED.actions + + "</b> recipe actions" + + (tot ? " — " + (+tot.passed || 0) + " passed, " + (+tot.failed || 0) + " failed, " + + (+tot.skipped || 0) + " skipped" : "") + + (EMBEDDED.durationMs ? ", in " + Math.round(EMBEDDED.durationMs / 1000) + "s" : "") + + ". " + (snap ? "It is what the colours use now." + : loaded ? "The file you loaded overrides it." + : "It is switched off — nothing is colouring by outcome."); +} +function showReportState(){ + if (!report){ + setReportStatus("warn", + "No run report loaded — “run outcome” colours everything as unknown.", ""); + } else if (report.source === "embedded"){ + setReportStatus("good", + "Using the built-in snapshot of the " + whenText(EMBEDDED) + " " + EMBEDDED.suite + + " run — " + EMBEDDED.matched + " of " + EVENTS.length + " actions matched.", + repDetail(EMBEDDED)); + } else { + setReportStatus("good", + "Loaded " + report.name + " — " + report.matched + " of " + EVENTS.length + + " actions matched.", repDetail(report)); + } } function readReport(file){ const fr = new FileReader(); - fr.onerror = () => { report = null; noReportYet(); - setReportStatus("bad", "Could not read that file.", ""); applyMode(modeId); }; + /* a file that cannot be used leaves the previous colouring ALONE and says + why: blanking the report because someone picked the wrong file in a + dialog is a second failure on top of the first */ + fr.onerror = () => { + setReportStatus("bad", "Could not read that file — nothing changed.", ""); + applyMode(modeId); + }; fr.onload = () => { let json; try { json = JSON.parse(String(fr.result)); } catch (err){ - report = null; - setReportStatus("bad", "That file is not JSON — expected a Playwright JSON report.", ""); + setReportStatus("bad", + "That file is not JSON — expected a Playwright JSON report. Nothing changed.", ""); applyMode(modeId); return; } const status = {}; @@ -2829,20 +3634,15 @@ <h4>The two fields the legend counts</h4> })(json); const matched = EVENTS.filter(a => status[a.id]).length; if (!matched){ - report = null; setReportStatus("bad", "Read " + specs + " specs, but none of their titles start with an action id " - + "— is this a journey report?", ""); + + "— is this a journey report? Nothing changed.", ""); applyMode(modeId); return; } - report = {name: file.name, status: status, specs: specs, matched: matched}; - const t = tallyByPred(MODE_BY_ID.run); - setReportStatus("good", - "Loaded " + file.name + " — " + matched + " of " + EVENTS.length + " actions matched.", - "passed " + (t.passed || 0) + " · failed " + (t.failed || 0) - + " · skipped " + (t.skipped || 0) + " · flaky/interrupted " + (t.other || 0) - + " · not in the report " + (t.unknown || 0) - + ". Read from " + specs + " specs in the file; not remembered across reloads."); + /* a loaded report always outranks the built-in snapshot */ + report = {name: file.name, status: status, specs: specs, matched: matched, + source: "loaded", when: "", suite: "", actions: 0, totals: null, durationMs: 0}; + showReportState(); /* loading a report is an unambiguous request to see outcomes */ applyMode("run", true); }; @@ -2852,11 +3652,15 @@ <h4>The two fields the legend counts</h4> const f = e.target.files && e.target.files[0]; if (f) readReport(f); }); +/* one step back per click: a loaded file falls back to the snapshot, the + snapshot falls back to nothing at all */ $("sRepClear").addEventListener("click", () => { - report = null; $("sRepFile").value = ""; - noReportYet(); applyMode(modeId); + $("sRepFile").value = ""; + report = (report && report.source === "loaded" && EMBEDDED) ? EMBEDDED : null; + showReportState(); + applyMode(modeId); }); -noReportYet(); +showReportState(); /* ── downloading JSONL ─────────────────────────────────────────────── The bytes handed out are the SOURCE LINES kept in RAW_LINE, so the full diff --git a/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs b/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs new file mode 100644 index 00000000..7eeefbb3 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Bake a real journey run into recipe-player.html, so its `run outcome` colour +// mode answers "did it pass" on open instead of "nobody has told me". +// +// bash scripts/run.sh journey # writes .artifacts/results.json +// node scripts/embed-run-report.mjs # splices it in, reduced +// node scripts/embed-run-report.mjs <path-to-a-playwright-json-report> +// +// Note what is NOT needed here: `--reporter=json > report.json`. The json +// reporter is already in playwright.config.ts (it writes +// .artifacts/results.json on every run), and redirecting stdout in this +// container captures the Nix/devenv/quitsh banner ahead of the JSON, so the +// file does not parse. run.sh does forward a --reporter flag if you pass one, +// but the file on disk is the thing to read. +// +// REDUCED, on purpose. The player joins on exactly three things — the action +// id, the outcome bucket and how long it took — while a full report carries +// stdout, attachments, stack frames and error snippets. Those snippets are also +// the hazard: an inline script block ends at the first LITERAL close tag even +// inside a JSON string, so every `</` is escaped here the same way +// splice-player.mjs escapes the recipe. +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const skillDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const playerPath = path.join(skillDir, "recipe-player.html") +const recipePath = path.join(skillDir, "recipe.jsonl") +const reportPath = process.argv[2] || path.join(skillDir, ".artifacts", "results.json") + +const raw = fs.readFileSync(reportPath, "utf8") +if (/^\s*[^[{]/.test(raw)) { + throw new Error( + `${reportPath} does not start with JSON — it looks like captured stdout.\n` + + `Read .artifacts/results.json (written by the json reporter in ` + + `playwright.config.ts) instead of redirecting a run's stdout.`, + ) +} +const report = JSON.parse(raw) + +// the recipe is the authority on which ids exist: a report naming something +// else is a report of another recipe, and would colour ticks that are not there +const ids = new Set( + fs + .readFileSync(recipePath, "utf8") + .split("\n") + .filter((l) => /"id"\s*:/.test(l)) + .map((l) => JSON.parse(l).id), +) + +// recipe.spec.ts titles every test `[<id>] <title>`, which is the join key +const BUCKET = { + passed: "passed", expected: "passed", + failed: "failed", unexpected: "failed", timedOut: "failed", interrupted: "failed", + skipped: "skipped", flaky: "flaky", +} +const status = {} +const totals = { passed: 0, failed: 0, skipped: 0, flaky: 0, other: 0 } +let specs = 0 +let unmatched = 0 +let maxEnd = 0 +;(function walk(node) { + if (!node || typeof node !== "object") return + for (const sp of node.specs || []) { + specs++ + const m = /^\[([^\]]+)\]/.exec(String(sp.title || "")) + if (!m) { unmatched++; continue } + const test = (sp.tests || [])[0] || {} + const results = test.results || [] + const last = results[results.length - 1] || {} + const s = String(last.status || test.status || "unknown") + const dur = Math.round(+last.duration || 0) + if (!ids.has(m[1])) { unmatched++; continue } + status[m[1]] = [s, dur] + const b = BUCKET[s] || "other" + totals[b]++ + const start = Date.parse(last.startTime || "") + if (!isNaN(start)) maxEnd = Math.max(maxEnd, start + dur) + } + for (const s of node.suites || []) walk(s) +})(report) + +const matched = Object.keys(status).length +if (!matched) throw new Error(`no spec title in ${reportPath} starts with a recipe action id`) + +const startedAt = Date.parse(report.stats?.startTime || "") || 0 +const payload = { + kind: "hackagon-journey-run", + suite: "journey", + // the run's own clock, not this script's — a snapshot must be dated by when + // it was PRODUCED, or its date says nothing about the code it describes + generatedAt: new Date(startedAt || Date.now()).toISOString(), + recipeActions: ids.size, + specs, + durationMs: Math.round(report.stats?.duration || (maxEnd && startedAt ? maxEnd - startedAt : 0)), + totals, + status, +} + +const json = JSON.stringify(payload) +const escaped = json.split("</").join("<\\/") + +const html = fs.readFileSync(playerPath, "utf8") +const open = '<script id="run-report" type="application/json">' +const start = html.indexOf(open) +if (start < 0) throw new Error("run-report open marker not found in recipe-player.html") +const bodyStart = start + open.length +const close = "</" + "script>" +const end = html.indexOf(close, bodyStart) +if (end < 0) throw new Error("run-report close marker not found") +fs.writeFileSync(playerPath, html.slice(0, bodyStart) + "\n" + escaped + "\n" + html.slice(end)) + +// read it back and prove it parses in place, and that the file still has the +// three close tags it is supposed to have — a splice nobody verified is how the +// player once showed 10 actions of 274 +const back = fs.readFileSync(playerPath, "utf8") +const s2 = back.indexOf(open) + open.length +const e2 = back.indexOf(close, s2) +const round = JSON.parse(back.slice(s2, e2).trim().split("<\\/").join("</")) +const tags = back.split(close).length - 1 +if (Object.keys(round.status).length !== matched) + throw new Error(`embedded ${Object.keys(round.status).length} entries, expected ${matched}`) +if (tags !== 3) throw new Error(`expected 3 literal close tags in the player, found ${tags}`) + +const pct = (100 * escaped.length) / raw.length +console.log( + `embedded ${matched} of ${ids.size} actions from ${path.basename(reportPath)} — ` + + `${totals.passed} passed / ${totals.failed} failed / ${totals.skipped} skipped` + + (totals.flaky ? ` / ${totals.flaky} flaky` : "") + + (unmatched ? ` (${unmatched} specs not recipe actions)` : ""), +) +console.log( + `reduced ${(raw.length / 1024).toFixed(1)} KiB → ${(escaped.length / 1024).toFixed(1)} KiB ` + + `(${pct.toFixed(1)}% of the report) · run of ${payload.generatedAt} · ` + + `${Math.round(payload.durationMs / 1000)}s · 3 literal close tags`, +) diff --git a/.claude/skills/hackathon-e2e/scripts/run.sh b/.claude/skills/hackathon-e2e/scripts/run.sh index 406c9d85..312806b9 100644 --- a/.claude/skills/hackathon-e2e/scripts/run.sh +++ b/.claude/skills/hackathon-e2e/scripts/run.sh @@ -18,6 +18,15 @@ # --grep <p> filter tests by title # --until-act <n> journey only: play the story up to act <n> and leave the # stack frozen in that state for inspection (1..8) +# --reporter=<r> passed straight to Playwright, as is anything after `--` +# +# On reporters: you almost certainly do not need one. playwright.config.ts +# already runs the json reporter and writes .artifacts/results.json on EVERY +# run, which is what scripts/embed-run-report.mjs reads. Do not do +# `run.sh journey --reporter=json > report.json`: everything in this container +# prints Nix/devenv/quitsh banners to stdout ahead of the test output, so the +# redirected file does not parse. If you want Playwright to write a second copy +# itself, set PLAYWRIGHT_JSON_OUTPUT_NAME and pass --reporter=json. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$HERE/lib.sh" @@ -27,6 +36,10 @@ SUITE="smoke" RESET=1 HEADED=0 GREP="" +# extra flags forwarded verbatim to `playwright test`. --reporter used to hit +# the catch-all below and exit 2, so the documented "run.sh journey +# --reporter=json" failed before it started. +PW_EXTRA=() while [ $# -gt 0 ]; do case "$1" in @@ -41,8 +54,18 @@ while [ $# -gt 0 ]; do shift export JOURNEY_UNTIL_ACT="${1:?--until-act needs an act number (1..8)}" ;; + --reporter=*) PW_EXTRA+=("$1") ;; + --reporter) + shift + PW_EXTRA+=("--reporter=${1:?--reporter needs a value}") + ;; + --) + shift + PW_EXTRA+=("$@") + break + ;; -h | --help) - sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 ;; *) @@ -59,6 +82,7 @@ if [ "$SUITE" = "all" ]; then args=() [ "$HEADED" -eq 1 ] && args+=(--headed) [ -n "$GREP" ] && args+=(--grep "$GREP") + args+=("${PW_EXTRA[@]+"${PW_EXTRA[@]}"}") bash "${BASH_SOURCE[0]}" smoke "${args[@]+"${args[@]}"}" bash "${BASH_SOURCE[0]}" journey "${args[@]+"${args[@]}"}" exit 0 @@ -247,6 +271,7 @@ pnpm exec playwright install --with-deps firefox 2>/dev/null || PW_ARGS=(test --project="$SUITE") [ "$HEADED" -eq 1 ] && PW_ARGS+=(--headed) [ -n "$GREP" ] && PW_ARGS+=(--grep "$GREP") +PW_ARGS+=("${PW_EXTRA[@]+"${PW_EXTRA[@]}"}") # Inside the Nix dev shell, ldd is Nix's glibc ldd whose linker does not # search /usr/lib — Playwright's host validation then reports every system From e06f1f39da1c8e704be8f7e451177ec789533618 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:54:55 +0200 Subject: [PATCH 03/25] fix(dev): stop the stack killing itself, and stop the browse page lying about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults that have been poisoning results for days. None was what it looked like, and the first was not what I described when handing this over. ## 1. A crash loop nobody could see The readiness probes do NOT enter the Nix shell — toolchain.nix calls grpcurl and curl by store path. What enters it is each SERVICE's own exec (`just develop just run`), so the whole Nix wait lands INSIDE the readiness budget, whose clock starts when the command launches, not when a port is bound. A cold backend restart takes 486 s against a quiet lock; `just nix::develop default true` alone is 44 s, 80 s with one competitor. The engine was `frontend` at 54 restarts in 50 minutes: the harness's own built server holds [::1]:8081, so vite can never bind (`Port 8081 is already in use` x63), and uncapped `restart: on_failure` retried forever — one full `nix develop` every ~55 s, which is what starved everything else. It read `Running Ready` throughout, because the readiness probe is a GET of :8081 and the OTHER server answered it. **A port probe cannot say which process holds the port.** And the kill is invisible by design: SIGTERM after the Go handler is up exits 0, so process-compose marks it `Completed` and `on_failure` never restarts it — down forever, reported as a clean stop. Reproduced deterministically both on a synthetic service and on the real one under a 12-way lock hammer. Fixed: backend `restart: always` with `max_restarts: 3` and the threshold raised; frontend capped too; `prod-frontend.sh ensure` now stops vite unconditionally instead of returning early on its fast path; `wait-ready.sh` reads the restart counters back and warns. Verified under identical pressure — restarts settle at 3 and stay, `nix develop` invocations back to zero. ## 2. The build had no lock and no atomic swap Two callers built AND served the same `build/service`. Honest result: two concurrent bare builds did not corrupt anything in testing — it took three people to hit it. The INTERRUPTED build reproduces every time, leaving no entry point, which is the missing `server/index.js` people saw. New lib/frontend-build.sh: exclusive flock with staleness re-checked inside the lock, build into a temp dir, pre-swap check of index.js/handler.js/server/index.js, atomic rename. A build killed 40 s in now leaves the old tree byte-identical and still bootable. Directory rename on the 9p mount intermittently returns EPERM — it retries and rolls back rather than leaving no tree at all. ## 3. The browse page said "no hackathons yet" when it meant "I cannot reach the backend" Two causes: one module-scope channel with grpc-js's default backoff (120 s cap), and `.catch(() => ({ hackathons: [] }))` in both public list loads — with a comment calling an empty list "calm and truthful". It is neither. After a 7-minute outage gRPC answered again in 35 s and the page still showed 0 for a further 51 s while the database held 8. That is why the seed data kept looking destroyed when it was not. Backoff capped at 2 s; both loads carry `listUnavailable` so the page says which it means. check-reconnect.sh is the regression test: page count == gRPC count, outage reported as an outage, recovery now 0 s. Runs: journey 467 passed / 0 failed / 0 skipped, TWICE back to back. Smoke 137/1/2 — the failure is pre-existing and proven so with a control revert to HEAD: 22-hackathon-pages `dragRowTo` computes endY from the destination's box BEFORE the drag while the list reorders live, so downward drags land a row short. Diagnosed in CLAUDE.md, not silently patched. Not fixed, flagged: git-lfs is absent from the container, so three LFS-pointer files read as permanently modified and the worktree is dirty with zero edits — Nix can therefore never reach its clean-revision fast path, which is the 44 s floor under all of the above. The fix is a Dockerfile change, i.e. a container recreate (trap 2), so it is deliberately left to a moment when that is safe. --- .claude/CLAUDE.md | 168 +++++++++++++++- .../cloudflare-tunnel/scripts/prod-serve.sh | 26 ++- .../hackathon-e2e/scripts/check-reconnect.sh | 145 ++++++++++++++ .../hackathon-e2e/scripts/prod-frontend.sh | 60 ++++-- .../hackathon-e2e/scripts/wait-ready.sh | 59 ++++++ .claude/skills/lib/frontend-build.sh | 185 ++++++++++++++++++ .../frontend/src/lib/server/grpc/client.ts | 25 ++- .../src/routes/(public)/+page.server.ts | 35 +++- .../frontend/src/routes/(public)/+page.svelte | 7 +- .../routes/(public)/hackathon/+page.server.ts | 41 +++- .../routes/(public)/hackathon/+page.svelte | 10 +- tools/nix/hackagon/lib/toolchain.nix | 81 +++++++- 12 files changed, 800 insertions(+), 42 deletions(-) create mode 100644 .claude/skills/hackathon-e2e/scripts/check-reconnect.sh create mode 100644 .claude/skills/lib/frontend-build.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fbab9c3a..66e8bd45 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -136,8 +136,8 @@ directories under it are ignored (`node_modules/`, `.state/`, `.artifacts/`, | Suite | Result | When | | --- | --- | --- | -| journey (463-action recipe) | **467 passed / 0 failed / 0 skipped** | 2026-08-12 | -| smoke | **140 passed / 0 failed** | 2026-08-12 | +| journey (463-action recipe) | **467 passed / 0 failed / 0 skipped**, twice back to back | 2026-08-13 | +| smoke | **137 passed / 1 failed / 2 did not run** — see below | 2026-08-13 | | mobile | **121 passed** | 2026-08-10 | | backend `go test ./internal/...` | all ok (service 258 specs) | 2026-08-10 | | openreplay (9 tests) | **13 passed / 0 skipped** | 2026-08-11 | @@ -146,6 +146,25 @@ directories under it are ignored (`node_modules/`, `.state/`, `.artifacts/`, Playwright totals include the 4 auth-setup tests every suite depends on, so journey's 467 is 4 setup + 463 recipe actions. +⚠ **smoke is one short of its baseline, deterministically** (2026-08-13, open): +`22-hackathon-pages.spec.ts:234` "dragging a row saves the whole new order in one +write". Its first drag (bottom row to the top) passes; the RESTORE drag — the +same row, now at the top, dragged back to the bottom — lands one position short, +`[Welcome, Rules & Guidelines, Schedule]` where `[Welcome, Schedule, Rules & +Guidelines]` was asked for. The two tests after it are the rest of a +`mode: "serial"` describe, so they never run: 137 + 1 + 2 = 140. + +It is test-side, and the cause is in `dragRowTo` (same file, ~line 74): `endY` is +computed from the DESTINATION row's bounding box **before the drag starts**, +while the list reorders live on `dragover`. Moving DOWN, everything below the +lifted row shifts up by one row height, so the pointer arrives at what has become +the middle row — moving UP shifts the other way and the pre-computed centre still +lands inside the intended row, which is exactly why one direction passes and the +other does not. Recompute the destination box mid-drag, or aim past its far edge. +**Not caused by the 2026-08-13 infrastructure work**: reverting those five +frontend files to HEAD, rebuilding and re-running the one test reproduces it +unchanged. + ⚠ **`mode: "serial"` in `tests/journey/recipe.spec.ts` is load-bearing for STATE, not just for stopping at the first failure.** Without it Playwright tears the worker down after a failing test — and `vars` (hackathonId, team ids, saved @@ -516,6 +535,151 @@ checked-in configs keep working). Opt-in: `just up` still starts devenv's copies and they would fight over ports. Note `postgres:18+` wants a single mount at `/var/lib/postgresql`, not `/var/lib/postgresql/data`. +**4. `nix develop` is a GLOBAL MUTEX on this repo, and every service in the +stack goes through it** (fixed 2026-08-13). This is the one that poisoned +several days of test results, and it never once looked like an infrastructure +problem — it looked like product bugs, at four different places in four runs. + +The worktree here is permanently dirty, so every `nix develop` re-fetches and +re-hashes the whole tree, holding a repo-wide lock while it does +(`waiting for another Nix process to finish fetching input +'git+file:///workspaces/hackagon'…`). Measured: **44 s to enter that shell +unopposed, 80 s with one competitor.** And the stack's own processes are +`just develop just run` / `just develop just serve`, so entering that shell is +inside every service's startup — while process-compose's readiness clock is +already running. **The probe budget is spent waiting for Nix, not on the +server.** (The probes themselves are fine; they are `${pkgs.grpcurl}/bin/grpcurl` +and `${pkgs.curl}/bin/curl` by store path and enter no shell.) + +⚠ **"Permanently dirty" is not about your edits — it is true with ZERO changes, +and the cause is that `git-lfs` is not installed in this container.** Three +files are LFS pointers in HEAD (`components/frontend/static/favicon.png`, +`static/og-default.jpg`, `tools/configs/keycloak/.../img/favicon.ico`) and hold +their real bytes in the worktree, smudged by the Windows host the workspace is +bind-mounted from. Inside `dev` there is no `filter.lfs.smudge`, so git compares +pointer against content and reports all three modified, always. Nix therefore +never reaches its clean-revision fast path, and the 44 s floor above is the +normal state rather than a consequence of active work. **Not fixed:** the durable +form is `git-lfs` in `.devcontainer/Dockerfile`, which is a container recreate +(trap 2), and any development session dirties the tree anyway. Worth doing next +time the image is rebuilt for another reason. The host's own git reports those +files clean, which is why nobody saw it. + +What that budget actually was: probes land ~15 s apart (process-compose's default +period), so the backend's `failure_threshold: 50` was ~12.7 min — against a +**COLD backend restart measured at 486 s on a quiet lock.** 64% of the budget +spent before one competitor is added, each competitor costing ~+36 s. + +When the budget runs out process-compose **kills the service**, and both of its +two possible endings are bad. Reproduced on the real `just develop just run` +with the budget scaled down: + +| how the SIGTERM lands | exit | `restart: on_failure` does | result | +| --- | --- | --- | --- | +| the Go signal handler is up | **0** | nothing — 0 is not a failure | **down forever**, recorded as `Completed` | +| it lands before the handler | **143** | restarts, uncapped | **149 restarts in 151 s** = one `nix develop` per second | + +The first is what the logs showed in the wild: `grpc server listening`, then +`received shutdown signal`, then `exit_code=0` — which reads like a clean stop +and is a kill. Downstream it was mid-run `NS_ERROR_CONNECTION_REFUSED`, a +`reset.sh` that printed "State wiped" while data survived, and a stack needing +manual restarts. + +**What generated the contention was a crash loop nobody could see.** Found live: +process-compose's `frontend` at **54 restarts in 50 minutes**, exit 1, +`Error: Port 8081 is already in use` — because the harness's own adapter-node +server holds `[::1]:8081` (that is its job, trap 2b) and nothing had put vite +down. `prod-frontend.sh ensure`'s fast path ("the built frontend already serves +:8081 — leaving it alone") returned without touching process-compose, so the +loop ran forever, one full `nix develop` per round. + +⚠ **And `process list` said `frontend Running Ready` throughout.** Its readiness +probe is `curl http://localhost:8081`, which the OTHER server was answering. **A +probe on a PORT cannot tell you which PROCESS holds it** — this is the +infrastructure member of the silent-green family above, and the same trap bit the +reproduction rig itself (a leftover scratch backend on :3001 made a run report +Ready in 10 s having tested nothing). The `RESTARTS` column said 54 the whole +time and nothing read it. + +Four changes, no compose change and no rule to remember: + +- `prod-frontend.sh`'s `ensure` calls `stop_vite` **unconditionally** — it is the + built server that gets left alone, never vite. +- `toolchain.nix` frontend: `max_restarts = 3`, so a port conflict costs three + shell entries rather than one an hour. +- `toolchain.nix` backend: `restart = "always"` **plus `max_restarts = 3`** + (`always` alone converts a permanent outage into an unbounded loop — that is + the 149-restarts row), and `failure_threshold` 50 → 150 (~37 min). A generous + budget costs nothing when healthy, because probing stops at the first success, + and **the thing that should decide "the backend did not come up" is + `wait-ready.sh`'s own 300 s timeout, which names the service** — not a + supervisor whose only move is to kill a server that was merely slow. +- `wait-ready.sh` now **reads the restart counters back** and warns, with the + exit code, when any service is ≥3. The number was always there. + +**5. Two concurrent `pnpm build`s corrupt `components/frontend/build/service`** +(fixed 2026-08-13; hit by three agents in one day). `pnpm build` is +`vite build -m production`, `svelte.config.js` sends adapter-node's output to +`${QUITSH_BUILD_DIR:-build}/service`, and there were **two independent callers +that both build AND SERVE that one tree** — `hackathon-e2e/prod-frontend.sh` on +:8081 and `cloudflare-tunnel/prod-serve.sh` on :8082. So they do not merely race +to build it, they race to replace it while the other is serving it. Symptoms: +`Unexpected end of JSON input`, then a missing `build/service/server/index.js` at +boot. + +Both callers now go through **`.claude/skills/lib/frontend-build.sh`**, which +does two things for two different holes: an **exclusive `flock`**, so two builds +cannot interleave and the second caller waits and then finds the first one's +fresh output (staleness is re-checked INSIDE the lock — checking it outside is +how both callers decide to build); and a **build into a temp dir + atomic swap**, +so `build/service` only ever contains a complete tree. The lock cannot help with +the second: an interrupted build's writer is gone, not concurrent, and what it +had written so far stays there looking like a build. `if-stale` is the entry +point for the harness, `build` for an unconditional rebuild. + +Two things measured while building that, both worth keeping: + +- **Two concurrent bare builds did NOT reliably corrupt anything** — one attempt + with a 5 s stagger left an intact tree, because adapter-node's copy phase is + short and the two missed each other. That is consistent with it taking three + people in one day to hit; it is a narrow window, not a certainty. The + *interrupted* build reproduces every time, which is why the atomic swap is the + half with a deterministic proof: killed at the instant `build/service/index.js` + was gone, the tree was left with no entry point; through the helper that window + **never opens at all**, and a build killed 40 s in leaves `build/service` with + the same inode it had before. +- ⚠ **A directory rename on the 9p bind mount intermittently answers EPERM** + (`mv: cannot move '…/build/service' to '…/build/.service-old-352884': + Permission denied`), and it is NOT open descriptors — the same rename succeeded + a minute later with the same servers running and nothing open under the tree. + The swap therefore retries, and rolls the old tree back if the second rename + fails, so `build/service` is never left missing. Anything else here that + renames a directory on this mount needs the same treatment. + +**6. An empty list is not an answer — say "I could not ask"** (fixed +2026-08-13). The built :8081 server keeps ONE module-scope gRPC channel +(`lib/server/grpc/client.ts`) for its whole life. grpc-js does reconnect, but on +a backoff that grows to a **120 s cap**, and every RPC issued while it waits +fails immediately — so a backend that was down for a few minutes leaves the app +serving errors for up to two more minutes AFTER the backend is demonstrably +healthy. The browse page then rendered **0 events while `grpcurl` returned 8 from +the same database.** + +That alone was survivable; what cost the hours was the page's own load doing +`.catch(() => ({ hackathons: [] }))`, with a comment calling an empty list "a +calm and truthful thing for a visitor to read during an outage". Calm, yes; +truthful, no — **"the database is empty" and "I cannot reach the backend" became +the same page**, and in a container where every run wipes and reseeds the +database, that is the most expensive confusion available. + +Both halves fixed: the channel caps its reconnect backoff at 2 s (a failed +connect on loopback costs nothing), and the load carries `listUnavailable` so the +page says which of the two it is. The regression test is +**`hackathon-e2e/scripts/check-reconnect.sh`** — restart the backend under a +running :8081, assert the browse page lists its events again, *and* assert that +while the backend is down the page says unavailable rather than empty. Without +that second assertion half the script passes against a page that is lying. + ## The tunnel's auth wiring (why login kept breaking) `run.sh` unwires the tunnel before a suite — every persona logs in over diff --git a/.claude/skills/cloudflare-tunnel/scripts/prod-serve.sh b/.claude/skills/cloudflare-tunnel/scripts/prod-serve.sh index 5a0e553d..26299028 100644 --- a/.claude/skills/cloudflare-tunnel/scripts/prod-serve.sh +++ b/.claude/skills/cloudflare-tunnel/scripts/prod-serve.sh @@ -243,8 +243,15 @@ cmd_start() { fi if [ "$build" -eq 1 ]; then - echo "==> Building the frontend (pnpm run build:prod)..." - (cd "$FRONTEND_DIR" && pnpm run build:prod) + # Through the shared serializer, NOT a bare `pnpm build`. This script and + # hackathon-e2e/scripts/prod-frontend.sh both build AND SERVE the same + # `build/service` tree (this one on :8082, that one on :8081), so they do + # not merely race to build it — they race to replace it while the other is + # serving it. Two concurrent builds into that one directory corrupted it + # three times in one day: `Unexpected end of JSON input`, then a missing + # build/service/server/index.js at boot. The helper takes an exclusive lock + # and swaps a complete tree into place. + bash "$ROOT_DIR/.claude/skills/lib/frontend-build.sh" build fi if [ ! -f "$FRONTEND_DIR/$SERVER_ENTRY" ]; then echo "error: $SERVER_ENTRY missing — run without --no-build." >&2 @@ -387,14 +394,15 @@ cmd_ensure() { # The bundle is a snapshot of src/, so it has to be rebuilt when src/ moved # under it — but rebuilding a current one costs ~40s of a tunnel handover for -# nothing. Same freshness test as hackathon-e2e/scripts/prod-frontend.sh. +# nothing. +# +# The test lives in the shared builder now, so this script and +# hackathon-e2e/scripts/prod-frontend.sh cannot drift apart on what "stale" +# means — they build and serve the SAME build/service tree, and two callers +# disagreeing about whether it needs rebuilding is one of them rebuilding it +# under the other. bundle_is_stale() { - [ -f "$FRONTEND_DIR/$SERVER_ENTRY" ] || return 0 - local newer - newer="$(cd "$FRONTEND_DIR" && - find src static package.json pnpm-lock.yaml svelte.config.js vite.config.ts \ - -newer "$SERVER_ENTRY" -print -quit 2>/dev/null || true)" - [ -n "$newer" ] + bash "$ROOT_DIR/.claude/skills/lib/frontend-build.sh" stale } start_with_current_bundle() { # <origin> diff --git a/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh b/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh new file mode 100644 index 00000000..b3ad6de7 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# THE regression test for "the frontend cannot see the seed data". +# +# Restart the backend under a running :8081, then assert the browse page still +# lists its events. +# +# Why this is a test of its own rather than a Playwright spec: the subject is a +# process-level fact (one module-scope gRPC channel in the built server, outliving +# a backend restart), it needs to STOP a service the rest of the suite depends on, +# and the assertion is a count in HTML — no browser adds anything. Running it +# inside smoke would perturb 140 tests to check one. +# +# WHAT WENT WRONG, so the shape of the check is not mysterious. On 2026-08-13 the +# browse page rendered ZERO events while grpcurl returned eight from the same +# database. Two independent causes, both fixed, both regressions worth catching: +# +# 1. lib/server/grpc/client.ts creates ONE channel at module load. grpc-js +# reconnects on its own but on a backoff that doubles to a 120s cap, and +# every RPC issued while it waits fails immediately. Measured with a 7-minute +# outage: the page was still wrong 51 SECONDS after the backend was +# demonstrably healthy, and the lag grows with the outage toward that cap. +# The channel now caps the backoff at 2s; the same measurement is 0s. +# 2. The page's load turned any error into `hackathons: []`, which renders "No +# hackathons have been published yet." — so "the database is empty" and "I +# cannot reach the backend" were the same page. It now carries +# `listUnavailable` and says which. +# +# The check asserts BOTH: the count comes back, and while the backend is down the +# page says unavailable rather than empty. The second half is the one that would +# have saved the hours — without it, half of this script's assertions pass on a +# page that is lying. +# +# Usage: check-reconnect.sh (needs a stack up, seeded, and :8081 serving) +set -euo pipefail +trap 'echo "check-reconnect.sh: aborted at line $LINENO (status $?)" >&2' ERR +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +PC_SOCKET_FILE="$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" +SOCK="$(cat "$PC_SOCKET_FILE" 2>/dev/null || true)" +BROWSE="$FRONTEND_URL/hackathon" +FAILED=0 + +pc() { process-compose --unix-socket "$SOCK" "$@" >/dev/null 2>&1; } + +# One <a href="/hackathon/{uuid}"> per listed event. +page_html() { curl -fsS --max-time 15 "$BROWSE" 2>/dev/null || true; } +page_count() { + page_html | grep -oE 'href="/hackathon/[0-9a-f-]{36}"' | sort -u | wc -l | tr -d ' ' +} +# jq, not a grep for `"id"`. Every hackathon in this response also carries +# nested ids (capability modifiers, creator, …), so a naive count reported 16 for +# 8 events — and an indentation-anchored grep reported 0, which this script's own +# guard would have read as "nothing to test" and exited cleanly having tested +# nothing. Ask the structure. +rpc_count() { + grpcurl -plaintext -d '{"visibility_filter":1}' "$GRPC_ADDR" \ + hackathon.HackathonService/List 2>/dev/null | + jq -r '.hackathons | length' 2>/dev/null || echo 0 +} + +fail() { + echo " ✗ $1" >&2 + FAILED=1 +} + +if [ -z "$SOCK" ] || [ ! -S "$SOCK" ]; then + echo "error: no process-compose socket — start the stack first (scripts/up.sh)." >&2 + exit 1 +fi + +echo "==> Baseline" +BEFORE_RPC="$(rpc_count)" +BEFORE_PAGE="$(page_count)" +echo " gRPC lists $BEFORE_RPC public events; the browse page renders $BEFORE_PAGE" +if [ "$BEFORE_RPC" -eq 0 ]; then + echo "error: no public hackathons to check against — seed the instance first." >&2 + echo " (An assertion whose subject is absent verifies nothing.)" >&2 + exit 1 +fi +[ "$BEFORE_PAGE" -eq "$BEFORE_RPC" ] || + fail "before any restart the page already disagrees with gRPC ($BEFORE_PAGE vs $BEFORE_RPC)" + +echo "==> Stopping the backend" +pc process stop backend +for _ in $(seq 1 15); do + [ "$(rpc_count)" -eq 0 ] && break + sleep 1 +done + +echo "==> While the backend is down the page must say UNAVAILABLE, not EMPTY" +DOWN_HTML="$(page_html)" +if echo "$DOWN_HTML" | grep -q 'data-testid="listUnavailable"'; then + echo " ✓ the page reports the outage" +else + if echo "$DOWN_HTML" | grep -q "No hackathons have been published yet"; then + fail "the page claims an EMPTY PLATFORM while the backend is down — this is the bug" + elif [ -z "$DOWN_HTML" ]; then + fail "the page did not render at all while the backend was down (it used to degrade)" + else + fail "the page neither reported the outage nor rendered the empty state" + fi +fi + +echo "==> Starting the backend" +pc process start backend +S=$(date +%s) +while :; do + [ "$(rpc_count)" -gt 0 ] && break + if [ $(($(date +%s) - S)) -gt 600 ]; then + echo "error: the backend did not come back within 600s — not a channel problem." >&2 + exit 1 + fi + sleep 5 +done +HEALTHY_AT=$(date +%s) +echo " gRPC answers again after $((HEALTHY_AT - S))s" + +# THE assertion. The lag allowed here is a channel reconnect, not a boot: the +# capped backoff is 2s, so 60s is ~30x headroom and still an order of magnitude +# below the 120s default that caused the incident. If this ever needs raising, +# the channel options in client.ts regressed — raise those, not this. +echo "==> The page must agree with gRPC again" +LAG=-1 +for _ in $(seq 1 20); do + if [ "$(page_count)" -eq "$(rpc_count)" ] && [ "$(page_count)" -gt 0 ]; then + LAG=$(($(date +%s) - HEALTHY_AT)) + break + fi + sleep 3 +done +if [ "$LAG" -lt 0 ]; then + fail "the page still disagrees with gRPC 60s after the backend was healthy (page=$(page_count) grpc=$(rpc_count)) — the channel did not reconnect" +else + echo " ✓ the page recovered ${LAG}s after the backend was healthy" +fi + +if [ "$FAILED" -ne 0 ]; then + echo "" + echo "FAILED — a backend restart is visible to users of :8081." >&2 + exit 1 +fi +echo "" +echo "PASSED — a backend restart heals itself, and an outage never reads as an empty platform." diff --git a/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh index da41abca..ba2581eb 100644 --- a/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh +++ b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh @@ -55,7 +55,7 @@ FRONTEND_DIR="$ROOT_DIR/components/frontend" ENTRY="build/service/index.js" PIDFILE="$ROOT_DIR/.output/run/e2e-prod-frontend.pid" LOG="$ROOT_DIR/.output/run/e2e-prod-frontend.log" -BUILD_LOG="$ROOT_DIR/.output/run/e2e-prod-frontend-build.log" +# The build log belongs to the build, which is shared — see FRONTEND_BUILD below. # Written by `just deploy::up` (tools/deploy/process-compose/justfile); holds # the path of the process-compose control socket. PC_SOCKET_FILE="$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" @@ -118,6 +118,25 @@ resolve_pid() { return 0 } +# Put process-compose's `frontend` (vite) DOWN and keep it down. +# +# This is not tidiness — an un-stopped vite next to our server on :8081 is the +# single most expensive failure mode this container has. vite cannot bind, exits +# 1, and `availability.restart` sends it round again; each round is a full +# `just develop` = `nix develop` on a DIRTY worktree, which takes the repo-wide +# fetch lock on `git+file:///workspaces/hackagon`. Measured 2026-08-13: 44 s to +# enter that shell unopposed, 80 s with one competitor, and a stack found in +# this state had 54 restarts in 50 minutes — a lock acquisition every ~55 s, +# forever. Everything else that enters the shell then queues behind it: the +# backend's own start command is `just develop just run`, and its readiness +# budget is spent WAITING FOR NIX rather than on the server. When the budget +# runs out process-compose SIGTERMs it, the Go server shuts down gracefully, +# exit code 0 — which `restart: on_failure` does not consider a failure, so the +# backend stays down and everything downstream reads as connection refused. +# +# It is invisible from `process list`, which reported `frontend Running Ready` +# throughout: the readiness probe is `curl http://localhost:8081` and OUR server +# was answering it. The probe measures the PORT, not the PROCESS. stop_vite() { local sock sock="$(cat "$PC_SOCKET_FILE" 2>/dev/null || true)" @@ -158,13 +177,18 @@ stop() { # vite served source; the build is a snapshot, so it has to be rebuilt when the # source moved under it. Skipping this is how a suite silently tests yesterday's # frontend and reports green. +# +# Both the question and the build now live in .claude/skills/lib/frontend-build.sh, +# because this script is not the only caller: cloudflare-tunnel/prod-serve.sh +# builds and serves the SAME build/service tree on :8082. Two concurrent +# `pnpm build`s into one output directory is not a theoretical race — it +# corrupted that tree three times in one day (`Unexpected end of JSON input`, +# then a missing build/service/server/index.js at boot). The helper holds an +# exclusive lock and swaps a COMPLETE tree into place; nothing here needs to +# know that, which is the point. +FRONTEND_BUILD="$ROOT_DIR/.claude/skills/lib/frontend-build.sh" needs_build() { - [ -f "$FRONTEND_DIR/$ENTRY" ] || return 0 - local newer - newer="$(cd "$FRONTEND_DIR" && - find src static package.json pnpm-lock.yaml svelte.config.js vite.config.ts \ - -newer "$ENTRY" -print -quit 2>/dev/null || true)" - [ -n "$newer" ] + bash "$FRONTEND_BUILD" stale } launch() { @@ -196,13 +220,12 @@ start() { mkdir -p "$(dirname "$PIDFILE")" stop - if needs_build; then - echo "==> Building the frontend (build/ is missing or older than src/)..." - if ! (cd "$FRONTEND_DIR" && pnpm build) >"$BUILD_LOG" 2>&1; then - echo "error: pnpm build failed — see $BUILD_LOG" >&2 - tail -30 "$BUILD_LOG" >&2 - return 1 - fi + # `if-stale` re-asks the question INSIDE the lock, so two harnesses starting at + # once produce one build and the loser serves it rather than rebuilding over + # the winner. Do not hoist the staleness check back out here. + if ! bash "$FRONTEND_BUILD" if-stale; then + echo "error: the frontend build failed — see $ROOT_DIR/.output/run/frontend-build.log" >&2 + return 1 fi for attempt in 1 2 3; do @@ -241,6 +264,15 @@ case "${1:-ensure}" in # and current, is left alone. if ours_is_up && serving && ! needs_build; then echo "==> The built frontend already serves :$PORT — leaving it alone." + # "Leaving it alone" is about OUR server, never about vite. This branch + # used to return without touching process-compose at all, and that is the + # whole of how the crash loop documented above survived: `just deploy::up` + # starts vite on every boot, our server already holds :8081 whenever a + # previous run left one up (the common case — nothing stops it between + # runs), vite therefore exits 1 and is restarted forever, and this fast + # path was the one place that would have stopped it. `stop_vite` is + # idempotent and costs one socket call, so it is unconditional now. + stop_vite else start "${1:-}" fi diff --git a/.claude/skills/hackathon-e2e/scripts/wait-ready.sh b/.claude/skills/hackathon-e2e/scripts/wait-ready.sh index ff10cc0a..678f5fd6 100644 --- a/.claude/skills/hackathon-e2e/scripts/wait-ready.sh +++ b/.claude/skills/hackathon-e2e/scripts/wait-ready.sh @@ -8,6 +8,62 @@ ensure_toolchain "${BASH_SOURCE[0]}" "$@" TIMEOUT="${E2E_READY_TIMEOUT:-300}" +# A crash-looping service is not a "ready" stack, and process-compose will not +# tell you unasked. +# +# On 2026-08-13 this stack ran for 50 minutes with its `frontend` process at 54 +# restarts — vite exiting 1 on "Port 8081 is already in use" roughly once a +# minute, each round a full `just develop`, i.e. one acquisition of the repo-wide +# `git+file:///workspaces/hackagon` fetch lock. That starved every other startup +# on the machine; the backend's own start command is `just develop just run` and +# its readiness budget is spent WAITING FOR NIX. When the budget ran out +# process-compose SIGTERMed a perfectly healthy backend, which exited 0, which +# `restart: on_failure` did not treat as a failure — so it stayed down and the +# suite reported connection-refused failures at four different points in four +# runs. +# +# Nothing reported any of it. `process list` said `frontend Running Ready` +# because the readiness probe is a GET of :8081 and the OTHER server was +# answering — a probe on a PORT cannot say which PROCESS holds it. The RESTARTS +# column said 54 the whole time and no one read it. This reads it. +# +# A warning rather than a hard failure: a restart or two during boot is normal +# (`restart: on_failure` retrying a service whose dependency was a second late), +# and refusing to run the suite over that would be its own kind of flake. What is +# never normal is a number that keeps climbing, so print the count AND the exit +# code, which together name the cause. +report_restarts() { + local sock pc line name restarts exitcode noisy=0 + sock="$(cat "$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" 2>/dev/null || true)" + [ -n "$sock" ] && [ -S "$sock" ] || return 0 + command -v process-compose >/dev/null 2>&1 || return 0 + pc="$(process-compose --unix-socket "$sock" process list -o wide 2>/dev/null || true)" + [ -n "$pc" ] || return 0 + while read -r line; do + name="$(echo "$line" | awk '{print $2}')" + # RESTARTS and EXITCODE are the LAST TWO columns, counted from the end on + # purpose: HEALTH is "Not Ready" — TWO whitespace-separated words — for every + # service that is starting up, so fixed field numbers ($6/$7) read the health + # text as the restart count on exactly the rows worth reading. Counting from + # NF is stable across both widths, and "Disabled" rows (`- -`) too. + restarts="$(echo "$line" | awk '{print $(NF - 1)}')" + exitcode="$(echo "$line" | awk '{print $NF}')" + case "$restarts" in '' | *[!0-9]*) continue ;; esac + if [ "$restarts" -ge 3 ]; then + echo " ⚠ $name has restarted $restarts times (last exit code $exitcode)." >&2 + noisy=1 + fi + done <<EOF +$(echo "$pc" | awk 'NR>1 && NF>=7') +EOF + if [ "$noisy" -eq 1 ]; then + echo " ⚠ A service that keeps restarting is a service whose every attempt" >&2 + echo " re-enters the Nix dev shell and takes the repo-wide fetch lock," >&2 + echo " which starves the startup of everything else. Check its log under" >&2 + echo " .output/run/process-compose/ before trusting this run's results." >&2 + fi +} + echo "==> Waiting for the stack to be ready (timeout ${TIMEOUT}s per service)..." wait_for "postgres" "$TIMEOUT" pg_isready -h 127.0.0.1 -p 5432 -U postgres wait_for "keycloak" "$TIMEOUT" curl -fsS \ @@ -36,4 +92,7 @@ if ! wait_for "frontend" "$TIMEOUT" curl -fsS --max-time 10 "$FRONTEND_URL"; the echo " cd components/frontend && just serve" >&2 exit 1 fi +# After everything answers, not before: a restart count taken mid-boot is mostly +# noise, and the question this answers is "is what I am about to test stable". +report_restarts echo "==> Stack is ready." diff --git a/.claude/skills/lib/frontend-build.sh b/.claude/skills/lib/frontend-build.sh new file mode 100644 index 00000000..c98a42fe --- /dev/null +++ b/.claude/skills/lib/frontend-build.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# THE ONE WRITER of components/frontend/build/service. +# +# frontend-build.sh build # build, unconditionally +# frontend-build.sh if-stale # build only if src/ moved under the last build +# frontend-build.sh stale # exit 0 when a build is needed (no build) +# +# WHY THIS EXISTS. +# +# `pnpm build` in components/frontend is `vite build -m production`, and +# svelte.config.js sends adapter-node's output to +# `${QUITSH_BUILD_DIR:-build}/service`. Nothing in that chain is atomic and +# nothing in it is exclusive, so two builds running at once write the same tree +# — and vite also shares `.svelte-kit/output` between them. Observed +# 2026-08-13, three agents driving the harness concurrently: +# +# Unexpected end of JSON input (a half-written manifest read back) +# Cannot find module '…/build/service/server/index.js' (at server boot) +# +# There were TWO independent callers before this file existed, both writing +# `build/service` with no coordination whatsoever: +# +# hackathon-e2e/scripts/prod-frontend.sh serves that tree on :8081 +# cloudflare-tunnel/scripts/prod-serve.sh serves the SAME tree on :8082 +# +# so the two servers do not merely race to build it, they race to build it out +# from under each other while serving it. +# +# The fix is both halves, because they close different holes: +# +# 1. an exclusive LOCK, so two builds cannot interleave, and so the second +# caller waits and then discovers the first caller's fresh output instead +# of redoing it (staleness is re-checked INSIDE the lock — checking it +# outside is how both callers decide to build); +# +# 2. an atomic SWAP, so `build/service` only ever contains a COMPLETE tree. +# A build is minutes long on the 9p mount and gets interrupted (Ctrl-C, a +# container recreate, a suite timeout); without this, whatever it had +# written so far stays there looking like a build, and the next server to +# boot dies on a missing or truncated file. The lock alone cannot help +# with that — the writer is gone, not concurrent. +# +# Callers should not have to know any of this, which is why staleness lives here +# too rather than being reimplemented per caller. +set -euo pipefail +trap 'echo "frontend-build.sh: aborted at line $LINENO (status $?)" >&2' ERR + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$HERE/../../.." && pwd)" +FRONTEND_DIR="$ROOT_DIR/components/frontend" +OUT_PARENT="$FRONTEND_DIR/build" +OUT="$OUT_PARENT/service" +ENTRY="$OUT/index.js" +RUN_DIR="$ROOT_DIR/.output/run" +LOCK="$RUN_DIR/frontend-build.lock" +BUILD_LOG="$RUN_DIR/frontend-build.log" +# Long, because the thing being waited for is a full production build of this app +# on a 9p bind mount. Measured 2026-08-13: ~2 min warm. A caller that gives up +# early is a caller that builds concurrently, which is the bug. +LOCK_WAIT="${FRONTEND_BUILD_LOCK_WAIT:-900}" + +mkdir -p "$RUN_DIR" + +# True when src/ (or the build's own inputs) moved since the last build. Same +# rule prod-frontend.sh used to carry inline; a stale build is worse than no +# build, because a suite then reports green against yesterday's frontend. +stale() { + [ -f "$ENTRY" ] || return 0 + local newer + newer="$(cd "$FRONTEND_DIR" && + find src static package.json pnpm-lock.yaml svelte.config.js vite.config.ts \ + -newer "$ENTRY" -print -quit 2>/dev/null || true)" + [ -n "$newer" ] +} + +# Build into a private directory, then move it into place. +# +# QUITSH_BUILD_DIR is read by svelte.config.js (`out = $QUITSH_BUILD_DIR/service`), +# so the temp tree needs no config change — and it stays inside `build/`, which +# is gitignored, so an interrupted build cannot dirty the worktree either. That +# matters more here than it looks: a dirty worktree is what makes every +# `nix develop` in this repo re-fetch and re-hash the tree under a global lock. +build_locked() { + local tmp="$OUT_PARENT/.build-$$" + # Sweep any temp trees a KILLED build left behind. Safe here and only here: we + # hold the lock, so no live build owns one. Without this they accumulate — the + # whole point of building elsewhere is that an interrupted build leaves its mess + # somewhere nothing reads, but somewhere is still on disk. + rm -rf "$OUT_PARENT"/.build-* "$OUT_PARENT"/.service-old-* + mkdir -p "$tmp" + + echo "==> Building the frontend (exclusive; log: $BUILD_LOG)..." + if ! (cd "$FRONTEND_DIR" && QUITSH_BUILD_DIR="$tmp" pnpm build) >"$BUILD_LOG" 2>&1; then + echo "error: pnpm build failed — see $BUILD_LOG" >&2 + tail -30 "$BUILD_LOG" >&2 + rm -rf "$tmp" + return 1 + fi + # Check the tree BEFORE swapping it in, and check the file that actually went + # missing. "vite exited 0" and "the server can boot" are different claims — the + # reported failure was `Cannot find module …/build/service/server/index.js`, at + # boot, from a build that had reported success. Nothing incomplete gets to + # replace a working tree. + local f + for f in index.js handler.js server/index.js; do + if [ ! -f "$tmp/service/$f" ]; then + echo "error: build reported success but $tmp/service/$f is missing —" >&2 + echo " refusing to swap it over the working build. See $BUILD_LOG." >&2 + rm -rf "$tmp" + return 1 + fi + done + + # Two renames on one filesystem, old tree out of the way first, so the window + # in which `build/service` does not exist is a single rename long. Callers + # start their server after this function returns, so nothing reads it during + # the swap. + # + # RETRIED, because `build/` is on the 9p bind mount and a directory rename + # there intermittently answers EPERM: + # + # mv: cannot move '…/build/service' to '…/build/.service-old-352884': + # Permission denied + # + # Observed 2026-08-13 mid-run and NOT reproducible a minute later with the same + # processes running and no open descriptors anywhere under the tree — so it is + # the filesystem, not a lock we could take or a handle we could close. An + # abort here is safe (the working tree is untouched) but it fails a build for a + # reason that clears itself, which is its own kind of flake. + local old="$OUT_PARENT/.service-old-$$" i + rm -rf "$old" + for i in 1 2 3 4 5; do + [ -d "$OUT" ] || break + mv "$OUT" "$old" 2>/dev/null && break + sleep 2 + done + if [ -d "$OUT" ]; then + echo "error: could not move $OUT aside after 5 attempts (9p EPERM — see above)." >&2 + echo " The existing build is untouched; the new one is in $tmp." >&2 + return 1 + fi + # If THIS one fails the tree would be missing entirely, which is the exact + # state this whole file exists to prevent — put the old one back. + if ! mv "$tmp/service" "$OUT"; then + if [ -d "$old" ]; then mv "$old" "$OUT"; fi + echo "error: could not move the new build into place; restored the previous one." >&2 + return 1 + fi + rm -rf "$old" "$tmp" + echo " built" +} + +case "${1:-if-stale}" in + stale) + # No lock: a read-only question, and the answer is only ever used to decide + # whether to CALL this script, which locks properly. `exit`, not a bare + # `stale`, so "not stale" is an ANSWER rather than an aborted script — the + # ERR trap above would otherwise print a scary line for the normal case. + if stale; then exit 0; else exit 1; fi + ;; + build) + flock -w "$LOCK_WAIT" 9 || { + echo "error: another frontend build held the lock for ${LOCK_WAIT}s" >&2 + exit 1 + } + build_locked + ;; + if-stale) + flock -w "$LOCK_WAIT" 9 || { + echo "error: another frontend build held the lock for ${LOCK_WAIT}s" >&2 + exit 1 + } + # INSIDE the lock. Whoever waited here was very likely waiting for exactly + # the build it wanted; re-asking is what turns N concurrent builds into one. + if stale; then + build_locked + else + echo "==> The frontend build is current — nothing to do." + fi + ;; + *) + echo "usage: frontend-build.sh [build|if-stale|stale]" >&2 + exit 2 + ;; +esac 9>"$LOCK" diff --git a/components/frontend/src/lib/server/grpc/client.ts b/components/frontend/src/lib/server/grpc/client.ts index 37605888..327c70aa 100644 --- a/components/frontend/src/lib/server/grpc/client.ts +++ b/components/frontend/src/lib/server/grpc/client.ts @@ -26,7 +26,30 @@ import type { VoteServiceClient } from "./generated/vote/vote_service" import type { SitePageServiceClient } from "./generated/site/site_page_service" import type { StorageServiceClient } from "./generated/storage/storage_service" -const channel = createChannel("localhost:3000") +// ONE channel for the whole process, created at module load and never replaced. +// grpc-js reconnects by itself, so a backend restart does heal — but on ITS +// schedule, and that schedule is tuned for a remote service rather than for a +// supervisor-managed backend on loopback that gets stopped and started several +// times in a single test run. The defaults are a 1s initial backoff, x1.6 per +// failure, capped at 120s (`GRPC_INITIAL_RECONNECT_BACKOFF_MS` / +// `GRPC_MAX_RECONNECT_BACKOFF_MS`), and while the channel is in TRANSIENT_FAILURE +// waiting out that interval every RPC fails IMMEDIATELY. +// +// So a backend that was down for a couple of minutes leaves this server serving +// errors for up to two more minutes after the backend is demonstrably healthy — +// and because the public list loads turn any error into an empty array, what +// reaches the browser is a confident "no hackathons yet" over a fully populated +// database. That cost hours on 2026-08-13: `grpcurl` returned 8 events while the +// browse page rendered none, which is indistinguishable from "the seed data is +// gone" unless you already suspect the channel. +// +// The window is what is wrong here, not the reconnect. Cap it: on loopback a +// failed connect costs nothing, so retrying every 2s means a restarted backend +// is picked up in about as long as it takes to bind its port. +const channel = createChannel("localhost:3000", undefined, { + "grpc.initial_reconnect_backoff_ms": 200, + "grpc.max_reconnect_backoff_ms": 2000, +}) // Unauthenticated health client for the startup check in hooks.server.ts export const healthClient = createClientFactory().create( diff --git a/components/frontend/src/routes/(public)/+page.server.ts b/components/frontend/src/routes/(public)/+page.server.ts index a604a891..0df7c29e 100644 --- a/components/frontend/src/routes/(public)/+page.server.ts +++ b/components/frontend/src/routes/(public)/+page.server.ts @@ -15,13 +15,35 @@ export const load: PageServerLoad = async (event) => { // This awaited bare, so any unreachable backend turned the platform's front // page into a 500 — measured at 98 of 356 samples during one e2e run, which // wipes and reboots Postgres and the backend by design. The rest of the page - // is static marketing copy that needs no backend at all, and "no events to - // show" is a truthful, calm thing to render; a stack trace is not. + // is static marketing copy that needs no backend at all, and a stack trace is + // not something to render at a visitor. // - // The awards block below already degrades this way. Now the list does too. - const result = await publicHackathonClient - .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) - .catch(() => ({ hackathons: [] })) + // But "no events to show" is NOT the truthful thing to render, which is what + // the comment here used to claim. It is truthful only when there are no + // events; when the list could not be FETCHED it is a different fact, and + // flattening the two cost hours on 2026-08-13 — this page and /hackathon both + // showed nothing while the database held eight public editions and `grpcurl` + // returned them (the gRPC channel was waiting out its reconnect backoff; see + // lib/server/grpc/client.ts). In a container where every test run wipes and + // reseeds the database, "empty" and "unreachable" looking identical is the + // most expensive confusion available. So the failure is carried, not hidden. + let hackathons: Awaited< + ReturnType<typeof publicHackathonClient.list> + >["hackathons"] = [] + let listUnavailable = false + try { + const listed = await publicHackathonClient.list({ + visibilityFilter: Visibility.VISIBILITY_PUBLIC, + }) + hackathons = listed.hackathons + } catch (e) { + event.locals.logger.error( + { err: e }, + "Public hackathon list unavailable on the landing page — rendering the outage state, not an empty platform.", + ) + listUnavailable = true + } + const result = { hackathons } // Winners, from the events that actually finished and recorded them. // @@ -70,6 +92,7 @@ export const load: PageServerLoad = async (event) => { return { session: event.locals.session, hackathons: result.hackathons, + listUnavailable, awards, } } diff --git a/components/frontend/src/routes/(public)/+page.svelte b/components/frontend/src/routes/(public)/+page.svelte index 3eef8c04..bdce3619 100644 --- a/components/frontend/src/routes/(public)/+page.svelte +++ b/components/frontend/src/routes/(public)/+page.svelte @@ -161,7 +161,12 @@ </div> <div class="mt-0 divide-y divide-line"> - {#if data.hackathons.length === 0} + {#if data.listUnavailable} + <!-- "Unreachable" is not "empty" — see +page.server.ts. --> + <p class="py-6 text-sm text-ink-3" data-testid="listUnavailable"> + We could not reach the service to load events. Please try again in a moment. + </p> + {:else if data.hackathons.length === 0} <p class="py-6 text-sm text-ink-3">No hackathons available yet.</p> {:else} {#each data.hackathons as h, i (h.id)} diff --git a/components/frontend/src/routes/(public)/hackathon/+page.server.ts b/components/frontend/src/routes/(public)/hackathon/+page.server.ts index 8296eeff..59211a4d 100644 --- a/components/frontend/src/routes/(public)/hackathon/+page.server.ts +++ b/components/frontend/src/routes/(public)/hackathon/+page.server.ts @@ -9,14 +9,43 @@ import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibi // and private events are filtered out server-side rather than hidden in the UI. export const load: PageServerLoad = async (event) => { // Degrades rather than 500s when the backend is unreachable — same reasoning - // as the landing page. An empty list renders "no hackathons yet", which is a - // calm and truthful thing for a visitor to read during an outage. - const result = await publicHackathonClient - .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) - .catch(() => ({ hackathons: [] })) + // as the landing page. + // + // But it must SAY SO. This used to be `.catch(() => ({ hackathons: [] }))` + // with a comment calling an empty list "a calm and truthful thing for a + // visitor to read during an outage", and it is only the first of those: an + // empty list is not truthful when the truth is "I could not ask". Measured + // 2026-08-13: this page rendered ZERO events while `grpcurl` returned eight + // from the same database, for 51 seconds AFTER the backend was healthy again + // (the gRPC channel was still waiting out its reconnect backoff, whose + // default cap is 120s — see lib/server/grpc/client.ts). "The seed data is + // gone" and "I cannot reach the backend" are indistinguishable from the + // browser unless the page distinguishes them, and hours went into the wrong + // one. + // + // So the failure is carried to the component instead of being flattened into + // the success shape. The page still renders, still has no stack trace on it, + // and no longer claims an empty platform. + let hackathons: Awaited< + ReturnType<typeof publicHackathonClient.list> + >["hackathons"] = [] + let listUnavailable = false + try { + const result = await publicHackathonClient.list({ + visibilityFilter: Visibility.VISIBILITY_PUBLIC, + }) + hackathons = result.hackathons + } catch (e) { + event.locals.logger.error( + { err: e }, + "Public hackathon list unavailable — rendering the outage state, not an empty platform.", + ) + listUnavailable = true + } return { session: event.locals.session, - hackathons: result.hackathons, + hackathons, + listUnavailable, } } diff --git a/components/frontend/src/routes/(public)/hackathon/+page.svelte b/components/frontend/src/routes/(public)/hackathon/+page.svelte index f764dae9..cb86a8ff 100644 --- a/components/frontend/src/routes/(public)/hackathon/+page.svelte +++ b/components/frontend/src/routes/(public)/hackathon/+page.svelte @@ -84,7 +84,15 @@ people invited to them. </p> - {#if data.hackathons.length === 0} + {#if data.listUnavailable} + <!-- NOT "no hackathons yet". The list could not be fetched, which is a + different fact, and saying the wrong one of the two sent people + looking for a wiped database for hours. See +page.server.ts. --> + <p class="mt-8 text-ink-3" data-testid="listUnavailable"> + The hackathon list is temporarily unavailable — we could not reach the service. + This is not an empty platform; please try again in a moment. + </p> + {:else if data.hackathons.length === 0} <p class="mt-8 text-ink-3">No hackathons have been published yet.</p> {:else} <div class="mt-6"> diff --git a/tools/nix/hackagon/lib/toolchain.nix b/tools/nix/hackagon/lib/toolchain.nix index 3b8d2cd4..af0e705d 100644 --- a/tools/nix/hackagon/lib/toolchain.nix +++ b/tools/nix/hackagon/lib/toolchain.nix @@ -131,6 +131,16 @@ let keycloak.process-compose.log_location = createProcCompLog "keycloak"; backend = lib.mkIf withBackend { + # NOTE: this enters the Nix dev shell, and that is the single + # most expensive fact about this process. Entering it costs + # 44s at best on a dirty worktree, and it takes a REPO-WIDE + # lock while it re-fetches the tree + # ("waiting for another Nix process to finish fetching input + # 'git+file:///workspaces/hackagon'…"), so every other + # `just develop` anywhere on the machine queues behind it. + # The readiness budget below has to cover that wait, because + # the probe clock starts when THIS command is launched, not + # when the server it eventually starts binds a port. exec = "just develop just run"; process-compose = { log_location = createProcCompLog "backend"; @@ -141,7 +151,30 @@ let }; }; availability = { - restart = "on_failure"; + # `always`, not `on_failure`, because of how a + # readiness-probe kill actually looks. When the budget + # below runs out process-compose SIGTERMs the process; the + # Go server handles that and exits 0 — and `on_failure` + # does not consider 0 a failure, so the backend stayed + # DOWN and the supervisor recorded it as `Completed`, + # exit_code=0, i.e. as having finished successfully. + # Reproduced 2026-08-13: `grpc server listening` followed + # by `received shutdown signal`, then nothing, forever. + # Every downstream symptom (connection refused mid-run, a + # browse page listing nothing) came from that. + restart = "always"; + # LOAD-BEARING, not belt-and-braces. The same kill can + # also land BEFORE the Go signal handler exists, in which + # case the wrapper dies with 143 — which `on_failure` + # does consider a failure. Measured 2026-08-13 with the + # budget scaled down to force it: 149 restarts in 151 + # seconds, i.e. one full `nix develop` PER SECOND, which + # starves every other service's startup and is exactly the + # runaway this file is being changed to prevent. An + # uncapped restart policy on a process that enters the Nix + # shell is a self-amplifying outage; cap it and a bad + # start costs 3 attempts and then says so. + max_restarts = 3; }; readiness_probe = { exec = { @@ -150,7 +183,24 @@ let initial_delay_seconds = 10; timeout_seconds = 5; success_threshold = 1; - failure_threshold = 50; + # Was 50. Measured on this container 2026-08-13: probes + # land ~15s apart (process-compose's default period), so + # 50 was a ~12.7 min budget — and a COLD restart of this + # service (enter the Nix shell, build quitsh, build the Go + # service, boot) took 486s on a QUIET lock. That is 64% of + # the budget spent before one competitor for the fetch + # lock is added, and each competitor measured +36s. The + # margin was ~4 minutes on a machine where the frontend + # could take the lock every 55 seconds. + # + # 150 makes it ~37 min. This costs nothing when the + # service is healthy — probing stops at the first success — + # and the thing that should decide "the backend did not + # come up" is the harness's own timeout + # (hackathon-e2e/scripts/wait-ready.sh, 300s, which says + # WHICH service and prints why), not a supervisor whose + # only move is to kill a server that was merely slow. + failure_threshold = 150; }; }; }; @@ -167,6 +217,33 @@ let }; availability = { restart = "on_failure"; + # THE RUNAWAY THIS FILE EXISTS TO PREVENT, found live + # 2026-08-13 with 54 restarts in 50 minutes. + # + # `vite dev` binds [::1]:8081. So does the adapter-node + # build the e2e harness serves in its place + # (hackathon-e2e/scripts/prod-frontend.sh — see the comment + # on stop_vite for why it has to). Whenever a previous run + # has left that server up, vite cannot bind, exits 1 with + # "Error: Port 8081 is already in use", and an uncapped + # `on_failure` sends it round again — every ~55s, forever, + # and each round is a full `just develop`, i.e. one + # acquisition of the repo-wide git+file:// fetch lock. + # That is what starved the backend's own startup. + # + # It was also INVISIBLE: `process list` said + # `frontend Running Ready` throughout, because the + # readiness probe below is a plain GET of :8081 and the + # OTHER server was answering it. A probe that measures a + # PORT cannot tell you which PROCESS holds it. + # + # 3 is enough for a genuine transient (a port freed a + # moment later) and small enough that a permanent conflict + # costs three shell entries instead of one an hour. + # `wait-ready.sh` now reads these counters back and says so + # out loud, because the number was there all along and + # nothing was looking at it. + max_restarts = 3; }; readiness_probe = { exec = { From feb88bfcbcf00b8c1ec508f6c648f87220a54fdd Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:19:43 +0200 Subject: [PATCH 04/25] fix(hackathon): make the offered action work, create the missing row, keep the tint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs the recipe found and had been pinning as-is. ## 1. Two definitions of "current phase", one page The hub's plan-vs-reality warning comes from `currentAndNextPhase`, which falls back to the DATES when nothing is declared. The "Enable it" button behind it posted `applyPhaseCapabilities`, which had its own narrower lookup — `phases.find(p => p.id === currentPhaseId)` — and answered 400 when that was empty. So the button was offered exactly where it could not work. Fixed by making the action resolve "current" the way the page does, not by hiding the control: the warning is TRUE in the by-dates state — the phase the calendar says is running really does name a capability participants cannot use — and declaring a phase is an explicit act nobody is obliged to perform, so gating a true, actionable warning behind a marker would report the gap in fewer situations than it exists in. There was only ever one bug: a second definition of a shared concept in one code path. The phase pointer does not move and SetCapabilities stays additive. The 400 survives for the genuinely empty case, which the panel cannot reach because `unmet` is then empty. ## 2. SetCapabilities refused the whole batch over one missing row The organiser's form posts all six; one ungoverned row answered NotFound and made the panel unusable. It now verifies the hackathon exists (a bogus id still gets NotFound — about the hackathon, which is the true statement) and upserts inside the existing transaction. Skipping the row was the dangerous alternative: UNGOVERNED is ALLOWED (`capability.State.Allowed` returns true), so dropping a row the caller set to false would report a successful save while participants kept the permission — a silent no-op on a gate. The schema already treats a full set as the invariant, so a missing row is a data gap rather than a decision. Incidental in the same handler: `capabilityStatuses` documented itself as reporting "every capability the way Get does" while building from stored rows alone, so its reply was short where Get's was six long. It uses `capabilityStatusesFromEnt` now. ## 3. .chip:hover erased .chip-active (0,2,0) beat (0,1,0), so the selected tab lost its tint under the cursor. Added `.chip-active:hover` at (0,2,0) — winning on source order, the same mechanism `.chip-active` already uses against `.chip` — rather than (0,3,0), because `chip-active` is also worn by a `btn btn-quiet` in MarkdownEditor where `.btn-quiet:hover` was erasing it identically. ## What re-specifying cost, which is the interesting part `act5.pilot.cap.unmet.bydates` pinned the 400; fixing the bug turned it red on purpose. It now asserts the switches actually move, with `nowBadge: "By dates"` as a positive control — without that it passes against a DECLARED phase, which a neighbouring action already covers. The fix also made two neighbours vacuous: `act5.pilot.phase.declare.applied` claims ADVANCING is what turns team preferences on, but the by-dates click now turns them on first, so that claim would be green whatever AdvancePhase did. Two actions were added to put the switch back and assert it. `act5.cap.ungoverned` needed re-specifying too — same request, same NotFound, different reason — so the runner gained `expect.errorMatches`: a status code says HOW a server refused, never WHAT ABOUT, and that is exactly how a re-specified action keeps passing with a lying `outcome`. The upsert path is unreachable from the API, so it is pinned in Go with a spec that deletes a row to get there. Recipe 463 → 465 actions, player re-spliced, run report regenerated from the final green run. journey 469 passed / 0 failed / 0 skipped, twice. smoke 139/1/2 (baseline 137/1/2 plus 2 new chip specs; the failure is the documented pre-existing drag bug in 22-hackathon-pages). Backend service 311/312 with 1 pending, capability 37/37, middleware 43/43. Frontend units 462. svelte-check 0 errors. Each fix was broken and watched fail before being restored: the by-id lookup (2 unit specs plus the e2e action), the old refusal (5 service specs), and the deleted hover rule (both states painting the identical pixel). --- .claude/CLAUDE.md | 110 +++++-- .../skills/hackathon-e2e/helpers/recipe.ts | 27 ++ .../skills/hackathon-e2e/recipe-player.html | 8 +- .claude/skills/hackathon-e2e/recipe.jsonl | 6 +- .../tests/smoke/24-chip-states.spec.ts | 307 ++++++++++++++++++ .../internal/service/hackathon_service.go | 98 +++++- .../service/hackathon_service_test.go | 181 +++++++++++ .../hackathon/CapabilitiesPanel.svelte | 24 +- .../hackathon/CapabilitiesPanel.test.ts | 19 +- .../lib/server/hackathon/stateActions.test.ts | 209 ++++++++++++ .../src/lib/server/hackathon/stateActions.ts | 53 ++- components/frontend/src/themes/hackagon.css | 23 ++ 12 files changed, 995 insertions(+), 70 deletions(-) create mode 100644 .claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts create mode 100644 components/frontend/src/lib/server/hackathon/stateActions.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 66e8bd45..9315a5be 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -18,7 +18,7 @@ required beyond the repo itself (Nix dev shell via `just`). ## The recipe = the product spec -`skills/hackathon-e2e/recipe.jsonl` — **463 actions, one JSON per line**, +`skills/hackathon-e2e/recipe.jsonl` — **465 actions, one JSON per line**, covering platform setup → publication → configuration → registration (13-person wave, forms, waitlist) → the capacity pilot (a capped side sprint: FCFS seats, queue fairness, over-capacity approval, the Join race) → @@ -27,8 +27,8 @@ overrides) → voting (single-choice, ranked, points) → prizes (admin final voice) → post-event (winners, gallery uploads, wrap-up blog, profile churn). Executed in order by `tests/journey/recipe.spec.ts` via `helpers/recipe.ts`. -Each action carries: `priority` (P1 323 / P2 131 / P3 9), `outcome` -(human-readable expectation), an optional `todo` (placeholder note, 65 +Each action carries: `priority` (P1 325 / P2 131 / P3 9), `outcome` +(human-readable expectation), an optional `todo` (placeholder note, 66 actions) and an optional `gate` (24 actions — skip until the listed RPCs exist, capability-probed at runtime by `scripts/probe.sh`, so actions wake up automatically as the backend lands). `implement: false` meant "deliberately @@ -40,25 +40,76 @@ three cases — start the first phase, declare the live one, advance past it — plus Review N waiting and Edit details), the folded Manage nav, the capability panel, `StorageService.ListObjects` across every scope and refusal, the markdown toolbar and its paste-a-table converter, bulk team import, Manage Pages -reordering, and the Join gate with the sign-in interstitial. Two states in that -set are **unreachable from outside and therefore not asserted**: a capability -that is `UNGOVERNED` (`Create` seeds a row for all six, so only a hackathon with -no rows at all reaches that code path — `act5.cap.ungoverned` uses one), and a +reordering, and the Join gate with the sign-in interstitial. One state in that +set is **unreachable from outside and therefore not asserted end-to-end**: a WAITLISTED owner (`AddOwner` answers `FailedPrecondition` for anyone on the waiting list, so `canEditHackathon`'s narrower gate cannot be exercised -end-to-end). Both are written down in the actions' own `todo`s rather than faked. - -**One product divergence the new actions found, pinned as it stands.** The hub's -plan-vs-reality warning is computed from `currentAndNextPhase`, which falls back -to the DATES when no phase is declared — but the `Enable it` button behind it -posts `applyPhaseCapabilities`, which looks the phase up by `current_phase_id` -and answers `400 "This hackathon has no current phase to take settings from"` -when that is empty. So the warning is offered in a state where its one action -cannot work. `act5.pilot.cap.unmet.bydates` pins that refusal; the working path -(declare the phase, switch its capability off by hand, then click) is -`act5.pilot.phase.declare` → `act5.pilot.cap.plan.again` → `act5.pilot.cap.unmet`. -Fixing it will turn the first of those red on purpose — re-specify it, do not -delete it. +end-to-end). It is written down in the action's own `todo` rather than faked. + +### Three manage-panel bugs, fixed 2026-08-13 + +All three were found by the recipe and had been left pinned as they stood. + +**1. The hub offered a button that could not work.** The plan-vs-reality warning +is computed from `currentAndNextPhase`, which falls back to the DATES when no +phase is declared — while the `Enable it` button behind it posted +`applyPhaseCapabilities`, which looked the phase up by `current_phase_id` alone +and answered `400 "no current phase to take settings from"` whenever that was +empty. Declaring a phase is an explicit act nobody has to perform, so the state +where the two disagreed is the state most events are in. **The action resolves +"current" the same way the page does now** — one definition of the word across +the product. Hiding the warning instead was the alternative and is worse: it is +TRUE in that state, and gating a true, actionable warning on a marker nobody is +required to set reports the gap in fewer situations than it exists in. The 400 +survives for the case that is genuinely empty under BOTH meanings. + +`act5.pilot.cap.unmet.bydates` used to pin the refusal and now asserts the +switches move, with `nowBadge: "By dates"` as its positive control — without it +the action passes against a DECLARED phase, which is what `act5.pilot.cap.unmet` +already covers. **Two actions had to be ADDED with it** +(`act5.pilot.cap.bydates.reset` + `.readback`): the by-dates click now switches +team preferences on, and `act5.pilot.phase.declare.applied` asserts that +ADVANCING is what switches them on — so without putting the switch back first, +that claim would have been green whatever `AdvancePhase` did. Re-specifying an +action can quietly make its NEIGHBOURS vacuous; check what the state it leaves +behind is the premise of. + +**2. `SetCapabilities` refused a whole batch over one ungoverned row.** It +answered `NotFound` if any capability in the batch had no stored row, and the +panel posts all six on every save — so one absent row made the capability screen +unusable, with a 404 as its only explanation and no RPC anywhere that could +create the missing row. **It creates the row now.** Skipping was the dangerous +alternative: `UNGOVERNED` is ALLOWED (`capability.State.Allowed` returns true for +it), so dropping a row the caller asked to set to `false` would report a +successful save while participants kept the permission — a silent no-op on a +gate. Refusing-with-a-name is honest and still leaves the panel dead. The schema +already calls a full set the invariant ("one row per capability per hackathon, +pre-created on hackathon creation"), so a missing row is a data gap, never a +decision. The hackathon's existence is checked first, so a bogus id still answers +`NotFound` — about the HACKATHON, which is the true statement. + +That state is unreachable from the API (`Create` seeds all six, nothing deletes +one), so it is pinned in Go — `hackathon_service_test.go`, "SetCapabilities with +an ungoverned capability", which deletes a row to get there. +`act5.cap.ungoverned` was re-specified to what it can actually reach and gained +`expect.errorMatches`, a new field: the same request answered the same code for a +different reason before and after, and a status code alone cannot tell those +apart. The panel's copy changed with it — it used to warn that the save would be +refused, which stopped being true. + +**3. `.chip:hover` (0,2,0) beat `.chip-active` (0,1,0)**, so pointing at the tab +you were already on erased its accent tint. Fixed with a `.chip-active:hover` +rule of its own, at (0,2,0) so it also covers the `btn btn-icon btn-quiet` that +wears `chip-active` in the markdown editor — `.btn-quiet:hover` was erasing that +one the same way. It wins its tie on SOURCE ORDER and must stay last in the +layer. `tests/smoke/24-chip-states.spec.ts` asserts the **computed style**, never +the class: `chip-active` was on the element the whole time the bug shipped, so +every class-based assertion that could have been written would have passed. It +measures the pixel the browser actually paints (a 1×1 canvas composited over the +page background) because Firefox reports `color-mix(in oklab, …)` back as +`oklab(…)` and `--color-raised` as `rgb(…)` — two syntaxes for the same kind of +fact, and `fillStyle` silently keeps its old value on a colour it cannot parse, +which is why the measurement carries a sentinel. `recipe-player.html` — self-contained animated replay of the recipe (open in any browser). Rebuild after recipe edits with @@ -108,8 +159,8 @@ denied (site pages need the *global* Admin role), publish makes it world-readable, duplicate/invalid slugs are rejected, and a `<script>` payload pasted into the markdown must not execute (`sitePageSanitized`). -Act sizes: 0 = 15, 1 = 63, 2 = 66, 3 = 13, 4 = 29, 5 = 118, 6 = 62, 7 = 40, -8 = 57. By kind: 322 `rpc`, 84 `ui.assert`, 50 `ui.flow`, 6 `rpc.race`, +Act sizes: 0 = 15, 1 = 63, 2 = 66, 3 = 13, 4 = 29, 5 = 120, 6 = 62, 7 = 40, +8 = 57. By kind: 324 `rpc`, 85 `ui.assert`, 49 `ui.flow`, 6 `rpc.race`, 1 `files.generate`. **`rpc.race` fires its `calls` simultaneously** (Promise.all over separately @@ -136,15 +187,15 @@ directories under it are ignored (`node_modules/`, `.state/`, `.artifacts/`, | Suite | Result | When | | --- | --- | --- | -| journey (463-action recipe) | **467 passed / 0 failed / 0 skipped**, twice back to back | 2026-08-13 | -| smoke | **137 passed / 1 failed / 2 did not run** — see below | 2026-08-13 | +| journey (465-action recipe) | **469 passed / 0 failed / 0 skipped** | 2026-08-13 | +| smoke | **139 passed / 1 failed / 2 did not run** — see below | 2026-08-13 | | mobile | **121 passed** | 2026-08-10 | -| backend `go test ./internal/...` | all ok (service 258 specs) | 2026-08-10 | +| backend `go test ./internal/...` | all ok (service 311/312, capability 37, middleware 43) | 2026-08-13 | | openreplay (9 tests) | **13 passed / 0 skipped** | 2026-08-11 | -| frontend units (9 files) | **154 passed** | 2026-08-08 | +| frontend units (26 files) | **462 passed** | 2026-08-13 | Playwright totals include the 4 auth-setup tests every suite depends on, so -journey's 467 is 4 setup + 463 recipe actions. +journey's 469 is 4 setup + 465 recipe actions. ⚠ **smoke is one short of its baseline, deterministically** (2026-08-13, open): `22-hackathon-pages.spec.ts:234` "dragging a row saves the whole new order in one @@ -152,7 +203,7 @@ write". Its first drag (bottom row to the top) passes; the RESTORE drag — the same row, now at the top, dragged back to the bottom — lands one position short, `[Welcome, Rules & Guidelines, Schedule]` where `[Welcome, Schedule, Rules & Guidelines]` was asked for. The two tests after it are the rest of a -`mode: "serial"` describe, so they never run: 137 + 1 + 2 = 140. +`mode: "serial"` describe, so they never run: 139 + 1 + 2 = 142. It is test-side, and the cause is in `dragRowTo` (same file, ~line 74): `endY` is computed from the DESTINATION row's bounding box **before the drag starts**, @@ -171,7 +222,8 @@ worker down after a failing test — and `vars` (hackathonId, team ids, saved tokens) lives in that worker's module scope, so every later action self-skips with "depends on 'hackathonId' from a step that was skipped or did not run". A break-run with serial off therefore reports a flood of skips rather than the -failures it was looking for: 276 of 467 never ran. To see several deliberate +failures it was looking for: 276 of 467 never ran (measured when the recipe was +463 actions long). To see several deliberate failures in one sitting, keep serial ON and exclude the already-proven ones with `--grep-invert` (assertion-only actions save no vars, so removing them poisons nothing). `loadRecipe()` counts by `id`, diff --git a/.claude/skills/hackathon-e2e/helpers/recipe.ts b/.claude/skills/hackathon-e2e/helpers/recipe.ts index 2760edca..05e5cba5 100644 --- a/.claude/skills/hackathon-e2e/helpers/recipe.ts +++ b/.claude/skills/hackathon-e2e/helpers/recipe.ts @@ -121,6 +121,13 @@ export interface RecipeAction { expect?: { ok?: boolean error?: string + /** With `error`: a case-insensitive regex the refusal's TEXT must match. + * A status code says how the server refused, never what it refused about, + * and two different faults answering the same code is exactly how a + * re-specified action keeps passing while its `outcome` has become a lie — + * `SetCapabilities` used to answer NotFound for a missing capability ROW + * and now answers it for a missing HACKATHON, from the same request. */ + errorMatches?: string check?: string checkArgs?: unknown /** Succeed, OR fail with one of these codes — for restore steps whose @@ -1271,6 +1278,19 @@ const UI_ASSERTS: Record<string, UiAssert> = { const panel = capabilityPanel(page) const warning = panel.locator("div[role=status]").filter({ hasText: "is meant to include" }) + // WHICH of the two meanings of "current" is in play, read off the badge + // that states it rather than assumed from the story so far. The button + // below used to work only against a DECLARED phase while the warning was + // drawn for either — so an action that clicks it without pinning the + // resolution proves nothing about the case that was broken, and would go on + // passing if a declaration leaked in from an earlier step. + if (args.nowBadge) { + await expect( + phaseCard(page, "Now").locator("span.badge"), + "the Now card must state this is the resolution under test", + ).toHaveText(args.nowBadge as string) + } + await expect(warning, "the mismatch warning did not render").toBeVisible() await expect(warning, "it must name the phase whose plan is unmet").toContainText( args.phase as string, @@ -1982,6 +2002,13 @@ async function runRpc(test: AnyTest, a: RecipeAction): Promise<void> { } else if (a.expect?.error) { expect(res.ok, `expected ${a.expect.error} but the call succeeded`).toBe(false) expect(res.code, res.raw).toBe(a.expect.error) + if (a.expect.errorMatches) { + expect( + res.raw, + `the call refused with the right code for an unknown reason — ` + + `expected the message to match /${a.expect.errorMatches}/i`, + ).toMatch(new RegExp(a.expect.errorMatches, "i")) + } } else { expect(res.ok, `${a.method} failed: ${res.raw}`).toBe(true) } diff --git a/.claude/skills/hackathon-e2e/recipe-player.html b/.claude/skills/hackathon-e2e/recipe-player.html index a69d713f..627ff06c 100644 --- a/.claude/skills/hackathon-e2e/recipe-player.html +++ b/.claude/skills/hackathon-e2e/recipe-player.html @@ -1472,7 +1472,9 @@ <h3 id="hTitle">What the recipe is, and what every field on an action means</h3> {"id": "act5.pilot.cap.save", "priority": "P1", "implement": true, "outcome": "The form saves the whole set from the checkboxes: proposing closes, voting opens, and the panel confirms it in words before the badges are re-read.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: ticking two boxes and pressing Save changes the switches", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilitySave", "params": {"hackathonId": "{{var:capHackId}}", "uncheck": ["Propose projects"], "check": ["Vote"], "after": {"Propose projects": "Closed", "Vote": "Open"}}, "todo": "Unchecked boxes submit NOTHING, so the six are rebuilt from the enum order server-side. A save that dropped that would look identical on screen until the badges are read back."} {"id": "act5.pilot.cap.save.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; proposing is CLOSED, voting OPEN, registration still CLOSED and results still COMING - a whole-set save must not flatten the scheduled one into a plain closed switch.", "act": 5, "t": "T-1wk", "title": "END STATE: the saved form kept the scheduled capability scheduled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_PROPOSE_PROJECTS": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VOTE": "CAPABILITY_STATE_OPEN", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VIEW_RESULTS": "CAPABILITY_STATE_COMING"}}}} {"id": "act5.pilot.cap.plan", "priority": "P1", "implement": true, "outcome": "Succeeds; the live phase now NAMES a capability that is switched off, which is the plan-vs-reality gap the hub warns about.", "act": 5, "t": "T-1wk", "title": "PILOT: team preferences are planned for the phase that is running, and switched off", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} -{"id": "act5.pilot.cap.unmet.bydates", "priority": "P1", "implement": true, "outcome": "The warning is OFFERED while its button cannot work: the panel names the phase the DATES say is live, and the action behind Enable refuses because no phase is DECLARED. Pinned as it stands - the two halves disagree about what 'current' means.", "act": 5, "t": "T-1wk", "title": "BUG PINNED: Enable-it is offered for a phase resolved by dates, and refuses", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{var:capHackId}}/manage"}, {"expectText": "is meant to include"}, {"clickButton": "Enable it"}, {"expectText": "no current phase to take settings from"}], "todo": "The panel computes `unmet` from currentAndNextPhase, which falls back to the DATES; applyPhaseCapabilities looks the phase up by current_phase_id and fails when it is empty. Either the action should fall back the same way, or the warning should only appear against a declaration. When it is fixed, flip this action to assert the switches moved."} +{"id": "act5.pilot.cap.unmet.bydates", "priority": "P1", "implement": true, "outcome": "The warning is offered for the phase the DATES say is live, with no marker set anywhere - and its one click works: team preferences come on, registration and proposing are untouched, and the warning goes away because it has nothing left to say.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: Enable-it works for a phase resolved by dates, with nothing declared", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilityEnableUnmet", "params": {"hackathonId": "{{var:capHackId}}", "nowBadge": "By dates", "phase": "Pilot Sprint Evening", "names": ["set team preferences"], "button": "Enable it", "after": {"Set team preferences": "Open"}, "untouched": {"Register": "Closed", "Propose projects": "Closed"}}, "todo": "RE-SPECIFIED (was 'BUG PINNED: Enable-it is offered for a phase resolved by dates, and refuses'). It used to assert the 400 'no current phase to take settings from': the panel resolved the live phase with currentAndNextPhase, which falls back to the dates, while applyPhaseCapabilities looked it up by current_phase_id alone - so the control was offered exactly where it could not work. The action resolves it the same way now. `nowBadge` is the positive control: without it this passes against a DECLARED phase, which is the case act5.pilot.cap.unmet already covers."} +{"id": "act5.pilot.cap.bydates.reset", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences go back off, so the two actions below are once again about what ADVANCING does rather than about what the click above already did.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer undoes the by-dates enable before declaring anything", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "Added when act5.pilot.cap.unmet.bydates was re-specified from a refusal into a working click. Without it act5.pilot.phase.declare.applied would assert OPEN against a capability that was already open - green whatever AdvancePhase did with it."} +{"id": "act5.pilot.cap.bydates.reset.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences read CLOSED. This is the precondition the next two actions are judged against, so it is asserted rather than assumed.", "act": 5, "t": "T-1wk", "title": "END STATE: the by-dates enable really was undone", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_CLOSED"}}}} {"id": "act5.pilot.phase.declare", "priority": "P1", "implement": true, "outcome": "Succeeds; the marker lands on the running phase AND applies what that phase opens - team preferences come on, which is the transaction advancing is supposed to carry.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer declares the running phase current, which applies its plan", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AdvancePhase", "params": {"hackathonId": "{{var:capHackId}}", "phaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} {"id": "act5.pilot.phase.declare.applied", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences are OPEN because the phase the marker landed on names them - advancing applies the phase's plan in the same transaction that moves the marker, which is what makes the panel's copy true.", "act": 5, "t": "T-1wk", "title": "END STATE: advancing applied what the phase opens", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_OPEN"}}}} {"id": "act5.pilot.cap.plan.again", "priority": "P1", "implement": true, "outcome": "Succeeds; the organizer switches the phase's own capability back off by hand, which is how a real gap between plan and reality appears - the timeline moved, then somebody changed their mind.", "act": 5, "t": "T-1wk", "title": "PILOT: …and then switches team preferences off again by hand", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "That it really went off is read back by the action below: the plan-vs-reality warning can only appear while this capability is switched off AND named by the live phase."} @@ -1485,7 +1487,7 @@ <h3 id="hTitle">What the recipe is, and what every field on an action means</h3> {"id": "act5.cap.empty", "priority": "P2", "implement": true, "outcome": "Succeeds and changes nothing - an empty batch is a no-op, not an error, so a form that submits no ticks cannot fail on its way to closing everything.", "act": 5, "t": "T-1wk", "title": "PARAMS: an empty capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": []}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_VOTE": "CAPABILITY_STATE_CLOSED", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED"}}}} {"id": "act5.cap.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument, and nothing in the batch is written: every capability is resolved BEFORE the first row is touched, so a typo cannot apply the prefix before it.", "act": 5, "t": "T-1wk", "title": "PARAMS: a batch naming a capability the enum does not have", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_UNSPECIFIED", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} {"id": "act5.cap.badid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a malformed hackathon id fails before any permission is even considered.", "act": 5, "t": "T-1wk", "title": "PARAMS: a malformed hackathon id on the capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "not-a-uuid", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} -{"id": "act5.cap.ungoverned", "priority": "P2", "implement": true, "outcome": "Rejected with NotFound, and the WHOLE batch is refused: a capability with no stored row cannot be set, and the panel says so before an organizer spends a save on it.", "act": 5, "t": "T-1wk", "title": "PARAMS: setting a capability that has no stored row refuses the whole batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "NotFound"}, "todo": "A hackathon with NO capability rows is the reachable case for this code path - every event created through the API is seeded with all six, so an individually ungoverned capability cannot be produced from the outside."} +{"id": "act5.cap.ungoverned", "priority": "P2", "implement": true, "outcome": "Rejected with NotFound, and the message names the HACKATHON. A batch against an id that exists is never refused over a missing capability row any more - the row is created - so the only NotFound left here is 'no such hackathon', and nothing may be written for an id that names nothing.", "act": 5, "t": "T-1wk", "title": "PARAMS: a capability batch against a hackathon that does not exist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "NotFound", "errorMatches": "hackathon .* not found"}, "todo": "RE-SPECIFIED (was 'setting a capability that has no stored row refuses the whole batch'). SetCapabilities used to answer NotFound if ANY capability in the batch was ungoverned, and the organiser's panel posts all six - so one missing row made the whole capability screen unusable. It creates the row now. This id still refuses, but for the other reason, and `errorMatches` is what keeps the two apart: the same code from the same request meant a missing capability before and a missing hackathon now. The upsert itself is unreachable from the API (Create seeds all six and no RPC deletes one), so it is pinned in Go instead - hackathon_service_test.go, 'SetCapabilities with an ungoverned capability', which deletes a row to get there."} {"id": "act5.cap.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - the switches are hackathon Write, and a participant does not hold it.", "act": 5, "t": "T-1wk", "title": "a participant cannot change what participants may do", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} {"id": "act5.cap.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - 'who are you' and 'not you' are different answers, and an anonymous caller gets the first one.", "act": 5, "t": "T-1wk", "title": "an anonymous caller cannot change the capability switches", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "Unauthenticated"}} {"id": "act5.import.template", "priority": "P1", "implement": true, "outcome": "Both formats download as files, carry the columns the importer reads and this event's REAL roster - and the importer accepts the very file the page just handed out, with nothing left to apply.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: the template downloads, and its own importer accepts it", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportTemplate", "params": {"mustContain": ["bob@mail.org", "hiro.tanaka@example.org", "FAIR Pipeline Builder", "Team Matterhorn"], "roundTrip": true}, "todo": "Two halves shipped separately drift apart in one commit, and the failure surfaces only when a real organizer downloads one - so the template is not merely parsed here, it is fed straight back in through the UI."} @@ -1726,7 +1728,7 @@ <h3 id="hTitle">What the recipe is, and what every field on an action means</h3> So this file now holds THREE literal close tags: the recipe, this report and the program. --> <script id="run-report" type="application/json"> -{"kind":"hackagon-journey-run","suite":"journey","generatedAt":"2026-08-13T13:06:58.292Z","recipeActions":463,"specs":467,"durationMs":157695,"totals":{"passed":463,"failed":0,"skipped":0,"flaky":0,"other":0},"status":{"act0.about.absent":["passed",1486],"act0.about.create":["passed",1184],"act0.about.draft.hidden":["passed",834],"act0.about.rogue":["passed",73],"act0.about.anon":["passed",15],"act0.about.publish":["passed",2136],"act0.about.live":["passed",922],"act0.about.xss":["passed",75],"act0.about.sanitized":["passed",889],"act0.privacy.create":["passed",34],"act0.terms.create":["passed",30],"act0.slug.dupe":["passed",29],"act0.slug.invalid":["passed",30],"act0.footer.links":["passed",1622],"act0.ghost":["passed",17],"act1.guard":["passed",333],"act1.publish":["passed",54],"act1.logo.presign":["passed",31],"act1.logo.rogue":["passed",79],"act1.logo.anon":["passed",16],"act1.logo.svg":["passed",30],"act1.logo.toobig":["passed",30],"act1.roundtrip":["passed",43],"act1.config.regform":["passed",33],"act1.config.subform":["passed",33],"act1.config.subform.url":["passed",33],"act1.config.voting":["passed",35],"act1.config.emails":["passed",35],"act1.race.emails":["passed",36],"act1.race.emails.check":["passed",35],"act1.race.emails.restore":["passed",33],"act1.config.branding":["passed",33],"act1.config.windows":["passed",30],"act1.window.early":["passed",67],"act1.prizes":["passed",1813],"act1.admin.whoami":["passed",30],"act1.admin.users":["passed",28],"act1.public":["passed",856],"act1.ui.cover":["passed",360],"act1.typo":["passed",37],"act1.typo.check":["passed",35],"act1.typo.fix":["passed",36],"act1.typo.fixed":["passed",36],"act1.reschedule":["passed",37],"act1.venue":["passed",58],"act1.venue.check":["passed",36],"act1.edit.rogue":["passed",31],"act1.draft.create":["passed",39],"act1.draft.hidden":["passed",305],"act1.draft.api":["passed",20],"act1.draft.hub.noreview":["passed",818],"act1.joinable":["passed",280],"act1.rogue":["passed",30],"act1.flow.anon":["passed",940],"act1.flow.bob":["passed",1657],"act1.flow.abandon":["passed",1179],"act1.signin.deeplink":["passed",658],"act1.signin.offsite":["passed",903],"act1.signin.roundtrip":["passed",3095],"act1.flow.wrongpw":["passed",1796],"act1.flow.joinstub":["passed",856],"act1.page.welcome":["passed",35],"act1.page.conduct":["passed",33],"act1.editor.toolbar":["passed",1180],"act1.editor.table.tsv":["passed",607],"act1.editor.table.csv":["passed",628],"act1.editor.table.semicolon":["passed",653],"act1.editor.table.ambiguous":["passed",579],"act1.editor.table.override":["passed",1247],"act1.editor.table.pipe":["passed",629],"act1.editor.table.ragged":["passed",576],"act1.editor.table.noheader":["passed",638],"act1.editor.table.cancel":["passed",586],"act1.editor.typo":["passed",1396],"act1.editor.fix":["passed",755],"act1.editor.readback":["passed",35],"act1.track.ds":["passed",29],"act1.track.rdi":["passed",31],"act2.window.open":["passed",30],"act2.join.alice":["passed",36],"act2.join.bob":["passed",37],"act2.join.charles":["passed",1937],"act2.join.dana":["passed",100],"act2.join.erik":["passed",98],"act2.join.fatima":["passed",100],"act2.join.giulia":["passed",106],"act2.midway":["passed",35],"act2.pause":["passed",31],"act2.pause.ui":["passed",294],"act2.pause.api":["passed",44],"act2.resume":["passed",35],"act2.resume.ui":["passed",295],"act2.join.hiro":["passed",101],"act2.join.ines":["passed",103],"act2.join.jonas":["passed",96],"act2.join.katya":["passed",99],"act2.join.liam":["passed",97],"act2.join.mei":["passed",99],"act2.form.alice":["passed",33],"act2.form.bob":["passed",33],"act2.form.charles":["passed",80],"act2.form.dana":["passed",33],"act2.form.erik":["passed",33],"act2.form.giulia":["passed",66],"act2.form.hiro":["passed",32],"act2.form.katya":["passed",32],"act2.form.mei":["passed",32],"act2.form.missing":["passed",30],"act2.form.unknown":["passed",31],"act2.form.alice.readback":["passed",31],"act2.form.alice.correct":["passed",33],"act2.form.alice.recheck":["passed",30],"act2.form.bob.snoop":["passed",49],"act2.form.admin.read":["passed",31],"act2.idempotent":["passed",31],"act2.anonymous":["passed",16],"act2.anonymous.register":["passed",14],"act2.roster":["passed",39],"act2.users.grown":["passed",30],"act2.flow.admin.users":["passed",2241],"act2.users.rogue":["passed",31],"act2.flow.alice.users":["passed",891],"act2.join.badid":["passed",48],"act2.join.ghost":["passed",56],"act2.whoami.bob":["passed",31],"act2.ui.waitlisted":["passed",309],"act2.ui.locked":["passed",329],"act2.flow.bob":["passed",1730],"act2.flow.anxious":["passed",1510],"act2.cap.create":["passed",43],"act2.cap.set":["passed",36],"act2.cap.join.room":["passed",38],"act2.cap.race":["passed",94],"act2.cap.roster":["passed",37],"act2.cap.join.full":["passed",40],"act2.cap.remove":["passed",50],"act2.cap.nojump":["passed",38],"act2.cap.ui.queued":["passed",292],"act2.cap.noautopromote":["passed",40],"act2.cap.approve.fill":["passed",46],"act2.cap.approve.over":["passed",51],"act2.cap.roster.final":["passed",35],"act2.cap.ui.gauge":["passed",636],"act2.cap.ui.in":["passed",315],"act3.propose.fair":["passed",39],"act3.propose.litdata":["passed",38],"act3.propose.genomelens":["passed",37],"act3.approve.fair":["passed",34],"act3.approve.litdata":["passed",1294],"act3.rogue":["passed",31],"act3.propose.sensor":["passed",34],"act3.withdraw":["passed",35],"act3.edit.fair":["passed",34],"act3.propose.anonymous":["passed",13],"act3.approve.ghost":["passed",30],"act3.propose.waitlisted":["passed",35],"act3.ui.proposals":["passed",929],"act4.pref.bob":["passed",34],"act4.pref.dana":["passed",33],"act4.export":["passed",32],"act4.team.matterhorn":["passed",33],"act4.team.bernina":["passed",29],"act4.team.anon":["passed",14],"act4.assign.bob":["passed",47],"act4.assign.alice":["passed",36],"act4.assign.dana":["passed",35],"act4.assign.erik":["passed",45],"act4.assign.anon":["passed",15],"act4.removeuser.anon":["passed",14],"act4.pref.erik":["passed",32],"act4.pref.update":["passed",36],"act4.window.prefclose":["passed",32],"act4.window.preflate":["passed",31],"act4.team.placeholder":["passed",30],"act4.team.placeholder.delete":["passed",31],"act4.rebalance.add":["passed",48],"act4.rebalance.remove":["passed",1318],"act4.rebalance.final":["passed",39],"act4.assign.hiro":["passed",48],"act4.assign.ines":["passed",52],"act4.assign.fatima":["passed",52],"act4.team.edit":["passed",38],"act4.team.edit.anon":["passed",17],"act4.team.delete.anon":["passed",14],"act4.ui.teams":["passed",553],"act4.webinars":["passed",32],"act5.approve.alice":["passed",32],"act5.approve.bob":["passed",33],"act5.approve.dana":["passed",32],"act5.approve.erik":["passed",51],"act5.approve.fatima":["passed",43],"act5.approve.giulia":["passed",34],"act5.approve.hiro":["passed",32],"act5.approve.ines":["passed",35],"act5.approve.double":["passed",34],"act5.roster.full":["passed",38],"act5.ui.member":["passed",294],"act5.ui.open":["passed",497],"act5.ui.about":["passed",334],"act5.flow.bob":["passed",2093],"act5.flow.admin":["passed",1642],"act5.flow.alice":["passed",1881],"act5.flow.search":["passed",1740],"act5.pref.reopen":["passed",35],"act5.flow.prefer":["passed",1532],"act5.pref.close":["passed",35],"act5.dropout.before":["passed",36],"act5.dropout.remove":["passed",34],"act5.dropout.after":["passed",29],"act5.dropout.team":["passed",37],"act5.backfill":["passed",65],"act5.backfill.access":["passed",35],"act5.backfill.team":["passed",33],"act5.roster.final":["passed",37],"act5.hub.tiles":["passed",615],"act5.hub.rogue":["passed",844],"act5.hub.review.click":["passed",988],"act5.hub.edit.click":["passed",1107],"act5.flow.reach.manage":["passed",1333],"act5.flow.hub.back":["passed",1051],"act5.hub.title.check":["passed",38],"act5.nav.fold":["passed",1188],"act5.pilot.join.offered":["passed",290],"act5.pilot.phase.wrap":["passed",35],"act5.pilot.hub.start":["passed",443],"act5.pilot.phase.day":["passed",33],"act5.pilot.hub.declare":["passed",1065],"act5.pilot.phase.advance":["passed",1019],"act5.pilot.phase.readback":["passed",38],"act5.pilot.cap.close":["passed",36],"act5.pilot.cap.schedule":["passed",36],"act5.pilot.cap.states":["passed",400],"act5.pilot.cap.readback":["passed",40],"act5.pilot.join.closed":["passed",295],"act5.pilot.join.rpc":["passed",32],"act5.pilot.cap.save":["passed",813],"act5.pilot.cap.save.readback":["passed",39],"act5.pilot.cap.plan":["passed",36],"act5.pilot.cap.unmet.bydates":["passed",1011],"act5.pilot.phase.declare":["passed",38],"act5.pilot.phase.declare.applied":["passed",62],"act5.pilot.cap.plan.again":["passed",36],"act5.pilot.cap.unmet":["passed",549],"act5.pilot.cap.unmet.readback":["passed",40],"act5.pilot.cap.refresh":["passed",786],"act5.pilot.cap.refresh.readback":["passed",39],"act5.cap.dupe":["passed",34],"act5.cap.dupe.check":["passed",40],"act5.cap.empty":["passed",37],"act5.cap.unknown":["passed",32],"act5.cap.badid":["passed",29],"act5.cap.ungoverned":["passed",32],"act5.cap.rogue":["passed",33],"act5.cap.anon":["passed",16],"act5.import.template":["passed",717],"act5.import.badmail":["passed",553],"act5.import.badproject":["passed",1150],"act5.import.dupe":["passed",556],"act5.import.waitlisted":["passed",537],"act5.import.halfrow":["passed",560],"act5.import.semicolon":["passed",567],"act5.import.newteam":["passed",556],"act5.import.malformed":["passed",561],"act5.import.bom":["passed",572],"act5.import.accents":["passed",612],"act5.import.apply":["passed",682],"act5.import.revert":["passed",1330],"act5.import.again":["passed",595],"act5.import.readback":["passed",36],"act5.approve.ghost":["passed",30],"act5.remove.ghost":["passed",31],"act5.approve.badid":["passed",28],"act5.window.regclose":["passed",31],"act5.window.regclosed":["passed",33],"act5.ui.charles":["passed",286],"act5.ui.charles.locked":["passed",237],"act5.rogue.approve":["passed",30],"act5.rogue.remove":["passed",28],"act5.owner.rogue":["passed",31],"act5.owner.waitlisted":["passed",32],"act5.owner.promote":["passed",31],"act5.owner.alice.hub":["passed",460],"act5.owner.alice.media":["passed",40],"act5.owner.alice.other.media":["passed",29],"act5.owner.alice.other.cap":["passed",29],"act5.owner.alice.other.hub":["passed",837],"act5.owner.self":["passed",46],"act5.owner.demote":["passed",32],"act5.owner.last":["passed",32],"act5.race.owner.doubleadd":["passed",35],"act5.race.owner.doubleadd.verify":["passed",38],"act5.race.owner.remove":["passed",45],"act5.race.owner.invariant":["passed",41],"act5.race.owner.restore":["passed",34],"act5.race.owner.restore2":["passed",34],"act5.race.owner.final":["passed",40],"act5.forms.roster":["passed",31],"act5.forms.rogue":["passed",29],"act5.forms.anon":["passed",17],"act5.state.facade":["passed",34],"act5.state.rogue":["passed",32],"act5.state.restore":["passed",34],"act5.phase.alias":["passed",36],"act5.audit":["passed",37],"act6.begin":["passed",33],"act6.ui.active":["passed",311],"act6.flow.anon":["passed",917],"act6.list.active":["passed",19],"act6.noshow":["passed",35],"act6.noshow.access":["passed",39],"act6.walkin.signup":["passed",54],"act6.walkin.override":["passed",33],"act6.walkin.join":["passed",35],"act6.walkin.approve":["passed",970],"act6.walkin.form":["passed",35],"act6.walkin.access":["passed",41],"act6.walkin.team":["passed",39],"act6.ui.teams":["passed",363],"act6.roster.walkin":["passed",46],"act6.announce":["passed",60],"act6.announce.ui":["passed",909],"act6.phase.ideation":["passed",33],"act6.phase.hacking":["passed",32],"act6.phase.judging":["passed",31],"act6.phase.rogue":["passed",28],"act6.ui.timeline":["passed",498],"act6.phase.current":["passed",1279],"act6.hub.now":["passed",470],"act6.hub.advance":["passed",997],"act6.hub.marker.readback":["passed",40],"act6.pages.ids":["passed",30],"act6.pages.movedown":["passed",670],"act6.pages.moveup":["passed",666],"act6.pages.reorder":["passed",591],"act6.pages.reorder.readback":["passed",33],"act6.pages.excerpt":["passed",368],"act6.pages.setorder.partial":["passed",34],"act6.pages.setorder.dupe":["passed",30],"act6.pages.setorder.empty":["passed",27],"act6.pages.setorder.ghost":["passed",30],"act6.pages.setorder.rogue":["passed",27],"act6.pages.setorder.anon":["passed",14],"act6.pages.setorder.restore":["passed",34],"act6.pages.order.final":["passed",32],"act6.flow.day1end":["passed",1019],"act6.flow.day2":["passed",2280],"act6.files":["passed",40],"act6.submit.draft":["passed",1215],"act6.submit.draft.id":["passed",34],"act6.submit.final":["passed",1049],"act6.submit.bernina":["passed",35],"act6.submit.bernina.edit":["passed",49],"act6.submit.bernina.final":["passed",39],"act6.submit.abandoned":["passed",35],"act6.logo.refresh":["passed",34],"act6.logo.check":["passed",37],"act6.submit.rogue":["passed",28],"act6.submit.anon":["passed",13],"act6.submit.edit.anon":["passed",15],"act6.submit.final.anon":["passed",15],"act6.submit.invalid":["passed",32],"act6.window.subclose":["passed",34],"act6.window.sublate":["passed",28],"act6.window.override":["passed",31],"act6.submit.grace":["passed",35],"act6.ui.submissions":["passed",324],"act7.cat.impact":["passed",35],"act7.cat.tech":["passed",31],"act7.cat.demo":["passed",32],"act7.cat.ranked":["passed",31],"act7.cat.points":["passed",34],"act7.voting.open":["passed",1154],"act7.monitor.open":["passed",32],"act7.cast.alice":["passed",38],"act7.cast.bob":["passed",1027],"act7.cast.dana":["passed",41],"act7.cast.erik":["passed",37],"act7.cast.giulia":["passed",37],"act7.cast.hiro":["passed",37],"act7.cast.ines":["passed",37],"act7.cast.jonas":["passed",38],"act7.cast.noor":["passed",36],"act7.cast.alice2":["passed",36],"act7.cast.bob2":["passed",38],"act7.cast.ines2":["passed",40],"act7.cast.giulia2":["passed",40],"act7.race.cat":["passed",34],"act7.race.doublevote":["passed",44],"act7.race.check":["passed",33],"act7.ranked.gap":["passed",31],"act7.ranked.dupe":["passed",30],"act7.ranked.bob":["passed",38],"act7.ranked.wrongmethod":["passed",30],"act7.points.over":["passed",29],"act7.points.bob":["passed",38],"act7.points.ines":["passed",38],"act7.cast.admin":["passed",30],"act7.cast.waitlisted":["passed",31],"act7.cast.double":["passed",35],"act7.close":["passed",32],"act7.cast.late":["passed",83],"act7.result.impact":["passed",33],"act7.result.ranked":["passed",36],"act7.result.points":["passed",35],"act7.results":["passed",31],"act7.prizes.finalize":["passed",30],"act8.end":["passed",32],"act8.ui.finished":["passed",304],"act8.latejoin":["passed",30],"act8.flow.anon":["passed",1021],"act8.audit":["passed",42],"act8.thanks":["passed",36],"act8.thanks.ui":["passed",328],"act8.retention.alice":["passed",325],"act8.prizes.edit":["passed",34],"act8.prizes.rogue":["passed",27],"act8.retention":["passed",989],"act8.flow.charles":["passed",1833],"act8.photos":["passed",35],"act8.media.presign":["passed",28],"act8.media.rogue":["passed",29],"act8.media.svg":["passed",28],"act8.media.upload":["passed",242],"act8.media.upload2":["passed",193],"act8.objects.hackathon":["passed",36],"act8.objects.noowner":["passed",28],"act8.objects.badowner":["passed",28],"act8.objects.rogue":["passed",30],"act8.objects.anon":["passed",14],"act8.objects.site.admin":["passed",36],"act8.objects.site.rogue":["passed",26],"act8.objects.all.admin":["passed",438],"act8.objects.all.rogue":["passed",30],"act8.objects.scope.zero":["passed",29],"act8.objects.page.one":["passed",430],"act8.objects.page.two":["passed",441],"act8.objects.pagesize.max":["passed",404],"act8.objects.pagesize.over":["passed",30],"act8.objects.badtoken":["passed",33],"act8.flow.reach.gallery":["passed",1724],"act8.gallery.admin":["passed",972],"act8.gallery.rogue":["passed",872],"act8.picker.halves":["passed",710],"act8.picker.reject":["passed",527],"act8.picker.cancel":["passed",475],"act8.pilot.finished":["passed",41],"act8.pilot.join.finished":["passed",948],"act8.pilot.join.finished.rpc":["passed",43],"act8.flow.bob":["passed",1229],"act8.ui.winners":["passed",593],"act8.blog":["passed",37],"act8.ui.blog":["passed",316],"act8.profile.rename":["passed",38],"act8.profile.sticks":["passed",30],"act8.profile.blank":["passed",29],"act8.menu.alice":["passed",1710],"act8.menu.admin":["passed",877],"act8.form.ui.edit":["passed",924],"act8.account.liam":["passed",48],"act8.account.mei":["passed",43],"act8.account.check":["passed",32],"act8.page.cleanup":["passed",35],"act8.draft.delete":["passed",41]}} +{"kind":"hackagon-journey-run","suite":"journey","generatedAt":"2026-08-13T17:06:49.902Z","recipeActions":465,"specs":469,"durationMs":156823,"totals":{"passed":465,"failed":0,"skipped":0,"flaky":0,"other":0},"status":{"act0.about.absent":["passed",1465],"act0.about.create":["passed",1181],"act0.about.draft.hidden":["passed",849],"act0.about.rogue":["passed",79],"act0.about.anon":["passed",13],"act0.about.publish":["passed",1873],"act0.about.live":["passed",937],"act0.about.xss":["passed",69],"act0.about.sanitized":["passed",919],"act0.privacy.create":["passed",36],"act0.terms.create":["passed",29],"act0.slug.dupe":["passed",28],"act0.slug.invalid":["passed",28],"act0.footer.links":["passed",1570],"act0.ghost":["passed",17],"act1.guard":["passed",354],"act1.publish":["passed",47],"act1.logo.presign":["passed",29],"act1.logo.rogue":["passed",56],"act1.logo.anon":["passed",15],"act1.logo.svg":["passed",31],"act1.logo.toobig":["passed",29],"act1.roundtrip":["passed",42],"act1.config.regform":["passed",34],"act1.config.subform":["passed",33],"act1.config.subform.url":["passed",31],"act1.config.voting":["passed",30],"act1.config.emails":["passed",32],"act1.race.emails":["passed",34],"act1.race.emails.check":["passed",31],"act1.race.emails.restore":["passed",30],"act1.config.branding":["passed",31],"act1.config.windows":["passed",30],"act1.window.early":["passed",30],"act1.prizes":["passed",1829],"act1.admin.whoami":["passed",29],"act1.admin.users":["passed",28],"act1.public":["passed",847],"act1.ui.cover":["passed",372],"act1.typo":["passed",38],"act1.typo.check":["passed",38],"act1.typo.fix":["passed",33],"act1.typo.fixed":["passed",36],"act1.reschedule":["passed",36],"act1.venue":["passed",33],"act1.venue.check":["passed",34],"act1.edit.rogue":["passed",30],"act1.draft.create":["passed",37],"act1.draft.hidden":["passed",299],"act1.draft.api":["passed",20],"act1.draft.hub.noreview":["passed",806],"act1.joinable":["passed",281],"act1.rogue":["passed",32],"act1.flow.anon":["passed",938],"act1.flow.bob":["passed",1634],"act1.flow.abandon":["passed",1196],"act1.signin.deeplink":["passed",667],"act1.signin.offsite":["passed",929],"act1.signin.roundtrip":["passed",3122],"act1.flow.wrongpw":["passed",1769],"act1.flow.joinstub":["passed",839],"act1.page.welcome":["passed",33],"act1.page.conduct":["passed",29],"act1.editor.toolbar":["passed",1133],"act1.editor.table.tsv":["passed",587],"act1.editor.table.csv":["passed",609],"act1.editor.table.semicolon":["passed",611],"act1.editor.table.ambiguous":["passed",587],"act1.editor.table.override":["passed",1262],"act1.editor.table.pipe":["passed",608],"act1.editor.table.ragged":["passed",594],"act1.editor.table.noheader":["passed",643],"act1.editor.table.cancel":["passed",585],"act1.editor.typo":["passed",1426],"act1.editor.fix":["passed",753],"act1.editor.readback":["passed",32],"act1.track.ds":["passed",33],"act1.track.rdi":["passed",27],"act2.window.open":["passed",31],"act2.join.alice":["passed",43],"act2.join.bob":["passed",39],"act2.join.charles":["passed",1963],"act2.join.dana":["passed",101],"act2.join.erik":["passed",95],"act2.join.fatima":["passed",96],"act2.join.giulia":["passed",100],"act2.midway":["passed",38],"act2.pause":["passed",34],"act2.pause.ui":["passed",293],"act2.pause.api":["passed",17],"act2.resume":["passed",35],"act2.resume.ui":["passed",298],"act2.join.hiro":["passed",114],"act2.join.ines":["passed",99],"act2.join.jonas":["passed",101],"act2.join.katya":["passed",93],"act2.join.liam":["passed",97],"act2.join.mei":["passed",93],"act2.form.alice":["passed",31],"act2.form.bob":["passed",34],"act2.form.charles":["passed",66],"act2.form.dana":["passed",36],"act2.form.erik":["passed",35],"act2.form.giulia":["passed",34],"act2.form.hiro":["passed",31],"act2.form.katya":["passed",33],"act2.form.mei":["passed",31],"act2.form.missing":["passed",29],"act2.form.unknown":["passed",28],"act2.form.alice.readback":["passed",31],"act2.form.alice.correct":["passed",32],"act2.form.alice.recheck":["passed",32],"act2.form.bob.snoop":["passed",43],"act2.form.admin.read":["passed",30],"act2.idempotent":["passed",31],"act2.anonymous":["passed",16],"act2.anonymous.register":["passed",13],"act2.roster":["passed",35],"act2.users.grown":["passed",28],"act2.flow.admin.users":["passed",2286],"act2.users.rogue":["passed",31],"act2.flow.alice.users":["passed",883],"act2.join.badid":["passed",29],"act2.join.ghost":["passed",35],"act2.whoami.bob":["passed",29],"act2.ui.waitlisted":["passed",289],"act2.ui.locked":["passed",319],"act2.flow.bob":["passed",1718],"act2.flow.anxious":["passed",1494],"act2.cap.create":["passed",39],"act2.cap.set":["passed",33],"act2.cap.join.room":["passed",34],"act2.cap.race":["passed",85],"act2.cap.roster":["passed",34],"act2.cap.join.full":["passed",38],"act2.cap.remove":["passed",47],"act2.cap.nojump":["passed",35],"act2.cap.ui.queued":["passed",288],"act2.cap.noautopromote":["passed",38],"act2.cap.approve.fill":["passed",49],"act2.cap.approve.over":["passed",44],"act2.cap.roster.final":["passed",32],"act2.cap.ui.gauge":["passed",572],"act2.cap.ui.in":["passed",313],"act3.propose.fair":["passed",37],"act3.propose.litdata":["passed",35],"act3.propose.genomelens":["passed",36],"act3.approve.fair":["passed",32],"act3.approve.litdata":["passed",1289],"act3.rogue":["passed",31],"act3.propose.sensor":["passed",37],"act3.withdraw":["passed",32],"act3.edit.fair":["passed",33],"act3.propose.anonymous":["passed",15],"act3.approve.ghost":["passed",27],"act3.propose.waitlisted":["passed",35],"act3.ui.proposals":["passed",962],"act4.pref.bob":["passed",37],"act4.pref.dana":["passed",36],"act4.export":["passed",33],"act4.team.matterhorn":["passed",30],"act4.team.bernina":["passed",32],"act4.team.anon":["passed",14],"act4.assign.bob":["passed",55],"act4.assign.alice":["passed",35],"act4.assign.dana":["passed",34],"act4.assign.erik":["passed",94],"act4.assign.anon":["passed",16],"act4.removeuser.anon":["passed",15],"act4.pref.erik":["passed",33],"act4.pref.update":["passed",32],"act4.window.prefclose":["passed",31],"act4.window.preflate":["passed",31],"act4.team.placeholder":["passed",30],"act4.team.placeholder.delete":["passed",31],"act4.rebalance.add":["passed",48],"act4.rebalance.remove":["passed",1370],"act4.rebalance.final":["passed",36],"act4.assign.hiro":["passed",49],"act4.assign.ines":["passed",49],"act4.assign.fatima":["passed",49],"act4.team.edit":["passed",35],"act4.team.edit.anon":["passed",14],"act4.team.delete.anon":["passed",14],"act4.ui.teams":["passed",567],"act4.webinars":["passed",35],"act5.approve.alice":["passed",37],"act5.approve.bob":["passed",35],"act5.approve.dana":["passed",34],"act5.approve.erik":["passed",36],"act5.approve.fatima":["passed",36],"act5.approve.giulia":["passed",38],"act5.approve.hiro":["passed",33],"act5.approve.ines":["passed",32],"act5.approve.double":["passed",32],"act5.roster.full":["passed",37],"act5.ui.member":["passed",282],"act5.ui.open":["passed",504],"act5.ui.about":["passed",326],"act5.flow.bob":["passed",2141],"act5.flow.admin":["passed",1630],"act5.flow.alice":["passed",1877],"act5.flow.search":["passed",1734],"act5.pref.reopen":["passed",35],"act5.flow.prefer":["passed",1560],"act5.pref.close":["passed",32],"act5.dropout.before":["passed",37],"act5.dropout.remove":["passed",34],"act5.dropout.after":["passed",33],"act5.dropout.team":["passed",37],"act5.backfill":["passed",63],"act5.backfill.access":["passed",36],"act5.backfill.team":["passed",35],"act5.roster.final":["passed",39],"act5.hub.tiles":["passed",617],"act5.hub.rogue":["passed",847],"act5.hub.review.click":["passed",974],"act5.hub.edit.click":["passed",1071],"act5.flow.reach.manage":["passed",1288],"act5.flow.hub.back":["passed",1050],"act5.hub.title.check":["passed",42],"act5.nav.fold":["passed",1170],"act5.pilot.join.offered":["passed",286],"act5.pilot.phase.wrap":["passed",33],"act5.pilot.hub.start":["passed",450],"act5.pilot.phase.day":["passed",35],"act5.pilot.hub.declare":["passed",1059],"act5.pilot.phase.advance":["passed",1005],"act5.pilot.phase.readback":["passed",38],"act5.pilot.cap.close":["passed",40],"act5.pilot.cap.schedule":["passed",75],"act5.pilot.cap.states":["passed",387],"act5.pilot.cap.readback":["passed",43],"act5.pilot.join.closed":["passed",288],"act5.pilot.join.rpc":["passed",32],"act5.pilot.cap.save":["passed",845],"act5.pilot.cap.save.readback":["passed",44],"act5.pilot.cap.plan":["passed",38],"act5.pilot.cap.unmet.bydates":["passed",564],"act5.pilot.cap.bydates.reset":["passed",38],"act5.pilot.cap.bydates.reset.readback":["passed",37],"act5.pilot.phase.declare":["passed",35],"act5.pilot.phase.declare.applied":["passed",37],"act5.pilot.cap.plan.again":["passed",36],"act5.pilot.cap.unmet":["passed",578],"act5.pilot.cap.unmet.readback":["passed",43],"act5.pilot.cap.refresh":["passed",842],"act5.pilot.cap.refresh.readback":["passed",43],"act5.cap.dupe":["passed",37],"act5.cap.dupe.check":["passed",39],"act5.cap.empty":["passed",37],"act5.cap.unknown":["passed",31],"act5.cap.badid":["passed",32],"act5.cap.ungoverned":["passed",31],"act5.cap.rogue":["passed",32],"act5.cap.anon":["passed",16],"act5.import.template":["passed",705],"act5.import.badmail":["passed",559],"act5.import.badproject":["passed",1149],"act5.import.dupe":["passed",563],"act5.import.waitlisted":["passed",538],"act5.import.halfrow":["passed",544],"act5.import.semicolon":["passed",562],"act5.import.newteam":["passed",550],"act5.import.malformed":["passed",567],"act5.import.bom":["passed",561],"act5.import.accents":["passed",607],"act5.import.apply":["passed",781],"act5.import.revert":["passed",1211],"act5.import.again":["passed",594],"act5.import.readback":["passed",35],"act5.approve.ghost":["passed",35],"act5.remove.ghost":["passed",31],"act5.approve.badid":["passed",29],"act5.window.regclose":["passed",32],"act5.window.regclosed":["passed",29],"act5.ui.charles":["passed",285],"act5.ui.charles.locked":["passed",236],"act5.rogue.approve":["passed",33],"act5.rogue.remove":["passed",30],"act5.owner.rogue":["passed",29],"act5.owner.waitlisted":["passed",31],"act5.owner.promote":["passed",32],"act5.owner.alice.hub":["passed",458],"act5.owner.alice.media":["passed",34],"act5.owner.alice.other.media":["passed",29],"act5.owner.alice.other.cap":["passed",31],"act5.owner.alice.other.hub":["passed",839],"act5.owner.self":["passed",48],"act5.owner.demote":["passed",35],"act5.owner.last":["passed",29],"act5.race.owner.doubleadd":["passed",35],"act5.race.owner.doubleadd.verify":["passed",40],"act5.race.owner.remove":["passed",52],"act5.race.owner.invariant":["passed",40],"act5.race.owner.restore":["passed",36],"act5.race.owner.restore2":["passed",60],"act5.race.owner.final":["passed",38],"act5.forms.roster":["passed",30],"act5.forms.rogue":["passed",32],"act5.forms.anon":["passed",15],"act5.state.facade":["passed",34],"act5.state.rogue":["passed",32],"act5.state.restore":["passed",35],"act5.phase.alias":["passed",33],"act5.audit":["passed",39],"act6.begin":["passed",36],"act6.ui.active":["passed",300],"act6.flow.anon":["passed",912],"act6.list.active":["passed",20],"act6.noshow":["passed",37],"act6.noshow.access":["passed",39],"act6.walkin.signup":["passed",55],"act6.walkin.override":["passed",32],"act6.walkin.join":["passed",38],"act6.walkin.approve":["passed",987],"act6.walkin.form":["passed",35],"act6.walkin.access":["passed",38],"act6.walkin.team":["passed",36],"act6.ui.teams":["passed",369],"act6.roster.walkin":["passed",39],"act6.announce":["passed",34],"act6.announce.ui":["passed",934],"act6.phase.ideation":["passed",32],"act6.phase.hacking":["passed",33],"act6.phase.judging":["passed",32],"act6.phase.rogue":["passed",31],"act6.ui.timeline":["passed",508],"act6.phase.current":["passed",1230],"act6.hub.now":["passed",481],"act6.hub.advance":["passed",993],"act6.hub.marker.readback":["passed",39],"act6.pages.ids":["passed",30],"act6.pages.movedown":["passed",668],"act6.pages.moveup":["passed",663],"act6.pages.reorder":["passed",579],"act6.pages.reorder.readback":["passed",33],"act6.pages.excerpt":["passed",388],"act6.pages.setorder.partial":["passed",34],"act6.pages.setorder.dupe":["passed",34],"act6.pages.setorder.empty":["passed",27],"act6.pages.setorder.ghost":["passed",27],"act6.pages.setorder.rogue":["passed",27],"act6.pages.setorder.anon":["passed",14],"act6.pages.setorder.restore":["passed",35],"act6.pages.order.final":["passed",32],"act6.flow.day1end":["passed",958],"act6.flow.day2":["passed",2285],"act6.files":["passed",40],"act6.submit.draft":["passed",1230],"act6.submit.draft.id":["passed",33],"act6.submit.final":["passed",1066],"act6.submit.bernina":["passed",35],"act6.submit.bernina.edit":["passed",38],"act6.submit.bernina.final":["passed",35],"act6.submit.abandoned":["passed",34],"act6.logo.refresh":["passed",34],"act6.logo.check":["passed",36],"act6.submit.rogue":["passed",29],"act6.submit.anon":["passed",14],"act6.submit.edit.anon":["passed",15],"act6.submit.final.anon":["passed",14],"act6.submit.invalid":["passed",32],"act6.window.subclose":["passed",75],"act6.window.sublate":["passed",30],"act6.window.override":["passed",32],"act6.submit.grace":["passed",34],"act6.ui.submissions":["passed",320],"act7.cat.impact":["passed",36],"act7.cat.tech":["passed",31],"act7.cat.demo":["passed",32],"act7.cat.ranked":["passed",31],"act7.cat.points":["passed",34],"act7.voting.open":["passed",1155],"act7.monitor.open":["passed",32],"act7.cast.alice":["passed",40],"act7.cast.bob":["passed",1071],"act7.cast.dana":["passed",39],"act7.cast.erik":["passed",37],"act7.cast.giulia":["passed",38],"act7.cast.hiro":["passed",37],"act7.cast.ines":["passed",37],"act7.cast.jonas":["passed",42],"act7.cast.noor":["passed",39],"act7.cast.alice2":["passed",36],"act7.cast.bob2":["passed",35],"act7.cast.ines2":["passed",37],"act7.cast.giulia2":["passed",36],"act7.race.cat":["passed",30],"act7.race.doublevote":["passed",43],"act7.race.check":["passed",71],"act7.ranked.gap":["passed",33],"act7.ranked.dupe":["passed",29],"act7.ranked.bob":["passed",40],"act7.ranked.wrongmethod":["passed",32],"act7.points.over":["passed",31],"act7.points.bob":["passed",38],"act7.points.ines":["passed",42],"act7.cast.admin":["passed",32],"act7.cast.waitlisted":["passed",36],"act7.cast.double":["passed",35],"act7.close":["passed",32],"act7.cast.late":["passed",31],"act7.result.impact":["passed",35],"act7.result.ranked":["passed",33],"act7.result.points":["passed",34],"act7.results":["passed",29],"act7.prizes.finalize":["passed",31],"act8.end":["passed",35],"act8.ui.finished":["passed",311],"act8.latejoin":["passed",33],"act8.flow.anon":["passed",1038],"act8.audit":["passed",41],"act8.thanks":["passed",38],"act8.thanks.ui":["passed",336],"act8.retention.alice":["passed",324],"act8.prizes.edit":["passed",34],"act8.prizes.rogue":["passed",30],"act8.retention":["passed",970],"act8.flow.charles":["passed",1815],"act8.photos":["passed",35],"act8.media.presign":["passed",26],"act8.media.rogue":["passed",27],"act8.media.svg":["passed",26],"act8.media.upload":["passed",222],"act8.media.upload2":["passed",189],"act8.objects.hackathon":["passed",33],"act8.objects.noowner":["passed",27],"act8.objects.badowner":["passed",28],"act8.objects.rogue":["passed",29],"act8.objects.anon":["passed",50],"act8.objects.site.admin":["passed",38],"act8.objects.site.rogue":["passed",26],"act8.objects.all.admin":["passed",488],"act8.objects.all.rogue":["passed",28],"act8.objects.scope.zero":["passed",29],"act8.objects.page.one":["passed",479],"act8.objects.page.two":["passed",486],"act8.objects.pagesize.max":["passed",491],"act8.objects.pagesize.over":["passed",29],"act8.objects.badtoken":["passed",30],"act8.flow.reach.gallery":["passed",1715],"act8.gallery.admin":["passed",972],"act8.gallery.rogue":["passed",838],"act8.picker.halves":["passed",701],"act8.picker.reject":["passed",524],"act8.picker.cancel":["passed",472],"act8.pilot.finished":["passed",38],"act8.pilot.join.finished":["passed",921],"act8.pilot.join.finished.rpc":["passed",35],"act8.flow.bob":["passed",1228],"act8.ui.winners":["passed",583],"act8.blog":["passed",34],"act8.ui.blog":["passed",301],"act8.profile.rename":["passed",33],"act8.profile.sticks":["passed",29],"act8.profile.blank":["passed",30],"act8.menu.alice":["passed",1709],"act8.menu.admin":["passed",860],"act8.form.ui.edit":["passed",928],"act8.account.liam":["passed",58],"act8.account.mei":["passed",51],"act8.account.check":["passed",33],"act8.page.cleanup":["passed",37],"act8.draft.delete":["passed",40]}} </script> <script> diff --git a/.claude/skills/hackathon-e2e/recipe.jsonl b/.claude/skills/hackathon-e2e/recipe.jsonl index 044fdfb2..85da6412 100644 --- a/.claude/skills/hackathon-e2e/recipe.jsonl +++ b/.claude/skills/hackathon-e2e/recipe.jsonl @@ -245,7 +245,9 @@ {"id": "act5.pilot.cap.save", "priority": "P1", "implement": true, "outcome": "The form saves the whole set from the checkboxes: proposing closes, voting opens, and the panel confirms it in words before the badges are re-read.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: ticking two boxes and pressing Save changes the switches", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilitySave", "params": {"hackathonId": "{{var:capHackId}}", "uncheck": ["Propose projects"], "check": ["Vote"], "after": {"Propose projects": "Closed", "Vote": "Open"}}, "todo": "Unchecked boxes submit NOTHING, so the six are rebuilt from the enum order server-side. A save that dropped that would look identical on screen until the badges are read back."} {"id": "act5.pilot.cap.save.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; proposing is CLOSED, voting OPEN, registration still CLOSED and results still COMING - a whole-set save must not flatten the scheduled one into a plain closed switch.", "act": 5, "t": "T-1wk", "title": "END STATE: the saved form kept the scheduled capability scheduled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_PROPOSE_PROJECTS": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VOTE": "CAPABILITY_STATE_OPEN", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VIEW_RESULTS": "CAPABILITY_STATE_COMING"}}}} {"id": "act5.pilot.cap.plan", "priority": "P1", "implement": true, "outcome": "Succeeds; the live phase now NAMES a capability that is switched off, which is the plan-vs-reality gap the hub warns about.", "act": 5, "t": "T-1wk", "title": "PILOT: team preferences are planned for the phase that is running, and switched off", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} -{"id": "act5.pilot.cap.unmet.bydates", "priority": "P1", "implement": true, "outcome": "The warning is OFFERED while its button cannot work: the panel names the phase the DATES say is live, and the action behind Enable refuses because no phase is DECLARED. Pinned as it stands - the two halves disagree about what 'current' means.", "act": 5, "t": "T-1wk", "title": "BUG PINNED: Enable-it is offered for a phase resolved by dates, and refuses", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{var:capHackId}}/manage"}, {"expectText": "is meant to include"}, {"clickButton": "Enable it"}, {"expectText": "no current phase to take settings from"}], "todo": "The panel computes `unmet` from currentAndNextPhase, which falls back to the DATES; applyPhaseCapabilities looks the phase up by current_phase_id and fails when it is empty. Either the action should fall back the same way, or the warning should only appear against a declaration. When it is fixed, flip this action to assert the switches moved."} +{"id": "act5.pilot.cap.unmet.bydates", "priority": "P1", "implement": true, "outcome": "The warning is offered for the phase the DATES say is live, with no marker set anywhere - and its one click works: team preferences come on, registration and proposing are untouched, and the warning goes away because it has nothing left to say.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: Enable-it works for a phase resolved by dates, with nothing declared", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilityEnableUnmet", "params": {"hackathonId": "{{var:capHackId}}", "nowBadge": "By dates", "phase": "Pilot Sprint Evening", "names": ["set team preferences"], "button": "Enable it", "after": {"Set team preferences": "Open"}, "untouched": {"Register": "Closed", "Propose projects": "Closed"}}, "todo": "RE-SPECIFIED (was 'BUG PINNED: Enable-it is offered for a phase resolved by dates, and refuses'). It used to assert the 400 'no current phase to take settings from': the panel resolved the live phase with currentAndNextPhase, which falls back to the dates, while applyPhaseCapabilities looked it up by current_phase_id alone - so the control was offered exactly where it could not work. The action resolves it the same way now. `nowBadge` is the positive control: without it this passes against a DECLARED phase, which is the case act5.pilot.cap.unmet already covers."} +{"id": "act5.pilot.cap.bydates.reset", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences go back off, so the two actions below are once again about what ADVANCING does rather than about what the click above already did.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer undoes the by-dates enable before declaring anything", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "Added when act5.pilot.cap.unmet.bydates was re-specified from a refusal into a working click. Without it act5.pilot.phase.declare.applied would assert OPEN against a capability that was already open - green whatever AdvancePhase did with it."} +{"id": "act5.pilot.cap.bydates.reset.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences read CLOSED. This is the precondition the next two actions are judged against, so it is asserted rather than assumed.", "act": 5, "t": "T-1wk", "title": "END STATE: the by-dates enable really was undone", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_CLOSED"}}}} {"id": "act5.pilot.phase.declare", "priority": "P1", "implement": true, "outcome": "Succeeds; the marker lands on the running phase AND applies what that phase opens - team preferences come on, which is the transaction advancing is supposed to carry.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer declares the running phase current, which applies its plan", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AdvancePhase", "params": {"hackathonId": "{{var:capHackId}}", "phaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} {"id": "act5.pilot.phase.declare.applied", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences are OPEN because the phase the marker landed on names them - advancing applies the phase's plan in the same transaction that moves the marker, which is what makes the panel's copy true.", "act": 5, "t": "T-1wk", "title": "END STATE: advancing applied what the phase opens", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_OPEN"}}}} {"id": "act5.pilot.cap.plan.again", "priority": "P1", "implement": true, "outcome": "Succeeds; the organizer switches the phase's own capability back off by hand, which is how a real gap between plan and reality appears - the timeline moved, then somebody changed their mind.", "act": 5, "t": "T-1wk", "title": "PILOT: …and then switches team preferences off again by hand", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "That it really went off is read back by the action below: the plan-vs-reality warning can only appear while this capability is switched off AND named by the live phase."} @@ -258,7 +260,7 @@ {"id": "act5.cap.empty", "priority": "P2", "implement": true, "outcome": "Succeeds and changes nothing - an empty batch is a no-op, not an error, so a form that submits no ticks cannot fail on its way to closing everything.", "act": 5, "t": "T-1wk", "title": "PARAMS: an empty capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": []}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_VOTE": "CAPABILITY_STATE_CLOSED", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED"}}}} {"id": "act5.cap.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument, and nothing in the batch is written: every capability is resolved BEFORE the first row is touched, so a typo cannot apply the prefix before it.", "act": 5, "t": "T-1wk", "title": "PARAMS: a batch naming a capability the enum does not have", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_UNSPECIFIED", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} {"id": "act5.cap.badid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a malformed hackathon id fails before any permission is even considered.", "act": 5, "t": "T-1wk", "title": "PARAMS: a malformed hackathon id on the capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "not-a-uuid", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} -{"id": "act5.cap.ungoverned", "priority": "P2", "implement": true, "outcome": "Rejected with NotFound, and the WHOLE batch is refused: a capability with no stored row cannot be set, and the panel says so before an organizer spends a save on it.", "act": 5, "t": "T-1wk", "title": "PARAMS: setting a capability that has no stored row refuses the whole batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "NotFound"}, "todo": "A hackathon with NO capability rows is the reachable case for this code path - every event created through the API is seeded with all six, so an individually ungoverned capability cannot be produced from the outside."} +{"id": "act5.cap.ungoverned", "priority": "P2", "implement": true, "outcome": "Rejected with NotFound, and the message names the HACKATHON. A batch against an id that exists is never refused over a missing capability row any more - the row is created - so the only NotFound left here is 'no such hackathon', and nothing may be written for an id that names nothing.", "act": 5, "t": "T-1wk", "title": "PARAMS: a capability batch against a hackathon that does not exist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "NotFound", "errorMatches": "hackathon .* not found"}, "todo": "RE-SPECIFIED (was 'setting a capability that has no stored row refuses the whole batch'). SetCapabilities used to answer NotFound if ANY capability in the batch was ungoverned, and the organiser's panel posts all six - so one missing row made the whole capability screen unusable. It creates the row now. This id still refuses, but for the other reason, and `errorMatches` is what keeps the two apart: the same code from the same request meant a missing capability before and a missing hackathon now. The upsert itself is unreachable from the API (Create seeds all six and no RPC deletes one), so it is pinned in Go instead - hackathon_service_test.go, 'SetCapabilities with an ungoverned capability', which deletes a row to get there."} {"id": "act5.cap.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - the switches are hackathon Write, and a participant does not hold it.", "act": 5, "t": "T-1wk", "title": "a participant cannot change what participants may do", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} {"id": "act5.cap.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - 'who are you' and 'not you' are different answers, and an anonymous caller gets the first one.", "act": 5, "t": "T-1wk", "title": "an anonymous caller cannot change the capability switches", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "Unauthenticated"}} {"id": "act5.import.template", "priority": "P1", "implement": true, "outcome": "Both formats download as files, carry the columns the importer reads and this event's REAL roster - and the importer accepts the very file the page just handed out, with nothing left to apply.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: the template downloads, and its own importer accepts it", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportTemplate", "params": {"mustContain": ["bob@mail.org", "hiro.tanaka@example.org", "FAIR Pipeline Builder", "Team Matterhorn"], "roundTrip": true}, "todo": "Two halves shipped separately drift apart in one commit, and the failure surfaces only when a real organizer downloads one - so the template is not merely parsed here, it is fed straight back in through the UI."} diff --git a/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts new file mode 100644 index 00000000..56155702 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts @@ -0,0 +1,307 @@ +import { test, expect, type Locator, type Page } from "@playwright/test" + +// The chip's four appearances, measured rather than inferred. +// +// The bug: `.chip:hover` is (0,2,0) and `.chip-active` is (0,1,0), so pointing +// at the tab you are already on ERASED its accent tint and painted it with the +// same neutral `raised` an unselected chip gets. Selected and unselected became +// the same pixels for exactly as long as the pointer was on them — which is the +// moment a reader is most likely to be checking which tab is which. +// +// Why this file asserts COMPUTED STYLE and never a class name. `chip-active` was +// on the element the whole time the bug shipped; every class-based assertion +// that could have been written would have passed. That is the +// "locator that contains the thing it asserts about" family from +// .claude/CLAUDE.md, in its CSS form: the class is the INPUT to the rule, and +// what broke was the rule. Only the resolved colour is the fact. +// +// Carrier: the public landing page's Trending tab row — one `chip chip-active` +// ("Hackathons") and two plain `chip` — because it is anonymous, three chips in +// one row, and no fixture data is involved. The rule itself is global to the +// theme, so any `.chip` anywhere would do. + +type RGB = [number, number, number] + +/** + * The colour a reader actually sees, as sRGB bytes. + * + * Not a regex over the computed string, which was the first attempt and does + * not survive contact with this theme: `.chip-active` is + * `color-mix(in oklab, …)` and Firefox reports that back as + * `oklab(0.8 -0.101689 0.11698 / 0.2)`, while `.chip:hover` resolves to a plain + * `rgb(…)`. Two syntaxes for the same kind of fact, and one of them names a + * colour space this file has no business reimplementing. + * + * So the browser is asked to paint it. The colour goes onto a 1×1 canvas OVER + * the page's own background, which resolves both the colour space and the alpha + * exactly the way the page resolves them — a 20%-accent wash and a 30% one are + * two different pixels, which is the claim, and reading them as `rgba(…,0.2)` + * vs `rgba(…,0.3)` would have been a claim about the stylesheet instead. + * + * `fillStyle` silently KEEPS its previous value when handed something it cannot + * parse, so an unsupported syntax would quietly measure whatever was set last + * and every comparison would come out equal — a vacuous pass of the worst kind. + * The sentinel makes that a thrown error. + */ +async function paintedColour(page: Page, value: string, over: string): Promise<RGB> { + const out = await page.evaluate( + ([v, backdrop]) => { + const canvas = document.createElement("canvas") + canvas.width = 1 + canvas.height = 1 + const ctx = canvas.getContext("2d") + if (!ctx) return null + + const SENTINEL = "#ff00ff" + ctx.fillStyle = SENTINEL + ctx.fillStyle = v + if (ctx.fillStyle === SENTINEL) return null + + ctx.fillStyle = backdrop + ctx.fillRect(0, 0, 1, 1) + ctx.fillStyle = v + ctx.fillRect(0, 0, 1, 1) + const d = ctx.getImageData(0, 0, 1, 1).data + + return [d[0], d[1], d[2]] + }, + [value, over] as const, + ) + if (!out) { + throw new Error(`the browser could not paint '${value}' — nothing was measured`) + } + + return out as RGB +} + +/** Straight-line distance in sRGB between two painted colours. */ +function colourDistance(a: RGB, b: RGB): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) +} + +/** + * A settled computed value. + * + * `.chip` carries `transition: background-color 120ms ease`, and + * `getComputedStyle` mid-transition returns the INTERMEDIATE colour — so a read + * taken straight after `hover()` is a point on the way to the answer and would + * make every comparison below timing-dependent. Reads until two consecutive + * samples agree rather than sleeping a guessed interval. + */ +async function settledStyle( + el: Locator, + prop: "background-color" | "color", +): Promise<string> { + const read = () => + el.evaluate( + (node, p) => getComputedStyle(node).getPropertyValue(p), + prop, + ) + + let previous = await read() + for (let i = 0; i < 25; i++) { + await el.page().waitForTimeout(40) + const current = await read() + if (current === previous) return current + previous = current + } + throw new Error(`${prop} never settled on this element`) +} + +interface ChipPaint { + background: RGB + /** Kept as the computed string: it is compared for equality, never measured. */ + ink: string +} + +/** Move the pointer off every chip and let the transition finish. */ +async function unhover(page: Page) { + await page.mouse.move(0, 0) +} + +async function paintOf(el: Locator, backdrop: string): Promise<ChipPaint> { + const background = await settledStyle(el, "background-color") + + return { + background: await paintedColour(el.page(), background, backdrop), + ink: await settledStyle(el, "color"), + } +} + +/** + * The whole claim, as one function, so the control below can call the exact + * check the real test calls rather than a lookalike. + * + * Four appearances, and every pair that must differ: + * + * selected at rest ─┬─ vs unselected at rest : selection is visible + * └─ vs selected hovered : hover answers on it too + * unselected at rest ─── vs unselected hovered : hover stays visible + * unselected hovered ─── vs selected hovered : THE BUG — these were equal + * + * Plus the "reads as both" claim, which the four inequalities alone do not + * make: a selected-and-hovered chip painted some unrelated third colour would + * satisfy all of them. It has to stay in the accent family, so its background + * is required to be NEARER to selected-at-rest than to unselected-hovered, and + * its ink has to be the selected ink exactly. + */ +async function expectChipStatesDistinct(page: Page) { + const row = page.locator("#trending") + const selected = row.locator("button.chip.chip-active") + const unselected = row.locator("button.chip:not(.chip-active)").first() + + // Positive control for the whole file: without both kinds of chip on screen + // every comparison below is between two reads of the same element. + await expect(selected, "the landing page must render one selected chip").toHaveCount(1) + await expect(unselected, "…and at least one unselected chip").toBeVisible() + + // The backdrop these washes are composited over: the chips sit directly on the + // page in the Trending row, with no card between them, so the body's own + // background is what shows through a `transparent` chip. Read once and used + // for all four, which is what makes the four numbers comparable. + const backdrop = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + await unhover(page) + const selectedRest = await paintOf(selected, backdrop) + const unselectedRest = await paintOf(unselected, backdrop) + + await unselected.hover() + const unselectedHover = await paintOf(unselected, backdrop) + + await selected.hover() + const selectedHover = await paintOf(selected, backdrop) + + await unhover(page) + + const d = (a: ChipPaint, b: ChipPaint) => colourDistance(a.background, b.background) + const show = (c: ChipPaint) => `rgb(${c.background.join(",")})` + // 8 rather than 0: two colours a reader cannot tell apart are not two states, + // and an exact-inequality check would accept a one-unit rounding difference + // as a distinguishable style. + const VISIBLE = 8 + + expect( + d(selectedRest, unselectedRest), + `the selected chip is painted like an unselected one at rest ` + + `(${show(selectedRest)} vs ${show(unselectedRest)})`, + ).toBeGreaterThan(VISIBLE) + + // `> 0`, not `> VISIBLE`, and deliberately so. HOW FAR the unselected hover + // moves is a design decision that differs by colour mode — light mode's + // `raised` is two lightness points from `canvas` — and this file has no + // business ruling on it. That the hover rule REACHES the chip at all is the + // property; the integers come from getImageData, so equality here is exact + // rather than approximate. + expect( + d(unselectedRest, unselectedHover), + `hover does not change an unselected chip at all ` + + `(${show(unselectedRest)} vs ${show(unselectedHover)})`, + ).toBeGreaterThan(0) + + // The regression itself. Before the fix both of these resolved to + // `--color-raised` with `--color-ink`, i.e. the selection vanished under the + // pointer. + expect( + d(unselectedHover, selectedHover), + `THE BUG: a hovered selected chip is painted exactly like a hovered ` + + `unselected one (${show(selectedHover)} vs ${show(unselectedHover)}) — ` + + `.chip:hover (0,2,0) is beating .chip-active (0,1,0)`, + ).toBeGreaterThan(VISIBLE) + + expect( + d(selectedRest, selectedHover), + `the selected chip does not answer the pointer at all ` + + `(${show(selectedRest)} vs ${show(selectedHover)})`, + ).toBeGreaterThan(VISIBLE) + + // Reads as BOTH: still accent-tinted, not merely "some other colour". + expect( + d(selectedHover, selectedRest), + `selected-and-hovered has left the accent family: it is nearer to the ` + + `unselected hover (${show(unselectedHover)}) than to the selected ` + + `rest state (${show(selectedRest)})`, + ).toBeLessThan(d(selectedHover, unselectedHover)) + + expect( + selectedHover.ink, + `a hovered selected chip must keep the selected ink (${selectedRest.ink}); ` + + `it took the unselected hover ink (${unselectedHover.ink}) instead`, + ).toBe(selectedRest.ink) +} + +test.describe("chip states stay distinguishable", () => { + test("selected, hovered, and selected-and-hovered are three different chips", async ({ + page, + }) => { + await page.goto("/") + await expectChipStatesDistinct(page) + }) + + // ─── Control: the check, shown failing ────────────────────────────────────── + // + // Deleting the rule from the live CSSOM restores the exact stylesheet that + // shipped the bug, so this is the defect itself rather than an imitation of + // it. Done in the page rather than by editing the theme and re-running by + // hand: a source edit proves it once, for whoever was watching, and then + // stops existing. + test("CONTROL: without .chip-active:hover the check fails", async ({ page }) => { + await page.goto("/") + + // Passes as it stands, first — a check that threw unconditionally would + // look exactly like a good one from here. + await expectChipStatesDistinct(page) + + const deleted = await page.evaluate(() => { + // Anything that OWNS rules and can drop one: a stylesheet, or a grouping + // rule. `@layer components { … }` wraps this whole theme, so the rule is + // never at the top level of the sheet and the walk has to recurse — and + // deletion is by index ON THE OWNER, which is why the owner is what gets + // passed down rather than a bare CSSRuleList. + type RuleOwner = { cssRules: CSSRuleList; deleteRule(index: number): void } + + const strip = (owner: RuleOwner): number => { + let n = 0 + const rules = owner.cssRules + // Backwards: deleting shifts every later index down by one. + for (let i = rules.length - 1; i >= 0; i--) { + const rule = rules[i] as CSSRule & Partial<RuleOwner> & { selectorText?: string } + if (rule.cssRules && typeof rule.deleteRule === "function") { + n += strip(rule as unknown as RuleOwner) + } + if (rule.selectorText === ".chip-active:hover") { + owner.deleteRule(i) + n++ + } + } + + return n + } + + let total = 0 + for (const sheet of Array.from(document.styleSheets)) { + try { + total += strip(sheet) + } catch { + // A cross-origin sheet cannot be read; none of ours are. + } + } + + return total + }) + + expect( + deleted, + "the control removed no rule — either the selector was renamed (update " + + "this control with it) or the fix is not in the stylesheet at all, and " + + "the test above is passing for some other reason", + ).toBeGreaterThan(0) + + await expect( + expectChipStatesDistinct(page), + "with the rule gone the selected chip is repainted by .chip:hover, and " + + "this check is what must say so", + ).rejects.toThrow(/THE BUG/) + }) +}) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index d0e41340..fa3c754b 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -1212,6 +1212,26 @@ func (s *HackathonService) SetCapabilities( wanted[entCapability] = t.GetEnabled() } + // The hackathon itself, not its capabilities: the batch below CREATES a row + // for anything ungoverned, and a create against an id that names nothing is + // a foreign-key error rather than an answer. Asked once, here, so a caller + // naming a hackathon that does not exist still gets NotFound about the + // HACKATHON — which is the true statement — instead of NotFound about a + // capability, which used to be the same reply and said the wrong thing. + // + // After RequirePermission on purpose: a stranger must not learn which + // hackathon ids exist from the difference between PermissionDenied and + // NotFound. + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + rows, err := s.dbClient.Capability.Query(). Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). All(ctx) @@ -1225,11 +1245,6 @@ func (s *HackathonService) SetCapabilities( for _, row := range rows { present[row.Capability] = row } - for c := range wanted { - if _, ok := present[c]; !ok { - return nil, status.Errorf(codes.NotFound, "hackathon %s has no %s capability", id, c) - } - } txn, err := s.dbClient.Tx(ctx) if err != nil { @@ -1244,7 +1259,59 @@ func (s *HackathonService) SetCapabilities( } for c, enabled := range wanted { - row := present[c] + row, governed := present[c] + + // An ungoverned capability is CREATED rather than refused. + // + // This used to answer NotFound for the whole batch, and the organiser's + // panel posts all six switches every save — so one absent row made the + // entire capability screen unusable, with a 404 as the only explanation + // and no RPC anywhere that could create the missing row. The panel had + // grown a paragraph of copy warning about it, which is a product + // explaining its own data gap to the person least able to close it. + // + // Of the three possible answers, creating is the only one that is both + // safe and true to what the request says. SKIPPING is the dangerous one: + // an ungoverned capability is ALLOWED (`capability.State` reports + // UNGOVERNED and `Allowed` returns true for it), so quietly dropping a + // row the caller asked to set to `false` would report a save that + // changed nothing while participants kept the permission — a silent + // no-op on a gate. REFUSING with the capability named is honest but + // still leaves the panel dead, because there is no way to act on the + // name. And the schema already calls a full set the invariant — "one row + // per capability per hackathon, pre-created on hackathon creation" — so + // a missing row is a gap (an older event, a partial restore, a + // capability added to the enum after the event was made), never a + // decision anyone took. `SetCapabilities` takes a whole list rather than + // a delta, which means "these are the values afterwards"; creating what + // is missing is what makes that sentence true. + if !governed { + if _, err := txn.Capability.Create(). + SetCapability(c). + SetEnabled(enabled). + SetHackathonID(id). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + // The unique index is `(capability, hackathon)`, so this is a + // concurrent writer that governed it first — a real outcome, and + // a different one from a broken request. Retrying takes the + // UpdateOne branch. + if ent.IsConstraintError(err) { + return nil, status.Errorf( + codes.Aborted, + "another change to %s landed first; retry", + c, + ) + } + slog.Error("create capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + + continue + } + // Already correct: skipping the write keeps modified_at and the modifier // meaningful, so "who last changed this" stays a real answer. if row.Enabled == enabled { @@ -1316,14 +1383,17 @@ func (s *HackathonService) capabilityStatuses( return nil } - clock := newCapabilityClock(order, hack.CurrentPhaseID) - now := time.Now() - out := make([]*ents.CapabilityStatus, 0, len(rows)) - for _, row := range rows { - out = append(out, capabilityStatusFromEnt(row, clock, now)) - } - - return out + // `capabilityStatusesFromEnt`, the same mapper Get uses, rather than one + // status per stored row: it fills the vocabulary, reporting UNGOVERNED for a + // capability with no row. Built from the rows alone this reply was SHORT + // wherever Get's was six long — one handler giving two answers to "what are + // this hackathon's capabilities", and the shorter one is the reply a client + // gets immediately after saving. + return capabilityStatusesFromEnt( + rows, + newCapabilityClock(order, hack.CurrentPhaseID), + time.Now(), + ) } // AdvancePhase declares which phase a hackathon is now in, and switches its diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index 7f9f36f9..21f91c25 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -1426,6 +1426,187 @@ var _ = Describe("HackathonService", func() { Expect(status.Code(err)).To(BeElementOf(codes.NotFound, codes.PermissionDenied)) }) + // SetCapabilities used to answer NotFound if ANY capability in the batch + // had no stored row, and the organiser's panel posts all six switches on + // every save — so one absent row made the whole capability screen + // unusable, with a 404 as its only explanation. + Describe("SetCapabilities with an ungoverned capability", func() { + // The six the panel sends, every save. Written out rather than + // derived so a capability added to the enum shows up here as a + // compile-time gap rather than as a batch that quietly got smaller. + wholeForm := func(vote bool) []*entities.CapabilityToggle { + return []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_REGISTER, Enabled: false}, + { + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: true, + }, + { + Capability: entities.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + Enabled: true, + }, + { + Capability: entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + Enabled: true, + }, + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: vote}, + { + Capability: entities.Capability_CAPABILITY_VIEW_RESULTS, + Enabled: true, + }, + } + } + + // stateOf reads one capability's state out of Get, which reports the + // whole vocabulary — including UNGOVERNED for a capability with no + // row, which is the state under test. + stateOf := func(c entities.Capability) entities.CapabilityState { + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + for _, s := range resp.GetHackathon().GetCapabilities() { + if s.GetCapability() == c { + return s.GetState() + } + } + Fail("capability missing from Get response") + + return entities.CapabilityState_CAPABILITY_STATE_UNSPECIFIED + } + + // Deleting the row is the ONLY way to reach this state, and that is + // the whole reason the bug survived: Create seeds all six and no RPC + // removes one, so nothing driven through the API is ever ungoverned. + // The gap is real off the API (an event older than a capability the + // enum gained later, a partial restore) and has to be made here. + BeforeEach(func() { + n, err := dbClient.Capability.Delete(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + Exec(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(1), "nothing was ungoverned — the specs below would prove nothing") + + // The positive control. Without it, every claim under this + // Describe would also hold against a governed row, i.e. against + // the setup silently failing. + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_UNGOVERNED), + ) + }) + + It("creates the missing row instead of refusing the whole batch", func() { + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("lands the rest of the batch, which the refusal used to lose", func() { + // The cost of the old behaviour was never the one row: it was the + // other five. `register: false` is the switch that decides whether + // anyone can still sign up, and it never reached the database. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_REGISTER)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + }) + + It("governs a capability asked to be OFF rather than leaving it allowed", func() { + // Why creating beats skipping, stated as the outcome rather than + // as a preference. UNGOVERNED is ALLOWED (`capability.State`'s + // Allowed returns true for it), so a handler that skipped the + // missing row would report a successful save while participants + // kept the permission the organiser had just switched off. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(false), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + "an ungoverned capability set to false must end up governed and "+ + "closed — UNGOVERNED here would mean the save was a no-op on a gate", + ) + }) + + It("attributes the row it creates to whoever saved the form", func() { + // A created row is a row like any other: "who last changed this" + // has to keep working across the gap, or the first save after a + // restore is the one edit with no author. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + row, err := dbClient.Capability.Query(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + WithModifier(). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(row.Edges.Modifier).NotTo(BeNil()) + Expect(row.Edges.Modifier.KeycloakID).To(Equal(testAdmin)) + }) + + It("still refuses a hackathon that does not exist, and says so", func() { + // The other half of removing the NotFound: an id that names + // nothing must not be answered by creating rows for it. It keeps + // NotFound — but now about the HACKATHON, which is the true + // statement; the old reply named a capability and was misleading + // about which thing was missing. + missing := uuid.New() + + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: missing.String(), + Capabilities: []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: true}, + }, + }) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + Expect(err.Error()).To(ContainSubstring("hackathon")) + + n, qErr := dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(missing))). + Count(context.Background()) + Expect(qErr).NotTo(HaveOccurred()) + Expect(n).To(Equal(0), "a refused call must not have written rows") + }) + + It("reports the created row in its own response, not only on the next Get", func() { + // `capabilityStatuses` says it "reports every capability the way + // Get does" and used to build from the stored rows alone, so a + // capability with no row was simply absent from this reply while + // Get reported it as UNGOVERNED. Two answers to the same question + // from one handler. + resp, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetCapabilities()).To(HaveLen(6)) + }) + }) + Describe("phase schedule", func() { var adminUserID uuid.UUID diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte index f87b49e2..ac5c78fa 100644 --- a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte @@ -63,10 +63,13 @@ : `${unmetNames.slice(0, -1).join(', ')} and ${unmetNames.at(-1)}` ); - // Capabilities with no stored row. `SetCapabilities` answers NotFound for - // any of them and refuses the WHOLE batch, and the save posts all six — so - // one ungoverned row makes the form unusable, which the organiser should - // read here rather than deduce from a 404. + // Capabilities with no stored row. This used to be a warning that the save + // would be REFUSED: `SetCapabilities` answered NotFound for any ungoverned + // capability and threw away the whole batch, and this form posts all six — + // so one absent row made the panel unusable and the copy existed to explain + // a 404. The handler creates the row now, so what is left to say is the part + // that was always the real news: nothing governs this yet, which means the + // server is ALLOWING it. const ungoverned = $derived(rows.filter((c) => capabilityIsUngoverned(c.state))); const ungovernedSentence = $derived( ungoverned.map((c) => capabilitySubject(c.capability) ?? 'A capability').join(', ') @@ -174,14 +177,19 @@ </form> {#if ungoverned.length > 0} - <!-- Not a permission check — the backend decides, and it decides by - refusing. This says so before the organiser spends a save on it. --> + <!-- The state itself, not an error about it. An unticked box and an + ungoverned capability look the same in this form and mean + opposite things — nobody has switched it on, versus nobody has + switched it on and everyone may do it anyway — so the sentence + has to say which, and say what saving will do about it. --> <p class="m-0 text-xs text-warning-ink" role="status"> {ungovernedSentence} {ungoverned.length === 1 ? 'has' : 'have'} no stored setting on this hackathon, so the server allows - {ungoverned.length === 1 ? 'it' : 'them'} and will refuse to save this form until the - {ungoverned.length === 1 ? 'row exists' : 'rows exist'}. + {ungoverned.length === 1 ? 'it' : 'them'} right now, whatever the box above says. + Saving this form creates the + {ungoverned.length === 1 ? 'setting' : 'settings'} and puts + {ungoverned.length === 1 ? 'it' : 'them'} under these switches. </p> {/if} diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts index 63ee0a33..c2af487f 100644 --- a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts @@ -155,15 +155,24 @@ describe("the four capability states", () => { }) describe("capabilities with no stored row", () => { - it("warns that the server allows them and will refuse the save", () => { - // `SetCapabilities` answers NotFound for a capability with no row and - // refuses the WHOLE batch, and the form posts all six. An organiser should - // read that here rather than deduce it from a 404. + it("says the server allows them, and what saving will do about it", () => { + // An unticked box and an ungoverned capability are the same pixel and the + // opposite fact: nobody switched it on, versus nobody switched it on and + // everyone may do it anyway. This sentence is the only thing that tells + // them apart, so it has to state the permission — and, since + // `SetCapabilities` creates the missing row now rather than refusing the + // batch over it, what pressing Save will do. It used to say the save would + // be REFUSED, which stopped being true when that was fixed. mount(FOUR_STATES) const warning = screen.getByText(/no stored setting on this hackathon/) expect(warning).toHaveTextContent(capabilitySubject(VOTE) as string) - expect(warning).toHaveTextContent(/will refuse to save/) + expect(warning).toHaveTextContent(/the server allows it right now/) + expect(warning).toHaveTextContent(/Saving this form creates the setting/) + expect( + warning.textContent, + "the panel must not still be telling organisers the save will be refused", + ).not.toMatch(/refuse/i) }) it("says nothing when every capability has a row", () => { diff --git a/components/frontend/src/lib/server/hackathon/stateActions.test.ts b/components/frontend/src/lib/server/hackathon/stateActions.test.ts new file mode 100644 index 00000000..e5177302 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/stateActions.test.ts @@ -0,0 +1,209 @@ +/** + * `applyPhaseCapabilities` — the action behind the hub's "Enable it" button. + * + * The bug this file exists for: the hub decided WHICH phase is live with + * `currentAndNextPhase`, which falls back to the calendar when no phase has been + * declared, and then offered a button whose action looked the phase up by + * `current_phase_id` alone. Declaring a phase is an explicit act nobody is + * required to perform, so in the ordinary state of a running hackathon the + * warning rendered and its one action answered 400, every time. + * + * The tests are written against the RESOLUTION, not against the lookup: what + * matters is that the button switches on what the live phase names, whichever + * way "live" was decided. A test that asserted "the action calls + * `currentAndNextPhase`" would pass against an implementation that resolved the + * phase correctly and then wrote the wrong capabilities. + */ + +import { describe, expect, it, vi } from "vitest" +import type { RequestEvent } from "@sveltejs/kit" +import { applyPhaseCapabilities } from "./stateActions" + +const REGISTER = 1 +const PROPOSE = 2 +const TEAM_PREFS = 3 +const VOTE = 5 + +const OPEN = 2 +const CLOSED = 3 + +const HOUR = 3_600_000 +const now = Date.now() + +/** A phase whose dates are running right now. */ +const live = { + id: "phase-live", + name: "Sprint Evening", + startsAt: new Date(now - HOUR), + endsAt: new Date(now + HOUR), +} +/** One that finished yesterday, so the calendar cannot pick it. */ +const past = { + id: "phase-past", + name: "Warm-up", + startsAt: new Date(now - 48 * HOUR), + endsAt: new Date(now - 24 * HOUR), +} + +interface Row { + capability: number + state: number + openInPhaseId?: string +} + +/** + * A hackathon the action can read, plus the spy that records what it wrote. + * + * `get` is answered from the world rather than from a fixed literal, because the + * action re-reads state on purpose (a stale page must not be able to switch + * something back on) — so the read has to be the same object the write is judged + * against. + */ +function world(opts: { currentPhaseId?: string; capabilities: Row[] }) { + const setCapabilities = vi.fn(async () => ({})) + const grpc = { + hackathon: { + get: async () => ({ + hackathon: { + currentPhaseId: opts.currentPhaseId ?? "", + phases: [past, live], + capabilities: opts.capabilities, + }, + }), + setCapabilities, + }, + } + + return { + setCapabilities, + event: { + locals: { grpc }, + request: new Request("http://localhost/", { method: "POST" }), + } as unknown as RequestEvent, + } +} + +/** The `{capability, enabled}` list the action posted, as a plain map. */ +function written(spy: ReturnType<typeof vi.fn>): Record<number, boolean> { + expect(spy, "the action wrote nothing at all").toHaveBeenCalledTimes(1) + const req = spy.mock.calls[0]?.[0] as + | { capabilities: { capability: number; enabled: boolean }[] } + | undefined + expect(req, "the write carried no payload to read back").toBeDefined() + + return Object.fromEntries( + (req?.capabilities ?? []).map((c) => [c.capability, c.enabled]), + ) +} + +/** + * The live phase names team preferences, which are off; registration is off and + * named by no phase; voting is on and named by no phase. That is the shape the + * hub's warning appears in, and the shape every claim below is about. + */ +const gap: Row[] = [ + { capability: REGISTER, state: CLOSED }, + { capability: PROPOSE, state: CLOSED }, + { capability: TEAM_PREFS, state: CLOSED, openInPhaseId: live.id }, + { capability: VOTE, state: OPEN }, +] + +describe("applyPhaseCapabilities", () => { + it("applies the live phase's plan when no phase has been declared", async () => { + // The regression. `currentPhaseId` is empty — the calendar is what says + // `phase-live` is running — and this used to be an unconditional 400. + const { event, setCapabilities } = world({ capabilities: gap }) + + const result = await applyPhaseCapabilities(event, "h1") + + expect( + (result as { status?: number }).status, + "a by-dates phase is still a current phase, and this must not refuse it", + ).toBeUndefined() + expect(written(setCapabilities)[TEAM_PREFS]).toBe(true) + }) + + it("applies the declared phase's plan, ignoring what the dates say", async () => { + // A declaration outranks the calendar everywhere else in the product + // (`resolvePhaseStatus`), so the fallback must not have become the rule: + // `phase-past` is declared and names nothing, `phase-live` is running by its + // dates and names team preferences. Nothing may come on. + const { event, setCapabilities } = world({ + currentPhaseId: past.id, + capabilities: gap, + }) + + await applyPhaseCapabilities(event, "h1") + + expect( + written(setCapabilities)[TEAM_PREFS], + "the declared phase names nothing, so nothing the calendar's phase names " + + "may be switched on", + ).toBe(false) + }) + + it("only ever switches things on", async () => { + // The additive rule, asserted on the two capabilities no phase names: + // registration must stay closed and voting must stay open. Applying a + // phase's plan is a catch-up, never a reset. + const { event, setCapabilities } = world({ capabilities: gap }) + + await applyPhaseCapabilities(event, "h1") + const sent = written(setCapabilities) + + expect(sent[VOTE], "voting was on and no phase names it").toBe(true) + expect(sent[REGISTER], "registration was off and no phase names it").toBe(false) + }) + + it("refuses when neither a declaration nor the calendar names a phase", async () => { + // The one state that genuinely has no plan to read. It keeps a 400 — but it + // is now reachable only when BOTH meanings of "current" come back empty, + // which is what the message says. + const setCapabilities = vi.fn(async () => ({})) + const event = { + locals: { + grpc: { + hackathon: { + get: async () => ({ + hackathon: { + currentPhaseId: "", + phases: [past], + capabilities: gap, + }, + }), + setCapabilities, + }, + }, + }, + request: new Request("http://localhost/", { method: "POST" }), + } as unknown as RequestEvent + + const result = (await applyPhaseCapabilities(event, "h1")) as { + status: number + data: { message: string } + } + + expect(result.status).toBe(400) + expect(result.data.message).toMatch(/no phase's dates cover today/i) + expect( + setCapabilities, + "refusing must not have written anything on the way", + ).not.toHaveBeenCalled() + }) + + it("refuses a declaration that names a phase this hackathon does not have", async () => { + // `currentAndNextPhase` deliberately does NOT fall back to the dates for a + // dangling pointer — the organiser has decided, and quietly applying some + // other phase's plan would be worse than applying none. Pinned here because + // the fallback added above is exactly the change that could erode it. + const { event, setCapabilities } = world({ + currentPhaseId: "deleted-phase", + capabilities: gap, + }) + + const result = (await applyPhaseCapabilities(event, "h1")) as { status: number } + + expect(result.status).toBe(400) + expect(setCapabilities).not.toHaveBeenCalled() + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/stateActions.ts b/components/frontend/src/lib/server/hackathon/stateActions.ts index da543a31..edbc2ee9 100644 --- a/components/frontend/src/lib/server/hackathon/stateActions.ts +++ b/components/frontend/src/lib/server/hackathon/stateActions.ts @@ -19,7 +19,10 @@ import { enabledCapabilities, phaseCapabilities, } from "$lib/server/hackathon/phaseForm" -import { withPhaseCapabilitiesEnabled } from "$lib/utils/phase" +import { + currentAndNextPhase, + withPhaseCapabilitiesEnabled, +} from "$lib/utils/phase" import { fail } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" @@ -30,12 +33,12 @@ export function toCapabilityFailure(e: unknown) { message: "You don't have permission to change this hackathon", }) } - // TODO(backend: project-preferences-capability): on `SetCapabilities` a - // NotFound means the hackathon has no HackathonState row — and by then the - // casbin policies have already been written, because they are added inside the - // capability loop before the state re-read fails (`hackathon_service.go:655` - // then `:681`). So a failure reported here may have granted permissions anyway. - // No hackathon reachable from the app is in that state, so this is a guard. + // On `SetCapabilities` a NotFound now means one thing only: the HACKATHON does + // not exist. It used to also mean "one capability in the batch has no stored + // row", which refused all six and reported the wrong missing thing; that case + // creates the row instead (`hackathon_service.go`, SetCapabilities). So a 404 + // reaching a viewer here is a hackathon that was deleted under them, and + // `e.details` names it. if (e instanceof ClientError && e.code === Status.NOT_FOUND) { return fail(404, { message: e.details }) } @@ -138,6 +141,33 @@ export async function saveCapabilities( * Switch on whatever the current phase expects and is off. Additive only — see * `withPhaseCapabilitiesEnabled`; nothing is ever switched off here, so this * cannot close registration as a side effect of moving through phases. + * + * "Current" is resolved by `currentAndNextPhase`, which is the SAME answer the + * hub used when it drew the warning this button sits under — an organiser's + * declaration if there is one, otherwise whichever phase's own dates are + * running. It used to be `phases.find(p => p.id === currentPhaseId)`, a second, + * narrower definition of the word, and the two disagreed in the state most + * hackathons are actually in: declaring a phase is an explicit act nobody has to + * perform, so with no marker set the panel named the live phase off the calendar + * and offered `Enable it`, and this action answered + * `400 "…no current phase to take settings from"` every single time. The + * control was offered exactly where it could not work. + * + * Resolving by dates here rather than hiding the warning, because the warning is + * TRUE in that state — the phase the calendar says is running does name a + * capability participants cannot use — and hiding a true, actionable warning + * behind a marker nobody is required to set would report the gap in fewer + * situations than it exists in. Nothing about the phase POINTER moves: this + * still writes only capability switches, and declaring a phase stays the + * separate, deliberate act it is. `SetCapabilities` is additive here, so the + * worst a by-dates resolution can do is switch on something the calendar says + * should already be on. + * + * The 400 survives for the case that is genuinely impossible — no declaration + * AND no phase whose dates cover today — where there is no phase to read a plan + * from under either meaning of the word. Not reachable from the panel (with no + * current phase there are no phase capabilities, so `unmet` is empty and the + * warning does not render), so it stays a guard on a direct POST. */ export async function applyPhaseCapabilities( event: RequestEvent, @@ -147,10 +177,15 @@ export async function applyPhaseCapabilities( try { const state = await readState(grpc, hackathonId) - const current = state.phases.find((p) => p.id === state.currentPhaseId) + const { current } = currentAndNextPhase( + state.phases, + state.currentPhaseId || undefined, + ) if (!current) { return fail(400, { - message: "This hackathon has no current phase to take settings from", + message: + "No phase is current here — none is declared and no phase's dates " + + "cover today — so there are no settings to take", }) } diff --git a/components/frontend/src/themes/hackagon.css b/components/frontend/src/themes/hackagon.css index c81035bf..c68b84f4 100644 --- a/components/frontend/src/themes/hackagon.css +++ b/components/frontend/src/themes/hackagon.css @@ -593,4 +593,27 @@ background-color: color-mix(in oklab, var(--color-accent) 20%, transparent); color: var(--color-accent-ink); } + /* The selected chip, hovered. Without this the pointer ERASED the selection: + * `.chip:hover` is (0,2,0) and `.chip-active` is (0,1,0), so the tab the + * viewer is on lost its accent tint for exactly as long as they pointed at + * it — the one moment they are most likely to be reading which tab is which. + * + * A rule of its own rather than raising `.chip-active`, for two reasons. + * `.chip-active` is also worn by a `btn btn-icon btn-quiet` (the markdown + * editor's table toggle), so `.chip.chip-active` would have left that one + * untinted; and `.btn-quiet:hover` is (0,2,0) too, so the same erasure was + * happening there. This selector is (0,2,0) and covers both. + * + * ⚠ It TIES with `.chip:hover` and `.btn-quiet:hover` and wins on source + * order, which is the same way `.chip-active` already beats `.chip`. Keep it + * last: moved above either of those it stops applying and nothing but the + * computed-style check in `24-chip-states.spec.ts` would notice. + * + * 20% → 30% is the step `.btn-accent`/`.btn-accent:hover` already uses, so + * selected-and-hovered reads as both: still accent (not the neutral + * `raised` an unselected chip gets), and visibly lifted from selected-at-rest. */ + .chip-active:hover { + background-color: color-mix(in oklab, var(--color-accent) 30%, transparent); + color: var(--color-accent-ink); + } } From d750974995a65b413f41e25093147f54a3d48a00 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:56:35 +0200 Subject: [PATCH 05/25] =?UTF-8?q?test(e2e):=20make=20mutation=20testing=20?= =?UTF-8?q?routine=20=E2=80=94=2038=20mutations,=2012=20gaps=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 465 recipe actions ran green while only ~13 had ever been observed FAILING. This turns "we broke things by hand once" into a check that runs, and the first run found twelve properties nothing was guarding. ## Arenas, because the e2e suite is the wrong place for most of this The journey is serial with chained vars, so `--grep` cannot reach an action and `--until-act N` is the only lever; a backend mutation additionally needs the server rebuilt against it. Minutes each, and useless while the stack is down. So each mutation is routed to its cheapest witness: `go` (~9 s), `vitest` (4–16 s narrowed), and `journey`/`smoke` only where nothing cheaper can see it. The fast tier never enters `nix develop` — that shell is a repo-wide mutex costing 44 s on a permanently-dirty worktree — and needs no running stack, which is why it could be built straight through a container recreate. 38 mutations in ~65 min, dominated by Go recompiles and 3.5 s `git status` calls on the 9p mount, not by tests. ## What it asserts Exactly the expected tests go red. NO REDS fails the run loudly — that is the signal, not an inconvenience. EXTRA REDS fails too and names what else fell. Restoration is verified with `git status` on a trap path, and the runner refuses to start on a dirty tree, because a mutation left applied and then committed is the worst thing this tool could do. ## First run: 26 caught, 12 gaps Caught exactly: all four capability states (a two-state flattening cannot hide), both AdvancePhase boundaries, requireCapability and its organizer bypass, the anonymous-says-Unauthenticated rule (6 reds), both global-admin gates, all three capacity rules, all three presign conditions, all 8 client-side mutations. The twelve gaps are all backend, and eleven cluster into three surfaces the Go suite does not touch at all: - requireWindowOpen — ALL of it. Deadlines never closing, the now-anchored override ignored, registration opening early: zero reds each. - RemoveOwner — ALL of it. The last-organizer guard, self-demotion, and demotion-leaves-Member. Ownership is a casbin fact with no column to assert against, which is likely why the specs were never written. - Join's guards — private-needs-invite, already-finished, and the granted role. Making Join hand every joiner OWNER instead of Member goes unnoticed by every Go spec. Plus RequireUser admitting the anonymous subject (the exact change eight TeamService handlers were made for), and checkContentType's allowlist — the rule keeping image/svg+xml out of an origin we serve, which has no journey witness either. These are gaps in the FAST tier; each is crossRef'd to the journey actions that also pin the property. But those reds are DEDUCED from each action's declared expect.error, never observed — so window enforcement and the last-organizer invariant currently rest on a suite costing minutes and a whole stack. Coupling found: flattening the client's ungoverned case reddens joinOffer as well as capability.test.ts — the two client gates agreeing, in tests, that UNGOVERNED permits. ## Three bugs the tool found in itself Two edits to one file journal two backups and the second holds already-mutated text, so replaying forwards left a file broken with every backup intact and the journal reading as unwound — restore is newest-first now, and only a post-restore `git status` could have caught it. `exec` in the wrapper killed its own trap, making the belt-and-braces restore decoration. And the known-flaky filter swallowed a genuine red: a listed test that truly witnesses a mutation belongs in its expectReds, where the excuse cannot reach it. Not validated: the journey and smoke arenas have never executed a mutation. The report parser is checked against the real 469-spec results.json, and the runner REFUSES to run a backend-mutating e2e entry against an unmutated binary — a suite driven against stale code reports NO REDS and looks exactly like an untested property. --- .claude/CLAUDE.md | 180 ++++ .../skills/devcontainer-up/scripts/mutate.sh | 26 + .claude/skills/hackathon-e2e/SKILL.md | 34 +- .../hackathon-e2e/mutations/manifest.jsonl | 47 + .../skills/hackathon-e2e/mutations/run.mjs | 925 ++++++++++++++++++ .../skills/hackathon-e2e/scripts/mutate.sh | 76 ++ 6 files changed, 1287 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/devcontainer-up/scripts/mutate.sh create mode 100644 .claude/skills/hackathon-e2e/mutations/manifest.jsonl create mode 100644 .claude/skills/hackathon-e2e/mutations/run.mjs create mode 100644 .claude/skills/hackathon-e2e/scripts/mutate.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9315a5be..b6f250f6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -504,6 +504,186 @@ mode" — `clickButton` prefers an exact match now. And `login: true` only works with `fresh: true`: with a persona's saved session Keycloak SSOs straight through and the helper waits forever for a `#username` field that never renders. +## Mutation testing — making "can this test go red?" a thing that runs + +The section above is the expensive one, and every entry in it was found BY +HAND, once. `.claude/skills/hackathon-e2e/mutations/` turns that hunt into a +check: a **manifest** of deliberate, reversible breakages, each paired with the +exact set of tests that MUST notice, and a **runner** that applies one, runs +the tests, and asserts exactly that set failed. + +```bash +bash .claude/skills/devcontainer-up/scripts/mutate.sh run # from the host +bash .claude/skills/hackathon-e2e/scripts/mutate.sh list # the manifest +bash .claude/skills/hackathon-e2e/scripts/mutate.sh check # anchors still match source +bash .claude/skills/hackathon-e2e/scripts/mutate.sh run owner # one id, or a prefix +bash .claude/skills/hackathon-e2e/scripts/mutate.sh restore # after a run was killed +``` + +**`NO REDS` is the result this exists for, and it FAILS the run.** Not a +curiosity to note and move past: it means nothing in the suite holds that +property, which is the same fact the sidebar-fold assertion turned out to be +stating and the same fact `usersLackNames` stated for months. `MISMATCH` fails +too and names the extras — an over-broad mutation, or coupling nobody knew +about. Only `EXACT` passes. + +### Adding one + +Append a line to `mutations/manifest.jsonl`: + +```json +{"id":"cap.allowed.flatten","property":"UNGOVERNED PERMITS. Flattening …", + "arena":"go","tier":"fast", + "file":"components/backend/internal/capability/capability.go", + "find":"\treturn s == StateOpen || s == StateUngoverned", + "replace":"\treturn s == StateOpen", + "expectReds":["capability::Capability > Allowed > allows an ungoverned capability"], + "crossRef":["act5.cap.ungoverned"]} +``` + +`find` must match its file **exactly once** — a fragment of real source, tabs +and all. A manifest whose anchor has drifted is the same disease as a test that +has stopped asserting, so `apply()` throws rather than skipping, and `check` +exists to be cheap enough to run on every commit. Two edits in one mutation go +in `edits: [{file,find,replace}, …]`; that shape exists because +`markdown.script-survives` has to weaken BOTH `ALLOWED_TAGS` and `FORBID_TAGS` +— defence in depth, and a half-applied mutation would report the property as +tested when only the other half held. + +Author `expectReds` with `--record`, which prints the observed reds instead of +judging them, then **read them before you freeze them**: recording is how a +manifest agrees with whatever the code happens to do. An entry with an empty +`expectReds` is rejected at load time unless it also says `"gap": true` with a +`gapReason` — because `expectReds: []` is the one value that would make every +mutation pass, and that is precisely the vacuous shape this tool is for. A gap +that later starts producing reds is reported as `GAP CLOSED`, so promoting it +is prompted rather than remembered. + +### Arenas, and why the journey is the last resort + +An arena is where the evidence is. Cost is why there is more than one. + +| arena | what it runs | cost | identity | +| --- | --- | --- | --- | +| `go` | `go test -tags "test unittest"`, six packages | **~9 s** | `pkg::Describe > Context > It` (Ginkgo), `pkg::TestXxx` (plain) | +| `vitest` | frontend units, narrowed to `arenaConfig.files` | ~10 s narrowed, 60 s full | `file::full test name` | +| `journey` | `run.sh journey --until-act N` | minutes, + a backend restart or frontend rebuild | the recipe action id | +| `smoke` | `run.sh smoke` | ~1.4 min against the built frontend | `file::title` | + +The **fast tier (`go` + `vitest`) needs no running stack at all** — it drives +the compilers straight from source — which is what makes 30 mutations a +five-minute check rather than an afternoon, and means it still works while the +stack is down, being rebuilt, or in use by somebody else. + +Two things make that possible and neither is incidental. **The runner never +enters `nix develop`**: that shell is a repo-wide mutex costing 44 s unopposed +on a permanently-dirty worktree (container trap 4), and 30 entries through it +would cost more than every test they run. `.devenv/profile/bin` already holds +`go`, `node` and `pnpm` and costs nothing to put on `PATH`. And **the journey +cannot be `--grep`ped**: it is serial with chained `vars`, so the only lever is +`--until-act N`, and a backend mutation additionally needs the running server +rebuilt against it. That is minutes per entry against seconds, so the manifest +routes a property to the journey only when nothing cheaper can witness it — and +records the journey action in `crossRef` when a cheap arena is the primary +witness, so the two are not confused for each other. + +### Restoration is verified, not assumed + +A mutation left in the tree that then gets committed is the worst outcome this +tool can produce, so it has three independent recoveries. The runner refuses to +start when any file it may write is already dirty; it writes the original bytes +to `.state/backup/` and fsyncs them **before** editing, journals the edit, and +restores on exit, on signal, and on `restore`. `scripts/mutate.sh` restores +from the same journal in its own `trap EXIT` and then checks `git status` +itself — because a trap cannot survive a SIGKILL or a container recreate, and +the journal on disk is what makes a later `restore` possible at all. + +**That check earned its keep on the first multi-edit mutation, by finding a bug +in the runner itself.** `markdown.script-survives` edits ONE file TWICE, so it +journals two backups — and the second holds the file as it stood after edit 1, +i.e. already mutated. Replaying the journal forwards restored the original and +then overwrote it with the half-mutated copy: every backup on disk intact, the +journal reading as fully unwound, and `markdown.ts` left broken. Restore runs +**newest first** now, so each entry undoes exactly the edit that produced it. +Nothing but a post-restore `git status` could have caught that — the tool +believed it had cleaned up. + +⚠ **The cleanliness check is SCOPED to the files the manifest names**, plus +`components/`, and that is deliberate twice over. A repo-wide "git status is +empty" check can never pass in this container (three git-lfs pointer files read +as permanently modified — trap 4), and it is a check on other people: the first +run of this tool aborted ten mutations because a second agent added a file +elsewhere under `.claude/` while it worked. A safety check that cries wolf on +somebody else's work is a safety check that gets deleted. + +### What the first manifest found (2026-08-13, 38 mutations) + +**26 caught, 12 with NO REDS.** Every one of the twelve is a backend property, +and eleven of them cluster into three surfaces that the 6-second Go suite does +not touch at all: + +- **`requireWindowOpen` — all of it.** Deadlines never closing, the + now-anchored override ignored, registration opening early: three mutations, + zero reds. No Go spec exercises a window in any package. +- **`RemoveOwner` — all of it.** The last-organizer guard, the + cannot-demote-yourself rule, and demotion leaving Member behind: three + mutations, zero reds, and a fourth (dropping `ownerMu`) that no unit test + could see anyway. Ownership is a casbin fact here with no column to assert + against, which is likely why the specs were never written. +- **`Join`'s guards.** The invite requirement on a private event, the + already-finished refusal, and the ROLE a join grants. `join.grants-member` + hands every joiner OWNER instead of Member and not one Go spec notices. + +Plus `RequireUser` admitting the anonymous subject (the change eight TeamService +handlers were made for), and `checkContentType`'s allowlist — the rule keeping +`image/svg+xml` out of an origin we serve. + +**These are gaps in the FAST tier, not proof the product is unguarded**: each +one is pinned by journey actions, listed in the entry's `crossRef`. But that +means the only thing standing behind window enforcement and the +last-organizer invariant is a suite that costs minutes, needs the whole stack, +and cannot be run on a branch. **Those reds are also DEDUCED** — from each +action's declared `expect.error` — not observed, because no journey mutation +has been run yet. + +The frontend half came out the other way round: all 8 vitest mutations produced +reds, including a cross-file one — flattening `capabilityAllows` turns +`joinOffer`'s ungoverned case red as well, which is the two gates agreeing, in +the test suite, that UNGOVERNED permits. + +### The baseline is not green, and that is handled rather than hidden + +"Exactly the expected set failed" means nothing against a suite that is not +green to start with. `internal/service`'s **"Capacity > never oversells the last +place under simultaneous joins" fails intermittently** under in-memory SQLite +(roughly one run in five; the error is `Internal: couldn't join hackathon`). +Left alone it lands in an arbitrary mutation's extras column and reads as +coupling. So reds are diffed against a baseline taken on the clean tree, and an +unexpected red is re-checked against a FRESH clean run before it is called +coupling. + +**That re-check is not sufficient on its own, and the first verification run +proved it**: `cap.gate.removed` came back MISMATCH naming exactly that spec, +while the fresh sample happened to pass — an intermittent failure that does not +reproduce in one extra sample is indistinguishable from coupling. A tool that +randomly fails one entry in five is a tool people stop reading. So the flake is +also **declared**, in `KNOWN_FLAKY` at the top of the arena section, with the +reason it is there. + +Both paths report; neither drops. Every ignored red is printed **with its +reason**, because an ignored red is a claim, and a list of tests whose failures +don't count is precisely the shape that could hide a real one. Adding a line to +`KNOWN_FLAKY` is a claim about the SUITE that wants justifying — never a way to +quieten a mutation that is genuinely over-broad. + +⚠ **A flaky test can still be a genuine witness, and that trap fired within the +hour.** That capacity spec hammers concurrent joins against a cap — which is +precisely what `capacity.oversell-by-one` breaks — so under THAT mutation its +failure is the evidence, and the first freeze had stripped it as noise. The rule: +when a listed test really does witness a mutation, it belongs in that mutation's +`expectReds`, where the excuse cannot reach it (the filter only ever looks at +reds that are NOT expected). Excusing an extra prints that reminder every time. + ## Container traps (Windows/macOS hosts) — read before touching compose These cost hours; all of them are handled in `.devcontainer/` (or, for 2b, in diff --git a/.claude/skills/devcontainer-up/scripts/mutate.sh b/.claude/skills/devcontainer-up/scripts/mutate.sh new file mode 100644 index 00000000..7aa76ca4 --- /dev/null +++ b/.claude/skills/devcontainer-up/scripts/mutate.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Run the mutation-testing manifest INSIDE the devcontainer — the default way +# to run it, and a sibling of e2e.sh. All arguments forward verbatim to +# .claude/skills/hackathon-e2e/scripts/mutate.sh: +# +# mutate.sh list # the manifest +# mutate.sh check # every anchor still matches its source +# mutate.sh run # the fast tier (go + vitest, no stack) +# mutate.sh run owner.last-guard # one mutation +# mutate.sh restore # after a run that was killed outright +# +# The fast tier needs NO running stack: it drives `go test` and `vitest` +# straight from source. That is deliberate — it means mutation testing stays +# available while the stack is down, being rebuilt, or in use by someone else. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +require_docker +if ! container_running; then + bash "$HERE/up.sh" +fi + +args="" +if [ $# -gt 0 ]; then args=$(printf "%q " "$@"); fi +in_container "bash .claude/skills/hackathon-e2e/scripts/mutate.sh $args" diff --git a/.claude/skills/hackathon-e2e/SKILL.md b/.claude/skills/hackathon-e2e/SKILL.md index a1f26356..e913a598 100644 --- a/.claude/skills/hackathon-e2e/SKILL.md +++ b/.claude/skills/hackathon-e2e/SKILL.md @@ -1,6 +1,6 @@ --- name: hackathon-e2e -description: Deterministic end-to-end testing of the full hackathon lifecycle. Boots the whole stack from scratch (Keycloak, Postgres, backend, frontend), then runs Playwright (Firefox) suites with a 15-person cast — admin, organizer, a 13-strong registration wave, capacity cut-off, waitlist, dropout, day-1 no-show, and a same-day walk-in — plus generated file-upload fixtures and a 309-action recipe (recipe.jsonl) with priority/outcome/gate triage. Runs inside the devcontainer by default (see the devcontainer-up skill). Use when asked to run e2e/browser tests, verify the hackathon lifecycle (publication → registration → teams → event → voting → post-event), smoke-test the platform, or check which lifecycle RPCs the backend implements. +description: Deterministic end-to-end testing of the full hackathon lifecycle. Boots the whole stack from scratch (Keycloak, Postgres, backend, frontend), then runs Playwright (Firefox) suites with a 15-person cast — admin, organizer, a 13-strong registration wave, capacity cut-off, waitlist, dropout, day-1 no-show, and a same-day walk-in — plus generated file-upload fixtures and a 309-action recipe (recipe.jsonl) with priority/outcome/gate triage. Runs inside the devcontainer by default (see the devcontainer-up skill). Also owns mutation testing (`mutations/`, `scripts/mutate.sh`): breaks the product on purpose and checks the suites notice, so a passing test is one that has been seen to go red. Use when asked to run e2e/browser tests, verify the hackathon lifecycle (publication → registration → teams → event → voting → post-event), smoke-test the platform, check which lifecycle RPCs the backend implements, or ask whether a test would actually catch a given bug. --- # Hackathon lifecycle e2e testing @@ -394,6 +394,38 @@ recipe is largely made of look like ordinary failures in a log. convert that `rpc` action into a `ui.flow` (clicks / `setInputFiles` with the generated bundle); keep the expectations. +## Mutation testing (`mutations/`) + +A green suite proves nothing until you have watched it go red. `mutations/` +holds a manifest of deliberate, reversible breakages, each paired with the +tests that MUST notice; `scripts/mutate.sh` applies one, runs them, and asserts +**exactly** the expected set failed. + +```bash +bash scripts/mutate.sh list # the manifest +bash scripts/mutate.sh check # every anchor still matches its source +bash scripts/mutate.sh run # the fast tier — go + vitest, NO stack needed +bash scripts/mutate.sh run cap # one id, or every id under a prefix +bash scripts/mutate.sh run --tier all --arena journey +bash scripts/mutate.sh restore # only after a run was killed outright +``` + +**`NO REDS` fails the run**: nothing in the suite holds that property. That is +the result the tool exists to produce, not an aside. `MISMATCH` fails too and +names the extras (over-broad mutation, or coupling). Only `EXACT` passes. + +The fast tier drives `go test` and `vitest` straight from source, so it needs +no running stack and never enters `nix develop` (`.devenv/profile/bin` has the +binaries; the dev shell is a repo-wide mutex — container trap 4). The `journey` +arena exists but is the last resort: the recipe is serial with chained `vars`, +so `--until-act N` is the only lever, and a backend mutation needs the server +rebuilt against it first. Where a cheap arena is the real witness, the journey +action that ALSO pins the property is recorded in the entry's `crossRef` — the +two are related, not interchangeable. + +Full contract — how to add an entry, what `gap` means, how restoration is +verified — is in `.claude/CLAUDE.md`, "Mutation testing". + ## Troubleshooting - **Firefox: `libgtk-3.so.0: cannot open shared object file`** → the dev diff --git a/.claude/skills/hackathon-e2e/mutations/manifest.jsonl b/.claude/skills/hackathon-e2e/mutations/manifest.jsonl new file mode 100644 index 00000000..abf35699 --- /dev/null +++ b/.claude/skills/hackathon-e2e/mutations/manifest.jsonl @@ -0,0 +1,47 @@ +{"comment":"─── Capability model: the four states, and the two ways to flatten them ───"} +{"id":"cap.allowed.flatten","property":"UNGOVERNED PERMITS. Flattening the four states to open/closed silently closes every capability that has no stored row.","arena":"go","tier":"fast","file":"components/backend/internal/capability/capability.go","find":"\treturn s == StateOpen || s == StateUngoverned","replace":"\treturn s == StateOpen","expectReds":["capability::Capability > Allowed > allows an ungoverned capability"],"crossRef":["act5.cap.ungoverned"]} +{"id":"cap.states.absent-denies","property":"A capability with no entry in the resolved map is ungoverned, i.e. allowed — the map having no opinion is not a refusal.","arena":"go","tier":"fast","file":"components/backend/internal/capability/capability.go","find":"\treturn !ok || state.Allowed()","replace":"\treturn ok && state.Allowed()","expectReds":["capability::Capability > Allowed > allows a capability missing from the map entirely"]} +{"id":"cap.schedule-overrides-flag","property":"The stored flag is checked first and unconditionally: a wrong phase date can produce a wrong countdown, never a wrong permission.","arena":"go","tier":"fast","file":"components/backend/internal/capability/capability.go","find":"\tif r.Enabled {\n\t\treturn StateOpen\n\t}\n\tif r.pending(now) {\n\t\treturn StateComing\n\t}","replace":"\tif r.pending(now) {\n\t\treturn StateComing\n\t}\n\tif r.Enabled {\n\t\treturn StateOpen\n\t}","expectReds":["capability::Capability > ResolveRow after a manual advance > keeps the flag decisive regardless of position","capability::Capability > ResolveRow schedule > ignores the schedule entirely when the flag is on","service::HackathonService > EditCapability > phase schedule > leaves the flag alone when only the schedule is edited","service::HackathonService > EditCapability > phase schedule > lets the flag win over a future phase"]} +{"id":"cap.pending.off-by-one","property":"A capability whose opening phase IS the current phase is not still 'coming' — the boundary is strictly-greater, not greater-or-equal.","arena":"go","tier":"fast","file":"components/backend/internal/capability/capability.go","find":"\t\treturn r.OpenInPhase != nil && *r.OpenInPhase > *r.CurrentPhase","replace":"\t\treturn r.OpenInPhase != nil && *r.OpenInPhase >= *r.CurrentPhase","expectReds":["capability::Capability > ResolveRow after a manual advance > reports coming at the boundary only before the phase is reached"]} +{"id":"cap.advance.opens","property":"Advancing INTO a capability's opening phase opens it; the comparison is inclusive of the target position.","arena":"go","tier":"fast","file":"components/backend/internal/capability/capability.go","find":"\t\topened := *r.OpenInPhase <= target","replace":"\t\topened := *r.OpenInPhase < target","expectReds":["capability::Capability > Advance > keeps a spanning capability open across intermediate phases","capability::Capability > Advance > keeps an open-ended capability open once reached","capability::Capability > Advance > opens a capability once its phase is reached","service::HackathonService > EditCapability > phase schedule > AdvancePhase > closes what the previous phase opened when moving on","service::HackathonService > EditCapability > phase schedule > AdvancePhase > opens the capabilities scheduled for the target phase","service::HackathonService > EditCapability > phase schedule > AdvancePhase > restores the earlier flags when advancing backwards"]} +{"id":"cap.advance.closes","property":"Advancing past a capability's closing phase closes it — a window is a PAIR of positions, not just an opening.","arena":"go","tier":"fast","file":"components/backend/internal/capability/capability.go","find":"\t\tclosed := r.ClosedInPhase != nil && target >= *r.ClosedInPhase","replace":"\t\tclosed := false","expectReds":["capability::Capability > Advance > closes a capability once its closing phase is reached","capability::Capability > Advance > closes a capability whose window is inverted","capability::Capability > Advance > keeps a spanning capability open across intermediate phases","service::HackathonService > EditCapability > phase schedule > AdvancePhase > closes what the previous phase opened when moving on","service::HackathonService > EditCapability > phase schedule > AdvancePhase > restores the earlier flags when advancing backwards"]} +{"id":"cap.gate.removed","property":"requireCapability actually refuses: a closed capability blocks the mutation it guards with FailedPrecondition.","arena":"go","tier":"fast","file":"components/backend/internal/service/capability.go","find":"\tif !states.Allowed(c) {","replace":"\tif !states.Allowed(c) && false {","expectReds":["service::HackathonService > EditCapability > blocks Join once registration is closed","service::HackathonService > EditCapability > phase schedule > HackathonState facade > carries no enforcement of its own","service::HackathonService > EditCapability > phase schedule > keeps a scheduled capability blocked for enforcement"],"crossRef":["act5.window.regclosed","act7.cast.late"]} +{"id":"cap.gate.bypass-inverted","property":"Anyone who can WRITE the hackathon bypasses the capability gate — organizers must be able to fix things outside the window.","arena":"go","tier":"fast","file":"components/backend/internal/service/capability.go","find":"\tif bypass {\n\t\treturn nil\n\t}","replace":"\tif bypass && false {\n\t\treturn nil\n\t}","expectReds":["service::HackathonService > EditCapability > lets an owner join even when registration is closed"]} +{"comment":"─── The pinned status codes: who are you, versus not you ───"} +{"id":"rbac.anon-says-denied","property":"An anonymous caller refused by casbin is told Unauthenticated, never PermissionDenied — 'who are you' and 'not you' are different answers.","arena":"go","tier":"fast","file":"components/backend/internal/middleware/rbac.go","find":"\t\tif claims, found := GetClaims(ctx); found {\n\t\t\tif sub, err := claims.GetSubject(); err == nil && sub == AnonSubject {\n\t\t\t\treturn status.Error(codes.Unauthenticated, \"authentication required\")\n\t\t\t}\n\t\t}","replace":"\t\tif claims, found := GetClaims(ctx); found {\n\t\t\tif sub, err := claims.GetSubject(); err == nil && sub == AnonSubject {\n\t\t\t\treturn status.Error(codes.PermissionDenied, \"permission denied\")\n\t\t\t}\n\t\t}","expectReds":["service::HackathonService > Authentication and RBAC > Create permissions > denies anonymous requests from creating","service::HackathonService > Edit > denies anonymous users from editing","service::HackathonService > HackathonSettings > EditSettings > denies anonymous users","service::PageService > Create > requires authentication to create","service::PhaseService > Create > requires authentication to create","service::StorageService.ListObjects > one event's media > tells an anonymous caller to authenticate"],"crossRef":["act2.anonymous","act5.cap.anon","act6.pages.setorder.anon","act8.objects.anon"]} +{"id":"rbac.global-admin.anon","property":"RequireGlobalAdmin tells an anonymous caller to authenticate before it tells them they are not an admin.","arena":"go","tier":"fast","file":"components/backend/internal/middleware/rbac.go","find":"\tif uid == AnonSubject {\n\t\treturn status.Error(codes.Unauthenticated, \"authentication required\")\n\t}","replace":"\tif uid == AnonSubject && false {\n\t\treturn status.Error(codes.Unauthenticated, \"authentication required\")\n\t}","expectReds":["service::StorageService.ListObjects > site media — the platform's own imagery > tells an anonymous caller to authenticate, never that it is denied","service::StorageService.ListObjects > the whole media library > tells an anonymous caller to authenticate"],"crossRef":["act0.about.anon"]} +{"id":"rbac.global-admin.denies","property":"A signed-in non-admin cannot write site pages — the global Admin role is the gate, not merely being logged in.","arena":"go","tier":"fast","file":"components/backend/internal/middleware/rbac.go","find":"\tif !ok {\n\t\treturn status.Error(codes.PermissionDenied, \"permission denied\")\n\t}\n\n\treturn nil\n}\n\n//exhaustruct:optional","replace":"\tif !ok && false {\n\t\treturn status.Error(codes.PermissionDenied, \"permission denied\")\n\t}\n\n\treturn nil\n}\n\n//exhaustruct:optional","expectReds":["service::StorageService.ListObjects > site media — the platform's own imagery > denies a hackathon ORGANIZER, who may create events but not edit the site","service::StorageService.ListObjects > site media — the platform's own imagery > denies a signed-in user who is not a platform admin","service::StorageService.ListObjects > the whole media library > denies an event OWNER — owning one event is not seeing every event's files"],"crossRef":["act0.about.rogue"]} +{"id":"auth.require-user.admits-anon","property":"RequireUser rejects the anonymous subject: endpoints that act on a PERSON must not accept 'anonymous' as an identity.","arena":"go","tier":"fast","file":"components/backend/internal/middleware/auth.go","find":"\tif sub == AnonSubject {\n\t\treturn \"\", nil, status.Error(codes.Unauthenticated, \"sign in to do that\")\n\t}","replace":"\tif sub == AnonSubject && false {\n\t\treturn \"\", nil, status.Error(codes.Unauthenticated, \"sign in to do that\")\n\t}","crossRef":["act2.anonymous.register","act4.team.anon","act6.submit.anon"],"expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. Not one Go spec calls a RequireUser endpoint anonymously, so admitting the 'anonymous' subject as an identity breaks nothing in 6 seconds of backend tests — even though eight TeamService handlers were moved onto RequireUser specifically to stop an anonymous caller getting NotFound and probing which team ids exist. The witnesses are all journey actions (crossRef), i.e. minutes and a running stack. Their reds are DEDUCED from each action's declared expect.error, not observed."} +{"comment":"─── Capacity: the FCFS rule and the fairness of the queue ───"} +{"id":"capacity.queue-fairness","property":"Once anyone is waiting, a new joiner queues BEHIND them rather than sniping a freed seat.","arena":"go","tier":"fast","file":"components/backend/internal/service/capacity.go","find":"\treturn confirmed >= int(*maxParticipants) || waiting > 0","replace":"\treturn confirmed >= int(*maxParticipants)","expectReds":["service::Capacity > leaves a freed place to the organizer and keeps the queue unjumped","service::joinLandsWaitlisted > queues behind existing waiters even when a place is free"],"crossRef":["act2.cap.race","act2.cap.ui.queued"]} +{"id": "capacity.oversell-by-one", "property": "The capped event seats exactly max_participants — the full test is >=, and > sells one seat too many.", "arena": "go", "tier": "fast", "file": "components/backend/internal/service/capacity.go", "find": "\treturn confirmed >= int(*maxParticipants) || waiting > 0", "replace": "\treturn confirmed > int(*maxParticipants) || waiting > 0", "expectReds": ["service::Capacity > hands out places first-come-first-served on a capped event", "service::Capacity > leaves a freed place to the organizer and keeps the queue unjumped", "service::Capacity > lets an organizer approve past capacity", "service::Capacity > never oversells the last place under simultaneous joins", "service::Capacity > reports the current state on an idempotent re-join", "service::joinLandsWaitlisted > waitlists once the confirmed roster reaches capacity"], "note": "The capacity concurrency spec is on the runner's KNOWN_FLAKY list AND is a genuine witness here: this mutation makes the cap off-by-one, which is exactly what that spec hammers. It is listed explicitly so the flake excuse — which only applies to UNEXPECTED reds — cannot swallow the evidence."} +{"id":"capacity.unlimited-confirms","property":"Zero or unset capacity means UNLIMITED and keeps the approval model — everyone lands on the waiting list for an organizer to confirm.","arena":"go","tier":"fast","file":"components/backend/internal/service/capacity.go","find":"\t\t// Unlimited: the approval model — everyone starts on the waiting list.\n\t\treturn true","replace":"\t\t// Unlimited: the approval model — everyone starts on the waiting list.\n\t\treturn false","expectReds":["service::Capacity > clears the capacity back to unlimited with 0","service::Capacity > keeps the approval model for uncapped events","service::HackathonService > Join > allows authorized user to join hackathon","service::HackathonService > Join > returns success if user already joined (idempotent)","service::joinLandsWaitlisted > treats zero and negative capacity as unlimited","service::joinLandsWaitlisted > waitlists everyone when capacity is unset (the approval model)"],"crossRef":["act2.join.bob"]} +{"comment":"─── Deadline windows, and the now-anchored overrides ───"} +{"id":"window.never-closes","property":"A hackathon with a windows row and a passed deadline refuses the action with FailedPrecondition.","arena":"go","tier":"fast","file":"components/backend/internal/service/config_service.go","find":"\t\tif closes == nil || !now.After(*closes) {\n\t\t\treturn false\n\t\t}","replace":"\t\tif closes == nil || !now.After(*closes) || true {\n\t\t\treturn false\n\t\t}","crossRef":["act5.window.regclosed","act6.window.sublate","act4.window.preflate"],"expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. requireWindowOpen has no Go coverage at all — neither a passed deadline nor an override is exercised by any spec in any package. Deadline enforcement is pinned only by journey actions (crossRef), whose reds are deduced from their declared expect.error rather than observed. This is the largest single hole the first manifest found."} +{"id":"window.override-ignored","property":"A now-anchored override reopens a closed window until it expires — the override is what makes a late submission a support decision.","arena":"go","tier":"fast","file":"components/backend/internal/service/config_service.go","find":"\t\treturn override == nil || now.After(*override)","replace":"\t\treturn true","crossRef":["act6.window.override"],"expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. Same hole as window.never-closes: the now-anchored override — the mechanism that makes a late submission a support decision rather than a lockout — is exercised by no Go spec."} +{"id":"window.opens-early","property":"Registration before its opening moment is refused — a window has a start as well as an end.","arena":"go","tier":"fast","file":"components/backend/internal/service/config_service.go","find":"\t\tif w.RegistrationOpens != nil && now.Before(*w.RegistrationOpens) {","replace":"\t\tif false && w.RegistrationOpens != nil && now.Before(*w.RegistrationOpens) {","crossRef":["act1.window.early"],"expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. Same hole as window.never-closes, for the opening half of a window. Only act1.window.early pins it."} +{"comment":"─── Ownership: the guard that keeps an event from becoming unowned ───"} +{"id":"owner.last-guard","property":"The last owner cannot be demoted — an event whose every organizer is gone cannot be edited by anyone short of a global admin.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\tif len(owners) == 1 {","replace":"\tif len(owners) == 0 {","crossRef":["act5.owner.last","act5.race.owner.remove"],"expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. RemoveOwner has no Go specs whatsoever, so the last-organizer guard — one of the two invariants a real concurrency bug broke — is held up entirely by act5.owner.last and the act5.race.owner.* trio. Ownership is a casbin fact on this branch with no column to assert against, which is probably why no unit test was written; a spec driving the enforcer directly would close it."} +{"id":"owner.self-demote","property":"You cannot remove your own organizer role: the permission you are giving up is the one that would let you undo it.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\tif uid == user.KeycloakID {\n\t\treturn nil, status.Error(\n\t\t\tcodes.PermissionDenied,\n\t\t\t\"cannot remove your own organizer role\",\n\t\t)\n\t}","replace":"\tif uid == user.KeycloakID && false {\n\t\treturn nil, status.Error(\n\t\t\tcodes.PermissionDenied,\n\t\t\t\"cannot remove your own organizer role\",\n\t\t)\n\t}","crossRef":["act5.owner.self"],"expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. Same hole as owner.last-guard: nothing in Go calls RemoveOwner, so 'you cannot give up the permission that would let you undo it' is pinned only by act5.owner.self."} +{"id":"owner.demote-restores-member","property":"A demoted owner is given Member back; without it their role resolves to UNSPECIFIED, which renders as a corrupted record rather than a demotion.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\tif _, err := s.enforcer.AddRole(user.KeycloakID, m.Member, h.ID.String()); err != nil {\n\t\tslog.Error(\"restore hackathon member role\", \"err\", err)","replace":"\tif _, err := s.enforcer.AddRole(user.KeycloakID, m.Owner, h.ID.String()); err != nil {\n\t\tslog.Error(\"restore hackathon member role\", \"err\", err)","expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. Same hole as owner.last-guard. Worth noting the mutation used here (re-grant Owner instead of Member) makes the demotion a complete no-op and is STILL invisible to every Go package."} +{"id":"owner.mutex-dropped","property":"RemoveOwner's read-owners/check/remove sequence is one decision under ownerMu — two owners demoting each other concurrently must not leave the event with none.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\ts.ownerMu.Lock()\n\tdefer s.ownerMu.Unlock()\n\n\towners, err := s.enforcer.HackathonOwners(h.ID.String())","replace":"\towners, err := s.enforcer.HackathonOwners(h.ID.String())","gap":true,"gapReason":"Concurrency, and no fast-tier witness for the surface either. The Go suite has no concurrent RemoveOwner spec, and act5.race.owner.remove reaches only the real backend — so nothing in the fast tier can see this. Promote to arena 'journey' once a backend-restart path is safe to automate.","crossRef":["act5.race.owner.remove","act5.race.owner.invariant"],"expectReds":[]} +{"comment":"─── Join: who may, when, and with what ───"} +{"id":"join.anon-code","property":"An anonymous Join is refused with Unauthenticated, not PermissionDenied.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\t\treturn nil, status.Error(codes.Unauthenticated, \"anonymous users cannot join hackathons\")","replace":"\t\treturn nil, status.Error(codes.PermissionDenied, \"anonymous users cannot join hackathons\")","expectReds":["service::HackathonService > Join > requires authentication to join"],"crossRef":["act2.anonymous"]} +{"id":"join.private-needs-invite","property":"A private hackathon is joinable only with a live invitation — privacy was discovery-only until this check, so anyone who learned the UUID could join.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\tif h.Visibility != enthackathon.VisibilityPublic {\n\t\tif err := s.requireLiveInvite(ctx, id, req.GetInviteToken()); err != nil {","replace":"\tif h.Visibility != enthackathon.VisibilityPublic && false {\n\t\tif err := s.requireLiveInvite(ctx, id, req.GetInviteToken()); err != nil {","expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. Nothing in Go joins a PRIVATE hackathon, so removing the invitation requirement entirely — the fix that turned privacy from discovery-only into a real gate, i.e. anyone who learned the UUID could join — breaks no backend spec. The invite path is exercised only through the journey."} +{"id":"join.finished","property":"Joining an event whose end date has passed is refused with FailedPrecondition.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\tif h.EndsAt != nil && h.EndsAt.Before(time.Now()) {\n\t\treturn nil, status.Error(codes.FailedPrecondition, \"hackathon is already finished\")\n\t}","replace":"\tif h.EndsAt != nil && h.EndsAt.Before(time.Now()) && false {\n\t\treturn nil, status.Error(codes.FailedPrecondition, \"hackathon is already finished\")\n\t}","crossRef":["act8.latejoin","act8.pilot.join.finished.rpc"],"expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. No Go spec joins an event whose end date has passed; the only red this mutation produced was the known capacity flake. Pinned by act8.latejoin and act8.pilot.join.finished.rpc, whose reds are deduced from their declared expect.error rather than observed."} +{"id":"join.grants-member","property":"Everyone on the roster holds Member at Join — including the waitlisted, which is what lets them propose and see the private event they signed up for.","arena":"go","tier":"fast","file":"components/backend/internal/service/hackathon_service.go","find":"\tif _, err := s.enforcer.AddRole(user.KeycloakID, m.Member, h.ID.String()); err != nil {\n\t\tslog.Error(\"add hackathon member on join\", \"err\", err)","replace":"\tif _, err := s.enforcer.AddRole(user.KeycloakID, m.Owner, h.ID.String()); err != nil {\n\t\tslog.Error(\"add hackathon member on join\", \"err\", err)","expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS, and the mutation is a big one: Join grants OWNER instead of Member, so every person who joins any event becomes its organizer, and not one Go spec notices. 'Member role at Join' is a pinned policy decision (.claude/CLAUDE.md) with no unit test behind it — the casbin role a Join writes is asserted nowhere in the fast tier."} +{"comment":"─── Presigned uploads: what is signed is what a proxy may not rewrite ───"} +{"id":"presign.content-type-unsigned","property":"Content-Type is a CONDITION on the presign, so a client that declares a different type is refused at authentication — before a byte is stored.","arena":"go","tier":"fast","file":"components/backend/internal/storage/client.go","find":"\theaders.Set(\"Content-Type\", contentType)","replace":"\t_ = contentType","expectReds":["storage::TestPresignPutSignsSizeAndType"]} +{"id":"presign.size-unsigned","property":"Content-Length is signed too, so an oversized upload is refused before most of its bytes are even sent.","arena":"go","tier":"fast","file":"components/backend/internal/storage/client.go","find":"\theaders.Set(\"Content-Length\", strconv.FormatInt(sizeBytes, 10))","replace":"\t_ = sizeBytes","expectReds":["storage::TestPresignPutSignsSizeAndType"]} +{"id":"presign.host-unsigned","property":"The signature names the STORE's hostname, not the browser's — this is the one header a reverse proxy rewrites, and its absence is invisible on public reads.","arena":"go","tier":"fast","file":"components/backend/internal/storage/sigv4.go","find":"\theaders.Set(\"Host\", c.signHost)","replace":"\t_ = c.signHost","expectReds":["storage::TestPresignPutSignsSizeAndType"],"crossRef":["tests/tunnel/upload.spec.ts"]} +{"id":"upload.content-type-allowlist","property":"The upload content-type allowlist actually refuses — image/svg+xml is excluded on purpose because /objects is our own origin.","arena":"go","tier":"fast","file":"components/backend/internal/service/storage_service.go","find":"\texts, allowed := rule.contentTypes[normalized]\n\tif !allowed {","replace":"\texts, allowed := rule.contentTypes[normalized]\n\tif !allowed && false {","expectReds":[],"gap":true,"gapReason":"NO FAST-TIER WITNESS. checkContentType's allowlist — the rule that keeps image/svg+xml out, because /objects is our own origin and a stored SVG is script running as the application — is asserted by no Go spec. StorageService's specs cover authorization and listing scopes; nothing presents a refused content type. This one has no journey witness either: the recipe uploads only well-formed PNGs, so a manifest entry here is a request for a test, not a pointer to one."} +{"comment":"─── Markdown sanitization: the recipe pastes a real <script> on purpose ───"} +{"id":"markdown.script-survives","property":"A <script> in stored markdown never reaches the DOM. Needs BOTH the allowlist and the denylist weakened — defence in depth, and a one-sided mutation would report the property as tested when only the other half held.","arena":"vitest","tier":"fast","edits":[{"file":"components/frontend/src/lib/utils/markdown.ts","find":" \"blockquote\",\n \"pre\",","replace":" \"blockquote\",\n \"pre\",\n \"script\","},{"file":"components/frontend/src/lib/utils/markdown.ts","find":" FORBID_TAGS: [\n \"script\",\n","replace":" FORBID_TAGS: [\n"}],"arenaConfig":{"files":["src/lib/utils/markdown.test.ts","src/lib/utils/markdown.dom.test.ts"]},"expectReds":["src/lib/utils/markdown.test.ts::markdownToPlainText: XSS defences takes nothing from a script buried mid-paragraph","src/lib/utils/markdown.test.ts::renderMarkdown: XSS defences strips <script> tags and their contents","src/lib/utils/markdown.test.ts::renderMarkdown: XSS defences strips inline <script> inside a paragraph"],"crossRef":["act0.about.xss","act0.about.sanitized"]} +{"id":"markdown.event-handler-survives","property":"Event-handler attributes are dropped: the attribute allowlist is what removes onclick/onerror/onload, not a rule about scripts.","arena":"vitest","tier":"fast","file":"components/frontend/src/lib/utils/markdown.ts","find":"const ALLOWED_ATTR = [\n \"href\",","replace":"const ALLOWED_ATTR = [\n \"onerror\",\n \"onload\",\n \"href\",","arenaConfig":{"files":["src/lib/utils/markdown.test.ts","src/lib/utils/markdown.dom.test.ts"]},"expectReds":["src/lib/utils/markdown.dom.test.ts::renderMarkdown (browser environment) strips scripts, handlers, javascript: URLs and stray iframes","src/lib/utils/markdown.test.ts::renderMarkdown: XSS defences strips event handler attributes"],"crossRef":["act0.about.xss"]} +{"id":"markdown.javascript-url","property":"href/src accept http(s), mailto and relative URLs only — javascript: and data: are not schemes an author gets to use.","arena":"vitest","tier":"fast","file":"components/frontend/src/lib/utils/markdown.ts","find":" /^(?:https?:|mailto:|[^a-z]|[a-z+.-]+(?:[^a-z+.\\-:]|$))/i","replace":" /^(?:https?:|mailto:|javascript:|data:|[^a-z]|[a-z+.-]+(?:[^a-z+.\\-:]|$))/i","arenaConfig":{"files":["src/lib/utils/markdown.test.ts","src/lib/utils/markdown.dom.test.ts"]},"expectReds":["src/lib/utils/markdown.dom.test.ts::renderMarkdown (browser environment) strips scripts, handlers, javascript: URLs and stray iframes","src/lib/utils/markdown.test.ts::renderMarkdown: XSS defences neutralizes javascript: URLs"]} +{"comment":"─── The Join gate on the dashboard, and the four states on the client ───"} +{"id":"join-offer.ignores-capability","property":"joinIsOffered consults the register capability: six finished events each rendered a Join button that could only ever answer FailedPrecondition.","arena":"vitest","tier":"fast","file":"components/frontend/src/lib/server/hackathon/joinOffer.ts","find":" return register === undefined || capabilityAllows(register.state)","replace":" return register !== undefined || capabilityAllows(register.state)","arenaConfig":{"files":["src/lib/server/hackathon/joinOffer.test.ts"]},"expectReds":["src/lib/server/hackathon/joinOffer.test.ts::joinIsOffered offers an event carrying no register row at all","src/lib/server/hackathon/joinOffer.test.ts::joinIsOffered reads the register row and not merely the first one","src/lib/server/hackathon/joinOffer.test.ts::joinIsOffered withholds a closed registration","src/lib/server/hackathon/joinOffer.test.ts::joinIsOffered withholds one that has not opened yet"],"crossRef":["act5.pilot.join.offered","act5.pilot.join.closed"]} +{"id":"join-offer.ignores-finished","property":"A finished event offers no Join, whatever its capabilities say — 'already finished' is the more useful answer and comes first.","arena":"vitest","tier":"fast","file":"components/frontend/src/lib/server/hackathon/joinOffer.ts","find":" if (h.status === STATUS_FINISHED) return false","replace":" if (h.status === STATUS_FINISHED && false) return false","arenaConfig":{"files":["src/lib/server/hackathon/joinOffer.test.ts"]},"expectReds":["src/lib/server/hackathon/joinOffer.test.ts::joinIsOffered withholds a finished event whatever its capabilities say"]} +{"id":"join-offer.missing-row-hides","property":"An absent capability row is UNGOVERNED and PERMITS — treating it as closed hides Join on every event that never configured capabilities at all.","arena":"vitest","tier":"fast","file":"components/frontend/src/lib/server/hackathon/joinOffer.ts","find":" return register === undefined || capabilityAllows(register.state)","replace":" return register !== undefined && capabilityAllows(register.state)","arenaConfig":{"files":["src/lib/server/hackathon/joinOffer.test.ts"]},"expectReds":["src/lib/server/hackathon/joinOffer.test.ts::joinIsOffered offers an event carrying no register row at all"]} +{"id":"cap-client.allowed.flatten","property":"The client's capabilityAllows keeps all four states: UNGOVERNED and CLOSED both look off and mean opposite things.","arena":"vitest","tier":"fast","file":"components/frontend/src/lib/utils/capability.ts","find":" state === CapabilityState.OPEN || state === CapabilityState.UNGOVERNED","replace":" state === CapabilityState.OPEN","arenaConfig":{"files":["src/lib/utils/capability.test.ts","src/lib/components/hackathon/CapabilitiesPanel.test.ts","src/lib/server/hackathon/joinOffer.test.ts"]},"expectReds":["src/lib/server/hackathon/joinOffer.test.ts::joinIsOffered OFFERS an ungoverned registration","src/lib/utils/capability.test.ts::the four capability states counts UNGOVERNED as permitted, matching the server's Allowed","src/lib/utils/capability.test.ts::the four capability states separates the stored flag from what is permitted"]} +{"id":"cap-client.is-on.conflated","property":"capabilityIsOn (the stored flag, what the checkbox reflects) is a DIFFERENT fact from capabilityAllows (what a participant may do) — conflating them is all of UNGOVERNED.","arena":"vitest","tier":"fast","file":"components/frontend/src/lib/utils/capability.ts","find":"export const capabilityIsOn = (state: number): boolean =>\n state === CapabilityState.OPEN","replace":"export const capabilityIsOn = (state: number): boolean =>\n state === CapabilityState.OPEN || state === CapabilityState.UNGOVERNED","arenaConfig":{"files":["src/lib/utils/capability.test.ts","src/lib/components/hackathon/CapabilitiesPanel.test.ts"]},"expectReds":["src/lib/components/hackathon/CapabilitiesPanel.test.ts::the four capability states ticks the box only for the capability whose flag is stored on","src/lib/utils/capability.test.ts::the four capability states separates the stored flag from what is permitted"]} diff --git a/.claude/skills/hackathon-e2e/mutations/run.mjs b/.claude/skills/hackathon-e2e/mutations/run.mjs new file mode 100644 index 00000000..130f9aca --- /dev/null +++ b/.claude/skills/hackathon-e2e/mutations/run.mjs @@ -0,0 +1,925 @@ +#!/usr/bin/env node +/** + * Mutation testing for the Hackagon test suites. + * + * The premise, in one sentence: a green test proves nothing until you have + * seen it go red. `.claude/CLAUDE.md`'s "Ways a test reported green while + * proving nothing" is nine entries long and every one of them was found BY + * HAND, once. This turns that hunt into something that runs. + * + * Each manifest entry is a precise, reversible source change plus the exact + * set of tests that MUST go red because of it. Three verdicts, and only one is + * a pass: + * + * NO REDS the property is not tested by anything. THE HEADLINE RESULT — + * this is what the whole exercise is for, and it is a failure + * of the run, never a curiosity to note and move past. + * EXTRA REDS something else broke too: an over-broad mutation, or coupling + * nobody knew about. Also a failure; the extras are named. + * EXACT the named tests went red and nothing else did. Pass. + * + * Restoration is verified, never assumed — see restore()/assertCleanTree(). + * Refuses to start on a dirty tree, journals every edit to disk before making + * it, and restores from that journal on exit, on signal, and on demand + * (`restore`), because a trap cannot survive a SIGKILL or a container + * recreate and a mutation left in the tree could be committed by someone else. + */ + +import { execFileSync, spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const SKILL_DIR = path.dirname(HERE) +const ROOT = path.resolve(SKILL_DIR, "../../..") +const STATE = path.join(HERE, ".state") +const JOURNAL = path.join(STATE, "journal.json") +const BACKUPS = path.join(STATE, "backup") +const MANIFEST = path.join(HERE, "manifest.jsonl") + +// The Nix dev shell is a repo-wide mutex here — 44s to enter unopposed, 80s +// with one competitor (container trap 4). Every binary this runner needs is +// already in devenv's profile, which is a plain directory of symlinks and +// costs nothing to put on PATH. Never call `just nix::develop` from this file: +// a 30-mutation run would spend half an hour re-hashing the worktree. +const DEVENV_BIN = path.join(ROOT, ".devenv/profile/bin") +const ENV = { + ...process.env, + PATH: fs.existsSync(DEVENV_BIN) ? `${DEVENV_BIN}${path.delimiter}${process.env.PATH}` : process.env.PATH, +} + +const C = { + red: (s) => `\x1b[31m${s}\x1b[0m`, + green: (s) => `\x1b[32m${s}\x1b[0m`, + yellow: (s) => `\x1b[33m${s}\x1b[0m`, + dim: (s) => `\x1b[2m${s}\x1b[0m`, + bold: (s) => `\x1b[1m${s}\x1b[0m`, +} + +// ─── The tree must be clean, and must be clean again afterwards ───────────── + +function gitStatus() { + return execFileSync("git", ["status", "--porcelain"], { cwd: ROOT, encoding: "utf8", env: ENV }) +} + +/** + * The paths a cleanliness check is allowed to have an opinion about: every file + * some mutation in the manifest names, and `components/` as a whole. + * + * SCOPED, and the scope is load-bearing twice over. + * + * A repo-wide "git status is empty" check is the strongest statement available + * and it is unusable here, for two independent reasons that both bite on a + * normal day. This container reports three git-lfs pointer files as permanently + * modified with zero edits in play (container trap 4), so the literal check can + * never pass and would simply be switched off by whoever hit it first. And a + * repo-wide check is a check on OTHER PEOPLE: the first run of this tool aborted + * ten mutations because a second agent added a file elsewhere in `.claude/` + * while it worked — nothing to do with any mutation, and exactly the kind of + * false alarm that gets a safety check deleted. + * + * So the guarantee is narrowed to something true and worth keeping: no file this + * runner may write is dirty afterwards. Everything else in the tree is somebody + * else's business. + */ +let mutablePrefixes = ["components/"] +let dirtyBaseline = null + +function setMutableScope(muts) { + const dirs = new Set(mutablePrefixes) + for (const m of muts) for (const e of m.edits) dirs.add(e.file) + mutablePrefixes = [...dirs] +} + +function inScope(p) { + return mutablePrefixes.some((prefix) => p === prefix || p.startsWith(prefix)) +} + +function dirtyPaths() { + return gitStatus() + .split("\n") + .map((l) => l.slice(3).trim()) + .filter(Boolean) + .filter(inScope) + .sort() +} + +function captureDirtyBaseline() { + dirtyBaseline = new Set(dirtyPaths()) +} + +function assertCleanTree(context) { + const unexpected = dirtyPaths().filter((p) => !dirtyBaseline?.has(p)) + if (unexpected.length > 0) { + throw new Error( + `${context}: files this runner may write are dirty and should not be:\n` + + unexpected.map((p) => ` ${p}`).join("\n") + + `\nA mutation may still be applied. Run: node ${path.relative(ROOT, HERE)}/run.mjs restore`, + ) + } +} + +function refuseIfDirty() { + const dirty = dirtyPaths() + if (dirty.length > 0) { + console.error( + C.red("refusing to start: files this tool mutates already have uncommitted changes.\n") + + dirty.map((p) => ` ${p}`).join("\n") + + "\n\nMutation testing edits these files and restores them from a journal. It " + + "cannot tell your work from its own, and a mutation that survives into a " + + "commit is the worst outcome this tool can produce. Commit or stash first.", + ) + process.exit(2) + } +} + +// ─── Journal: apply / restore, durable across a hard kill ─────────────────── + +function readJournal() { + if (!fs.existsSync(JOURNAL)) return [] + try { + return JSON.parse(fs.readFileSync(JOURNAL, "utf8")) + } catch { + return [] + } +} + +function writeJournal(entries) { + fs.mkdirSync(STATE, { recursive: true }) + fs.writeFileSync(JOURNAL, JSON.stringify(entries, null, 2)) +} + +/** + * Apply one mutation, having first written the original bytes to disk. + * + * The backup is written and fsync'd BEFORE the edit, so the only window in + * which an interrupted run can lose the original is one that ends with the + * original still in place. + */ +function apply(mut) { + mut.edits.forEach((edit, i) => { + const abs = path.join(ROOT, edit.file) + if (!fs.existsSync(abs)) throw new Error(`${mut.id}: no such file: ${edit.file}`) + const original = fs.readFileSync(abs, "utf8") + + const occurrences = original.split(edit.find).length - 1 + const want = edit.occurrences ?? 1 + if (occurrences !== want) { + // A manifest that silently stops mutating is the same disease as a test + // that silently stops asserting, so this is loud and fatal. + throw new Error( + `${mut.id}: anchor #${i + 1} matches ${occurrences} time(s) in ${edit.file}, expected ${want}.\n` + + `The code moved out from under this mutation — re-anchor it, do not relax the count.\n` + + ` anchor: ${JSON.stringify(edit.find)}`, + ) + } + + fs.mkdirSync(BACKUPS, { recursive: true }) + const backup = path.join(BACKUPS, `${mut.id.replace(/[^\w.-]/g, "_")}.${i}.bak`) + const fd = fs.openSync(backup, "w") + fs.writeSync(fd, original) + fs.fsyncSync(fd) + fs.closeSync(fd) + writeJournal([...readJournal(), { id: mut.id, file: edit.file, backup }]) + + fs.writeFileSync(abs, original.split(edit.find).join(edit.replace)) + }) +} + +/** + * Undo everything the journal records, NEWEST FIRST. + * + * The order is load-bearing and was a real bug, caught by this tool's own + * post-restore check on its first multi-edit mutation. Two edits to ONE file + * journal two backups: the first holds the pristine text, the SECOND holds the + * text as it stood after edit 1 — i.e. already mutated. Replaying forwards + * restores the original and then overwrites it with the half-mutated copy, so + * the file is left broken while every backup on disk is intact and the journal + * reads as fully unwound. Reversing makes each entry undo exactly the edit that + * produced it, and the last one written is the first one undone. + */ +function restore({ quiet = false } = {}) { + const entries = readJournal() + for (const e of [...entries].reverse()) { + if (!fs.existsSync(e.backup)) { + console.error(C.red(`restore: backup for ${e.id} is missing (${e.backup}) — restoring from git`)) + spawnSync("git", ["checkout", "--", e.file], { cwd: ROOT, env: ENV }) + continue + } + fs.copyFileSync(e.backup, path.join(ROOT, e.file)) + if (!quiet) console.log(C.dim(` restored ${e.file} (${e.id})`)) + } + writeJournal([]) + return entries.length +} + +// The trap. `exit` covers normal and thrown paths; the signals cover Ctrl-C and +// a `docker stop`. None of them covers SIGKILL — that is what the standalone +// `restore` subcommand and the journal on disk are for. +let cleanupInstalled = false +function installCleanup() { + if (cleanupInstalled) return + cleanupInstalled = true + const bail = (why) => { + if (readJournal().length > 0) { + console.error(C.yellow(`\n${why} — restoring mutated files`)) + restore() + } + } + process.on("exit", () => bail("run ended")) + for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(sig, () => { + bail(sig) + process.exit(130) + }) + } + process.on("uncaughtException", (e) => { + console.error(C.red(String(e?.stack ?? e))) + bail("uncaught exception") + process.exit(1) + }) +} + +// ─── Arenas ───────────────────────────────────────────────────────────────── +// +// An arena is "where the evidence is": how to run a body of tests and how to +// name each one. Cost is why there is more than one — see SKILL.md. Every +// arena returns a Set of stable test identities that FAILED. + +const BACKEND = path.join(ROOT, "components/backend") +const FRONTEND = path.join(ROOT, "components/frontend") + +// One scratch directory for the whole run, not one per report. These land in +// the container's /tmp (tmpfs), never under the 9p-mounted workspace. +let scratch = null +let scratchSeq = 0 +function tmpfile(name) { + scratch ??= fs.mkdtempSync(path.join(os.tmpdir(), "hackagon-mut-")) + return path.join(scratch, `${scratchSeq++}-${name}`) +} + +/** + * Ginkgo bootstrap functions, so their wrapper `TestXxx` red is not double + * counted alongside the spec reds it is merely the sum of. Derived from the + * source rather than listed, so a new suite needs no edit here. + * + * MEMOIZED, and that is not a micro-optimisation: it scans every `*_test.go` + * under `internal/` — ~20 files, several of them 1500-2500 lines — and in the + * devcontainer those live on the 9p bind mount. Recomputing it on every arena + * invocation was costing more per mutation than the Go test run it supports. + * No mutation in the manifest touches a `_test.go`, so the answer cannot change + * during a run. + */ +let ginkgoWrapperCache = null +function ginkgoWrappers() { + if (ginkgoWrapperCache) return ginkgoWrapperCache + const out = new Set() + const dirs = fs.readdirSync(path.join(BACKEND, "internal"), { withFileTypes: true }) + for (const d of dirs) { + if (!d.isDirectory()) continue + const dir = path.join(BACKEND, "internal", d.name) + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith("_test.go")) continue + const src = fs.readFileSync(path.join(dir, f), "utf8") + if (!src.includes("RunSpecs")) continue + for (const m of src.matchAll(/func\s+(Test\w+)\s*\(\s*\w+\s+\*testing\.T\s*\)/g)) out.add(m[1]) + } + } + ginkgoWrapperCache = out + return out +} + +const GO_PACKAGES = [ + "./internal/service/", + "./internal/middleware/", + "./internal/capability/", + "./internal/storage/", + "./internal/audit/", + "./internal/config/", +] + +/** + * The Go backend suites. ~6s for all six packages, cold, with no stack running + * and no Nix shell entered — which is why most of the manifest lives here. + * + * Collects BOTH shapes of Go test this repo has: Ginkgo specs (identified by + * their full `Describe > Context > It` text, read from --ginkgo.json-report) + * and plain `TestXxx` functions (read from `go test -json`). A package that has + * both contributes both. + */ +function runGo(cfg) { + const packages = cfg.packages ?? GO_PACKAGES + const reds = new Set() + const wrappers = ginkgoWrappers() + let buildFailure = null + + for (const pkg of packages) { + const report = tmpfile("ginkgo.json") + const r = spawnSync( + "go", + [ + "test", "-count=1", "-tags", "test unittest", "-json", pkg, + `--ginkgo.json-report=${report}`, + ], + { cwd: BACKEND, env: ENV, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, + ) + const short = pkg.replace(/^\.\/internal\//, "").replace(/\/$/, "") + + // A mutation that does not compile is not a test result. Reporting it as + // "every test went red" would let a mutation that cannot possibly be + // evaluated masquerade as a well-caught one. + const combined = `${r.stdout ?? ""}${r.stderr ?? ""}` + if (/\[build failed\]|cannot find package|# \S+\n.*\.go:\d+/.test(combined) && !fs.existsSync(report)) { + buildFailure = `${short}: ${(r.stderr || r.stdout || "").slice(0, 800)}` + continue + } + + let specCount = 0 + if (fs.existsSync(report)) { + try { + for (const suite of JSON.parse(fs.readFileSync(report, "utf8"))) { + for (const s of suite.SpecReports ?? []) { + const name = [...(s.ContainerHierarchyTexts ?? []), s.LeafNodeText].filter(Boolean).join(" > ") + if (!name) continue + specCount++ + if (s.State === "failed" || s.State === "panicked" || s.State === "interrupted") { + reds.add(`${short}::${name}`) + } + } + } + } catch { /* an unparseable report is handled by the build-failure path */ } + } + + for (const line of (r.stdout ?? "").split("\n")) { + if (!line.startsWith("{")) continue + let ev + try { ev = JSON.parse(line) } catch { continue } + if (!ev.Test || ev.Action !== "fail") continue + // A ginkgo wrapper's failure is the sum of its specs, which are already + // counted above with names that say what actually broke. + if (specCount > 0 && wrappers.has(ev.Test)) continue + reds.add(`${short}::${ev.Test}`) + } + + // A compile error in a package with no report and no JSON events at all. + if (r.status !== 0 && specCount === 0 && ![...reds].some((x) => x.startsWith(`${short}::`))) { + buildFailure ??= `${short}: ${(r.stderr || r.stdout || "").slice(0, 800)}` + } + } + + return { reds, buildFailure } +} + +/** + * The frontend unit suites (vitest, 26 files / 462 tests). + * + * ~60s for the whole set, because `src/` is on the 9p bind mount and the cost + * is transform + collect, not the tests (2.2s of the 57s). Naming `files` in + * the manifest is therefore worth doing and is what keeps this arena in the + * fast tier. + */ +function runVitest(cfg) { + const out = tmpfile("vitest.json") + const args = ["exec", "vitest", "run", "--reporter=json", `--outputFile=${out}`] + if (cfg.files?.length) args.push(...cfg.files) + const r = spawnSync("pnpm", args, { + cwd: FRONTEND, + // TEST_CONFIG_DIR is what .component.yaml's test-unittest target sets; + // without it every file fails at import time with "Config file not defined + // by env. variable TEST_CONFIG_DIR" — 26 files red for a reason that has + // nothing to do with the mutation. + env: { ...ENV, TEST_CONFIG_DIR: "data/test/config" }, + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + }) + + const reds = new Set() + if (!fs.existsSync(out)) { + return { reds, buildFailure: `vitest wrote no report: ${(r.stderr || r.stdout || "").slice(0, 800)}` } + } + const report = JSON.parse(fs.readFileSync(out, "utf8")) + let assertions = 0 + for (const file of report.testResults ?? []) { + const rel = path.relative(FRONTEND, file.name).replace(/\\/g, "/") + for (const a of file.assertionResults ?? []) { + assertions++ + if (a.status === "failed") reds.add(`${rel}::${a.fullName}`) + } + // A file that fails to even load reports zero assertions and a message. + if ((file.assertionResults ?? []).length === 0 && file.status === "failed") { + reds.add(`${rel}::<file failed to load>`) + } + } + if (assertions === 0) { + return { reds, buildFailure: "vitest collected zero tests — the report would be vacuous" } + } + return { reds, buildFailure: null } +} + +/** + * The Playwright suites — journey (recipe actions) and smoke (spec titles). + * + * The expensive arena, and the reason the manifest routes almost everything + * else away from it: the journey is strictly serial with chained `vars`, so a + * mutation whose evidence lands in act 2 still cannot be reached by --grep. The + * lever that exists is `--until-act N`, which plays acts 0..N and stops. + * + * A BACKEND mutation additionally needs the running backend restarted against + * the mutated source, and a FRONTEND one needs a rebuild + * (.claude/skills/lib/frontend-build.sh — never a bare `pnpm build`, see + * container trap 5). Both are the caller's business, declared per entry as + * `rebuild`, because both are minutes and neither is safe to do behind + * someone's back while another agent may be recreating the container. + */ +function runPlaywright(cfg) { + const suite = cfg.suite ?? "journey" + + // Getting the mutated code IN FRONT of the browser is the caller's problem, + // and saying so beats doing it approximately. A frontend mutation must go + // through lib/frontend-build.sh — never a bare `pnpm build`, which two + // concurrent callers have corrupted (container trap 5). A backend mutation + // needs the running server restarted against it, which this runner will not + // do behind anyone's back: it is minutes, it takes the whole stack with it if + // it goes wrong, and another agent may be mid-recreate. + if (cfg.rebuild === "frontend") { + const b = spawnSync("bash", [path.join(ROOT, ".claude/skills/lib/frontend-build.sh"), "build"], { + cwd: ROOT, env: ENV, encoding: "utf8", stdio: ["ignore", "inherit", "inherit"], + }) + if (b.status !== 0) { + return { reds: new Set(), buildFailure: `frontend-build.sh failed (exit ${b.status})` } + } + } else if (cfg.rebuild === "backend") { + return { + reds: new Set(), + buildFailure: + "this entry mutates backend source, so the RUNNING backend must be restarted against it " + + "before the suite means anything. Do that yourself (process-compose restart backend, then " + + "wait-ready.sh) and re-run with rebuild already done — a suite driven against the " + + "unmutated binary would report NO REDS and look exactly like an untested property.", + } + } + + const results = path.join(SKILL_DIR, ".artifacts/results.json") + // A run that dies before writing its report leaves the PREVIOUS run's file on + // disk, and reading that would report the last run's verdict as this one's — + // green, with nothing anywhere saying the suite never ran. Remember the mtime + // and require it to move. + const before = fs.existsSync(results) ? fs.statSync(results).mtimeMs : 0 + + const args = [path.join(SKILL_DIR, "scripts/run.sh"), suite] + if (cfg.untilAct !== undefined) args.push("--until-act", String(cfg.untilAct)) + if (cfg.grep) args.push("--grep", cfg.grep) + + const r = spawnSync("bash", args, { + cwd: ROOT, + // Recording per action costs 9p writes for a 465-step serial run and has + // failed a run outright (playwright.config.ts documents it). A mutation run + // only needs the verdict. + env: { ...ENV, E2E_TRACE: "off", E2E_VIDEO: "off" }, + encoding: "utf8", + stdio: ["ignore", "inherit", "inherit"], + maxBuffer: 256 * 1024 * 1024, + }) + + if (!fs.existsSync(results)) { + return { reds: new Set(), buildFailure: `no .artifacts/results.json after the run (exit ${r.status})` } + } + if (fs.statSync(results).mtimeMs <= before) { + return { + reds: new Set(), + buildFailure: + `.artifacts/results.json was not rewritten (exit ${r.status}) — this run produced no ` + + `report and the file on disk belongs to an earlier one.`, + } + } + const report = JSON.parse(fs.readFileSync(results, "utf8")) + const reds = new Set() + let seen = 0 + const walk = (suites) => { + for (const s of suites ?? []) { + for (const spec of s.specs ?? []) { + seen++ + const failed = (spec.tests ?? []).some((t) => + (t.results ?? []).some((res) => res.status === "failed" || res.status === "timedOut"), + ) + if (!failed) continue + // The journey names every test `[<action id>] <title>` — the action id + // is the identity the recipe itself uses, so that is what the manifest + // names too. + const m = /^\[([^\]]+)\]/.exec(spec.title) + reds.add(m ? m[1] : `${s.file ?? ""}::${spec.title}`) + } + walk(s.suites) + } + } + walk(report.suites) + if (seen === 0) return { reds, buildFailure: "the Playwright report contains no specs" } + return { reds, buildFailure: null } +} + +const ARENAS = { + go: { run: runGo, tier: "fast" }, + vitest: { run: runVitest, tier: "fast" }, + journey: { run: runPlaywright, tier: "e2e" }, + smoke: { run: (cfg) => runPlaywright({ ...cfg, suite: "smoke" }), tier: "e2e" }, +} + +// ─── Baselines ────────────────────────────────────────────────────────────── +// +// "Exactly the expected set failed" is only meaningful against a suite that is +// green to begin with, and this one is not quite: internal/service's +// "Capacity > never oversells the last place under simultaneous joins" fails +// intermittently under in-memory SQLite. Left unhandled that flake would land +// in an arbitrary mutation's EXTRA REDS column and read as coupling. +// +// So: reds are always diffed against a baseline taken on the clean tree, and an +// unexpected red is re-checked against a FRESH clean run before it is reported +// as an extra. Nothing is dropped silently — instability is named in the output. + +/** + * Tests known to fail intermittently on a clean tree, DECLARED rather than + * inferred, each with the reason it is here. + * + * The fresh-baseline re-check below is the general mechanism and it is not + * sufficient on its own: an intermittent failure that does not reproduce in the + * one extra sample is indistinguishable from coupling, and that is exactly what + * happened — `cap.gate.removed` verified MISMATCH on the flake below while the + * re-check came back green. A run that randomly fails one entry in five is a run + * people stop reading. + * + * This list is a silent-drop risk of its own, so it is never silent: every hit + * is printed with its reason on the mutation that hit it, and adding a line here + * is a claim about the SUITE that someone has to justify — not a way to quieten + * a mutation that is genuinely over-broad. + * + * ⚠ **A flaky test can still be a genuine witness**, and that trap fired within + * an hour of this list existing. The capacity spec below hammers concurrent + * joins against a cap — which is exactly what `capacity.oversell-by-one` breaks, + * so under THAT mutation its failure is the evidence, not noise. The rule is: + * when a listed test is a real witness for a mutation, it goes in that + * mutation's `expectReds`, where the excuse does not apply (this filter only + * ever looks at reds that are NOT expected). Excusing an extra therefore prints + * a warning saying so, because "ignored" is the one word that could hide the + * thing the mutation was written to find. + */ +const KNOWN_FLAKY = [ + { + test: "service::Capacity > never oversells the last place under simultaneous joins", + why: + "in-memory SQLite under concurrent Join; fails as `Internal: couldn't join hackathon` " + + "roughly 1 run in 5. Nothing to do with any mutation — see .claude/CLAUDE.md.", + }, +] +const flakyReason = (t) => KNOWN_FLAKY.find((f) => f.test === t)?.why + +const baselineCache = new Map() + +function baselineFor(arena, cfg, { refresh = false } = {}) { + const key = `${arena}:${JSON.stringify(cfg ?? {})}` + if (!refresh && baselineCache.has(key)) return baselineCache.get(key) + process.stdout.write(C.dim(` baseline (${arena}) … `)) + const t = Date.now() + const { reds, buildFailure } = ARENAS[arena].run(cfg ?? {}) + if (buildFailure) throw new Error(`baseline for ${arena} could not run: ${buildFailure}`) + console.log(C.dim(`${reds.size} red, ${((Date.now() - t) / 1000).toFixed(1)}s`)) + if (reds.size > 0) { + console.log(C.yellow(` note: ${reds.size} test(s) are ALREADY red on the clean tree:`)) + for (const r of reds) console.log(C.yellow(` ${r}`)) + } + // On a REFRESH, union rather than replace. The cached baseline accumulates + // every test that has been caught being flaky during this run, and a fresh + // sample that happens not to reproduce one would otherwise forget it — so the + // next mutation would report that same flake as coupling, which is the exact + // confusion the refresh exists to prevent. + const prev = baselineCache.get(key) + const merged = prev ? new Set([...prev, ...reds]) : reds + baselineCache.set(key, merged) + return merged +} + +// ─── Manifest ─────────────────────────────────────────────────────────────── + +function loadManifest() { + const lines = fs.readFileSync(MANIFEST, "utf8").split("\n").map((l) => l.trim()).filter(Boolean) + const muts = [] + const seen = new Set() + lines.forEach((line, i) => { + if (line.startsWith("//")) return + let m + try { + m = JSON.parse(line) + } catch (e) { + throw new Error(`manifest.jsonl line ${i + 1} is not valid JSON: ${e}`) + } + if (m.comment && !m.id) return + for (const field of ["id", "property", "arena"]) { + if (m[field] === undefined) throw new Error(`manifest.jsonl line ${i + 1}: missing '${field}'`) + } + // `file`/`find`/`replace` is the one-edit shorthand for `edits: [{...}]`. + // Most mutations are a single string swap; a few (weakening a sanitizer + // that names the same tag in an allowlist AND a denylist) genuinely need + // two, and a mutation that only half-applies would read as a well-tested + // property when in fact the defence in depth held. + m.edits = m.edits ?? [{ file: m.file, find: m.find, replace: m.replace, occurrences: m.occurrences }] + for (const [j, e] of m.edits.entries()) { + for (const field of ["file", "find", "replace"]) { + if (e[field] === undefined) throw new Error(`${m.id}: edit #${j + 1} is missing '${field}'`) + } + } + if (!ARENAS[m.arena]) throw new Error(`${m.id}: unknown arena '${m.arena}'`) + m.expectReds = m.expectReds ?? [] + if (seen.has(m.id)) throw new Error(`manifest.jsonl: duplicate mutation id '${m.id}'`) + seen.add(m.id) + // An entry with no expected reds is a CLAIM THAT NOTHING TESTS THIS, and + // has to say so out loud. Without this, "expectReds: []" would be the one + // value that makes every mutation pass — the exact shape of vacuous green + // this tool exists to find. + if ((m.expectReds ?? []).length === 0 && !m.gap) { + throw new Error( + `${m.id}: has no expectReds and is not marked "gap": true. An entry that ` + + `expects nothing to fail would pass no matter what the code does.`, + ) + } + if (m.gap && (m.expectReds ?? []).length > 0) { + throw new Error(`${m.id}: marked as a gap but also lists expectReds — pick one.`) + } + if (m.gap && !m.gapReason) throw new Error(`${m.id}: a gap must carry a 'gapReason'.`) + muts.push(m) + }) + return muts +} + +function tierOf(m) { + return m.tier ?? ARENAS[m.arena].tier +} + +// ─── Running one mutation ─────────────────────────────────────────────────── + +function evaluate(m, { record, wide }) { + const arena = ARENAS[m.arena] + // `arenaConfig.files` (vitest) and `arenaConfig.packages` (go) narrow a run to + // where the property lives, which is what keeps an arena in the fast tier — + // vitest drops from 60s to ~10s, and a go mutation confined to one package + // skips recompiling the rest. + // + // The cost is real and is named here rather than left to be discovered: a test + // OUTSIDE the narrowed set cannot be seen going red, so narrowing scopes the + // EXTRA REDS half of the check too — and that half is the one that finds + // coupling. Neither default is narrowed; `--wide` drops any narrowing an entry + // asked for and pays for the breadth. + const cfg = wide + ? { ...(m.arenaConfig ?? {}), files: undefined, packages: undefined } + : (m.arenaConfig ?? {}) + const base = baselineFor(m.arena, cfg) + + assertCleanTree(`before ${m.id}`) + apply(m) + let result + try { + process.stdout.write(C.dim(` mutated (${m.arena}) … `)) + const t = Date.now() + result = arena.run(cfg) + console.log(C.dim(`${result.reds.size} red, ${((Date.now() - t) / 1000).toFixed(1)}s`)) + } finally { + restore({ quiet: true }) + } + assertCleanTree(`after ${m.id}`) + + if (result.buildFailure) { + return { verdict: "INVALID", detail: `the mutated source does not build:\n${result.buildFailure}` } + } + + let newReds = [...result.reds].filter((r) => !base.has(r)).sort() + const expected = (m.expectReds ?? []).slice().sort() + + let unstable = [] + const extras = newReds.filter((r) => !expected.includes(r)) + if (extras.length > 0 && !record) { + // Two filters, in cost order. The declared list first, because it is free + // and covers the flake we have already paid to diagnose. Then, for anything + // still unexplained, ask the CLEAN tree again — a flake reported as coupling + // costs a person an afternoon. + unstable = extras.filter((r) => flakyReason(r) !== undefined) + const rest = extras.filter((r) => !unstable.includes(r)) + if (rest.length > 0) { + const fresh = baselineFor(m.arena, cfg, { refresh: true }) + unstable = [...unstable, ...rest.filter((r) => fresh.has(r))] + } + if (unstable.length > 0) { + newReds = newReds.filter((r) => !unstable.includes(r)) + for (const r of unstable) base.add(r) + } + } + + const missing = expected.filter((r) => !newReds.includes(r)) + const extra = newReds.filter((r) => !expected.includes(r)) + + if (record) return { verdict: "RECORD", observed: newReds, unstable } + if (newReds.length === 0) { + return { + verdict: m.gap ? "GAP" : "NO REDS", + detail: m.gap ? m.gapReason : undefined, + observed: newReds, + unstable, + } + } + if (m.gap) { + return { + verdict: "GAP CLOSED", + detail: `marked as a gap, but these went red: ${newReds.join(", ")}. Promote it to a real entry.`, + observed: newReds, + unstable, + } + } + if (missing.length === 0 && extra.length === 0) { + return { verdict: "EXACT", observed: newReds, unstable } + } + return { verdict: "MISMATCH", missing, extra, observed: newReds, unstable } +} + +// ─── CLI ──────────────────────────────────────────────────────────────────── + +const VERDICT_OK = new Set(["EXACT", "GAP", "RECORD"]) + +function main() { + const argv = process.argv.slice(2) + const cmd = argv[0] ?? "run" + + if (cmd === "restore") { + const n = restore() + if (n === 0) console.log("nothing to restore — the journal is empty") + return 0 + } + + const muts = loadManifest() + setMutableScope(muts) + + if (cmd === "list") { + for (const m of muts) { + console.log( + `${C.bold(m.id.padEnd(30))} ${tierOf(m).padEnd(5)} ${m.arena.padEnd(8)} ` + + `${m.gap ? C.yellow("GAP ") : `${String(m.expectReds.length).padStart(2)} red`} ${m.property}`, + ) + } + console.log(`\n${muts.length} mutations`) + return 0 + } + + // Anchors rot. Every mutation names a literal fragment of product source, and + // the day one stops matching is the day this manifest quietly stops testing + // anything — so `check` exists to be cheap enough to run on every commit. + if (cmd === "check") { + let bad = 0 + for (const m of muts) { + for (const [i, e] of m.edits.entries()) { + const abs = path.join(ROOT, e.file) + if (!fs.existsSync(abs)) { console.error(C.red(`${m.id}: no such file ${e.file}`)); bad++; continue } + const n = fs.readFileSync(abs, "utf8").split(e.find).length - 1 + const want = e.occurrences ?? 1 + if (n !== want) { + console.error(C.red(`${m.id}: anchor #${i + 1} matches ${n}x in ${e.file} (want ${want})`)) + console.error(C.dim(` ${JSON.stringify(e.find)}`)) + bad++ + } + } + } + console.log(bad === 0 ? C.green(`all ${muts.length} mutations still anchor`) : C.red(`${bad} broken anchor(s)`)) + return bad === 0 ? 0 : 1 + } + + if (cmd !== "run") { + console.error( + [ + "usage: run.mjs [list|check|run|restore] [ids...]", + " --tier fast|e2e|all fast = go + vitest, needs no stack (default)", + " --arena go|vitest|journey|smoke", + " --wide ignore arenaConfig narrowing, so EXTRA REDS are checked suite-wide", + " --record print the reds instead of judging them (authoring aid)", + " --json FILE write the results table", + ].join("\n"), + ) + return 2 + } + + const flags = { tier: "fast", arena: null, record: false, json: null, wide: false } + const ids = [] + for (let i = 1; i < argv.length; i++) { + const a = argv[i] + if (a === "--tier") flags.tier = argv[++i] + else if (a === "--arena") flags.arena = argv[++i] + else if (a === "--record") flags.record = true + else if (a === "--wide") flags.wide = true + else if (a === "--json") flags.json = argv[++i] + else if (a.startsWith("--")) { console.error(`unknown flag ${a}`); return 2 } + else ids.push(a) + } + + let selected = muts + if (ids.length > 0) { + selected = muts.filter((m) => ids.some((id) => m.id === id || m.id.startsWith(`${id}.`))) + const unknown = ids.filter((id) => !muts.some((m) => m.id === id || m.id.startsWith(`${id}.`))) + if (unknown.length) { console.error(`unknown mutation id(s): ${unknown.join(", ")}`); return 2 } + } else { + if (flags.tier !== "all") selected = selected.filter((m) => tierOf(m) === flags.tier) + if (flags.arena) selected = selected.filter((m) => m.arena === flags.arena) + } + if (selected.length === 0) { console.error("no mutations selected"); return 2 } + + refuseIfDirty() + captureDirtyBaseline() + installCleanup() + // A journal left over from a run that was killed outright. + if (readJournal().length > 0) { + console.log(C.yellow("a previous run left mutations applied — restoring them first")) + restore() + } + + console.log(C.bold(`\n${selected.length} mutation(s)\n`)) + const rows = [] + for (const m of selected) { + console.log(C.bold(`── ${m.id}`)) + console.log(C.dim(` ${m.property}`)) + let r + try { + r = evaluate(m, { record: flags.record, wide: flags.wide }) + } catch (e) { + r = { verdict: "ERROR", detail: String(e?.message ?? e) } + } + rows.push({ id: m.id, arena: m.arena, tier: tierOf(m), property: m.property, ...r }) + + const paint = + r.verdict === "EXACT" ? C.green + : r.verdict === "GAP" ? C.yellow + : r.verdict === "RECORD" ? C.dim + : C.red + console.log(` ${paint(r.verdict)}`) + if (r.verdict === "RECORD") { + console.log(` ${C.dim("observed:")} ${JSON.stringify(r.observed)}`) + } + if (r.missing?.length) console.log(C.red(` expected but stayed GREEN:\n${r.missing.map((x) => ` ${x}`).join("\n")}`)) + if (r.extra?.length) console.log(C.red(` went red unexpectedly:\n${r.extra.map((x) => ` ${x}`).join("\n")}`)) + if (r.unstable?.length) { + // Never silent. An ignored red is a claim, and it has to state its reason. + console.log(C.yellow(" ignored as flaky (not counted as caused by this mutation):")) + for (const x of r.unstable) { + console.log(C.yellow(` ${x}`)) + if (flakyReason(x)) { + console.log(C.dim(` declared in KNOWN_FLAKY: ${flakyReason(x)}`)) + console.log( + C.dim( + ` if this test is a REAL witness for this mutation, put it in ` + + `expectReds — the excuse only applies to unexpected reds.`, + ), + ) + } else { + console.log(C.dim(` also red on a fresh run of the clean tree`)) + } + } + } + if (r.detail) console.log(C.dim(` ${r.detail.replace(/\n/g, "\n ")}`)) + console.log() + } + + assertCleanTree("at the end of the run") + + console.log(C.bold("── Results ──────────────────────────────────────────────")) + for (const r of rows) { + const paint = + r.verdict === "EXACT" ? C.green + : r.verdict === "GAP" ? C.yellow + : r.verdict === "RECORD" ? C.dim + : C.red + console.log(` ${paint(r.verdict.padEnd(11))} ${r.id.padEnd(30)} ${r.arena}`) + } + const bad = rows.filter((r) => !VERDICT_OK.has(r.verdict)) + const noReds = rows.filter((r) => r.verdict === "NO REDS") + console.log() + console.log( + ` ${rows.length} run — ` + + `${C.green(`${rows.filter((r) => r.verdict === "EXACT").length} exact`)}, ` + + `${C.yellow(`${rows.filter((r) => r.verdict === "GAP").length} known gaps`)}, ` + + `${C.red(`${noReds.length} untested properties`)}, ` + + `${C.red(`${bad.length - noReds.length} other failures`)}`, + ) + if (noReds.length > 0) { + console.log( + C.red( + `\n NO REDS means the property below is not tested by anything in its arena.\n` + + ` That is a finding about the suite, not a bug in this tool:\n` + + noReds.map((r) => ` ${r.id}: ${r.property}`).join("\n"), + ), + ) + } + + if (flags.json) { + fs.mkdirSync(path.dirname(path.resolve(flags.json)), { recursive: true }) + fs.writeFileSync(path.resolve(flags.json), JSON.stringify({ at: new Date().toISOString(), rows }, null, 2)) + console.log(C.dim(`\n wrote ${flags.json}`)) + } + + return bad.length > 0 ? 1 : 0 +} + +process.exitCode = main() diff --git a/.claude/skills/hackathon-e2e/scripts/mutate.sh b/.claude/skills/hackathon-e2e/scripts/mutate.sh new file mode 100644 index 00000000..83d8663e --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/mutate.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Mutation testing: break the product on purpose and check the suite notices. +# +# mutate.sh list # the manifest, one line per mutation +# mutate.sh run # every FAST mutation (go + vitest) +# mutate.sh run cap.ungoverned-flat # one, by id +# mutate.sh run cap # every id under the `cap.` prefix +# mutate.sh run --tier all # includes the e2e tier (minutes each) +# mutate.sh run --arena go +# mutate.sh run --record # print the reds instead of judging them +# mutate.sh restore # undo a run that was killed outright +# +# A mutation that produces NO REDS fails the run. That is the whole point: it +# means nothing in the suite is holding that property. +# +# Two things this script is responsible for that the runner cannot be: +# +# 1. A SECOND, INDEPENDENT restore path. The runner journals every edit before +# making it and restores on exit and on signal; this trap restores from the +# same journal even if node dies in a way that runs none of its handlers. +# A mutation left in the tree that then gets committed is the worst outcome +# this tool can produce, so it gets two locks rather than one. +# 2. Keeping `nix develop` out of the loop. Every other script here calls +# ensure_toolchain, which re-enters the dev shell — a repo-wide mutex that +# costs 44s unopposed on a permanently-dirty worktree (container trap 4). +# devenv's profile has go, node and pnpm already and costs nothing, so the +# fast tier runs 30 mutations in the time one `nix develop` takes. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +RUNNER="$SKILL_DIR/mutations/run.mjs" +JOURNAL="$SKILL_DIR/mutations/.state/journal.json" + +DEVENV_BIN="$ROOT_DIR/.devenv/profile/bin" +if [ -d "$DEVENV_BIN" ]; then + export PATH="$DEVENV_BIN:$PATH" +fi + +if ! command -v node >/dev/null 2>&1; then + echo "error: node is not on PATH and $DEVENV_BIN does not exist." >&2 + echo " Run this inside the dev container (devcontainer-up/scripts/mutate.sh)," >&2 + echo " or enter the Nix dev shell first." >&2 + exit 1 +fi + +# The belt to the runner's braces. `[ -s ]` rather than `[ -f ]`: the runner +# writes an empty array when it has nothing outstanding, and restoring from an +# empty journal would print a scary message about a run that ended cleanly. +on_exit() { + if [ -s "$JOURNAL" ] && ! grep -q '^\[\]$' "$JOURNAL" 2>/dev/null; then + echo + echo "!! mutations were still applied when this script exited — restoring" >&2 + node "$RUNNER" restore || true + fi + # Verified, not assumed. Scoped to components/, which is the only tree the + # runner may touch; the repo-wide check can never pass in this container + # (three git-lfs pointer files read as permanently modified — trap 4). + local left + left="$(git -C "$ROOT_DIR" status --porcelain -- components/ || true)" + if [ -n "$left" ]; then + echo >&2 + echo "!! components/ IS NOT CLEAN after the run:" >&2 + echo "$left" >&2 + echo "!! Do not commit. Inspect, then: git -C '$ROOT_DIR' checkout -- components/" >&2 + exit 1 + fi +} +trap on_exit EXIT + +# NOT `exec`. `exec` replaces this shell with node, and a process that no longer +# exists runs no EXIT trap — the second restore path above would have been +# decoration. Run it as a child, keep its status, and let the trap fire. +STATUS=0 +node "$RUNNER" "$@" || STATUS=$? +exit "$STATUS" From e77e6f9949a8e1b96d1272d682010ef590a66100 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:21:32 +0200 Subject: [PATCH 06/25] build(devcontainer): install git-lfs, and bake in the packages a recreate would lose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three files read as permanently modified inside `dev` with zero edits: components/frontend/static/{favicon.png,og-default.jpg} and the Keycloak theme's favicon.ico. `.gitattributes` marks those extensions `filter=lfs`; the HEAD blob for favicon.png is a 129-byte pointer while the worktree holds 1174 bytes of real PNG, smudged by the Windows host, which HAS git-lfs. The container had neither the binary nor any `filter.lfs` config, so git compared pointer against content and reported ` M` forever. A filter that was never installed, not an edit. `git lfs install --system`, not `--global`: /home/vscode is a named volume, so anything the image writes to the user's home is masked at runtime. ## This does NOT fix a performance floor, and the earlier claim that it would was wrong Measured before touching the Dockerfile, `just nix::develop default true`: dirty tree (3 phantom LFS files) cold 14,951 ms steady ~5,600 ms git-lfs installed, tree CLEAN cold 10,436 ms steady ~5,700 ms A/B'd directly: with git-lfs the tree goes genuinely clean — Nix stops printing `warning: Git tree … is dirty` — and the time does not move. Contention behaves identically clean or dirty (3 concurrent invocations serialize to ~11 s either way). The real cost is self-inflicted elsewhere: tools/just/devenv.sh rewrites .devenv/state/pwd on EVERY invocation, so the `devenv-root` flake input gets a new lastModified and Nix's eval cache misses every single run. Against a fixed root file the second run drops 5,466 ms → 3,677 ms. So ~1.8 s is the eval-cache miss we cause ourselves and ~3.7 s is baseline flake eval. The 44 s that motivated this was almost certainly measured while `frontend` was crash-looping at one full `nix develop` per round — the 54-restarts finding already fixed by capping max_restarts. And LFS could never have been the whole story anyway: any UNTRACKED file makes the tree dirty for Nix too, which a peer agent's new mutations/ directory demonstrated while this was being measured. Keeping the change on its own merits: `git status` inside the container is now truthful, which matters for every tool that reads it — the mutation runner refuses to start on a dirty tree, and a repo-wide cleanliness check was already downgraded to a scoped one because it could never pass here. ## The recreate would have silently lost 21 packages Diffing `apt-mark showmanual` in the running container against the image found 21 Playwright dependencies living only in the writable layer — xvfb, libavcodec60, six font packages, and the usual X/cairo/pango set. They would have gone the way Firefox's libraries once did. All are in the image now, verified as a strict superset of the running container's package set BEFORE recreating, with a comment recording how to re-derive the list. Recreated with `up -d --no-deps dev` so compose could not touch caddy or the tunnel: same public URL, and login re-verified with the real browser round-trip rather than by serving a page. Journey 469 passed / 0 failed / 0 skipped. Data restored and counted over gRPC: 8 hackathons, 8 public, six editions with 4 prizes each carrying an image. --- .devcontainer/Dockerfile | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 474ac496..9829be1f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -8,6 +8,10 @@ FROM ${BASE_IMAGE} # "libgtk-3.so.0: cannot open shared object file". Baking them into the image # makes the browser survive recreation. # Kept in sync with `playwright install --with-deps firefox` on Ubuntu 24.04. +# To re-derive this list after a Playwright bump: run `playwright install +# --with-deps firefox` in a container, then diff `apt-mark showmanual` against +# the same command run in a fresh container of this image. Anything the diff +# names is living in the writable layer and dies at the next recreate. ARG INSTALL_BROWSER_DEPS=1 RUN if [ "${INSTALL_BROWSER_DEPS}" = "1" ]; then \ apt-get update \ @@ -32,9 +36,47 @@ RUN if [ "${INSTALL_BROWSER_DEPS}" = "1" ]; then \ libnss3 \ libnspr4 \ fonts-liberation \ + libcairo2 \ + libdbus-1-3 \ + libfontconfig1 \ + libfreetype6 \ + libgdk-pixbuf-2.0-0 \ + libglib2.0-0t64 \ + libpango-1.0-0 \ + libx11-6 \ + libxcb1 \ + libxext6 \ + libxrender1 \ + libavcodec60 \ + xvfb \ + xfonts-cyrillic \ + xfonts-scalable \ + fonts-freefont-ttf \ + fonts-ipafont-gothic \ + fonts-noto-color-emoji \ + fonts-tlwg-loma-otf \ + fonts-unifont \ + fonts-wqy-zenhei \ && rm -rf /var/lib/apt/lists/*; \ fi +# git-lfs. Three files in this repo are stored in LFS +# (components/frontend/static/favicon.png, static/og-default.jpg and +# tools/configs/keycloak/themes/hackagon/login/resources/img/favicon.ico). +# The workspace is bind-mounted from the host, which HAS git-lfs, so those +# files hold their real bytes in the worktree while HEAD holds the pointer. +# Without the lfs filters installed here, git inside the container compares +# pointer against content and reports all three modified — the worktree is +# dirty with zero edits, `git status` stops being a usable signal, and every +# `nix develop` evaluates a flake it must mark dirty. +# +# `--system` writes /etc/gitconfig on purpose: /home/vscode is a named volume, +# so anything this image writes to the user's home is masked at runtime. +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git-lfs \ + && rm -rf /var/lib/apt/lists/* \ + && git lfs install --system --skip-repo + # Space-separated list of extra apt packages to bake into the image # (set HACKAGON_DEV_EXTRA_APT_PACKAGES in .devcontainer/.env). ARG EXTRA_APT_PACKAGES="" From 9ea99b83e0a5282ea8417a1b7603fe86e0530e21 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:19:59 +0200 Subject: [PATCH 07/25] docs(e2e): a quality report that re-derives every number it prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `quality-report.html` — self-contained, opens from file:// — plus the generator beside splice-player.mjs so it can be refreshed rather than rot. It links to recipe-player.html rather than duplicating it: the player is the animated replay of what the suite does, this is the thing you hand someone who asks how well the product is tested and where it is not. Every figure is read from a file on disk and the source named. 205 figures are registered at build time, then the FINISHED HTML is read back and each one re-derived by a second, textual code path — regex line scans, not the parsed objects that produced it — and any disagreement exits 1. A report nobody will re-derive by hand is exactly where a stale number survives, and this session produced several: a legend that mislabelled all three of its colours, hand-typed counts that drifted on the next edit. The strongest of those checks: the 465 `[id]` prefixes in the Playwright report are set-equal AND order-identical to the 465 recipe ids, so "465 actions" and "465 specs passed" are demonstrably the same 465 things. The mutation section is blunt about what it means: 26 properties proven catchable, 12 gaps, and a gap means nothing would notice if that behaviour broke — `requireWindowOpen` entire, `RemoveOwner` entire, `Join`'s guards. It carries the caveat too, that eleven of those are pinned only by journey actions whose reds are DEDUCED from a declared expect.error rather than observed, and that `upload.content-type-allowlist` has no witness anywhere. Three things the derivation found while building it: - the `--ginkgo.v` note in CLAUDE.md is STALE. internal/audit and internal/storage both carry Ginkgo bootstraps from 13331242, an ancestor of HEAD, and both pass with the flag. Reported as stale with the command. - "99 of 107 RPCs have a caller" is superseded by docs/testing.md's seven; the audit re-run at HEAD reproduces exactly those seven of 108 declarations. Both are shown side by side rather than one silently replacing the other. - verify.json and manifest.jsonl disagree in a way the timestamps do not explain (the manifest's mtime is older than the run it should postdate). Stated as-is rather than resolved. Honest about its own limits: "RPCs the recipe calls" is labelled a FLOOR (66 of 108 is grpcurl only; the 134 browser actions reach more), the API-to-UI grep matches by method name so a called Get masks an uncalled Get, and every number without a machine-readable artefact behind it is marked "reported, not measured here" with the command to re-measure. Verified: 205/205 figures render the value they claim, both themes at 1440x900 and 390x844, zero non-file:// requests, zero console errors, no horizontal overflow, and two consecutive builds produce an identical sha256. --- .../skills/hackathon-e2e/quality-report.html | 952 ++++++++++ .../scripts/build-quality-report.mjs | 1650 +++++++++++++++++ 2 files changed, 2602 insertions(+) create mode 100644 .claude/skills/hackathon-e2e/quality-report.html create mode 100644 .claude/skills/hackathon-e2e/scripts/build-quality-report.mjs diff --git a/.claude/skills/hackathon-e2e/quality-report.html b/.claude/skills/hackathon-e2e/quality-report.html new file mode 100644 index 00000000..3cbd31cb --- /dev/null +++ b/.claude/skills/hackathon-e2e/quality-report.html @@ -0,0 +1,952 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Hackagon — e2e quality report + + + + +
+
Hackagon · e2e quality report
+
sketch/06-08-26 @ e77e6f99 · 2026-08-14
+ +
+ +
+ +
+
+
465A
+

recipe actions

+
+

Every one of them ran, in order, in the last journey suite — 465B specs on 2026-08-13B, 0B failed and + 0B skipped. The recipe is the product spec: it is + what this report is mostly about, and what the numbers below qualify.

+
+ +
+

Headline

+

A result without a date is a claim about now that may not be true, so every tile + carries the day it was observed. Only the journey tile is read from a machine-readable run report; + the rest are the suite table in .claude/CLAUDE.mdF, which is a written record, + not an artefact — treat those as reported rather than measured here.

+
+
+

journey — the lifecycle recipe

+

469Bspecs passed

+

0 failed0 skipped0 flaky

+

observed 2026-08-13B

+

4B auth-setup + 465B recipe actions, serial, in 2.6B min.

+
+
+

smoke — the seeded fixture

+

139Fpassed

+

1 failed2 did not run

+

observed 2026-08-13F

+

One deterministic failure and the two specs behind it in the same serial describe — below.

+
+
+

backend — go test ./internal/…

+

311Fof 312F service specs

+

all packages okcapability 37middleware 43

+

observed 2026-08-13F

+

One spec is pending. Six packages; the fast tier the mutation runner drives.

+
+
+

frontend units — vitest

+

462Fpassed

+

no failures reported26 files

+

observed 2026-08-13F

+ +
+
+

mobile — phone viewport

+

121Fpassed

+

no failures reportedolder observation

+

observed 2026-08-10F

+ +
+
+

openreplay — replay privacy

+

13Fpassed

+

0 skippedneeds the replay rig

+

observed 2026-08-11F

+ +
+
+ +
+

The run and the recipe are the same list

+

The 465B spec titles in the run report carry an + [id] prefix, and that sequence is identical — same members, same order — to the + 465A action ids in recipe.jsonl. + The recipe's count and the run's count are therefore the same + 465A things, which is the claim a headline number + normally leaves unproven — a suite can pass every test it ran while never running the ones + that matter.

+
+
+ +
+

What the recipe covers

+

"465A specs passed" says nothing about what + they exercise. These are the nine acts, in the order they run, and the split between the two things + an action can be: a call on the wire, or a browser using the product.

+ +
+

Driver

+

330Aover gRPC

+

134 in a browser1 fixture

+

A browser action is the only shape that catches a control wired to the wrong + argument; an rpc action is the only shape that can name a status code.

+
+

Priority

+

325AP1

+

P2 131P3 9

+

Nothing is deferred: no action sets implement: false any more.

+
+

Expected refusals

+

87Adeclare an error

+

24 gated66 carry a todo

+

A gated action skips until its RPC probes as present, then wakes up on its own.

+
+

Surface reached

+

66Aof 108G declared RPCs

+

12 services16 people

+

Called directly. The 134A browser actions reach more of them through the UI — this counts grpcurl, so it is a floor, not total coverage.

+
+
+ +
+ Action kinds + rpc 324A — one grpcurl call, judged on its status code and payload + ui.flow 49A — a browser drives the product: click, type, submit + ui.assert 85A — a browser reads a page back and asserts what it says + rpc.race 6A — several calls fired simultaneously, judged in aggregate + files.generate 1A — produces a fixture file for a later action +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Each act, what it is about, and what it is made of. Bars are stacked in the legend's + order; the counts beside them carry the same information in text.
ActActionsCompositionCounts
Act 0platform setup: the site itself, before any hackathon exists15Arpc 8 · ui.flow 6 · ui.assert 1
Act 1T-4 months: PUBLICATION & ANNOUNCEMENT63Arpc 35 · ui.flow 8 · ui.assert 19 · rpc.race 1
Act 2T-3 months: REGISTRATION OPENS (13 sign-ups vs capacity 8)51Arpc 42 · ui.flow 5 · ui.assert 4
Act 2bT-3 months: THE CAPACITY PILOT (a capped side sprint)15Arpc 11 · ui.assert 3 · rpc.race 1
Act 3T-2 months: PROJECT PROPOSALS DUE13Arpc 11 · ui.flow 1 · ui.assert 1
Act 4T-1.5 months: TEAMS ARRANGEMENT + T-1 month: WEBINARS29Arpc 27 · ui.flow 1 · ui.assert 1
Act 5T-1 week: REGISTRATION CLOSES (approve 8, dropout, backfill)28Arpc 19 · ui.flow 5 · ui.assert 3 · rpc.race 1
Act 5 (cont.)the organizer's own screens: the manage hub, the folded nav, the capability switches, bulk team composition92Arpc 55 · ui.flow 6 · ui.assert 29 · rpc.race 2
Act 6T=0 / T+1: HACKATHON DAYS (time travel: move the event, not the clock)62Arpc 43 · ui.flow 7 · ui.assert 11 · files.generate 1
Act 7T+1 evening: VOTING & AWARDS40Arpc 37 · ui.flow 2 · rpc.race 1
Act 8T+1 week: POST-EVENT57Arpc 36 · ui.flow 8 · ui.assert 13
+ +
+ + + + + + + + + + +
The 87A actions that expect to be REFUSED, by the + status code they demand. A refusal with the right code from the wrong reason is still a bug, which + is why one action additionally pins the message with errorMatches.
Status codeActionsShare
PermissionDenied27A
InvalidArgument25A
Unauthenticated17A
FailedPrecondition10A
NotFound6A
AlreadyExists2A
+
+ +
+

Mutation results — can these tests go red?

+

A green suite proves nothing until you know it can fail. The manifest is + 38C deliberate, reversible breakages, each paired with the exact + set of tests that MUST notice; the runner applies one, runs the tests and asserts exactly that set + went red. A gap means nothing in the fast tier would notice if that behaviour broke.

+ +
+

Properties with a declared witness

+

26Cof 38C

+

provably catchable

+
+

Gaps — nothing goes red

+

12Cof 38C

+

unguarded in the fast tier

+

Every one is a backend property.

+
+

Arenas

+

30Cgo

+

vitest 8

+

The fast tier needs no running stack — it drives the compilers from source, so the + whole manifest is a five-minute check. All 8C frontend entries produced reds.

+
+

Verification run

+

38Dentries judged

+

recorded 2026-08-13

+

Verdicts below. NO REDS is not a curiosity to note and move past — it + fails the run.

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Verdicts as recorded in mutations/.state/verify.jsonD on + 2026-08-13D.
VerdictEntriesWhat it means
EXACT23Dexactly the declared tests went red — the property is provably catchable
MISMATCH3Dthe declared set failed plus something else; every extra here is the one declared-flaky spec
GAP11Dnothing went red — no test in the fast tier holds this property
GAP CLOSED1Ddeclared a gap, but a red appeared — promote it to a real entry
+ +
+

All 3D MISMATCH verdicts have the same single + unexpected red

+

service::Capacity > never oversells the last place under simultaneous joins — it fails roughly one run in five under in-memory + SQLite, has nothing to do with any mutation, and is declared in the runner's + KNOWN_FLAKY with that reason. Every ignored red is printed with its excuse, because a + list of failures that don't count is exactly the shape that could hide a real one.

+

1D of the + 3D would be judged differently today: + capacity.oversell-by-one now lists that spec in its + OWN expectReds, where the excuse cannot reach it — a flaky test can still be a genuine + witness, and this one hammers concurrent joins against a cap, which is precisely what the mutation + breaks. The recorded run counted it as an unexpected extra, so the file and the manifest disagree, + and the timestamps do not explain it: the manifest is not newer + than the run (2026-08-13 21:47:46ZC vs + 2026-08-13 21:53:17ZD). + The table above is the last recorded judgement, not a live one — re-run the + manifest to refresh it.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Gaps cluster. Grouping is by the id prefix, so this is derived from the manifest rather + than editorial: 3C surfaces account for 10C of the 12C gaps.
ClusterEntriesCaughtGapsStateFiles
owner.*4C0C4Centire surface uncoveredhackathon_service.go
join.*4C1C3Cpartly uncoveredhackathon_service.go
window.*3C0C3Centire surface uncoveredconfig_service.go
auth.*1C0C1Cuncoveredauth.go
upload.*1C0C1Cuncoveredstorage_service.go
cap.*8C8C0Ccoveredcapability.go, capability.go
rbac.*3C3C0Ccoveredrbac.go
capacity.*3C3C0Ccoveredcapacity.go
presign.*3C3C0Ccoveredclient.go, sigv4.go
markdown.*3C3C0Ccoveredmarkdown.ts
join-offer.*3C3C0CcoveredjoinOffer.ts
cap-client.*2C2C0Ccoveredcapability.ts
+ +
+

3C surfaces carry + 10C of the + 12C gaps

+
  • owner.*all 4C of its mutations produced zero reds, in hackathon_service.go
  • join.*3C of 4C, in hackathon_service.go
  • window.*all 3C of its mutations produced zero reds, in config_service.go
+

No Go spec exercises a deadline window in any package. RemoveOwner has no Go specs + whatsoever, so the last-organizer guard — one of the two invariants a real concurrency bug broke — + is held up entirely by journey actions; ownership is a casbin fact on this branch with no column to + assert against, which is probably why the specs were never written. And Join's guards: + the invite requirement on a private event, the already-finished refusal, and the ROLE a join grants + — join.grants-member hands every joiner OWNER instead of Member and not one Go spec + notices.

+
+ +
+

The honesty caveat

+

These are gaps in the FAST tier, not proof the product is unguarded: + 8C of the 12C name journey actions in their crossRef, and 3C more describe a journey witness in prose without naming one. But those reds are DEDUCED + — from each action's declared expect.error, not observed, because no journey mutation + has been run. And 1C entry has no witness anywhere at all: upload.content-type-allowlist, the rule that keeps image/svg+xml out of an origin we serve. That one is a + request for a test, not a pointer to one.

+

What it adds up to: the only thing standing behind window enforcement and the last-organizer + invariant is a suite that costs minutes, needs the whole stack, and cannot be run on a branch.

+
+ +

All 12C gaps

+
+
+

auth.require-user.admits-anon + journey only

+

RequireUser rejects the anonymous subject: endpoints that act on a PERSON must not accept 'anonymous' as an identity.

+

components/backend/internal/middleware/auth.go

+

NO FAST-TIER WITNESS. Not one Go spec calls a RequireUser endpoint anonymously, so admitting the 'anonymous' subject as an identity breaks nothing in 6 seconds of backend tests — even though eight TeamService handlers were moved onto RequireUser specifically to stop an anonymous caller getting NotFound and probing which team ids exist. The witnesses are all journey actions (crossRef), i.e. minutes and a running stack. Their reds are DEDUCED from each action's declared expect.error, not observed.

+

Pinned by 3C journey actions: act2.anonymous.register act4.team.anon act6.submit.anon

+
+
+

window.never-closes + journey only

+

A hackathon with a windows row and a passed deadline refuses the action with FailedPrecondition.

+

components/backend/internal/service/config_service.go

+

NO FAST-TIER WITNESS. requireWindowOpen has no Go coverage at all — neither a passed deadline nor an override is exercised by any spec in any package. Deadline enforcement is pinned only by journey actions (crossRef), whose reds are deduced from their declared expect.error rather than observed. This is the largest single hole the first manifest found.

+

Pinned by 3C journey actions: act5.window.regclosed act6.window.sublate act4.window.preflate

+
+
+

window.override-ignored + journey only

+

A now-anchored override reopens a closed window until it expires — the override is what makes a late submission a support decision.

+

components/backend/internal/service/config_service.go

+

NO FAST-TIER WITNESS. Same hole as window.never-closes: the now-anchored override — the mechanism that makes a late submission a support decision rather than a lockout — is exercised by no Go spec.

+

Pinned by 1C journey action: act6.window.override

+
+
+

window.opens-early + journey only

+

Registration before its opening moment is refused — a window has a start as well as an end.

+

components/backend/internal/service/config_service.go

+

NO FAST-TIER WITNESS. Same hole as window.never-closes, for the opening half of a window. Only act1.window.early pins it.

+

Pinned by 1C journey action: act1.window.early

+
+
+

owner.last-guard + journey only

+

The last owner cannot be demoted — an event whose every organizer is gone cannot be edited by anyone short of a global admin.

+

components/backend/internal/service/hackathon_service.go

+

NO FAST-TIER WITNESS. RemoveOwner has no Go specs whatsoever, so the last-organizer guard — one of the two invariants a real concurrency bug broke — is held up entirely by act5.owner.last and the act5.race.owner.* trio. Ownership is a casbin fact on this branch with no column to assert against, which is probably why no unit test was written; a spec driving the enforcer directly would close it.

+

Pinned by 2C journey actions: act5.owner.last act5.race.owner.remove

+
+
+

owner.self-demote + journey only

+

You cannot remove your own organizer role: the permission you are giving up is the one that would let you undo it.

+

components/backend/internal/service/hackathon_service.go

+

NO FAST-TIER WITNESS. Same hole as owner.last-guard: nothing in Go calls RemoveOwner, so 'you cannot give up the permission that would let you undo it' is pinned only by act5.owner.self.

+

Pinned by 1C journey action: act5.owner.self

+
+
+

owner.demote-restores-member + journey only

+

A demoted owner is given Member back; without it their role resolves to UNSPECIFIED, which renders as a corrupted record rather than a demotion.

+

components/backend/internal/service/hackathon_service.go

+

NO FAST-TIER WITNESS. Same hole as owner.last-guard. Worth noting the mutation used here (re-grant Owner instead of Member) makes the demotion a complete no-op and is STILL invisible to every Go package.

+

No crossRef recorded; the witness is described in prose only.

+
+
+

owner.mutex-dropped + journey only

+

RemoveOwner's read-owners/check/remove sequence is one decision under ownerMu — two owners demoting each other concurrently must not leave the event with none.

+

components/backend/internal/service/hackathon_service.go

+

Concurrency, and no fast-tier witness for the surface either. The Go suite has no concurrent RemoveOwner spec, and act5.race.owner.remove reaches only the real backend — so nothing in the fast tier can see this. Promote to arena 'journey' once a backend-restart path is safe to automate.

+

Pinned by 2C journey actions: act5.race.owner.remove act5.race.owner.invariant

+
+
+

join.private-needs-invite + journey only

+

A private hackathon is joinable only with a live invitation — privacy was discovery-only until this check, so anyone who learned the UUID could join.

+

components/backend/internal/service/hackathon_service.go

+

NO FAST-TIER WITNESS. Nothing in Go joins a PRIVATE hackathon, so removing the invitation requirement entirely — the fix that turned privacy from discovery-only into a real gate, i.e. anyone who learned the UUID could join — breaks no backend spec. The invite path is exercised only through the journey.

+

No crossRef recorded; the witness is described in prose only.

+
+
+

join.finished + journey only

+

Joining an event whose end date has passed is refused with FailedPrecondition.

+

components/backend/internal/service/hackathon_service.go

+

NO FAST-TIER WITNESS. No Go spec joins an event whose end date has passed; the only red this mutation produced was the known capacity flake. Pinned by act8.latejoin and act8.pilot.join.finished.rpc, whose reds are deduced from their declared expect.error rather than observed.

+

Pinned by 2C journey actions: act8.latejoin act8.pilot.join.finished.rpc

+
+
+

join.grants-member + journey only

+

Everyone on the roster holds Member at Join — including the waitlisted, which is what lets them propose and see the private event they signed up for.

+

components/backend/internal/service/hackathon_service.go

+

NO FAST-TIER WITNESS, and the mutation is a big one: Join grants OWNER instead of Member, so every person who joins any event becomes its organizer, and not one Go spec notices. 'Member role at Join' is a pinned policy decision (.claude/CLAUDE.md) with no unit test behind it — the casbin role a Join writes is asserted nowhere in the fast tier.

+

No crossRef recorded; the witness is described in prose only.

+
+
+

upload.content-type-allowlist + no witness anywhere

+

The upload content-type allowlist actually refuses — image/svg+xml is excluded on purpose because /objects is our own origin.

+

components/backend/internal/service/storage_service.go

+

NO FAST-TIER WITNESS. checkContentType's allowlist — the rule that keeps image/svg+xml out, because /objects is our own origin and a stored SVG is script running as the application — is asserted by no Go spec. StorageService's specs cover authorization and listing scopes; nothing presents a refused content type. This one has no journey witness either: the recipe uploads only well-formed PNGs, so a manifest entry here is a request for a test, not a pointer to one.

+

No journey action reaches it either — this entry is a request for a test, not a pointer to one.

+
+
+
+ +
+

Known-broken and open

+ +
+

smoke is one short of its baseline, deterministically open

+

tests/smoke/22-hackathon-pages.spec.ts:234I — "dragging a row saves the whole new order in one write". The first drag passes; the + RESTORE drag lands one position short. It is test-side: dragRowTo computes + endY at line 84I from the destination row's + bounding box before the drag starts, while the list reorders live on dragover. + Moving down, everything below the lifted row shifts up by one row height and the pointer arrives at + what has become the middle row — which is exactly why one direction passes and the other does not. + The two specs after it are the rest of a mode: "serial" describe, so they never run: + 139 + 1 + 2F = 142F.

+
+ +
+

just check::test -c backend under --ginkgo.v stale entry — re-checked

+

.claude/CLAUDE.mdF records this as currently RED: the quitsh runner appends --ginkgo.v to every + package's test binary, and internal/audit and internal/storage were plain + testing packages that exited 1 on flag provided but not defined: -ginkgo.v + before running anything.

+

The tree says otherwise. Both packages now carry a Ginkgo bootstrap whose only + job is to register those flags: + components/backend/internal/audit/audit_suite_test.go (present) and components/backend/internal/storage/storage_suite_test.go (present) — both added in 13331242I, + an ancestor of this commit. + Confirmed once by hand on 2026-08-14 by running + go test -tags "test unittest" ./internal/audit/ ./internal/storage/ -count=1 --ginkgo.v + which reports ok for both. The CLAUDE.md entry is stale and should be retired; the + full quitsh command was not re-run for this report.

+
+ +
+

RPCs with no frontend caller by design

+

An endpoint nobody calls is an endpoint no test can reach through the product — and three of + them once turned out to be missing features rather than spare capacity. The audit is re-run here + against the tree, not copied from the doc: api/proto/**/*_service.protoG declares 108G RPCs under + 75G distinct method names (names repeat across services), and + 7H of those names appear nowhere in + components/frontend/src outside the generated clients — exactly the + 7E that docs/testing.mdE lists.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RPCWhy nothing calls itRe-checked
HackathonService.SetCurrentPhasealiases the AdvancePhase the timeline callsaudit agrees
VoteService.GetVoteCategorycovered by the list endpoint already driving the UIaudit agrees
VoteService.ListVotessameaudit agrees
TeamService.GetSubmissionsameaudit agrees
VoteService.SuggestResultscomputes a tally the UI records by hand with CreateVoteResultaudit agrees
StorageService.CreateDownloadUrlwaits for something private to serveaudit agrees
ProjectService.RemovePreferencethere is no un-prefer control to call itaudit agrees
+

Two limits worth knowing. The grep matches by method NAME, so a + Get called on one service masks an uncalled Get on another — the count is + a floor. And .claude/CLAUDE.mdF still says 8F of 107F; PageService.SetOrder left the list on 2026-08-12 when drag-and-drop started sending + the whole sequence in one call, and the denominator has moved to 108G since. Prefer the table.

+
+ +
+

Two states the recipe declines to fake not asserted end-to-end

+

An individually ungoverned capability. + act5.pilot.cap.statesA says it plainly: + 'Not governed' is absent because HackathonService.Create seeds a row for all six capabilities, so nothing reachable through the app is ungoverned - the state is unreachable here by construction, and the three states present are the positive control for asserting its absence.

+

A waitlisted owner. One state in that set is unreachable from outside and therefore not asserted end-to-end: a WAITLISTED owner (AddOwner answers FailedPrecondition for anyone on the waiting list, so canEditHackathon's narrower gate cannot be exercised end-to-end). It is written down in the action's own todo rather than faked.F The RPC-level + refusal is still pinned — act5.owner.waitlistedA expects + FailedPrecondition — it is + the narrower UI gate behind it that nothing can reach.

+

Neither is faked with a fixture, and both are pinned in Go instead: a state + manufactured by the test is not the state the product can be in, so an end-to-end assertion about + it would be an assertion about the harness.

+
+
+ +
+

How to reproduce every number

+

All of these run inside the devcontainer. The two that write the files this report is + built from are marked; everything else here is read-only.

+
+ + + + + + + + + + + + + +
WhatCommandNote
journey — the 465A recipe actionsbash .claude/skills/devcontainer-up/scripts/e2e.sh journeywrites .artifacts/results.json (source B); do not redirect stdout — the json reporter is the durable copy
smoke — the seeded fixturebash .claude/skills/devcontainer-up/scripts/e2e.sh smoke142F specs; one is the open drag failure
mobile / openreplaybash .claude/skills/hackathon-e2e/scripts/run.sh mobileopenreplay needs the rig up and the app wired at it, or every spec self-skips
backend unitsgo test -tags "test unittest" ./internal/... -count=1from components/backend, with .devenv/profile/bin on PATH
frontend unitsjust check::test -c frontendvitest, jsdom
mutations — the whole manifestbash .claude/skills/devcontainer-up/scripts/mutate.sh runrewrites mutations/.state/verify.json (source D); NO REDS fails the run
mutations — anchors onlybash .claude/skills/hackathon-e2e/scripts/mutate.sh checkcheap enough for every commit: asserts every find: still matches its file exactly once
API-to-UI auditgrep -rn '\.<method>(' components/frontend/src --include='*.ts' --include='*.svelte'ignore hits under src/lib/server/grpc/generated/
this reportnode .claude/skills/hackathon-e2e/scripts/build-quality-report.mjsre-derives every figure and refuses to write one that disagrees with its source
the recipe playernode .claude/skills/hackathon-e2e/scripts/splice-player.mjsrequired after any recipe edit — the player shows what is embedded, not the file
+
+ +
+

Sources

+

Every figure above carries a superscript letter pointing at the file it was read + from. The build refuses to write a figure it cannot re-derive from that file by a second, independent + code path — so a number here is traceable by construction, not by discipline.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyPathSizeModifiedWhat was read
A.claude/skills/hackathon-e2e/recipe.jsonl252.4 KiB2026-08-13465 action lines + 12 comment banners (11 of them act headings): kinds, + priorities, actors, gates, todos, expected status codes, and every RPC the recipe calls.
B.claude/skills/hackathon-e2e/.artifacts/results.json500.6 KiB2026-08-13Playwright's json reporter from the last journey run: stats block, per-project spec counts, and the [id] prefix on every spec title.
C.claude/skills/hackathon-e2e/mutations/manifest.jsonl30.5 KiB2026-08-13The mutation manifest: one deliberate breakage per line, its arena, its expected reds, and — for a gap — why nothing catches it.
D.claude/skills/hackathon-e2e/mutations/.state/verify.json23.4 KiB2026-08-13The verdict the mutation runner recorded for all 38 entries, with the reds each one actually produced.
Edocs/testing.md5.9 KiB2026-08-11The API-to-UI coverage table: which declared RPCs have no frontend caller, and why each is deliberate.
F.claude/CLAUDE.md69.9 KiB2026-08-13The suite-results table (the only source for smoke, mobile, openreplay, backend and frontend-unit numbers) and the known-broken prose.
Gapi/proto/2026-08-07Every rpc declaration in api/proto/**/*_service.proto — the denominator for both coverage counts.
Hcomponents/frontend/src/2026-08-12The frontend call sites: the API-to-UI audit is re-run here at build time rather than copied from the doc.
I.claude/skills/hackathon-e2e/tests/smoke/22-hackathon-pages.spec.ts14.7 KiB2026-08-12The failing smoke spec and the helper that causes it; line numbers below are read out of the file.
+
+ +
+

Generated by .claude/skills/hackathon-e2e/scripts/build-quality-report.mjs from + sketch/06-08-26 @ e77e6f99 (2026-08-14). No wall-clock is embedded, so two builds over one + tree are byte-identical. This is the status report; the animated replay of the recipe is + recipe-player.html.

+
+ +
+ + + + diff --git a/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs b/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs new file mode 100644 index 00000000..0b06512b --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs @@ -0,0 +1,1650 @@ +#!/usr/bin/env node +/** + * Build `quality-report.html` — a status report you could hand someone: what is + * tested, how well, what is not, and what is known-broken. + * + * This is NOT recipe-player.html. The player is an animated REPLAY of the + * recipe; this is the report about it. The report links to the player. + * + * ── The one rule ─────────────────────────────────────────────────────────── + * Nothing in the output is hand-typed. Every number is read from a file on + * disk at build time, rendered with the source it came from, and then READ + * BACK OUT of the finished HTML and re-derived by a second, independent code + * path before the build is allowed to succeed. A stale hand-written count is + * the failure mode this exists to make impossible — that is why `claim()` + * stamps `data-claim`/`data-value` on every figure, and why `rederive()` + * counts with textual scans rather than reusing the parsed objects. + * + * Sources (all repo-relative, all read fresh): + * A .claude/skills/hackathon-e2e/recipe.jsonl + * B .claude/skills/hackathon-e2e/.artifacts/results.json + * C .claude/skills/hackathon-e2e/mutations/manifest.jsonl + * D .claude/skills/hackathon-e2e/mutations/.state/verify.json + * E docs/testing.md + * F .claude/CLAUDE.md + * G api/proto/ ** /*_service.proto + * H components/frontend/src + * I the named spec / Go files under components/ and tests/ + * + * ── Traps this script is built around ────────────────────────────────────── + * 1. An inline + + +` + +fs.writeFileSync(OUT, html) + +/* ══════════════ read back, re-derive, and refuse to disagree ═════════════ */ + +/** + * The second code path. Deliberately textual where the first was structural: + * if both were `JSON.parse` + the same reduce, agreeing would prove nothing. + */ +function rederive() { + const out = {} + const raw = read(P.recipe) + const lines = raw.split(/\r?\n/).filter((l) => l.trim()) + const count = (re) => lines.filter((l) => re.test(l)).length + + out["recipe.actions"] = count(/"id"\s*:/) + out["recipe.actionsLede"] = out["recipe.actions"] + for (const k of KINDS) out[`recipe.kind.${k.key}`] = count(new RegExp(`"action"\\s*:\\s*"${k.key.replace(".", "\\.")}"`)) + for (const p of ["P1", "P2", "P3"]) out[`recipe.${p}`] = count(new RegExp(`"priority"\\s*:\\s*"${p}"`)) + out["recipe.gates"] = count(/"gate"\s*:/) + out["recipe.todos"] = count(/"todo"\s*:/) + out["recipe.grpc"] = out["recipe.kind.rpc"] + out["recipe.kind.rpc.race"] + out["recipe.browser"] = out["recipe.kind.ui.flow"] + out["recipe.kind.ui.assert"] + out["recipe.browserAgain"] = out["recipe.browser"] + out["recipe.files"] = out["recipe.kind.files.generate"] + let denials = 0 + for (const code of errCount.keys()) { + const c = count(new RegExp(`"error"\\s*:\\s*"${code}"`)) + out[`recipe.denial.${code}`] = c + denials += c + } + out["recipe.denials"] = denials + out["recipe.denialsTable"] = denials + out["recipe.actors"] = new Set( + lines.map((l) => (l.match(/"actor"\s*:\s*"([^"]+)"/) || [])[1]).filter(Boolean), + ).size + const methods = new Set() + for (const m of raw.matchAll(/"method"\s*:\s*"([^"]+)"/g)) methods.add(m[1]) + out["recipe.methods"] = methods.size + out["recipe.services"] = new Set([...methods].map((m) => m.split("/")[0])).size + + // per-section counts: split the raw text on ACT banners and count id lines. + // Both JSONL files here are written with a space after the colon, so every + // regex below allows for it — a `"key":"value"` pattern silently matches + // nothing against `"key": "value"`, which is a zero that looks like an answer. + const chunks = raw.split(/\n(?=\{"comment":\s*"─+ ACT )/) + const secCounts = [] + for (const ch of chunks) { + if (!/^\{"comment":\s*"─+ ACT /.test(ch)) continue + secCounts.push(ch.split(/\r?\n/).filter((l) => /"id"\s*:/.test(l)).length) + } + const secKinds = [] + for (const ch of chunks) { + if (!/^\{"comment":\s*"─+ ACT /.test(ch)) continue + const m = {} + for (const k of KINDS) + m[k.key] = ch + .split(/\r?\n/) + .filter((l) => new RegExp(`"action"\\s*:\\s*"${k.key.replace(".", "\\.")}"`).test(l)).length + secKinds.push(m) + } + sections.forEach((s, i) => { + const key = s.label.toLowerCase().replace(/[^a-z0-9]+/g, "") + out[`recipe.section.${key}`] = secCounts[i] + for (const k of KINDS) + if (secKinds[i][k.key]) out[`recipe.section.${key}.${k.key}`] = secKinds[i][k.key] + }) + + // B — results.json, read by regex off the raw text rather than the object tree + const rr = read(P.results) + const stats = JSON.parse(rr.slice(rr.lastIndexOf('"stats":') + 8).replace(/}\s*$/, "")) + out["journey.expected"] = stats.expected + out["journey.unexpected"] = stats.unexpected + out["journey.unexpectedHero"] = stats.unexpected + out["journey.skipped"] = stats.skipped + out["journey.skippedHero"] = stats.skipped + out["journey.flaky"] = stats.flaky + out["journey.minutes"] = (stats.duration / 60000).toFixed(1) + out["journey.when"] = stats.startTime.slice(0, 10) + out["journey.day"] = out["journey.when"] + out["journey.setupSpecs"] = (rr.match(/"projectName":\s*"setup"/g) || []).length + out["journey.recipeSpecs"] = (rr.match(/"projectName":\s*"journey"/g) || []).length + out["journey.recipeSpecsHero"] = out["journey.recipeSpecs"] + out["xcheck.runIds"] = (rr.match(/"title":\s*"\[[^\]"]+\]/g) || []).length + out["xcheck.recipeIds"] = out["recipe.actions"] + out["xcheck.same"] = out["recipe.actions"] + out["open.bootstrapCommit"] = git("log", "-1", "--format=%h", "--", rel(P.storageBoot)) + + // C — manifest, counted line by line + const ml = read(P.manifest).split(/\r?\n/).filter((l) => /"id"\s*:/.test(l)) + out["mut.total"] = ml.length + out["mut.totalTile"] = ml.length + out["mut.totalTile2"] = ml.length + out["mut.gaps"] = ml.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out["mut.gaps2"] = out["mut.gaps"] + out["mut.gaps3"] = out["mut.gaps"] + out["mut.gapsHeading"] = out["mut.gaps"] + out["mut.caught"] = ml.length - out["mut.gaps"] + out["mut.go"] = ml.filter((l) => /"arena"\s*:\s*"go"/.test(l)).length + out["mut.vitest"] = ml.filter((l) => /"arena"\s*:\s*"vitest"/.test(l)).length + out["mut.vitest2"] = out["mut.vitest"] + out["mut.gaps.crossRef"] = ml.filter((l) => /"gap"\s*:\s*true/.test(l) && /"crossRef"\s*:\s*\[/.test(l)).length + out["mut.gaps.noWitness"] = ml.filter( + (l) => /"gap"\s*:\s*true/.test(l) && /no journey witness/i.test(l), + ).length + out["mut.gaps.proseOnly"] = out["mut.gaps"] - out["mut.gaps.crossRef"] - out["mut.gaps.noWitness"] + out["mut.manifestDay"] = mtimeDay(P.manifest) + for (const l of ml) { + const id = (l.match(/"id"\s*:\s*"([^"]+)"/) || [])[1] + const cr = l.match(/"crossRef"\s*:\s*\[([^\]]*)\]/) + if (id && cr) out[`mut.gap.${id}.refs`] = (cr[1].match(/"/g) || []).length / 2 + } + for (const c of clusterList) { + const rows = ml.filter((l) => new RegExp(`"id"\\s*:\\s*"${c.key}\\.`).test(l)) + const g = rows.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out[`mut.cluster.${c.key}.total`] = rows.length + out[`mut.cluster.${c.key}.gaps`] = g + out[`mut.cluster.${c.key}.caught`] = rows.length - g + } + const major = clusterList.filter((c) => out[`mut.cluster.${c.key}.gaps`] >= MAJOR_GAP) + for (const c of major) { + const t = out[`mut.cluster.${c.key}.total`], g = out[`mut.cluster.${c.key}.gaps`] + if (t === g) out[`mut.major.${c.key}.all`] = t + else { out[`mut.major.${c.key}.gaps`] = g; out[`mut.major.${c.key}.total`] = t } + } + out["mut.majorClusters"] = major.length + out["mut.majorClustersHeading"] = major.length + out["mut.clusterGapShare"] = major.reduce((a, c) => a + out[`mut.cluster.${c.key}.gaps`], 0) + out["mut.clusterGapShareHeading"] = out["mut.clusterGapShare"] + out["mut.gaps4"] = out["mut.gaps"] + + // D — verify.json, counted by verdict string + const vr = read(P.verify) + for (const [v] of [["EXACT"], ["MISMATCH"], ["GAP"], ["GAP CLOSED"]]) { + const re = new RegExp(`"verdict"\\s*:\\s*"${v}"`, "g") + out[`mut.verdict.${v.replace(/\s/g, "")}`] = (vr.match(re) || []).length + } + // "GAP" also matches inside "GAP CLOSED"? No — the quote terminates it. Verify: + out["mut.verdict.GAP"] = (vr.match(/"verdict"\s*:\s*"GAP"/g) || []).length + out["mut.verifyRows"] = (vr.match(/"verdict"\s*:/g) || []).length + out["src.verifyRows"] = out["mut.verifyRows"] + out["src.actionLines"] = out["recipe.actions"] + out["src.bannerLines"] = + read(P.recipe).split(/\r?\n/).filter((l) => l.trim()).length - out["recipe.actions"] + out["src.actSections"] = (read(P.recipe).match(/^\{"comment":\s*"─+ ACT /gm) || []).length + const at = (vr.match(/"at"\s*:\s*"([^"]+)"/) || [])[1] + out["mut.verifyDay"] = at.slice(0, 10) + out["mut.verifyDay2"] = out["mut.verifyDay"] + out["mut.verifyDate"] = out["mut.verifyDay"] + out["mut.mismatch.superseded"] = supersededMismatch.length + out["mut.mismatch.rows"] = (vr.match(/"verdict"\s*:\s*"MISMATCH"/g) || []).length + out["mut.mismatch.rows2"] = out["mut.mismatch.rows"] + out["mut.manifestTime"] = + fs.statSync(P.manifest).mtime.toISOString().slice(0, 19).replace("T", " ") + "Z" + out["mut.verifyTime"] = at.slice(0, 19).replace("T", " ") + "Z" + + // E/G/H — proto + frontend, recounted with different expressions + let decls = 0 + for (const f of protoFiles) decls += (read(f).match(/^[ \t]*rpc[ \t]+[A-Za-z0-9_]+/gm) || []).length + out["proto.declarations"] = decls + out["proto.declarationsAudit"] = decls + out["proto.declarationsAudit2"] = decls + out["proto.names"] = new Set( + protoFiles.flatMap((f) => + [...read(f).matchAll(/^[ \t]*rpc[ \t]+([A-Za-z0-9_]+)/gm)].map((m) => m[1]), + ), + ).size + out["api.uncalledNow"] = [...methodNames].filter( + (m) => !new RegExp(`\\.${lcFirst(m)}\\s*\\(`).test(frontendCorpus), + ).length + const doc = read(P.testingDoc) + out["api.uncalledDoc"] = ( + doc.split(/^## API-to-UI coverage$/m)[1] || "" + ).split(/\r?\n/).filter((l) => /^\|\s*`[A-Za-z]+\.[A-Za-z]+`/.test(l)).length + const cm = doc.match(/\*\*(\w+) RPC declarations have no frontend caller\*\*/) + if (cm) { + const words = { Seven: 7, Eight: 8, Nine: 9, Six: 6, Five: 5 } + if (words[cm[1]] && words[cm[1]] !== out["api.uncalledDoc"]) + throw new Error( + `docs/testing.md says ${cm[1]} uncalled RPCs but its table lists ${out["api.uncalledDoc"]}`, + ) + } + + // F — the suite table, re-parsed with a stricter row regex + const rows = [...claudeMd.matchAll(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|$/gm)] + const find = (k) => rows.find((r) => r[1].toLowerCase().replace(/`/g, "").startsWith(k)) + const num = (s, re) => Number((String(s).match(re) || [])[1]) + const sm = find("smoke"), mo = find("mobile"), or = find("openreplay"), + fe = find("frontend"), be = find("backend"), jo = find("journey") + out["smoke.passed"] = num(sm[2], /(\d+) passed/) + out["smoke.failed"] = num(sm[2], /(\d+) failed/) + out["smoke.notrun"] = num(sm[2], /(\d+) did not run/) + out["smoke.when"] = sm[3] + out["open.smokeSum"] = `${out["smoke.passed"]} + ${out["smoke.failed"]} + ${out["smoke.notrun"]}` + out["open.smokeTotal"] = out["smoke.passed"] + out["smoke.failed"] + out["smoke.notrun"] + out["repro.smokeTotal"] = out["open.smokeTotal"] + out["repro.actions"] = out["recipe.actions"] + out["mobile.passed"] = num(mo[2], /(\d+) passed/); out["mobile.when"] = mo[3] + out["openreplay.passed"] = num(or[2], /(\d+) passed/) + out["openreplay.skipped"] = num(or[2], /(\d+) skipped/); out["openreplay.when"] = or[3] + out["feunits.passed"] = num(fe[2], /(\d+) passed/) + out["feunits.files"] = num(fe[1], /\((\d+) files\)/); out["feunits.when"] = fe[3] + out["backend.serviceSpecs"] = num(be[2], /service (\d+)\//) + out["backend.serviceTotal"] = num(be[2], /service \d+\/(\d+)/) + out["backend.capability"] = num(be[2], /capability (\d+)/) + out["backend.middleware"] = num(be[2], /middleware (\d+)/); out["backend.when"] = be[3] + if (num(jo[2], /(\d+) passed/) !== out["journey.expected"]) + throw new Error( + `.claude/CLAUDE.md claims journey ${num(jo[2], /(\d+) passed/)} but results.json recorded ${ + out["journey.expected"] + } — one of them is stale`, + ) + const cu = claudeMd + .replace(/\s+/g, " ") + .match(/coverage: \**(\d+) of (\d+) RPC declarations have a frontend caller/) + if (!cu) throw new Error("the API-to-UI sentence moved in .claude/CLAUDE.md") + out["claudemd.uncalled"] = Number(cu[2]) - Number(cu[1]) + out["claudemd.total"] = Number(cu[2]) + + // I — the two code sites, located by a different anchor + const ds = read(P.dragSpec).split(/\r?\n/) + out["open.dragLine"] = ds.findIndex((l) => /dragging a row saves the whole new order/.test(l)) + 1 + out["open.endYLine"] = ds.findIndex((l) => /\bendY\b\s*=/.test(l)) + 1 + return out +} + +const back = read(OUT) + +// One script block, exactly — a second close tag means data truncated the page. +const opens = (back.match(//g) || []).length +if (opens !== 1 || closes !== 1) + throw new Error(`expected exactly one script block, found ${opens} open / ${closes} close`) + +// No figure may have been escaped INTO the page as text. This is the shape a +// stamped number takes after esc() has run over it, and it is invisible to the +// data-claim scan (the escaped stamp simply leaves the checked set) — it showed +// up first as raw `` printed inside a tile. +const leaked = back.match(/<span class="num"/g) +if (leaked) + throw new Error( + `${leaked.length} stamped figure(s) were HTML-escaped into visible text — a field holding ` + + `n()'s markup was passed through esc()`, + ) + +const stamped = new Map() +for (const m of back.matchAll(/data-claim="([^"]+)"\s+data-value="([^"]*)"/g)) + stamped.set(m[1], m[2].replace(/&/g, "&").replace(/'/g, "'").replace(/"/g, '"')) + +const truth = rederive() +const problems = [] +for (const [id, shown] of stamped) { + if (!(id in truth)) { + problems.push(`${id}: rendered ${shown} but the re-derivation has no value for it`) + continue + } + if (String(truth[id]) !== String(shown)) + problems.push(`${id}: rendered ${shown}, re-derived ${truth[id]}`) +} +/* Every registered claim must be re-derivable, stamped or not. A figure that + only lives inside a chip is still a figure, and "the checker had nothing to + say about it" is the shape every silent-green bug in this repo has had. */ +for (const [id, v] of CLAIMS) { + if (!(id in truth)) { + problems.push(`${id}: built ${v} but rederive() covers it nowhere — add it or drop the claim`) + continue + } + if (!stamped.has(id) && String(truth[id]) !== String(v)) + problems.push(`${id} (unstamped): built ${v}, re-derived ${truth[id]}`) +} + +if (!idsIdentical) + problems.push( + `the run report and recipe.jsonl are not the same list of ids (` + + `${missingFromRun.length} never ran, ${extraInRun.length} unknown)`, + ) + +/* ── print what was embedded, the way splice-player.mjs does ─────────────── */ +const line = (k, v) => console.log(" " + String(k).padEnd(34) + String(v)) +console.log(`quality-report.html — ${(back.length / 1024).toFixed(1)} KiB, ${branch} @ ${headSha}`) +line("recipe actions", `${actions.length} in ${actCount.size} acts, ${sections.length} banner sections`) +line(" by kind", KINDS.map((k) => `${k.label} ${kindCount.get(k.key) || 0}`).join(", ")) +line(" by priority", [...prioCount].map(([k, v]) => `${k} ${v}`).join(", ")) +line(" driver", `${grpcActions} gRPC, ${uiActions} browser, ${fileActions} fixture`) +line(" gates / todos", `${gateCount} / ${todoCount}`) +line(" expected refusals", `${denialTotal} across ${errCount.size} status codes`) +line(" reaches", `${recipeCovered.length} of ${declarations.length} declared RPCs, ${recipeServices.size} services`) +line("journey run", `${results.stats.expected} passed, ${results.stats.unexpected} failed, ${results.stats.skipped} skipped (${runDay})`) +line(" spec ids == recipe ids", idsIdentical ? `yes, all ${runIds.length}, same order` : "NO") +line("mutations", `${manifest.length} entries — ${caught.length} with a witness, ${gaps.length} gaps`) +line(" verdicts", [...verdictCount].map(([k, v]) => `${k} ${v}`).join(", ") + ` (${verifyDay})`) +line(" gap clusters", majorGapClusters.map((c) => `${c.key} ${c.gaps}/${c.total}`).join(", ") || "none") +line("API-to-UI", `${uncalledNow.length} of ${methodNames.size} method names uncalled; ${declarations.length} declarations`) +line("suite table rows", suiteRows.map((r) => r.suite.split(" ")[0]).join(", ")) +line("figures stamped", `${stamped.size} (${CLAIMS.size} claims registered)`) + +if (problems.length) { + console.error(`\n✗ ${problems.length} figure(s) disagree with their source:`) + for (const p of problems) console.error(" " + p) + process.exit(1) +} +console.log( + `\n✓ ${CLAIMS.size} figures re-derived from their sources by a second code path and matched ` + + `(${stamped.size} of them also stamped in the HTML as data-claim/data-value)`, +) From cfa8a920d4b75a8b378019d5ba400c54772afa9c Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:08:10 +0200 Subject: [PATCH 08/25] feat(analytics): self-hosted Plausible for development, behind its own tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `plausible-stack` skill modelled on `openreplay-stack`: vendored upstream compose pinned to CE v3.2.1, non-interactive secrets into a gitignored .secrets.env that refuses to be written unless `git check-ignore` passes, its own cloudflared tunnel, and wire/restore scripts. Frontend gets a `plausible` config block, a component that injects nothing when unwired, and route-id derivation with unit tests. ## Consent: deliberately OUTSIDE the replay gate The banner asks one question in its own words — may we record your session — and consent is scoped to what was asked. Reusing that answer for a second collection is helping ourselves to permission nobody gave, the same argument that kept replay out of the registration consents. A second banner would ask about something with no artefact to permit or withdraw. What makes that honest is a property rather than a vendor claim: nothing is stored in or read from the browser, which is the thing ePrivacy requires consent for. DNT and GPC still suppress it, checked BEFORE the script is fetched, so the request is not made either. ## What it does with IPs, stated rather than glossed Plausible sees the IP and user agent and hashes them with a daily-rotated salt into a user_id; only the hash is stored. Verified against system.columns — events_v2 and sessions_v2 carry no IP or user-agent column, and verify.sh re-asks every run rather than trusting it once. No geolocation DB ships, so country and city stay empty. That is processing in transit, and the doc says so instead of calling it anonymous. ## URLs: the tracker never sends one `u` is SvelteKit's ROUTE ID — /hackathon/[id], /invite/[token] — so it cannot leak an id because it never touched one. Chosen over a scrubbing regex, which would have to enumerate what a secret looks like. Query strings dropped, internal referrers dropped, external ones reduced to origin. The cost is stated: no per-hackathon breakdowns, ever. Nothing correlates with the audit journal or with replay sessions. ## Proof A real Firefox visits /, CLICKS through to /hackathon/, opens /invite/; the captured POST bodies carry the three route ids and no uuid, token or internal referrer — and then Plausible's OWN Stats API returns exactly those three pages. Positive control first, because "no id was stored" is satisfied by an empty database. Unwired, the same script records zero requests. `--restore` proven to remove only its own key, with oidc and replay surviving intact — this is the overlay's THIRD writer and a whole-file rm there is invisible in both directions. A down --volumes / up / re-wire round trip found a real bug: the stored API key survived a wipe that deleted its row, so signup.sh probes the key and mints a new one on anything but a 200. Signup goes through `bin/plausible rpc`, not HTTP: GET /register serves a form but POST /register is 404, because registration is a LiveView handler on the websocket. Written down with its cost — it names internal Elixir functions, so it is version-pinned and fails loudly. ~750 MB RSS idle, one loopback port, coexists with the openreplay rig. --- .claude/CLAUDE.md | 20 +- .claude/skills/lib/config-overlay.sh | 10 +- .claude/skills/plausible-stack/.gitignore | 9 + .claude/skills/plausible-stack/SKILL.md | 286 ++++++++++++++++++ .../plausible-stack/compose.tunnel.yaml | 38 +++ .../skills/plausible-stack/scripts/doctor.sh | 112 +++++++ .../skills/plausible-stack/scripts/down.sh | 38 +++ .../plausible-stack/scripts/fetch-upstream.sh | 75 +++++ .claude/skills/plausible-stack/scripts/lib.sh | 190 ++++++++++++ .../plausible-stack/scripts/pageview.mjs | 134 ++++++++ .../skills/plausible-stack/scripts/secrets.sh | 94 ++++++ .../skills/plausible-stack/scripts/signup.sh | 192 ++++++++++++ .claude/skills/plausible-stack/scripts/up.sh | 189 ++++++++++++ .claude/skills/plausible-stack/scripts/url.sh | 20 ++ .../skills/plausible-stack/scripts/verify.sh | 253 ++++++++++++++++ .../plausible-stack/scripts/wire-frontend.sh | 221 ++++++++++++++ .../observability/PlausibleAnalytics.svelte | 162 ++++++++++ .../frontend/src/lib/schemas/config-schema.ts | 46 +++ .../src/lib/utils/analyticsRoute.test.ts | 86 ++++++ .../frontend/src/lib/utils/analyticsRoute.ts | 102 +++++++ .../frontend/src/routes/+layout.server.ts | 33 ++ components/frontend/src/routes/+layout.svelte | 6 + docs/frontend/analytics.md | 155 ++++++++++ 23 files changed, 2463 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/plausible-stack/.gitignore create mode 100644 .claude/skills/plausible-stack/SKILL.md create mode 100644 .claude/skills/plausible-stack/compose.tunnel.yaml create mode 100644 .claude/skills/plausible-stack/scripts/doctor.sh create mode 100644 .claude/skills/plausible-stack/scripts/down.sh create mode 100644 .claude/skills/plausible-stack/scripts/fetch-upstream.sh create mode 100644 .claude/skills/plausible-stack/scripts/lib.sh create mode 100644 .claude/skills/plausible-stack/scripts/pageview.mjs create mode 100644 .claude/skills/plausible-stack/scripts/secrets.sh create mode 100644 .claude/skills/plausible-stack/scripts/signup.sh create mode 100644 .claude/skills/plausible-stack/scripts/up.sh create mode 100644 .claude/skills/plausible-stack/scripts/url.sh create mode 100644 .claude/skills/plausible-stack/scripts/verify.sh create mode 100644 .claude/skills/plausible-stack/scripts/wire-frontend.sh create mode 100644 components/frontend/src/lib/components/observability/PlausibleAnalytics.svelte create mode 100644 components/frontend/src/lib/utils/analyticsRoute.test.ts create mode 100644 components/frontend/src/lib/utils/analyticsRoute.ts create mode 100644 docs/frontend/analytics.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b6f250f6..5c06f3bb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -14,6 +14,7 @@ required beyond the repo itself (Nix dev shell via `just`). | `dbml-diagrams` | Builds and validates the dbdiagram.io DBML (`docs/backend/schema.dbml`) from the ent schema; `scripts/validate.sh` runs the official parser. | | `docs-bundle` | Builds `docs/` into ONE self-contained HTML (`out/hackagon-docs.html`): images re-encoded to webp and inlined, mermaid pre-rendered to SVG, cross-doc links anchored. No network needed to read it; prints to PDF. | | `openreplay-stack` | Self-hosted OpenReplay (session replay) via docker compose behind a Cloudflare quick tunnel. Vendors the upstream compose into the skill, prepares secrets non-interactively, points the stack at the tunnel URL, wires the app at it and back (`wire-frontend.sh`), and purges expired sessions (`retention.sh` — upstream has no retention setting). Debug rig — needs 8 GB RAM of its own. | +| `plausible-stack` | Self-hosted Plausible Analytics (CE v3.2.1) via docker compose behind its own Cloudflare quick tunnel — Plausible plus its OWN Postgres and ClickHouse, never the app's database. Prepares secrets and the owner account non-interactively (the signup form is a LiveView, so it goes through `bin/plausible rpc`), wires the app at it and back (`wire-frontend.sh`, the THIRD writer of `config.local.yaml`), and proves a page view lands end to end with a real browser and Plausible's own Stats API. ~750 MB RSS — coexists with the openreplay rig. | | `seed-past-hackathons` | Populates a running instance with SDSC's real past hackathons — one source-cited JSON per edition under `data/` (details, phases, tracks, markdown pages, images). Uploads the pictures into the instance's object store, sets each event's cover, rewrites page markdown to the uploaded paths, and gives every edition a prize table with drawn (not photographed) badge art. | ## The recipe = the product spec @@ -933,10 +934,11 @@ guard against a repeat is a spec in `internal/config/config_test.go` asserting BOTH tracked configs still say `localhost`; `run.sh` reads the wired URL out of the overlay, so the overlay's absence is now the "no tunnel" signal. -**That overlay has TWO writers now, and neither may `rm` it.** `auth-wire.sh` -owns `oidc`; `openreplay-stack/scripts/wire-frontend.sh` owns `replay` (moved -there for the same reason — a wired dev machine used to carry a -`*.trycloudflare.com` ingest hostname in the tracked `config.yaml`). Both go +**That overlay has THREE writers now, and none of them may `rm` it.** +`auth-wire.sh` owns `oidc`; `openreplay-stack/scripts/wire-frontend.sh` owns +`replay` (moved there for the same reason — a wired dev machine used to carry a +`*.trycloudflare.com` ingest hostname in the tracked `config.yaml`); +`plausible-stack/scripts/wire-frontend.sh` owns `plausible`. All go through `.claude/skills/lib/config-overlay.sh`, which adds and removes ONE top-level key and deletes the file only when the last key leaves it. A whole-file `rm` is invisible in both directions: dropping `replay` stops recording, and an @@ -1106,6 +1108,16 @@ bash .claude/skills/hackathon-e2e/scripts/run.sh openreplay bash .claude/skills/openreplay-stack/scripts/wire-frontend.sh --restore ``` +Audience measurement (page views per SCREEN, cookieless, no URL ever sent — the +privacy decisions are in `docs/frontend/analytics.md`): + +```bash +bash .claude/skills/plausible-stack/scripts/up.sh # instance + its own tunnel, no prompts +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh +bash .claude/skills/plausible-stack/scripts/verify.sh # real browser → Plausible's own Stats API +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore +``` + Public URL with working login (see the cloudflare-tunnel skill): ```bash diff --git a/.claude/skills/lib/config-overlay.sh b/.claude/skills/lib/config-overlay.sh index 905bd80a..47ffac22 100644 --- a/.claude/skills/lib/config-overlay.sh +++ b/.claude/skills/lib/config-overlay.sh @@ -25,8 +25,9 @@ # # It now has MORE THAN ONE WRITER, and they are unaware of each other: # -# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh -# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh +# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh +# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh +# plausible: .claude/skills/plausible-stack/scripts/wire-frontend.sh # # Each owns exactly one top-level key. If either treated "unwire me" as # "delete the overlay", it would silently take the other's wiring with it — @@ -57,8 +58,9 @@ HEADER='# GENERATED — machine-local config overlay. Do not edit, do not commit # independently. Do not delete this file to "turn something off": that takes # the other tools'"'"' wiring with it. # -# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh [--restore] -# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh [--restore]' +# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh [--restore] +# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh [--restore] +# plausible: .claude/skills/plausible-stack/scripts/wire-frontend.sh [--restore]' usage() { sed -n '2,7p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 diff --git a/.claude/skills/plausible-stack/.gitignore b/.claude/skills/plausible-stack/.gitignore new file mode 100644 index 00000000..6ef46c93 --- /dev/null +++ b/.claude/skills/plausible-stack/.gitignore @@ -0,0 +1,9 @@ +# Upstream's compose is fetched, not vendored into git (pinned + SHA-recorded +# by scripts/fetch-upstream.sh, so a fetch is reproducible). +vendor/ +.state/ + +# Admin credentials + SECRET_KEY_BASE, generated by scripts/up.sh on first run. +# This entry is what protects them on a branch where .claude/ is TRACKED — +# secrets.sh refuses to write the file at all if git would not ignore it. +.secrets.env diff --git a/.claude/skills/plausible-stack/SKILL.md b/.claude/skills/plausible-stack/SKILL.md new file mode 100644 index 00000000..7407ae0f --- /dev/null +++ b/.claude/skills/plausible-stack/SKILL.md @@ -0,0 +1,286 @@ +--- +name: plausible-stack +description: Spin up a self-hosted Plausible Analytics (Community Edition) instance with docker compose, behind a Cloudflare quick tunnel so both the dashboard and the tracking script have a public origin. Vendors the upstream compose, prepares secrets and the owner account non-interactively, wires the SvelteKit app at it and back, and proves a page view lands end to end. Use when asked to run/try/evaluate Plausible, add privacy-friendly analytics, or count page views for the hackathon platform. +--- + +# Plausible CE behind a quick tunnel + +A **development rig**, not a deployment. Three containers plus a tunnel: +Plausible, its own Postgres, its own ClickHouse. Same shape as +`openreplay-stack`, one tenth the weight, and it answers a different question — +"is this page used at all", which the RPC journal structurally cannot see and +session replay is far too heavy to answer. + +## Commands + +```bash +bash .claude/skills/plausible-stack/scripts/doctor.sh # preflight +bash .claude/skills/plausible-stack/scripts/up.sh # fetch, secrets, tunnel, start, owner account, lock down +bash .claude/skills/plausible-stack/scripts/up.sh --dry-run # everything except `compose up` +bash .claude/skills/plausible-stack/scripts/url.sh --all # current public URL +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh # point the app at it +bash .claude/skills/plausible-stack/scripts/verify.sh # prove it end to end +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore +bash .claude/skills/plausible-stack/scripts/down.sh # stop, keep the statistics +bash .claude/skills/plausible-stack/scripts/down.sh --volumes # stop and delete everything +``` + +`up.sh` needs no input and asks for none: it mints the secrets, registers the +owner, creates the site and a Stats API key, then **closes registration behind +itself** and checks that `/register` refuses. + +## Layout + +``` +compose.tunnel.yaml overlay: adds cloudflared, publishes :8010 on loopback +scripts/ doctor · fetch-upstream · secrets · up · url · down + signup (owner + site + Stats API key, via `bin/plausible rpc`) + wire-frontend (point the app at this rig, and back) + verify (9 checks, ending at Plausible's own API) + pageview.mjs (drives a real Firefox; used by verify) +vendor/ upstream compose.yml + clickhouse/ (fetched, gitignored) +vendor/UPSTREAM.txt repo, ref, exact commit and the image tag +vendor/.env what compose interpolates (generated) +.state/ tunnel URL (gitignored) +.secrets.env owner credentials, SECRET_KEY_BASE, API key (gitignored) +``` + +`vendor/` is fetched at a pinned tag (`PLAUSIBLE_REF`, default `v3.2.1`) rather +than committed: **the compose file IS the version** — the image tag lives +inside it — so the ref pinned here and the release running are one decision. + +## Why the tunnel starts first + +Plausible reads `BASE_URL` **at boot** and uses it for link generation and for +the LiveView origin check, so a dashboard booted against the wrong hostname +serves HTML and then fails to connect its own websocket: a page that renders +and never loads any numbers. `up.sh` therefore starts `tunnel` alone +(`--no-deps`), reads the URL out of cloudflared's log, writes it, and only then +starts the app. Same ordering trap as openreplay-stack's `COMMON_DOMAIN_NAME`, +and the same consequence: **every `up.sh` mints a new URL**, so this is a +debugging tool. A lasting instance wants a named tunnel and a fixed `BASE_URL`. + +Two differences from that rig, both easy to get backwards: + +| | openreplay-stack | here | +| --- | --- | --- | +| tunnel's `networks:` | must name `openreplay-net` — upstream defines one | must name NOTHING — upstream defines none, so everything is on `default` | +| public hostname | bare host in `COMMON_DOMAIN_NAME` + `COMMON_PROTOCOL` | full URL in `BASE_URL`, scheme included | + +Getting either wrong produces the same symptom: a 502 on the public URL with +every container healthy. + +## The owner account, and why signup is an `rpc` + +Plausible CE seeds no account; whoever registers first owns the instance, and +with no mailer configured there is **no recovery path**. So the credentials are +generated into `.secrets.env` and the account is created from them — +`openreplay-stack` learned this by having to wipe its volumes once. + +The HTTP route *looks* scriptable and is not. `GET /register` serves a form +with `user[name]`, `user[email]`, `user[password]`, `user[password_confirmation]` +and a CSRF token; **`POST /register` is 404.** The form is a LiveView +(`phx-submit="register"`) — the account is created by a handler on the +websocket, and the form's own `action="/login"` only signs the new user in +afterwards. `signup.sh` therefore runs Elixir inside the release with +`bin/plausible rpc`, which is what that handler would have done. + +The cost, written down rather than discovered: that code names internal +functions (`Plausible.Auth.User.new/1`, `Plausible.Sites.create/2`, +`Plausible.Auth.create_stats_api_key/4`), so it is pinned to the version in +`vendor/UPSTREAM.txt` in a way an HTTP call would not be. Every step matches on +its expected result, so a moved API fails loudly instead of half-working. + +`.secrets.env` sits OUTSIDE `.state/` on purpose: it must survive `down.sh` and +even `--volumes`, so that a wipe is the RECOVERY path (the next `up.sh` +re-creates the same account) and not a second lockout. `secrets.sh` refuses to +write it at all if `git check-ignore` does not cover it. + +**Registration is closed after the account exists.** A quick-tunnel URL is +unguessable, not private, and an open `/register` on it is an invitation. The +check that matters is the one after the restart: "the variable is set" and "the +route refuses" are different claims, and only the second is verified. + +## Wiring the app + +```bash +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh # ON +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --print # show, change nothing +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore # OFF +``` + +It writes **`config.local.yaml`, never the tracked `config.yaml`** — the value +is a `*.trycloudflare.com` hostname that dies in a few hours, and this repo has +already had one of those committed and left dead in HEAD for several commits. +`internal/config/config_test.go` asserts both tracked configs still say +`localhost`; `verify.sh` re-checks it too. + +**This is the THIRD writer of that overlay** (`oidc` ← cloudflare-tunnel, +`replay` ← openreplay-stack, `plausible` ← here), and none of them knows about +the others, so `--restore` removes the **block** and never the file. +`.claude/skills/lib/config-overlay.sh` does the per-key surgery and deletes the +file only when the last key leaves it. An `rm` here would silently break login +(a tunnel with no issuer keeps serving every page) or stop session replay (an +empty OpenReplay UI looks exactly like the correct default). + +⚠ **Anything that READS this config must read the MERGED view.** Wiring writes +the overlay, so a reader that looked only at `config.yaml` finds `enabled` +absent on a perfectly wired machine, self-skips, and reports success having +checked nothing — precisely how the replay suite once passed while testing +nothing. `verify.sh` reads both. + +### The script variant is two decisions + +`…/js/script.local.manual.js`, and both parts are load-bearing: + +- **`local`** — the stock script refuses to send from a local address. Read out + of the served file, not the docs: + `/localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname)`. + The dev app is `http://localhost:8081`, so without this variant every page + view is dropped **by the browser** — everything looks wired and nothing + arrives. +- **`manual`** — the stock script otherwise sends a pageview by itself, using + `location.href`. That URL is the one thing this integration must never send. + Manual mode means every page view is one the app decided to send, with a URL + it built. + +`verify.sh` asserts both properties of the fetched script, because a wiring +that names the wrong variant is invisible until somebody asks why the dashboard +is empty. + +### Unwire before running an e2e suite + +Nothing breaks if you do not — the tracker's POSTs are fire-and-forget and no +suite asserts about them — but a journey run is 465 actions of page loads, so +the numbers become mostly Playwright. `run.sh` does NOT borrow this block away +(the same decision as for `replay`: a suite should see what a visitor sees), so +it is a manual `wire-frontend.sh --restore` before, and a re-wire after. + +## Privacy: the decisions, not the defaults + +Full statement in **`docs/frontend/analytics.md`**. The short version: + +- **The URL is never sent.** `u` is SvelteKit's ROUTE ID — + `/my/hackathon/[id]/teams` — so it cannot contain an id, because it never + touched one. `/invite/` (that token is a working credential) is + reported as `/invite/[token]`. Query strings are dropped, so `utm_*` is not + recorded either. The trade is accepted and stated: no per-hackathon + breakdowns, ever. +- **Cookieless, and that is the property consent turns on.** Nothing is stored + in or read from the browser. +- **Plausible still sees the IP and the user agent**, and hashes them with a + daily-rotated salt to count unique visitors. Only the hash is stored — + verified against `system.columns`, which shows no IP or user-agent column in + `events_v2`/`sessions_v2`, rather than against a marketing page. No + geolocation database is shipped, so country/city stay empty. +- **It sits OUTSIDE the session-replay consent gate**, deliberately: that + banner asks about session RECORDING, and consent is scoped to what was asked. + A second banner would ask about something with no artefact to permit or + withdraw. `DNT`/GPC still suppress it, before the script is fetched. +- **Nothing is correlated.** No user id, session id or replay id is ever sent; + these counts cannot be joined to the RPC journal or to a replay. + +## Cost, measured + +Idle, on this machine, with the app stack and the openreplay rig also running: + +| container | RSS | image | +| --- | --- | --- | +| `plausible` | 431 MB | 265 MB | +| `plausible_events_db` (ClickHouse) | 188 MB | 718 MB | +| `plausible_db` (Postgres 16) | 110 MB | 396 MB | +| `tunnel` | 18 MB | — | +| **total** | **≈ 750 MB** | ≈ 1.4 GB | + +Volumes after the first few page views: ~80 MB, nearly all of it Postgres's +initial database. + +**It coexists with the openreplay rig comfortably** — that one wants 8 GB *of +its own*, this one runs in under one, and the two were up together throughout +the verification below. Upstream recommends 2 GB RAM; the measured idle +footprint is well under that, and ClickHouse is configured small by upstream's +own `clickhouse/low-resources.xml`. + +Ports: **one**, `127.0.0.1:8010` (override `PLAUSIBLE_PORT`). Not upstream's +8000 — the most contested port on a developer machine, and a collision fails +`compose up` after the databases have started, which reads as a stack failure +rather than a clash. Loopback only; the tunnel is the entrypoint. + +## Verification status + +**Verified on 2026-08-14** against CE v3.2.1 (commit `ec6c4da`), Windows host, +Docker Desktop. `verify.sh` — all nine checks: + +1. every compose service has a running container +2. the dashboard answers **through the tunnel**, with a real login round-trip + (CSRF → `POST /login` → 302 → `/sites` lists the site) +3. `/register` refuses signup on that public URL +4. the app is wired, read from the **merged** config, and the tracked + `config.yaml` still says `localhost` +5. the tracker script is fetchable and is the `local` + `manual` variant +6. **a real Firefox** visits `/`, clicks through to `/hackathon/` and + opens `/invite/`; the captured POST bodies carry + `/`, `/hackathon/[id]`, `/invite/[token]` and **no UUID, no token, no + internal referrer** +7. **Plausible's own Stats API** returns those three pages for today +8. `events_v2`/`sessions_v2` have no IP or user-agent column +9. `--restore` removes `plausible` and leaves `oidc` and `replay` intact + (simulated on a copy; also done for real once, with both surviving) + +Step 6 does not self-skip. Without `--no-browser`, an unavailable browser is a +**failure**: a proof that quietly drops its own hardest step is how this repo +has been lied to before. Unwired, the same script records **zero** requests — +which is the other half of "absent config ⇒ absent script". + +The **wipe-and-recover** claim is verified too, not just written down: +`down.sh --volumes` (databases gone, frontend unwired, `oidc`/`replay` intact) +→ `up.sh` → a new tunnel URL, the SAME owner account re-created from +`.secrets.env`, the site re-created, registration closed again → re-wire → +`verify.sh` green. All nine checks above were re-run after that cycle. + +**That round-trip found a bug that only a real wipe could show.** The Stats API +key survives in `.secrets.env` by design, but its ROW was in the wiped +database — so `signup.sh`'s "is the key set" test was true and useless, and the +first thing to notice would have been a 401 in `verify.sh` step 7, pointing +nowhere near the cause. It probes the key against the Stats API now and mints a +new one when the answer is not 200. A file that outlives the thing it describes +is not a cache; it is a claim that needs checking. + +Playwright and Firefox are borrowed from the sibling `hackathon-e2e` skill (the +only place they are installed), through a `createRequire` anchored at that +package — ESM resolves bare specifiers relative to the FILE, and `cd`-ing does +not help. + +### Two traps this cost, both worth keeping + +**A carriage return inside a secret.** Git Bash's `openssl` prints CRLF, so +`openssl rand -base64 32 | tr -d '\n'` leaves a `\r` at the END OF THE VALUE — +no longer a line ending, just a byte in a secret. Plausible then refuses to +boot with `TOTP_VAULT_KEY must be Base64 encoded 32 bytes` about a key that +decodes to exactly 32 bytes in every tool you check it with. It survives every +obvious check: `cat -A` on a file `sed` has since rewritten shows nothing (MSYS +strips CRs on the way through), `docker compose config` shows it clean because +that reads `.env` rather than the process environment, and +`docker inspect --format '{{range .Config.Env}}'` renders the CR as the line +break it looks like. Only `{{json .Config.Env}}` shows it. Stripped in three +places now — at generation, on read, and on write into `.env` — because a +`.secrets.env` already on disk is not fixed by fixing the generator. + +**`curl -o /dev/null` on a Windows host.** `lib.sh` exports +`MSYS_NO_PATHCONV=1` (docker needs its own `/container/paths` left alone), so +`/dev/null` reaches `curl.exe` verbatim, which tries to create a file at that +literal path and prints `curl: (23) client returned ERROR on write` — in the +middle of a check that then PASSES, because `-w '%{http_code}'` already +produced the number. Use `$CURL_DISCARD`. The same shape bites +`curl … | grep … | head`: head closes the pipe at the first match while curl is +still writing. Capture, then match. + +**A restart that stops a server and does not start it.** The first +`wire-frontend.sh` ran its restarts as `cmd && cmd` with output on +`/dev/null`, and left the app tunnel's only upstream (`:8082`) stopped while +printing "analytics is ON". The public URL kept answering — caddy falls back to +`:8081` — right up until that was down too. Every restart step now captures its +output, prints `ok`/`FAILED`, and names the port that is down. **Three** servers +can be serving this app (process-compose's vite, `prod-frontend.sh` on :8081, +`prod-serve.sh` on :8082 for the tunnel), and the built ones read their config +once at boot, so all three are bounced. diff --git a/.claude/skills/plausible-stack/compose.tunnel.yaml b/.claude/skills/plausible-stack/compose.tunnel.yaml new file mode 100644 index 00000000..59822510 --- /dev/null +++ b/.claude/skills/plausible-stack/compose.tunnel.yaml @@ -0,0 +1,38 @@ +# Overlay merged on top of the vendored upstream compose: +# docker compose -f vendor/compose.yml -f compose.tunnel.yaml … +# +# Upstream ships no ingress at all (its README tells you to publish ports or +# put a reverse proxy in front), so this adds the two things a debugging rig +# needs: a Cloudflare quick tunnel, and a loopback port. +services: + tunnel: + image: cloudflare/cloudflared:latest + container_name: plausible-tunnel + restart: unless-stopped + # --no-autoupdate: the container is disposable; an in-place update would + # restart the process and silently mint a NEW public URL, which every + # wired browser would keep posting past. + command: tunnel --no-autoupdate --url http://plausible:8000 + depends_on: + - plausible + # ⚠ NO `networks:` KEY HERE, AND THAT IS DELIBERATE — the opposite of the + # openreplay overlay, for the opposite reason. Upstream's compose defines + # no networks, so every service lands on compose's implicit `default` and a + # service that named one would be the odd one out and fail to RESOLVE + # `plausible`. (OpenReplay's upstream DOES define `openreplay-net`, so its + # tunnel had to join it explicitly — that omission cost an afternoon of + # 502s with every container healthy.) The rule is the same either way: + # match whatever upstream does, and check with `compose config` that the + # tunnel and its target share a network. + + plausible: + ports: + # LOOPBACK ONLY. The tunnel is the entrypoint — the browser must reach + # the ingest endpoint from outside — but a local port is what lets a + # diagnostic tell "Plausible is broken" apart from "Cloudflare is + # broken", which are indistinguishable from the public URL. 127.0.0.1 so + # publishing it does not put a dashboard on the LAN. + # + # Container-side 8000 is upstream's HTTP_PORT default; up.sh pins + # HTTP_PORT=8000 in .env rather than relying on it. + - "127.0.0.1:${PLAUSIBLE_PORT:-8010}:8000" diff --git a/.claude/skills/plausible-stack/scripts/doctor.sh b/.claude/skills/plausible-stack/scripts/doctor.sh new file mode 100644 index 00000000..df7a7bd7 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/doctor.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Preflight. Cheaper than discovering the same facts three images into a pull. +# +# Plausible's own floor is modest (upstream README: 2 GB RAM for ClickHouse + +# Plausible), which is a third of what the openreplay rig wants — the two +# CAN coexist on this machine, and the measured numbers are in SKILL.md. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +fail=0 +warn=0 +ok() { printf ' [x] %s\n' "$1"; } +bad() { + printf ' ✕ %s\n' "$1" + fail=1 +} +soft() { + printf ' ! %s\n' "$1" + warn=1 +} + +echo "── docker" +if docker info >/dev/null 2>&1; then ok "daemon reachable"; else bad "daemon not reachable"; fi +docker compose version >/dev/null 2>&1 && ok "compose v2" || bad "docker compose v2 required" + +echo "── architecture" +arch=$(uname -m) +case "$arch" in + x86_64 | amd64 | aarch64 | arm64) ok "$arch (ClickHouse needs SSE4.2 or NEON)" ;; + *) bad "$arch — ClickHouse requires SSE 4.2 (x86) or NEON (arm)" ;; +esac + +echo "── resources (as seen by the docker host)" +mem=$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0) +memgb=$((mem / 1024 / 1024 / 1024)) +if [ "$memgb" -ge 4 ]; then + ok "${memgb} GB RAM" +elif [ "$memgb" -gt 0 ]; then + soft "${memgb} GB RAM — 2 GB is upstream's floor, and that is for Plausible ALONE" +else soft "could not read MemTotal"; fi + +cpus=$(docker info --format '{{.NCPU}}' 2>/dev/null || echo 0) +[ "$cpus" -ge 2 ] && ok "${cpus} vCPU" || soft "${cpus} vCPU" + +avail=$(df -Pk "$SKILL_DIR" 2>/dev/null | awk 'NR==2{print int($4/1024/1024)}') +[ -n "$avail" ] && { [ "$avail" -ge 10 ] && ok "${avail} GB free" || soft "${avail} GB free — 10 GB recommended"; } + +echo "── ports" +# The only host port this stack takes. Something else on it does not merely +# collide: `compose up` fails after the databases have started, which reads as +# a stack failure rather than a port clash. +# `ss` on Linux, `netstat` on a Windows host — and when NEITHER is available, +# say so rather than printing "free". A probe that cannot run and a probe that +# found nothing look identical from the outside, and only one of them is +# information. +listeners() { + if command -v ss >/dev/null 2>&1; then + ss -ltn 2>/dev/null + elif command -v netstat >/dev/null 2>&1; then + netstat -ano 2>/dev/null | grep -i listen + else + return 1 + fi +} +if ports="$(listeners)"; then + if printf '%s\n' "$ports" | grep -qE "[:.]$LOCAL_PORT[[:space:]]"; then + # Ours holding it is fine; anything else is not. + if docker ps --format '{{.Names}} {{.Ports}}' | grep -q ":$LOCAL_PORT->"; then + ok "port $LOCAL_PORT held by a container (this stack, presumably)" + else + bad "port $LOCAL_PORT in use by something else — set PLAUSIBLE_PORT" + fi + else + ok "port $LOCAL_PORT free" + fi +else + soft "no ss/netstat here — could not check whether port $LOCAL_PORT is free" +fi + +echo "── neighbours" +if docker ps --format '{{.Names}}' | grep -qx 'clickhouse'; then + soft "the openreplay rig is running — it has its OWN ClickHouse; this one is separate (~1 GB more)" +fi + +# ── a stack that is up: is ALL of it up? ─────────────────────────────────── +# `docker compose ps` shows what IS there and says nothing about what is not. +# A service whose container was removed reads exactly like a service that was +# never meant to run — that is how openreplay's `sink` went missing while every +# client-side check stayed green. +if [ -f "$VENDOR/compose.yml" ] && [ -n "$(compose ps -q 2>/dev/null)" ]; then + echo "── running stack" + missing="" + running="$(compose ps --format '{{.Service}}' 2>/dev/null | sort -u)" + while IFS= read -r svc; do + [ -n "$svc" ] || continue + printf '%s\n' "$running" | grep -qx "$svc" || missing="$missing $svc" + done </dev/null | sort -u) +EOF + if [ -n "$missing" ]; then + bad "not running:$missing — start them with: bash $HERE/up.sh" + else + ok "every compose service has a running container" + fi +fi + +echo "" +[ "$fail" -eq 0 ] && echo "Preflight passed$([ "$warn" -eq 1 ] && echo " (with warnings)")." || + { + echo "Preflight FAILED — fix the ✕ items first." + exit 1 + } diff --git a/.claude/skills/plausible-stack/scripts/down.sh b/.claude/skills/plausible-stack/scripts/down.sh new file mode 100644 index 00000000..8c2492b4 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/down.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Stop the rig. +# +# down.sh stop the containers, keep the collected statistics +# down.sh --volumes …and delete the databases (Postgres AND ClickHouse) +# +# It also UNWIRES THE FRONTEND, and that is not tidiness. A wired app points at +# a tunnel hostname that stops existing the moment this script runs; leaving the +# block behind means every browser loading the app tries to fetch a tracker +# script from a dead host on every page. Nothing breaks visibly — which is +# exactly why it would stay that way. +# +# `.secrets.env` is never touched: after `--volumes` the next up.sh re-creates +# the same owner account from it, which is what makes a wipe recoverable. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +require_docker + +VOLUMES=0 +[ "${1:-}" = "--volumes" ] && VOLUMES=1 + +bash "$HERE/wire-frontend.sh" --restore || true + +if [ "$VOLUMES" -eq 1 ]; then + echo "==> stopping and deleting volumes" + compose down -v --remove-orphans + # The tunnel URL and the "which URL was this wired at" marker describe a + # world that no longer exists. + rm -f "$STATE/tunnel-url" +else + echo "==> stopping (statistics kept)" + compose down --remove-orphans +fi + +echo " done. Bring it back with: bash $HERE/up.sh" +[ "$VOLUMES" -eq 1 ] && echo " (a new tunnel URL will be minted, and the owner account re-created from .secrets.env)" +exit 0 diff --git a/.claude/skills/plausible-stack/scripts/fetch-upstream.sh b/.claude/skills/plausible-stack/scripts/fetch-upstream.sh new file mode 100644 index 00000000..4fb1d019 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/fetch-upstream.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Fetch upstream's compose.yml + clickhouse/ into vendor/ and record the exact +# commit in vendor/UPSTREAM.txt, so a fetch is reproducible. +# +# Upstream's README is a manual quick-start (edit .env by hand, then +# `docker compose up -d`, then create the first user in a browser). up.sh does +# the same preparation non-interactively and keeps control — same relationship +# openreplay-stack has with upstream's install.sh. +# +# The COMPOSE FILE IS THE VERSION. `ghcr.io/plausible/community-edition:v3.2.1` +# is written inside it, so the ref pinned here and the release running are one +# decision rather than two that can drift. +# +# Usage: fetch-upstream.sh [--force] env: PLAUSIBLE_REF=v3.2.1 +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +[ "${1:-}" = "--force" ] && rm -rf "$VENDOR" +if [ -f "$VENDOR/compose.yml" ]; then + echo "already fetched: $(head -1 "$VENDOR/UPSTREAM.txt" 2>/dev/null)" + echo "(use --force to refetch)" + exit 0 +fi + +command -v git >/dev/null || { + echo "error: git not found" >&2 + exit 1 +} +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "==> cloning $UPSTREAM_REPO @ $UPSTREAM_REF…" +# Everything relative, inside $tmp. lib.sh exports MSYS_NO_PATHCONV=1 for +# docker's sake, which stops Git Bash translating POSIX paths for Windows +# binaries — so git.exe would put an absolute /tmp/... clone somewhere this +# shell cannot see. Relative paths sidestep it on every platform. +# +# `-c core.autocrlf=false -c core.eol=lf`: these files are read by LINUX +# containers, not by the host. ClickHouse's XML survives CRLF, but a compose +# file with CR line endings puts a trailing CR inside every unquoted scalar — +# which is how `BASE_URL` acquires an invisible character and Plausible starts +# generating links to a hostname nothing resolves. Same class of failure that +# cost openreplay-stack an afternoon of S3 signature mismatches. +( + cd "$tmp" + git -c core.autocrlf=false -c core.eol=lf \ + clone --depth 1 --branch "$UPSTREAM_REF" --single-branch \ + "$UPSTREAM_REPO" ce 2>&1 | tail -1 + cd ce + git rev-parse HEAD >../SHA +) +sha="$(cat "$tmp/SHA")" +[ -f "$tmp/ce/compose.yml" ] || { + echo "error: no compose.yml at $UPSTREAM_REF" >&2 + exit 1 +} +[ -d "$tmp/ce/clickhouse" ] || { + echo "error: no clickhouse/ config dir at $UPSTREAM_REF — compose bind-mounts it" >&2 + exit 1 +} + +mkdir -p "$VENDOR" +cp "$tmp/ce/compose.yml" "$VENDOR/compose.yml" +cp -r "$tmp/ce/clickhouse" "$VENDOR/clickhouse" +cp "$tmp/ce/README.md" "$VENDOR/README.md" 2>/dev/null || true +{ + echo "$UPSTREAM_REPO @ $UPSTREAM_REF" + echo "commit $sha" + echo "fetched $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "image $(grep -oE 'ghcr.io/plausible/community-edition:[^ ]*' "$VENDOR/compose.yml" | head -1)" +} >"$VENDOR/UPSTREAM.txt" + +echo "==> vendored $(find "$VENDOR" -type f | wc -l) files at ${sha:0:12}" +sed 's/^/ /' "$VENDOR/UPSTREAM.txt" diff --git a/.claude/skills/plausible-stack/scripts/lib.sh b/.claude/skills/plausible-stack/scripts/lib.sh new file mode 100644 index 00000000..cb49db54 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/lib.sh @@ -0,0 +1,190 @@ +# shellcheck shell=bash +# Shared helpers. Source after setting HERE. + +SKILL_DIR="$(dirname "$HERE")" +VENDOR="$SKILL_DIR/vendor" +STATE="$SKILL_DIR/.state" +PROJECT="${PLAUSIBLE_PROJECT:-plausible}" +# Pin upstream. The CE compose is a versioned artefact: the image tag inside it +# is the release, so the ref and the running version are one decision. +UPSTREAM_REPO="${PLAUSIBLE_REPO:-https://github.com/plausible/community-edition.git}" +UPSTREAM_REF="${PLAUSIBLE_REF:-v3.2.1}" + +# The port `plausible` is published on, on the LOOPBACK interface only. The +# tunnel is the entrypoint (the browser has to reach the ingest endpoint from +# outside), but a local port is what lets doctor/verify tell "the app is broken" +# apart from "the tunnel is broken" — two failures that look identical from the +# public URL. +# +# 8010, not upstream's 8000: 8000 is the single most contested port on a +# developer machine (it was already taken by an unrelated python process on the +# machine this was built on) and a collision here fails `compose up` AFTER the +# databases have started, which reads as a stack failure rather than a clash. +# The container still listens on 8000 inside; only the host side moves. +LOCAL_PORT="${PLAUSIBLE_PORT:-8010}" + +mkdir -p "$STATE" + +# Admin credentials + SECRET_KEY_BASE — generated by scripts/secrets.sh on +# first run, gitignored (skill .gitignore; also ignored repo-wide). +# +# OUTSIDE .state on purpose, exactly as in openreplay-stack: the password must +# survive down.sh, and surviving `--volumes` is what makes a wipe a RECOVERY +# path rather than a second loss — the next up.sh re-creates the same account +# from this file. +SECRETS_FILE="$SKILL_DIR/.secrets.env" + +# Fill unset PLAUSIBLE_* variables from the secrets file. Explicit environment +# wins — an exported PLAUSIBLE_PASSWORD is someone overriding on purpose. +# Plain KEY=value lines, no quoting: the value is everything after the first +# '=', so spaces survive without shell-quoting rules getting involved. +# +# ⚠ THE `\r` STRIP IS LOad-BEARING ON WINDOWS, and it cost an hour. Git Bash's +# openssl (/mingw64/bin/openssl) prints CRLF, so a naively captured +# `openssl rand -base64 32` ends in a carriage return. That CR then rides +# inside the VALUE — not at the end of the line, where every tool would treat +# it as a line ending — and Plausible dies at boot with +# `TOTP_VAULT_KEY must be Base64 encoded 32 bytes`, an error about a key that +# decodes to exactly 32 bytes everywhere you check it. +# +# It survives every obvious check: `cat -A` on a file sed has since rewritten +# shows nothing (MSYS sed strips CRs on the way through), `docker compose +# config` shows it clean because that reads .env rather than the process +# environment, and `docker inspect --format '{{range .Config.Env}}'` renders +# the CR as the line break it looks like. Only `{{json .Config.Env}}` shows it. +# Stripped here as well as at the point of generation, because a .secrets.env +# already on disk is not fixed by fixing the generator. +load_secrets() { + [ -f "$SECRETS_FILE" ] || return 0 + local line k v + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in '' | \#*) continue ;; esac + k="${line%%=*}" + case "$k" in *[!A-Za-z0-9_]* | '') continue ;; esac + v="${line#*=}" + v="${v%$'\r'}" + [ -n "${!k:-}" ] || export "$k=$v" + done <"$SECRETS_FILE" +} + +# Paths handed to docker.exe. On Git Bash/MSYS, MSYS_NO_PATHCONV stops the +# automatic POSIX→Windows translation (needed so docker's own /container/paths +# survive), which means a `/c/Users/...` argument reaches docker.exe verbatim +# and it resolves `C:\c\Users\...`. Hand it a Windows-style path instead; +# MSYS leaves those alone. Same fix as openreplay-stack/scripts/lib.sh. +COMPOSE_VENDOR="$VENDOR/compose.yml" +COMPOSE_OVERLAY="$SKILL_DIR/compose.tunnel.yaml" +COMPOSE_DIR="$VENDOR" +case "$(uname -s)" in + MINGW* | MSYS*) + export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*" + COMPOSE_VENDOR="$(cygpath -m "$COMPOSE_VENDOR")" + COMPOSE_OVERLAY="$(cygpath -m "$COMPOSE_OVERLAY")" + COMPOSE_DIR="$(cygpath -m "$COMPOSE_DIR")" + ;; +esac + +# `--project-directory vendor` matters twice: upstream's compose bind-mounts +# ./clickhouse/*.xml relative to it, and compose reads `.env` from it — which +# is the file up.sh writes BASE_URL and the secrets into. +# Where curl should throw a body away. +# +# NOT `/dev/null` on a Windows host: this file exports MSYS_NO_PATHCONV=1 for +# docker's sake, so `/dev/null` reaches curl.exe verbatim, curl tries to create +# a file at that literal path, and fails with +# `curl: (23) client returned ERROR on write of 72 bytes` — printed in the +# middle of a check that then passes anyway, because `-w '%{http_code}'` has +# already produced the number the caller wanted. A scary line attached to a +# successful step is worse than either a failure or silence. +CURL_DISCARD="/dev/null" +case "$(uname -s)" in + MINGW* | MSYS*) CURL_DISCARD="NUL" ;; +esac + +compose() { + docker compose -p "$PROJECT" \ + -f "$COMPOSE_VENDOR" -f "$COMPOSE_OVERLAY" \ + --project-directory "$COMPOSE_DIR" "$@" +} + +require_docker() { + command -v docker >/dev/null 2>&1 || { + echo "error: docker not found" >&2 + exit 1 + } + docker info >/dev/null 2>&1 || { + echo "error: docker daemon not reachable" >&2 + exit 1 + } +} + +require_vendor() { + [ -f "$VENDOR/compose.yml" ] || { + echo "error: upstream not fetched — run scripts/fetch-upstream.sh" >&2 + exit 1 + } +} + +ENV_FILE="$VENDOR/.env" + +# Idempotent `KEY=value` in vendor/.env. Compose reads that file for ${VAR} +# interpolation AND for the bare `- TOTP_VAULT_KEY` style pass-throughs in +# upstream's compose, so it is the one place configuration lives. +env_set() { # + mkdir -p "$VENDOR" + touch "$ENV_FILE" + # Same CR strip as load_secrets, for the same reason and one layer further + # out: whatever the value came from, a CR must not reach a container. + set -- "$1" "${2%$'\r'}" + if grep -q "^$1=" "$ENV_FILE"; then + # `|` as the sed delimiter: values here are URLs, and base64 secrets can + # contain `/`. + sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE" + else + printf '%s=%s\n' "$1" "$2" >>"$ENV_FILE" + fi +} + +# Write one KEY=value into .secrets.env, replacing any existing line. +# +# It exists for the API key, and the reason is the recovery story: after +# `down.sh --volumes` the databases are gone but this FILE survives on purpose, +# so a stored key names a row that no longer exists. Appending a second line +# would leave load_secrets reading whichever came first — so this replaces. +secrets_set() { # + local k="$1" v="${2%$'\r'}" + if command -v git >/dev/null 2>&1 && + git -C "$SKILL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 && + ! git -C "$SKILL_DIR" check-ignore -q "$SECRETS_FILE"; then + echo "error: $SECRETS_FILE is not gitignored — refusing to write a secret there." >&2 + return 1 + fi + umask 077 + touch "$SECRETS_FILE" + if grep -q "^$k=" "$SECRETS_FILE"; then + sed -i "s|^$k=.*|$k=$v|" "$SECRETS_FILE" + else + printf '%s=%s\n' "$k" "$v" >>"$SECRETS_FILE" + fi + export "$k=$v" +} + +env_get() { # -> stdout (empty when absent) + [ -f "$ENV_FILE" ] || return 0 + sed -n "s|^$1=||p" "$ENV_FILE" | tail -1 +} + +# Read the quick-tunnel URL out of cloudflared's log (it only ever prints it +# there). The state file is the cache; the log is the truth, because a tunnel +# that was restarted has a different URL and the same state file. +tunnel_url() { + docker logs "$(compose ps -q tunnel 2>/dev/null)" 2>&1 | + grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -1 +} + +# The URL to talk to Plausible on from THIS machine. Prefer the loopback port: +# it is the same server, one hop shorter, and it keeps a diagnostic from +# depending on Cloudflare being up. Callers that specifically mean to test the +# public path use tunnel_url. +local_url() { echo "http://127.0.0.1:$LOCAL_PORT"; } diff --git a/.claude/skills/plausible-stack/scripts/pageview.mjs b/.claude/skills/plausible-stack/scripts/pageview.mjs new file mode 100644 index 00000000..4acb3a74 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/pageview.mjs @@ -0,0 +1,134 @@ +/* + * Drive a real browser through the wired app and record what the tracker put + * on the wire. + * + * This is the CLIENT half of the proof. It exists because "the app renders a + * script tag" and "a page view was counted" are different claims, and because + * every privacy property this integration has is a property of the BYTES — + * an assertion that a config flag is set would have passed while the tracker + * shipped `/invite/`. + * + * It deliberately does NOT judge whether Plausible stored anything: that is + * the far end, and verify.sh asks Plausible itself. Session replay was green + * for three days here while every recorded session was unplayable, because + * every spec measured what left the browser and nothing asked the server. + * + * Usage (inside the dev container, from the hackathon-e2e skill dir so the + * playwright dependency resolves): + * node …/plausible-stack/scripts/pageview.mjs + */ +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" +import path from "node:path" +import { writeFileSync } from "node:fs" + +// Playwright and its Firefox come from the SIBLING skill, which is the only +// place in this repo that installs them. Borrowed through `createRequire` +// anchored at that package rather than imported directly, because ESM resolves +// bare specifiers relative to the FILE, and this file lives in a directory with +// no node_modules — `cd`-ing there first does not help. +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const require = createRequire( + path.join(HERE, "..", "..", "hackathon-e2e", "package.json"), +) +const { firefox } = require("@playwright/test") + +const [appUrl, plausibleOrigin, hackathonId, out] = process.argv.slice(2) +if (!appUrl || !plausibleOrigin || !out) { + console.error( + "usage: pageview.mjs ", + ) + process.exit(2) +} + +// A token that could not possibly be real, but has the SHAPE of one — the +// point is to prove the shape never reaches the wire, so it must be +// recognisable in a grep of the captured bodies. +const FAKE_TOKEN = "plausible-proof-token-4f2a9c7e" + +const events = [] +const browser = await firefox.launch() +const page = await browser.newPage() + +page.on("request", (req) => { + if (req.method() !== "POST") return + if (!req.url().startsWith(`${plausibleOrigin}/api/event`)) return + let body = req.postData() + try { + body = JSON.parse(body) + } catch { + /* keep the raw string — a body we cannot parse is still evidence */ + } + events.push({ url: req.url(), body }) +}) + +async function visit(path, { click } = {}) { + if (click) { + // A CLIENT-SIDE navigation, which is the case `afterNavigate` handles and + // the only case where document.referrer is non-empty and points at one of + // our own paths. + const link = page.locator(`a[href="${path}"]`).first() + if ((await link.count()) > 0) { + await link.click() + await page.waitForURL(`**${path}`, { timeout: 15000 }).catch(() => {}) + await page.waitForTimeout(2500) + return "clicked" + } + } + await page.goto(`${appUrl}${path}`, { waitUntil: "load", timeout: 30000 }) + // The first pageview is fired from an idle callback after first paint. + await page.waitForTimeout(2500) + return "goto" +} + +const visited = [] +visited.push(["/", await visit("/")]) +if (hackathonId) { + visited.push([ + `/hackathon/${hackathonId}`, + await visit(`/hackathon/${hackathonId}`, { click: true }), + ]) +} +visited.push([`/invite/${FAKE_TOKEN}`, await visit(`/invite/${FAKE_TOKEN}`)]) + +await browser.close() + +const raw = JSON.stringify(events) +const sent = events.map((e) => (e.body && e.body.u) || null) + +// ── the client-side claims, judged here because the bodies are here ──────── +const problems = [] +if (events.length === 0) + problems.push("no /api/event request left the browser at all") + +// The positive control comes FIRST: an "id absent" assertion agrees with +// everything when nothing was ever sent, and this repo has shipped exactly +// that mistake more than once. +const expectPage = hackathonId ? "/hackathon/[id]" : "/" +if (!sent.some((u) => u && u.endsWith(expectPage))) + problems.push(`no pageview carried the route template ${expectPage} (got ${JSON.stringify(sent)})`) + +if (hackathonId && raw.includes(hackathonId)) + problems.push(`the hackathon id ${hackathonId} appeared on the wire`) +if (raw.includes(FAKE_TOKEN)) + problems.push(`the invite token appeared on the wire`) +if (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(raw)) + problems.push("a UUID appeared on the wire") + +// Referrers: our own origin must never be sent with a path attached. +for (const e of events) { + const r = e.body && e.body.r + if (r && r.startsWith(appUrl) && r !== appUrl && r !== `${appUrl}/`) + problems.push(`an internal referrer with a path was sent: ${r}`) +} + +writeFileSync(out, JSON.stringify({ visited, events, problems }, null, 2)) + +console.log(`visited: ${visited.map(([p, how]) => `${p} (${how})`).join(", ")}`) +console.log(`events: ${events.length}`) +for (const u of sent) console.log(` u = ${u}`) +if (problems.length) { + for (const p of problems) console.log(`PROBLEM: ${p}`) + process.exit(1) +} +console.log("client-side: OK") diff --git a/.claude/skills/plausible-stack/scripts/secrets.sh b/.claude/skills/plausible-stack/scripts/secrets.sh new file mode 100644 index 00000000..2a71f979 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/secrets.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Mint everything this rig needs to be brought up without a single prompt, into +# a gitignored .secrets.env. Idempotent: values already present are kept. +# +# SECRET_KEY_BASE signs the dashboard's sessions (≥64 bytes, upstream) +# TOTP_VAULT_KEY encrypts TOTP secrets at rest (32 bytes, base64) +# PLAUSIBLE_EMAIL the admin account up.sh registers +# PLAUSIBLE_PASSWORD …and its password +# PLAUSIBLE_SITE the site (data-domain) events are attributed to +# +# WHY A FILE, NOT ENVIRONMENT VARIABLES. Plausible CE seeds no account: whoever +# registers first is the owner, and there is no recovery path without a working +# mailer. A password that exists only in one shell's environment is a dashboard +# that becomes unreachable the moment that shell closes — openreplay-stack +# learned this by having to wipe its volumes. Same convention as this repo's +# other dev secrets (.devcontainer/post-create.sh mints secrets.yaml the same +# way with openssl). +# +# The two Elixir secrets live here rather than in vendor/.env for one reason: +# vendor/ is deleted by `fetch-upstream.sh --force`, and rotating +# SECRET_KEY_BASE under a live database logs every session out and makes +# TOTP secrets undecryptable. up.sh copies them into vendor/.env on every run. +# +# Usage: secrets.sh [--print] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +load_secrets + +# A password is about to be written to disk — make sure git can never take it. +# On this branch .claude/ is TRACKED, so only the skill's own .gitignore +# protects this file, and a rule that is present-but-wrong looks exactly like a +# rule that works. Ask git, do not read the file. +guard_gitignored() { + command -v git >/dev/null 2>&1 || return 0 + git -C "$SKILL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + git -C "$SKILL_DIR" check-ignore -q "$SECRETS_FILE" && return 0 + echo "error: $SECRETS_FILE would be TRACKED by git — refusing to write secrets there." >&2 + echo " Add '.secrets.env' to $SKILL_DIR/.gitignore first." >&2 + exit 1 +} + +fresh=0 +if [ -z "${SECRET_KEY_BASE:-}" ] || [ -z "${PLAUSIBLE_PASSWORD:-}" ]; then + fresh=1 + guard_gitignored + + # `tr -d '\r\n'`, NOT `tr -d '\n'`. Git Bash's openssl prints CRLF, so the + # obvious version leaves a carriage return at the END OF THE VALUE — which + # is not a line ending any more, it is a byte inside a secret. Plausible + # then refuses to boot with "TOTP_VAULT_KEY must be Base64 encoded 32 bytes" + # about a key that decodes to exactly 32 bytes in every tool you check it + # with. See the long note in lib.sh:load_secrets for why nothing shows it. + + # ≥64 bytes. `openssl rand -base64 48` is upstream's own suggestion. + SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -base64 48 | tr -d '\r\n')}" + # Exactly 32 bytes, base64 — Plausible decodes this one and rejects a wrong + # length at boot rather than at first use. + TOTP_VAULT_KEY="${TOTP_VAULT_KEY:-$(openssl rand -base64 32 | tr -d '\r\n')}" + PLAUSIBLE_EMAIL="${PLAUSIBLE_EMAIL:-admin@hackagon.test}" + PLAUSIBLE_PASSWORD="${PLAUSIBLE_PASSWORD:-$(openssl rand -hex 16 | tr -d '\r\n')}" + PLAUSIBLE_NAME="${PLAUSIBLE_NAME:-Hackagon Analytics Admin}" + # RFC 2606 reserves .test for exactly this. It is a LABEL, not a hostname: + # Plausible attributes an event to a site by matching the tracker's + # `data-domain` string, and never resolves or contacts it. Using a domain we + # do not own (hackagon.dev) would work identically and be somebody else's + # namespace. + PLAUSIBLE_SITE="${PLAUSIBLE_SITE:-hackagon.test}" + + umask 077 + cat >"$SECRETS_FILE" </dev/null +load_secrets +: "${PLAUSIBLE_EMAIL:?}" "${PLAUSIBLE_PASSWORD:?}" "${PLAUSIBLE_SITE:?}" +NAME="${PLAUSIBLE_NAME:-Hackagon Analytics Admin}" + +base="$(local_url)" + +if [ "${1:-}" = "--print" ]; then + echo "email $PLAUSIBLE_EMAIL" + echo "password $PLAUSIBLE_PASSWORD" + echo "site $PLAUSIBLE_SITE" + echo "apiKey ${PLAUSIBLE_API_KEY:-}" + exit 0 +fi + +cid="$(compose ps -q plausible 2>/dev/null || true)" +[ -n "$cid" ] || { + echo "error: the plausible container is not running — scripts/up.sh first" >&2 + exit 1 +} + +rpc() { docker exec -i "$cid" bin/plausible rpc "$1"; } + +# ── the owner, the site ──────────────────────────────────────────────────── +# `email_verified: true` is set explicitly. ENABLE_EMAIL_VERIFICATION=false +# already means nobody is asked to verify, but the COLUMN still exists and a +# future flip of that variable would otherwise lock this account out of an +# instance with no mailer configured — i.e. permanently. +echo "==> ensuring the owner account and site exist" +out="$(rpc ' +email = "'"$PLAUSIBLE_EMAIL"'" +pass = "'"$PLAUSIBLE_PASSWORD"'" +name = "'"$NAME"'" +domain = "'"$PLAUSIBLE_SITE"'" + +user = + case Plausible.Auth.find_user_by(email: email) do + nil -> + u = + %{name: name, email: email, password: pass, password_confirmation: pass} + |> Plausible.Auth.User.new() + |> Ecto.Changeset.put_change(:email_verified, true) + |> Plausible.Repo.insert!() + + IO.puts("user=created") + u + + u -> + IO.puts("user=existing") + u + end + +case Plausible.Sites.get_by_domain(domain) do + nil -> + {:ok, %{site: _}} = Plausible.Sites.create(user, %{"domain" => domain, "timezone" => "UTC"}) + IO.puts("site=created") + + _ -> + IO.puts("site=existing") +end +')" +printf '%s\n' "$out" | sed 's/^/ /' +case "$out" in + *user=*) ;; + *) + echo "error: rpc did not report a user — Plausible internals may have moved" >&2 + exit 1 + ;; +esac + +# ── a Stats API key, so a machine can read the numbers back ──────────────── +# The dashboard is for humans; verify.sh needs to ASK PLAUSIBLE what it stored, +# and the Stats API is the only answer that goes through Plausible's own query +# layer rather than around it into ClickHouse. Created here rather than clicked +# in the UI for the same reason as everything else in this file. +# +# ⚠ A STORED KEY IS NOT A WORKING KEY, and that is the whole point of the probe +# below. `.secrets.env` deliberately survives `down.sh --volumes` so the owner +# account can be re-created after a wipe — but the API key row was IN the wiped +# database, so the file goes on naming a key that no longer exists. "Is it set" +# would be true and useless; the wipe would look recovered and verify.sh would +# then fail on a 401 several steps later, pointing nowhere near here. +if [ -n "${PLAUSIBLE_API_KEY:-}" ]; then + probe="$(curl -s -o "$CURL_DISCARD" -w '%{http_code}' -m 20 \ + -H "Authorization: Bearer $PLAUSIBLE_API_KEY" \ + "$base/api/v1/stats/aggregate?site_id=$PLAUSIBLE_SITE&period=day&metrics=visitors" || true)" + if [ "$probe" != "200" ]; then + echo "==> the stored Stats API key no longer works (HTTP $probe) — minting a new one" + PLAUSIBLE_API_KEY="" + fi +fi + +if [ -z "${PLAUSIBLE_API_KEY:-}" ]; then + echo "==> creating a Stats API key" + key="$(openssl rand -hex 24 | tr -d '\r\n')" + keyout="$(rpc ' +user = Plausible.Auth.find_user_by(email: "'"$PLAUSIBLE_EMAIL"'") +{:ok, team} = Plausible.Teams.get_by_owner(user) + +case Plausible.Auth.create_stats_api_key(user, team, "hackagon-verify", "'"$key"'") do + {:ok, _} -> IO.puts("apikey=created") + {:error, e} -> IO.puts("apikey=error " <> inspect(e)) +end +')" + case "$keyout" in + *apikey=created*) + # secrets_set REPLACES the line and re-checks `git check-ignore` first — + # the generator ran once, long ago, and a .gitignore can be edited since. + secrets_set PLAUSIBLE_API_KEY "$key" || exit 1 + echo " stored in $SECRETS_FILE" + ;; + *) + echo " ⚠ could not create a Stats API key: $keyout" >&2 + ;; + esac +fi + +# ── prove the account actually logs IN ───────────────────────────────────── +# A row in Postgres is not a working dashboard. This is the round trip a person +# will make: fetch the form for its CSRF token, post the credentials, follow +# the session to /sites, and find the site listed there. openreplay-stack +# learned to do this too — an account nobody had ever logged into once cost a +# full volume wipe. +echo "==> verifying login at $base" +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +# Captured first, then matched: `curl | grep | head` dies with +# `curl: (23) client returned ERROR on write` on a Windows host, because head +# closes the pipe at the first match while curl is still writing. +login_html="$(curl -fsS -c "$jar" "$base/login")" +csrf="$(printf '%s' "$login_html" | + grep -oE 'name="_csrf_token"[^>]*value="[^"]+"' | sed 's/.*value="//;s/"//' | head -1)" +[ -n "$csrf" ] || { + echo "error: no CSRF token on $base/login" >&2 + exit 1 +} +code="$(curl -fsS -b "$jar" -c "$jar" -o "$CURL_DISCARD" -w '%{http_code}' -X POST "$base/login" \ + --data-urlencode "_csrf_token=$csrf" \ + --data-urlencode "email=$PLAUSIBLE_EMAIL" \ + --data-urlencode "password=$PLAUSIBLE_PASSWORD" || true)" +[ "$code" = "302" ] || { + echo "error: login answered $code (expected a 302 to /sites)" >&2 + exit 1 +} +# Captured, not piped into `grep -q`: grep exits at the first match, and curl +# on a Windows host then dies writing into a closed pipe +# (`curl: (23) client returned ERROR on write`) — a scary line in the middle of +# a successful verification. +sites_html="$(curl -fsS -b "$jar" "$base/sites")" +case "$sites_html" in + *"$PLAUSIBLE_SITE"*) ;; + *) + echo "error: logged in, but $PLAUSIBLE_SITE is not listed on /sites" >&2 + exit 1 + ;; +esac +echo " login OK, $PLAUSIBLE_SITE listed" + +echo "" +echo " owner $PLAUSIBLE_EMAIL (password: $SECRETS_FILE)" +echo " site $PLAUSIBLE_SITE" +echo " apiKey ${PLAUSIBLE_API_KEY:-} (Stats API, read-only)" diff --git a/.claude/skills/plausible-stack/scripts/up.sh b/.claude/skills/plausible-stack/scripts/up.sh new file mode 100644 index 00000000..4c5c3d0d --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/up.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Bring Plausible Community Edition up behind a Cloudflare quick tunnel. +# +# doctor → fetch upstream → mint secrets → start the tunnel (learn the URL) +# → point BASE_URL at it → start the databases and the app +# → register the admin + the site → close registration again +# +# THE TUNNEL STARTS FIRST, and that ordering is not stylistic. Plausible reads +# BASE_URL at boot and uses it for link generation and for the LiveView +# origin/CSWSH check, so a dashboard booted against the wrong hostname serves +# HTML and then fails to connect its own websocket — which presents as a page +# that renders and never loads any numbers. Same trap as openreplay-stack's +# COMMON_DOMAIN_NAME, and the same consequence: a quick tunnel is a DEBUGGING +# tool here, because every fresh URL means rewriting the config again. +# +# Usage: up.sh [--skip-doctor] [--skip-signup] [--keep-registration-open] [--dry-run] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +DRY=0 +SKIP_DOCTOR=0 +SKIP_SIGNUP=0 +KEEP_OPEN=0 +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --skip-doctor) SKIP_DOCTOR=1 ;; + --skip-signup) SKIP_SIGNUP=1 ;; + --keep-registration-open) KEEP_OPEN=1 ;; + -h | --help) + sed -n '2,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +require_docker +[ "$SKIP_DOCTOR" -eq 1 ] || bash "$HERE/doctor.sh" +bash "$HERE/fetch-upstream.sh" +require_vendor +bash "$HERE/secrets.sh" +load_secrets + +# ── vendor/.env — everything upstream's compose interpolates or passes through ─ +# Rewritten on every run, from .secrets.env, because vendor/ is disposable +# (`fetch-upstream.sh --force` deletes it) while the secrets are not: rotating +# SECRET_KEY_BASE under a live database logs every session out. +env_set SECRET_KEY_BASE "$SECRET_KEY_BASE" +env_set TOTP_VAULT_KEY "$TOTP_VAULT_KEY" +env_set HTTP_PORT 8000 +# No mailer is configured, so email verification would create accounts that can +# never be used. Upstream's default is already false; pinned because "the +# default is what I want" is a claim that expires. +env_set ENABLE_EMAIL_VERIFICATION false +# Placeholder so compose can interpolate ${BASE_URL} for the tunnel-only start +# below. Only the `plausible` service reads it, and that service is not running +# yet — the real value is written before it is. +[ -n "$(env_get BASE_URL)" ] || env_set BASE_URL "http://localhost:$LOCAL_PORT" + +if [ "$DRY" -eq 1 ]; then + echo "" + echo "[dry-run] would start:" + compose config --services | sed 's/^/ /' + exit 0 +fi + +# ── phase 1: the tunnel alone, to learn the public URL ───────────────────── +# --no-deps: the overlay declares `tunnel → plausible`, and the point of this +# phase is that plausible must NOT start yet. cloudflared happily serves 502s +# until its origin exists; it re-resolves the name per connection. +echo "==> starting the quick tunnel…" +compose up -d --no-deps tunnel +url="" +for _ in $(seq 1 30); do + url="$(tunnel_url || true)" + [ -n "$url" ] && break + sleep 2 +done +[ -z "$url" ] && { + echo "error: no tunnel URL after 60s — check: docker logs plausible-tunnel" >&2 + exit 1 +} +echo " $url" +echo "$url" >"$STATE/tunnel-url" + +# ── phase 2: point the app at that URL, then start it ────────────────────── +# BASE_URL carries the SCHEME here (unlike OpenReplay's bare-hostname +# COMMON_DOMAIN_NAME + COMMON_PROTOCOL pair). https, because that is what the +# public URL is: Cloudflare terminates TLS at its edge and forwards plain http +# to the container, which is why HTTP_PORT is what the app listens on and no +# certificate is involved anywhere in here. +echo "==> pointing Plausible at $url" +env_set BASE_URL "$url" + +# Registration has to be OPEN for the admin to be created at all — CE seeds no +# account and the first person to register owns the instance. It is closed +# again at the end of this script, which matters here more than it does on a +# laptop: this dashboard is on a PUBLIC URL for as long as the tunnel lives. +[ "$SKIP_SIGNUP" -eq 1 ] || env_set DISABLE_REGISTRATION false + +echo "==> starting Plausible (first run pulls 3 images and migrates — a few minutes)…" +compose up -d + +# ── phase 3: wait for the app, not for the container ─────────────────────── +# `compose up -d` returns when the containers were CREATED. Plausible then +# creates its database and runs migrations; /api/health is the first thing that +# is true only once it can actually serve. +echo "==> waiting for /api/health…" +health="" +deadline=$(($(date +%s) + 600)) +while :; do + health="$(curl -fsS -m 5 "$(local_url)/api/health" 2>/dev/null || true)" + case "$health" in *'"ok"'* | *'"clickhouse"'*) break ;; esac + + # A crash loop is not slowness, and waiting 600s to say so is the single + # least useful thing this script could do. `restart: always` on a container + # that dies during config evaluation looks exactly like a slow boot from the + # outside — the port simply never answers. Ask the container instead, and + # print the line that names the cause. + state="$(docker inspect -f '{{.State.Status}} {{.RestartCount}}' \ + "$(compose ps -q plausible 2>/dev/null)" 2>/dev/null || true)" + case "$state" in + restarting\ [3-9]* | restarting\ [1-9][0-9]* | exited*) + echo "error: the plausible container is not staying up ($state)" >&2 + compose logs --tail 200 plausible 2>/dev/null | + grep -E '\*\* \(|ERROR!' | head -5 | sed 's/^/ /' >&2 + echo " full log: docker compose -p $PROJECT logs plausible" >&2 + exit 1 + ;; + esac + + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "error: $(local_url)/api/health did not answer within 600s" >&2 + echo " logs: docker compose -p $PROJECT logs plausible" >&2 + exit 1 + fi + sleep 5 +done +echo " $health" + +# ── phase 4: the admin account and the site ──────────────────────────────── +if [ "$SKIP_SIGNUP" -eq 0 ]; then + bash "$HERE/signup.sh" + + # ── phase 5: close registration behind us ──────────────────────────────── + # A quick tunnel URL is unguessable but public, and an open /register on it + # is an invitation. Closing it is one env var and a container recreate; the + # verification below is the part that matters, because "the variable is set" + # and "the route refuses" are different claims. + if [ "$KEEP_OPEN" -eq 0 ]; then + echo "==> closing registration" + env_set DISABLE_REGISTRATION true + # An env change is only picked up when the container is RE-CREATED — a + # restart re-runs the same process with the same baked environment. `up -d` + # notices the difference and recreates just this service. + compose up -d plausible >/dev/null + for _ in $(seq 1 60); do + curl -fsS -m 5 "$(local_url)/api/health" >/dev/null 2>&1 && break + sleep 2 + done + if curl -fsS -m 10 "$(local_url)/register" 2>/dev/null | grep -qi 'password_confirmation'; then + echo " ⚠ /register still serves a signup form — anyone with the tunnel URL can register." >&2 + else + echo " /register no longer offers signup" + fi + fi +fi + +load_secrets +echo "" +echo "── Plausible Community Edition ────────────────────────────" +echo " Public $url" +echo " Local $(local_url) (loopback only)" +echo " Login $url/login ${PLAUSIBLE_EMAIL:-} / see .secrets.env" +echo " Site ${PLAUSIBLE_SITE:-} (the tracker's data-domain must match this exactly)" +echo " Logs docker compose -p $PROJECT logs -f plausible" +echo " Stop bash $HERE/down.sh" +echo "" +echo " Wire the app at it: bash $HERE/wire-frontend.sh" +echo " Prove it works: bash $HERE/verify.sh" +echo "" +echo " ⚠ This URL dies with the tunnel. Re-running up.sh mints a new one and" +echo " rewrites BASE_URL — fine for debugging, not for anything lasting." diff --git a/.claude/skills/plausible-stack/scripts/url.sh b/.claude/skills/plausible-stack/scripts/url.sh new file mode 100644 index 00000000..543cd40d --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/url.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# The rig's current URLs. Reads cloudflared's log rather than the state file: +# a tunnel that was restarted has a new public URL and the same state file, and +# a stale URL fails SILENTLY — the tracker keeps posting into nothing. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +url="$(tunnel_url || true)" +if [ -z "$url" ]; then + echo "no tunnel running — bash $HERE/up.sh" >&2 + exit 1 +fi + +echo "$url" +if [ "${1:-}" = "--all" ]; then + echo "local $(local_url)" + echo "cached $(cat "$STATE/tunnel-url" 2>/dev/null || echo '-')" + echo "BASE_URL $(env_get BASE_URL)" +fi diff --git a/.claude/skills/plausible-stack/scripts/verify.sh b/.claude/skills/plausible-stack/scripts/verify.sh new file mode 100644 index 00000000..ecc1fbf3 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/verify.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Prove this rig works — end to end, from a real browser to Plausible's own +# query API and back. +# +# The claims, in the order they are checked, and why each is a SEPARATE claim: +# +# 1. every compose service has a running container +# "the service exists" and "the service works" are different; a +# container that was removed reads exactly like one that never ran. +# 2. the dashboard answers THROUGH THE TUNNEL, with a real login +# a 200 on / proves a proxy is up. Logging in proves Postgres, the +# session store and the app are all working from the outside. +# 3. registration is closed through the tunnel +# the URL is unguessable, not private. +# 4. the app is wired, read from the MERGED config +# a reader that looked only at the tracked config.yaml finds the key +# absent on a perfectly wired machine, skips, and reports success. +# 5. the tracker script is fetchable and is the right VARIANT +# the stock script silently refuses to send from localhost. +# 6. a real browser sends page views, carrying no id and no token +# the privacy properties are properties of the bytes. +# 7. Plausible has them: its own Stats API returns the route templates +# "the server accepted it" is not "the server can use it" — three days +# of green replay specs were bought with that confusion. +# 8. nothing it stored contains an id, and no column could hold an IP +# the positive control for 6 lives in 7, and this is the negative. +# 9. --restore removes ONE key from the shared overlay +# checked on a COPY, so it costs no restart. +# +# Usage: verify.sh [--no-browser] +# --no-browser skip step 6 EXPLICITLY. Without the flag, an unavailable +# browser is a FAILURE, not a skip: a proof that quietly +# removes its own hardest step is how this repo has been +# lied to before. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ROOT_DIR="$(cd "$HERE/../../../.." && pwd)" +CONFIG_DIR="$ROOT_DIR/components/frontend/data/test/config" +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" +APP_URL="${E2E_BASE_URL:-http://localhost:8081}" + +BROWSER=1 +[ "${1:-}" = "--no-browser" ] && BROWSER=0 + +fail=0 +ok() { printf ' [x] %s\n' "$1"; } +bad() { + printf ' ✕ %s\n' "$1" + fail=1 +} + +require_docker +load_secrets + +# ── 1. the stack ─────────────────────────────────────────────────────────── +echo "── containers" +missing="" +running="$(compose ps --format '{{.Service}}' 2>/dev/null | sort -u)" +while IFS= read -r svc; do + [ -n "$svc" ] || continue + printf '%s\n' "$running" | grep -qx "$svc" || missing="$missing $svc" +done </dev/null | sort -u) +EOF +[ -z "$missing" ] && ok "every compose service is running" || bad "not running:$missing" + +url="$(tunnel_url || true)" +[ -n "$url" ] || { + echo " ✕ no tunnel — scripts/up.sh first" >&2 + exit 1 +} + +# ── 2. the dashboard, through the tunnel, with a real login ──────────────── +echo "── dashboard at $url" +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +login_html="$(curl -fsS -m 30 -c "$jar" "$url/login" 2>/dev/null || true)" +case "$login_html" in + *'name="_csrf_token"'*) ok "GET /login serves the form" ;; + *) bad "GET /login did not serve a login form" ;; +esac +csrf="$(printf '%s' "$login_html" | + grep -oE 'name="_csrf_token"[^>]*value="[^"]+"' | sed 's/.*value="//;s/"//' | head -1)" +code="$(curl -fsS -m 30 -b "$jar" -c "$jar" -o "$CURL_DISCARD" -w '%{http_code}' -X POST "$url/login" \ + --data-urlencode "_csrf_token=$csrf" \ + --data-urlencode "email=${PLAUSIBLE_EMAIL:-}" \ + --data-urlencode "password=${PLAUSIBLE_PASSWORD:-}" 2>/dev/null || true)" +[ "$code" = "302" ] && ok "POST /login → 302 (session issued)" || bad "POST /login answered $code" +sites="$(curl -fsS -m 30 -b "$jar" "$url/sites" 2>/dev/null || true)" +case "$sites" in + *"${PLAUSIBLE_SITE:-__none__}"*) ok "/sites lists ${PLAUSIBLE_SITE:-}" ;; + *) bad "/sites does not list ${PLAUSIBLE_SITE:-} — the dashboard is not usable" ;; +esac + +# ── 3. registration closed ───────────────────────────────────────────────── +reg="$(curl -fsS -m 30 "$url/register" 2>/dev/null || true)" +case "$reg" in + *password_confirmation*) bad "/register still offers signup on a PUBLIC url" ;; + *) ok "/register does not offer signup" ;; +esac + +# ── 4. the app's wiring, from the MERGED view ────────────────────────────── +# config.yaml < config.local.yaml, which is what +# components/frontend/src/lib/server/settings.ts does. Reading only the tracked +# file is the trap: wiring never writes there, so the key is always absent and +# a reader that stopped there would report "not wired" on a wired machine — the +# exact shape that made the replay suite pass while testing nothing. +echo "── app wiring (merged config)" +base_block="$(bash "$OVERLAY" get "$CONFIG_DIR/config.yaml" plausible 2>/dev/null || true)" +over_block="$(bash "$OVERLAY" get "$CONFIG_DIR/config.local.yaml" plausible 2>/dev/null || true)" +if [ -n "$base_block" ] && [ -n "$over_block" ]; then + # The only case where "prefer the overlay's block" differs from a deep merge. + # Single quotes: backticks inside a double-quoted string are a command + # substitution, so this line would have tried to RUN `plausible`. + echo ' ! both config.yaml and config.local.yaml define "plausible" — the' + echo " loader deep-merges them; this check reads the overlay's block only." +fi +block="${over_block:-$base_block}" +script_url="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*scriptUrl:[[:space:]]*//p')" +domain="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*domain:[[:space:]]*//p')" +case "$block" in + *"enabled: true"*) ok "plausible.enabled: true" ;; + *) bad "the app is not wired — scripts/wire-frontend.sh" ;; +esac +[ -n "$script_url" ] && ok "scriptUrl $script_url" || bad "no scriptUrl in the merged config" +[ "$domain" = "${PLAUSIBLE_SITE:-}" ] && + ok "domain $domain matches the registered site" || + bad "domain '$domain' ≠ registered site '${PLAUSIBLE_SITE:-}' — events would be dropped" + +# The tracked file must NEVER carry a tunnel hostname. There is a Go spec for +# the same property on the OIDC issuer (internal/config/config_test.go); +# this is the one for ours, and it is cheap enough to run every time. +if grep -q 'trycloudflare' "$CONFIG_DIR/config.yaml" 2>/dev/null; then + bad "$CONFIG_DIR/config.yaml contains a tunnel hostname — it must stay on localhost" +else + ok "the TRACKED config.yaml has no tunnel hostname" +fi + +# ── 5. the tracker script, and the right variant ─────────────────────────── +echo "── tracker script" +js="$(curl -fsS -m 30 "$script_url" 2>/dev/null || true)" +[ -n "$js" ] && ok "fetchable through the tunnel" || bad "$script_url is not fetchable" +case "$js" in + *"localhost\$|^127"*) + bad "this is the NON-local variant: it silently refuses to send from localhost" + ;; + *) ok "no localhost guard (the .local variant)" ;; +esac +# `manual`: the script must not count anything by itself. The auto-tracking +# variants call the sender on load and on history changes; the manual one only +# installs the queue. Checked by the absence of the pushState patch. +case "$js" in + *pushState*) bad "this variant tracks navigations ITSELF — it would send location.href" ;; + *) ok "manual variant (every pageview is one the app decided to send)" ;; +esac + +# ── 6. a real browser ────────────────────────────────────────────────────── +if [ "$BROWSER" -eq 1 ]; then + echo "── browser" + hackathon_id="$(curl -fsS -m 20 "$APP_URL/" 2>/dev/null | + grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + # Firefox and playwright live in the sibling e2e skill, inside the dev + # container. No container ⇒ FAIL, never skip. + cname="$(docker ps --format '{{.Names}}' | grep -E '^devcontainer-dev-1$' | head -1)" + if [ -z "$cname" ]; then + bad "the dev container is not running, so no browser can be driven (--no-browser to accept that)" + else + out="$(docker exec -u vscode "$cname" \ + /workspaces/hackagon/.devenv/profile/bin/node \ + /workspaces/hackagon/.claude/skills/plausible-stack/scripts/pageview.mjs \ + "$APP_URL" "$url" "$hackathon_id" /tmp/plausible-pageview.json 2>&1)" + rc=$? + printf '%s\n' "$out" | sed 's/^/ /' + [ "$rc" -eq 0 ] && ok "a real browser sent page views, with no id and no token on the wire" || + bad "the browser proof failed (above)" + fi +fi + +# ── 7. what Plausible actually stored ────────────────────────────────────── +echo "── read-back (Plausible's own Stats API)" +if [ -z "${PLAUSIBLE_API_KEY:-}" ]; then + bad "no PLAUSIBLE_API_KEY in $SECRETS_FILE — run scripts/signup.sh" +else + q="$(curl -fsS -m 30 -X POST "$url/api/v2/query" \ + -H "Authorization: Bearer $PLAUSIBLE_API_KEY" -H 'Content-Type: application/json' \ + -d "{\"site_id\":\"$domain\",\"metrics\":[\"visitors\",\"pageviews\"],\"date_range\":\"day\",\"dimensions\":[\"event:page\"]}" 2>/dev/null || true)" + # Only the `results` array. The response ECHOES the query back under "query", + # dimensions and all, so a grep over the whole body reports `event:page` as a + # page that was visited — a fake row in a list whose whole job is to be read + # for what is and is not in it. + pages="$(printf '%s' "${q%%\"meta\"*}" | grep -oE '"dimensions":\["[^"]*"\]' | sed 's/.*\["//;s/"\]//')" + if [ -z "$pages" ]; then + bad "Plausible has no page views for $domain today — the browser's 202s went nowhere" + else + ok "pages stored today: $(printf '%s' "$pages" | tr '\n' ' ')" + # The positive control: the deep route must be one of them. Without it, + # "no id was stored" is satisfied by an empty database. + case "$pages" in + */hackathon/\[id\]*) ok "the deep route landed as its TEMPLATE, /hackathon/[id]" ;; + *) [ "$BROWSER" -eq 1 ] && bad "no /hackathon/[id] row — the deep-route pageview did not land" ;; + esac + # …and the negative: nothing stored may look like an id. + if printf '%s' "$pages" | grep -qE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; then + bad "a UUID is stored in Plausible's page list" + else + ok "no UUID in anything Plausible stored" + fi + fi +fi + +# ── 8. the schema cannot hold an IP ──────────────────────────────────────── +# The privacy claim in docs/frontend/analytics.md, asked of the database rather +# than of the vendor. +ch="$(compose exec -T plausible_events_db clickhouse-client -q \ + "SELECT count() FROM system.columns WHERE database='plausible_events_db' AND table IN ('events_v2','sessions_v2') AND (name ILIKE '%ip%' OR name ILIKE '%user_agent%')" 2>/dev/null | tr -d '\r\n ')" +if [ "$ch" = "0" ]; then + ok "events_v2/sessions_v2 have no IP or user-agent column" +elif [ -n "$ch" ]; then + bad "$ch column(s) in events_v2/sessions_v2 look like they hold an IP or user agent" +else + echo " ! could not query ClickHouse for the column check" +fi + +# ── 9. --restore takes exactly one key ───────────────────────────────────── +# On a COPY: the real thing would restart three servers, and this is a claim +# about config-overlay.sh, not about the running app. +echo "── unwiring (simulated on a copy)" +live="$CONFIG_DIR/config.local.yaml" +if [ -f "$live" ]; then + tmp="$(mktemp -d)/config.local.yaml" + cp "$live" "$tmp" + before="$(bash "$OVERLAY" keys "$tmp" | sort | tr '\n' ' ')" + bash "$OVERLAY" remove "$tmp" plausible >/dev/null + after="$(bash "$OVERLAY" keys "$tmp" 2>/dev/null | sort | tr '\n' ' ')" + expect="$(bash "$OVERLAY" keys "$live" | grep -vx plausible | sort | tr '\n' ' ')" + if [ "$after" = "$expect" ]; then + ok "removes 'plausible' and keeps [ ${after:-—}] (was [ $before])" + else + bad "removal changed the wrong keys: [$after] ≠ [$expect]" + fi + rm -rf "$(dirname "$tmp")" +else + echo " ! no config.local.yaml — nothing to unwire" +fi + +echo "" +if [ "$fail" -eq 0 ]; then + echo "VERIFIED$([ "$BROWSER" -eq 0 ] && echo " (browser step skipped ON PURPOSE — the end-to-end claim is NOT proven)")" +else + echo "FAILED — see the ✕ lines." + exit 1 +fi diff --git a/.claude/skills/plausible-stack/scripts/wire-frontend.sh b/.claude/skills/plausible-stack/scripts/wire-frontend.sh new file mode 100644 index 00000000..3484e79b --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/wire-frontend.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# Point the SvelteKit app at this Plausible, or unpoint it. +# +# wire-frontend.sh wire: read the live tunnel URL, write the +# `plausible:` block, restart the frontend +# wire-frontend.sh --restore turn measurement back OFF (the default state) +# wire-frontend.sh --print show what it would write, change nothing +# +# Measurement is OFF unless this script (or a human) has written +# `plausible.enabled: true`. An absent block parses to `{enabled:false}`, and +# the component then renders no script tag at all — no request, no console +# noise. Nothing counts because somebody forgot a flag. +# +# NOTHING TRACKED IS EDITED. The block goes into config.local.yaml, the +# gitignored overlay the loader deep-merges over config.yaml +# (components/frontend/src/lib/server/settings.ts). The value written here is a +# `*.trycloudflare.com` hostname that dies in a few hours: this repo has +# already had one of those committed and left dead in HEAD for several commits, +# and `internal/config/config_test.go` now asserts both tracked configs still +# say localhost. +# +# THIS SCRIPT OWNS EXACTLY ONE KEY IN THAT FILE: `plausible`. It is the THIRD +# writer — cloudflare-tunnel's auth-wire.sh owns `oidc`, openreplay-stack's +# wire-frontend.sh owns `replay` — and none of them knows about the others. So +# --restore removes the BLOCK, never the file: an `rm` here would silently +# unwire login (a tunnel with no issuer keeps serving every page and breaks +# only signing in) or stop session replay recording (an empty OpenReplay UI +# looks exactly like the correct default). .claude/skills/lib/config-overlay.sh +# does the per-key surgery for all three. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ROOT_DIR="$(cd "$HERE/../../../.." && pwd)" +FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml" +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" + +MODE="wire" +case "${1:-}" in + --restore) MODE="restore" ;; + --print) MODE="print" ;; + "") ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; +esac + +# The frontend reads its config ONCE at boot, so a rewrite is inert until it +# restarts — and getting that restart right is the whole trick, exactly as it +# is for openreplay-stack's wiring. THREE servers can be serving this app: +# +# process-compose `frontend` vite dev on :8081 +# prod-frontend.sh the adapter-node build on :8081 (the e2e +# harness puts it there and leaves it there) +# prod-serve.sh the adapter-node build on :8082, which is what +# the app's own Cloudflare tunnel proxies +# +# Restarting only the first succeeds, prints "Process frontend restarted", and +# changes nothing at all. +in_shell() { # run a command in the dev shell, wherever this script started + local pc="$ROOT_DIR/.claude/skills/devcontainer-up/scripts/exec.sh" + if command -v process-compose >/dev/null 2>&1; then + (cd "$ROOT_DIR" && bash -c "$1") + elif [ -f "$pc" ]; then + (cd "$ROOT_DIR" && MSYS_NO_PATHCONV=1 bash "$pc" just nix::develop default bash -c "$1") + else + return 1 + fi +} + +# ⚠ EVERY STEP HERE REPORTS. The first version of this function ran the +# restarts with `>/dev/null 2>&1` on both ends of an `&&` chain, which is how a +# stopped :8082 — the app tunnel's ONLY upstream — sat down for ten minutes +# with this script printing "analytics is ON" and nothing else. A restart that +# stops a server and fails to start it again is the one outcome that must never +# be quiet, because the public URL keeps answering (caddy falls back) right up +# until the fallback is down too. +# +# Each step is therefore: run, capture, and on failure print the tail and name +# the port that is now down. Slow, honest, and it costs one nix-shell entry per +# step (trap 4 in .claude/CLAUDE.md) — which is why they are three steps and +# not one. +step() { #
@@ -731,7 +643,7 @@

just check::test -c backend under --ginkgo.v

The tree says otherwise. Both packages now carry a Ginkgo bootstrap whose only job is to register those flags: - components/backend/internal/audit/audit_suite_test.go (present) and components/backend/internal/storage/storage_suite_test.go (present) — both added in 13331242I, + components/backend/internal/audit/audit_suite_test.go (present) and components/backend/internal/storage/storage_suite_test.go (present) — both added in 13331242eI, an ancestor of this commit. Confirmed once by hand on 2026-08-14 by running go test -tags "test unittest" ./internal/audit/ ./internal/storage/ -count=1 --ginkgo.v @@ -841,8 +753,8 @@

Sources

A .claude/skills/hackathon-e2e/recipe.jsonl - 252.4 KiB - 2026-08-13 + 252.7 KiB + 2026-08-14 465 action lines + 12 comment banners (11 of them act headings): kinds, priorities, actors, gates, todos, expected status codes, and every RPC the recipe calls. @@ -856,8 +768,8 @@

Sources

C .claude/skills/hackathon-e2e/mutations/manifest.jsonl - 30.5 KiB - 2026-08-13 + 29.0 KiB + 2026-08-14 The mutation manifest: one deliberate breakage per line, its arena, its expected reds, and — for a gap — why nothing catches it. @@ -870,36 +782,36 @@

Sources

E docs/testing.md - 5.9 KiB - 2026-08-11 + 6.3 KiB + 2026-08-14 The API-to-UI coverage table: which declared RPCs have no frontend caller, and why each is deliberate. F .claude/CLAUDE.md - 69.9 KiB - 2026-08-13 + 81.6 KiB + 2026-08-14 The suite-results table (the only source for smoke, mobile, openreplay, backend and frontend-unit numbers) and the known-broken prose. G api/proto/ — - 2026-08-07 + 2026-08-14 Every rpc declaration in api/proto/**/*_service.proto — the denominator for both coverage counts. H components/frontend/src/ — - 2026-08-12 + 2026-08-14 The frontend call sites: the API-to-UI audit is re-run here at build time rather than copied from the doc. I .claude/skills/hackathon-e2e/tests/smoke/22-hackathon-pages.spec.ts 14.7 KiB - 2026-08-12 + 2026-08-14 The failing smoke spec and the helper that causes it; line numbers below are read out of the file. @@ -908,7 +820,7 @@

Sources

Generated by .claude/skills/hackathon-e2e/scripts/build-quality-report.mjs from - sketch/06-08-26 @ e77e6f99 (2026-08-14). No wall-clock is embedded, so two builds over one + sketch/06-08-26 @ d3737691 (2026-08-14). No wall-clock is embedded, so two builds over one tree are byte-identical. This is the status report; the animated replay of the recipe is recipe-player.html.

diff --git a/.claude/skills/hackathon-e2e/recipe-player.html b/.claude/skills/hackathon-e2e/recipe-player.html index 627ff06c..39186d5f 100644 --- a/.claude/skills/hackathon-e2e/recipe-player.html +++ b/.claude/skills/hackathon-e2e/recipe-player.html @@ -1447,7 +1447,7 @@

What the recipe is, and what every field on an action means

{"id": "act5.backfill.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas has member access now", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} {"id": "act5.backfill.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas takes Fatima's seat on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:jonas.weber}}"}, "expect": {"ok": true}} {"id": "act5.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "final list confirmed: 8 approved, 4 waitlisted, 12 total (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} -{"comment": "── ACT 5 (cont.) — the organizer's own screens: the manage hub, the folded nav, the capability switches, bulk team composition ──"} +{"comment": "── ACT 5 (cont.) — the organizer's own screens: the manage hub, the flat Manage nav, the capability switches, bulk team composition ──"} {"id": "act5.hub.tiles", "priority": "P1", "implement": true, "outcome": "The hub tiles all nine organizer destinations and never itself, states 'Review 4 waiting' beside the approval queue, offers Edit details - and with no phases on the schedule the Now/Next box says so and offers no action at all.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the organizer's landing page tiles every screen it leads to", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Manage Teams", "href": "/teams/manage"}, {"label": "New Phase", "href": "/timeline/new"}, {"label": "Manage Pages", "href": "/pages"}, {"label": "Prizes", "href": "/prizes"}, {"label": "Deadlines", "href": "/windows"}, {"label": "Manage Forms", "href": "/forms"}, {"label": "Notifications", "href": "/email"}, {"label": "Invitation Links", "href": "/invites"}], "tileCount": 9, "reviewWaiting": 4, "mayEdit": true, "nowBadge": null, "next": null, "phaseAction": null, "clearMarker": false}, "todo": "The tiles are asserted INSIDE the tile grid: the sidebar renders the same nine labels on this very page, so a page-wide locator is satisfied by the nav and would pass with no tiles rendered at all."} {"id": "act5.hub.rogue", "priority": "P1", "implement": true, "outcome": "403 - the hub is gated on the same owner-or-admin rule that decides whether the sidebar offers it, so the nav can never offer a link the page then refuses.", "act": 5, "t": "T-1wk", "title": "a participant opening the manage hub is politely refused", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage", "status": 403}]} {"id": "act5.hub.review.click", "priority": "P1", "implement": true, "outcome": "Clicking the prompt lands on the participants screen where the four waiting people can actually be approved.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the approval prompt leads to the queue it counts", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Review 4 waiting"}, {"expectUrl": "/participants$"}, {"expectHeading": "All Participants"}, {"expectText": "Charles Whitfield"}]} @@ -1455,7 +1455,7 @@

What the recipe is, and what every field on an action means

{"id": "act5.flow.reach.manage", "priority": "P1", "implement": true, "outcome": "The organizer gets from the dashboard to a deadline form entirely by CLICKING: event row, then the folded Manage section, then the hub, then the tile - and the back button returns to the hub.", "act": 5, "t": "T-1wk", "title": "REACHABILITY: dashboard → event → unfold Manage → hub → Deadlines, by clicking", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/my/hackathon/[0-9a-f-]+/overview$"}, {"clickButton": "Show Manage Hackathon pages"}, {"clickLink": "Manage Hackathon"}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}, {"clickSelector": "xpath=//main//section[.//span[normalize-space()='Manage']]//a[normalize-space()='Deadlines']"}, {"expectUrl": "/windows$"}, {"expectHeading": "Deadlines"}, {"back": true}, {"expectUrl": "/manage$"}], "todo": "page.goto proves a route works, not that anyone can get there - three bugs once hid behind 'clicking my hackathons does nothing'. The tile is reached by an XPath scoped to the tile grid because the sidebar carries a link with the same accessible name."} {"id": "act5.flow.hub.back", "priority": "P2", "implement": true, "outcome": "Opening Edit details and pressing Back returns to the hub with the event untouched - the form is a detour, not a commitment.", "act": 5, "t": "T-1wk", "title": "SECOND THOUGHTS: the organizer opens Edit details and backs out", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Edit details"}, {"expectHeading": "Edit Hackathon"}, {"back": true}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}]} {"id": "act5.hub.title.check", "priority": "P2", "implement": true, "outcome": "Succeeds; the event's name is exactly as act 1 left it - backing out of a form must not have written anything.", "act": 5, "t": "T-1wk", "title": "END STATE: backing out of the edit form changed nothing", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} -{"id": "act5.nav.fold", "priority": "P1", "implement": true, "outcome": "The Manage section starts folded with only its hub on the rail, opens and closes from the chevron, remembers the choice across a page load, and unfolds itself when a page inside it is opened.", "act": 5, "t": "T-1wk", "title": "SIDEBAR: the ten organizer entries fold behind the hub instead of doubling the nav", "actor": "hackagon-admin", "action": "ui.assert", "assert": "sidebarManageFold", "params": {"entry": "Manage Pages", "insidePath": "tracks"}, "todo": "Asserted through getByRole: the entries stay in the DOM while folded and are hidden by inert + aria-hidden, so the accessibility tree is the only place that reads as hidden. A CSS check would agree with a fold that leaves ten links as the next thing Tab reaches."} +{"id": "act5.nav.fold", "priority": "P1", "implement": true, "outcome": "The Manage section is flat: every organizer entry sits on the rail directly, on a participant page and inside the section alike, with no disclosure to open and nothing about the rail that changes when you walk into Manage.", "act": 5, "t": "T-1wk", "title": "SIDEBAR: the organizer entries are drawn flat, with no fold to open", "actor": "hackagon-admin", "action": "ui.assert", "assert": "sidebarManageFold", "params": {"entry": "Manage Pages", "insidePath": "tracks"}, "todo": "Re-specified 2026-08-14 for develop's 942b60a7, which removed the fold this action used to pin (it force-opened on entering /manage/*, so the disclosure was already open wherever an organizer used it). The claim carrying the weight is the EQUALITY of the rail outside and inside Manage: a per-page presence check passes against a fold too, once it is open, and this one opened itself on exactly those pages. If a disclosure is ever reintroduced, re-specify this action again rather than delete it."} {"id": "act5.pilot.join.offered", "priority": "P1", "implement": true, "outcome": "The pilot sprint is open, so alice's dashboard offers Join and says nothing about being closed. This is the control for the refusal asserted below.", "act": 5, "t": "T-1wk", "title": "CONTROL: while registration is open the dashboard offers Join on the pilot sprint", "actor": "alice", "action": "ui.assert", "assert": "dashboardJoinOffered", "params": {"name": "SDSC Capacity Pilot Sprint"}} {"id": "act5.pilot.phase.wrap", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns pilotWrapPhase - a phase entirely in the future, so nothing is running on the pilot yet.", "act": 5, "t": "T-1wk", "title": "PILOT: the side sprint gets a wrap-up phase, next week", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{var:capHackId}}", "name": "Pilot Wrap-up", "startsAt": "{{now+2d}}", "endsAt": "{{now+3d}}", "description": "Retrospective and hand-over of the capacity pilot."}, "save": {"pilotWrapPhase": "phaseId"}, "expect": {"ok": true}} {"id": "act5.pilot.hub.start", "priority": "P1", "implement": true, "outcome": "Nothing is running and nothing is declared, so the one action offered is 'Start Pilot Wrap-up' - the case a single 'Advance to X' label used to collapse into a sentence naming a phase that appears nowhere on the page.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB (case 3): with no phase live, the action is to START the first one", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"hackathonId": "{{var:capHackId}}", "tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Deadlines", "href": "/windows"}], "tileCount": 9, "reviewWaiting": 3, "mayEdit": true, "nowBadge": null, "next": "Pilot Wrap-up", "phaseAction": "Start Pilot Wrap-up", "clearMarker": false}} diff --git a/.claude/skills/hackathon-e2e/recipe.jsonl b/.claude/skills/hackathon-e2e/recipe.jsonl index 85da6412..94fade83 100644 --- a/.claude/skills/hackathon-e2e/recipe.jsonl +++ b/.claude/skills/hackathon-e2e/recipe.jsonl @@ -220,7 +220,7 @@ {"id": "act5.backfill.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas has member access now", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} {"id": "act5.backfill.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas takes Fatima's seat on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:jonas.weber}}"}, "expect": {"ok": true}} {"id": "act5.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "final list confirmed: 8 approved, 4 waitlisted, 12 total (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} -{"comment": "── ACT 5 (cont.) — the organizer's own screens: the manage hub, the folded nav, the capability switches, bulk team composition ──"} +{"comment": "── ACT 5 (cont.) — the organizer's own screens: the manage hub, the flat Manage nav, the capability switches, bulk team composition ──"} {"id": "act5.hub.tiles", "priority": "P1", "implement": true, "outcome": "The hub tiles all nine organizer destinations and never itself, states 'Review 4 waiting' beside the approval queue, offers Edit details - and with no phases on the schedule the Now/Next box says so and offers no action at all.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the organizer's landing page tiles every screen it leads to", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Manage Teams", "href": "/teams/manage"}, {"label": "New Phase", "href": "/timeline/new"}, {"label": "Manage Pages", "href": "/pages"}, {"label": "Prizes", "href": "/prizes"}, {"label": "Deadlines", "href": "/windows"}, {"label": "Manage Forms", "href": "/forms"}, {"label": "Notifications", "href": "/email"}, {"label": "Invitation Links", "href": "/invites"}], "tileCount": 9, "reviewWaiting": 4, "mayEdit": true, "nowBadge": null, "next": null, "phaseAction": null, "clearMarker": false}, "todo": "The tiles are asserted INSIDE the tile grid: the sidebar renders the same nine labels on this very page, so a page-wide locator is satisfied by the nav and would pass with no tiles rendered at all."} {"id": "act5.hub.rogue", "priority": "P1", "implement": true, "outcome": "403 - the hub is gated on the same owner-or-admin rule that decides whether the sidebar offers it, so the nav can never offer a link the page then refuses.", "act": 5, "t": "T-1wk", "title": "a participant opening the manage hub is politely refused", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage", "status": 403}]} {"id": "act5.hub.review.click", "priority": "P1", "implement": true, "outcome": "Clicking the prompt lands on the participants screen where the four waiting people can actually be approved.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the approval prompt leads to the queue it counts", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Review 4 waiting"}, {"expectUrl": "/participants$"}, {"expectHeading": "All Participants"}, {"expectText": "Charles Whitfield"}]} @@ -228,7 +228,7 @@ {"id": "act5.flow.reach.manage", "priority": "P1", "implement": true, "outcome": "The organizer gets from the dashboard to a deadline form entirely by CLICKING: event row, then the folded Manage section, then the hub, then the tile - and the back button returns to the hub.", "act": 5, "t": "T-1wk", "title": "REACHABILITY: dashboard → event → unfold Manage → hub → Deadlines, by clicking", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/my/hackathon/[0-9a-f-]+/overview$"}, {"clickButton": "Show Manage Hackathon pages"}, {"clickLink": "Manage Hackathon"}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}, {"clickSelector": "xpath=//main//section[.//span[normalize-space()='Manage']]//a[normalize-space()='Deadlines']"}, {"expectUrl": "/windows$"}, {"expectHeading": "Deadlines"}, {"back": true}, {"expectUrl": "/manage$"}], "todo": "page.goto proves a route works, not that anyone can get there - three bugs once hid behind 'clicking my hackathons does nothing'. The tile is reached by an XPath scoped to the tile grid because the sidebar carries a link with the same accessible name."} {"id": "act5.flow.hub.back", "priority": "P2", "implement": true, "outcome": "Opening Edit details and pressing Back returns to the hub with the event untouched - the form is a detour, not a commitment.", "act": 5, "t": "T-1wk", "title": "SECOND THOUGHTS: the organizer opens Edit details and backs out", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Edit details"}, {"expectHeading": "Edit Hackathon"}, {"back": true}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}]} {"id": "act5.hub.title.check", "priority": "P2", "implement": true, "outcome": "Succeeds; the event's name is exactly as act 1 left it - backing out of a form must not have written anything.", "act": 5, "t": "T-1wk", "title": "END STATE: backing out of the edit form changed nothing", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} -{"id": "act5.nav.fold", "priority": "P1", "implement": true, "outcome": "The Manage section starts folded with only its hub on the rail, opens and closes from the chevron, remembers the choice across a page load, and unfolds itself when a page inside it is opened.", "act": 5, "t": "T-1wk", "title": "SIDEBAR: the ten organizer entries fold behind the hub instead of doubling the nav", "actor": "hackagon-admin", "action": "ui.assert", "assert": "sidebarManageFold", "params": {"entry": "Manage Pages", "insidePath": "tracks"}, "todo": "Asserted through getByRole: the entries stay in the DOM while folded and are hidden by inert + aria-hidden, so the accessibility tree is the only place that reads as hidden. A CSS check would agree with a fold that leaves ten links as the next thing Tab reaches."} +{"id": "act5.nav.fold", "priority": "P1", "implement": true, "outcome": "The Manage section is flat: every organizer entry sits on the rail directly, on a participant page and inside the section alike, with no disclosure to open and nothing about the rail that changes when you walk into Manage.", "act": 5, "t": "T-1wk", "title": "SIDEBAR: the organizer entries are drawn flat, with no fold to open", "actor": "hackagon-admin", "action": "ui.assert", "assert": "sidebarManageFold", "params": {"entry": "Manage Pages", "insidePath": "tracks"}, "todo": "Re-specified 2026-08-14 for develop's 942b60a7, which removed the fold this action used to pin (it force-opened on entering /manage/*, so the disclosure was already open wherever an organizer used it). The claim carrying the weight is the EQUALITY of the rail outside and inside Manage: a per-page presence check passes against a fold too, once it is open, and this one opened itself on exactly those pages. If a disclosure is ever reintroduced, re-specify this action again rather than delete it."} {"id": "act5.pilot.join.offered", "priority": "P1", "implement": true, "outcome": "The pilot sprint is open, so alice's dashboard offers Join and says nothing about being closed. This is the control for the refusal asserted below.", "act": 5, "t": "T-1wk", "title": "CONTROL: while registration is open the dashboard offers Join on the pilot sprint", "actor": "alice", "action": "ui.assert", "assert": "dashboardJoinOffered", "params": {"name": "SDSC Capacity Pilot Sprint"}} {"id": "act5.pilot.phase.wrap", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns pilotWrapPhase - a phase entirely in the future, so nothing is running on the pilot yet.", "act": 5, "t": "T-1wk", "title": "PILOT: the side sprint gets a wrap-up phase, next week", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{var:capHackId}}", "name": "Pilot Wrap-up", "startsAt": "{{now+2d}}", "endsAt": "{{now+3d}}", "description": "Retrospective and hand-over of the capacity pilot."}, "save": {"pilotWrapPhase": "phaseId"}, "expect": {"ok": true}} {"id": "act5.pilot.hub.start", "priority": "P1", "implement": true, "outcome": "Nothing is running and nothing is declared, so the one action offered is 'Start Pilot Wrap-up' - the case a single 'Advance to X' label used to collapse into a sentence naming a phase that appears nowhere on the page.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB (case 3): with no phase live, the action is to START the first one", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"hackathonId": "{{var:capHackId}}", "tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Deadlines", "href": "/windows"}], "tileCount": 9, "reviewWaiting": 3, "mayEdit": true, "nowBadge": null, "next": "Pilot Wrap-up", "phaseAction": "Start Pilot Wrap-up", "clearMarker": false}} From 807ce8719294f5acc30702a7d32e497878b97ace Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:10:20 +0200 Subject: [PATCH 19/25] test(e2e): re-specify the nav and footer checks for develop's chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 22 smoke reds after the merge, in two files, all label changes and none of them a regression. Collected in one run before anything was edited — fixing reds as they appear hides the shape, and the shape here is that both files pinned chrome develop deliberately rebuilt. ## 02-login (8 reds) — the header nav develop's 143a9612 makes the primary nav TWO entries, renames "Hackathons" to "All Hackathons" (the wordmark beside it already reads Hackathons, so the bare noun appeared twice in one row) and drops About from the bar — the page stays, the entry does not. The property both tests were written for is untouched: one meaning per entry, and the same set on both sides of the login. Only the membership moved, so the two are driven off one NAV_ENTRIES table now. About's removal gets a positive control rather than a bare absence. "No About in the header" is satisfied just as well by an About that was deleted from the product, so the test asserts the header has none AND that the footer's Platform nav still points at /about. An absence with no positive control agrees with everything — that is four entries in CLAUDE.md's own list. ## 21-footer (14 reds) — the rebuilt footer develop's 02658384 rebuilt the footer after datascience.ch: "Terms" is "Terms of use" (so the link text and the page's own

finally agree), and there is no GitHub link at all — the off-site row is SDSC's channels. Three changes, and two of them make the checks stronger than a rename would have: - FOOTER_LINKS carries the nav LANDMARK with each label. Not tidiness: the new footer also links datascience.ch/about, so footer-wide `name: "About"` now matches two links to two different places. A `.first()` would have silently followed whichever the DOM ordered first. Reported as a product finding too — two links with one accessible name in one region is what a screen-reader link list cannot disambiguate. - the GitHub test is RETIRED rather than repaired, because its subject left the product, and replaced with a claim about the row that took its place: every off-site anchor is absolute https AND has an accessible name. The second half is new and is the point — an icon-only link that loses its aria-label is invisible to a screen reader and to every name-based locator, and looks completely fine on screen. - "the same footer on both sides of the login" compared the public footer to the FOOTER_LINKS constant, which made it a claim about the footer's SIZE; the rebuild grew it from 4 links to 14, so the constant was wrong when a copy edit landed rather than when anything broke. Same disease as 03-dashboard's `connectedCount: 3`. The property was always the EQUALITY of the two sides, and that is asserted directly now, with a non-empty check so two empty lists cannot satisfy it. The new off-site check earned itself on its first run by failing: it called the ETH and EPFL logo links nameless, because it read aria-label and textContent and those are `ETH Zurich`. The alt text names the link perfectly well, so the check computes the name the way a screen reader does. A check that reports a correct page as broken is a check that gets deleted. ## Two recipe actions, same audit - act5.flow.reach.manage dropped its "Show Manage Hackathon pages" click: the rail is flat, so the chain is one click shorter. The claim — you can GET to a deadline form from the dashboard by clicking — is unchanged. - act8.form.ui.edit follows a control that MOVED rather than one that went away. develop's c596683c removed the overview's "Your registration answers -> View or edit" block and its 76037844 put the entry point on the participants roster, where View opens your own editable form and ?userId= someone else's for organisers. The product rule is the one this action exists for — SubmitRegistrationForm is an upsert precisely so a first typo is not permanent, which needs a way in from the UI — so the locator moved and the claim did not. It is matched on the href (the View with no ?userId= is your own row) rather than on a display name. smoke: 141 passed / 1 failed -> 142 passed after the accessible-name fix. The 22-hackathon-pages drag test, red in the recorded baseline, passed in both runs. --- .../skills/hackathon-e2e/helpers/reflow.ts | 41 ++++-- .../skills/hackathon-e2e/quality-report.html | 35 +++-- .../skills/hackathon-e2e/recipe-player.html | 4 +- .claude/skills/hackathon-e2e/recipe.jsonl | 4 +- .../tests/smoke/02-login.spec.ts | 78 ++++++++---- .../tests/smoke/21-footer.spec.ts | 120 +++++++++++++++--- 6 files changed, 202 insertions(+), 80 deletions(-) diff --git a/.claude/skills/hackathon-e2e/helpers/reflow.ts b/.claude/skills/hackathon-e2e/helpers/reflow.ts index d146915a..f8b23262 100644 --- a/.claude/skills/hackathon-e2e/helpers/reflow.ts +++ b/.claude/skills/hackathon-e2e/helpers/reflow.ts @@ -15,7 +15,7 @@ import { expect, type Page } from "@playwright/test" // 4. the consent banner — the one piece of chrome that LAYERS over the page — // is on screen without being asked to be, and covers no control once the // document is scrolled to its end (expectConsentBannerClearsContent); -// 5. the footer exists on this route and its four links are hit-testable at +// 5. the footer exists on this route and its links are hit-testable at // the bottom of the document (expectFooterOperable). Presence is a claim // of its own here — the footer is the only inbound link to the platform's // own SitePages, and it was absent from the whole signed-in half of the app @@ -252,12 +252,33 @@ export async function expectNoClippedText( // ─── The site footer: present AND clickable ────────────────────────────────── /** - * The links the footer carries. Privacy, Terms and About are SitePages — - * `[slug=sitepage]` records authored in /manage/pages — and this footer is the - * ONLY inbound link to any of them. A route without it is a route from which - * the platform's own pages cannot be reached. + * The links the footer carries, each with the nav landmark that owns it. + * + * Privacy, Terms of use and About are SitePages — `[slug=sitepage]` records + * authored in /manage/pages — and this footer is the ONLY inbound link to any + * of them. A route without it is a route from which the platform's own pages + * cannot be reached. + * + * ⚠ Scoped to a landmark rather than searched footer-wide, and that is not + * tidiness. develop's rebuilt footer (`02658384`) carries the SDSC org site's + * own links beside ours, and one of them is ALSO named exactly "About" + * (datascience.ch/about, next to our /about). A footer-wide + * `getByRole("link", { name: "About", exact: true })` matches both, so the + * count assertion below would fail on a footer that is perfectly correct — and, + * worse, a `.first()` would have silently followed whichever the DOM happened + * to order first. The landmark is the thing that says WHICH About this is. + * + * The duplicate name is worth fixing in the product too (two links with the + * same accessible name in one region is exactly what a screen-reader link list + * cannot disambiguate); reported rather than worked around silently. */ -export const FOOTER_LINKS = ["Privacy", "Terms", "About", "GitHub"] +export const FOOTER_LINKS: { label: string; nav: string }[] = [ + { label: "Hackathons", nav: "Platform" }, + { label: "Dashboard", nav: "Platform" }, + { label: "About", nav: "Platform" }, + { label: "Privacy", nav: "Legal" }, + { label: "Terms of use", nav: "Legal" }, +] /** * TWO claims, and the second is the one that keeps costing money here. @@ -294,10 +315,12 @@ export async function expectFooterOperable(page: Page, name: string) { ).toHaveCount(1) await expect(footer).toBeVisible() - for (const label of FOOTER_LINKS) { + for (const { label, nav } of FOOTER_LINKS) { await expect( - footer.getByRole("link", { name: label, exact: true }), - `${name}: the footer carries no "${label}" link`, + footer + .getByRole("navigation", { name: nav }) + .getByRole("link", { name: label, exact: true }), + `${name}: the footer's "${nav}" nav carries no "${label}" link`, ).toHaveCount(1) } diff --git a/.claude/skills/hackathon-e2e/quality-report.html b/.claude/skills/hackathon-e2e/quality-report.html index f06d7882..2e05c03f 100644 --- a/.claude/skills/hackathon-e2e/quality-report.html +++ b/.claude/skills/hackathon-e2e/quality-report.html @@ -186,7 +186,7 @@
Hackagon · e2e quality report
-
sketch/06-08-26 @ d3737691 · 2026-08-14
+
sketch/06-08-26 @ a9caa83e · 2026-08-14