From 6da8c4ec139c50799941d1f46ec4b7d8bfd34311 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 12 Jul 2026 14:28:00 -0700 Subject: [PATCH 1/5] refactor(dashboard): poll takes onSnapshot/onStale callbacks, not a store (JEF-411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple the poll from any state container: startPolling now takes plain { tab, onSnapshot, onStale, liveRegion, … } callbacks so App can wire them to its useState updaters. Everything else is byte-identical — POLL_MS=5000, the synchronous first tick(), the stale-on-failure paths, the mid-selection defer guard, and (verbatim) the JEF-408/ADR-0027 `(ms, fn) => setInterval(fn, ms)` default that keeps the handler a function (no number→string eval under the strict CSP). Tests: poll.test.js ports to spy the callbacks; the two JEF-408 regressions (>1-tick recurring poll + native setInterval gets a FUNCTION handler) and the selection-guard defer test are retained. Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- engine/web/src/poll.js | 34 ++++++++++++------- engine/web/test/poll.test.js | 63 ++++++++++++++++++++---------------- 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/engine/web/src/poll.js b/engine/web/src/poll.js index 146c698..8b3fe25 100644 --- a/engine/web/src/poll.js +++ b/engine/web/src/poll.js @@ -1,5 +1,6 @@ -// The dashboard v4 poll engine (ADR-0025 / JEF-397). It fetches the active tab's same-origin JSON -// snapshot every 5s and feeds it to the store. Two rules ported verbatim from the v3 client: +// The dashboard v4 poll engine (ADR-0025 / JEF-397 / JEF-411). It fetches the active tab's +// same-origin JSON snapshot every 5s and hands each result to callbacks the caller supplies. Two +// rules ported verbatim from the v3 client: // // 1. Same-origin only. The URL is always relative (`/api/{tab}.json`), so the strict CSP // `connect-src 'self'` is the hard floor — the client can reach nothing but its own origin. @@ -9,9 +10,12 @@ // once the selection clears. (The keyed reconcile no longer nukes scroll, so the v3 // scroll-restore hack is gone — only the selection guard survives.) // -// A failed poll does NOT throw or blank the view: it marks the store STALE, so the last-good -// snapshot stays on screen under an honest "not updating — this is a connection problem, not an -// all-clear" banner (never a stale green). +// A failed poll does NOT throw or blank the view: it calls `onStale`, so the last-good snapshot +// stays on screen under an honest "not updating — this is a connection problem, not an all-clear" +// banner (never a stale green). +// +// The poll is decoupled from any state container (JEF-411): it takes plain `onSnapshot` / `onStale` +// callbacks, so `App` can wire them to its `useState` updaters without a store dependency. /** The poll cadence — 5s, matching the v3 client. */ export const POLL_MS = 5000; @@ -44,23 +48,29 @@ export function hasLiveSelection(container) { * interval and cancels in-flight application (the last fetch's result is ignored after stop). * * @param {object} opts - * @param {import("./store.js").Store} opts.store the store to feed snapshots / staleness into. * @param {() => string} opts.tab reads the CURRENT active tab (so a client tab-swap repoints the * poll without a restart). + * @param {(snapshot: unknown) => void} opts.onSnapshot called with each successful snapshot (the + * caller applies it — goes live). Not called on a deferred (mid-selection) or failed tick. + * @param {() => void} opts.onStale called when a tick fails (non-ok response or transport error) so + * the caller can mark the connection stale (never a false green). * @param {() => (Node | null)} opts.liveRegion reads the DOM node the selection guard checks. * @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests pass a stub; default is global). * @param {(ms: number, fn: () => void) => number} [opts.setIntervalImpl] injectable interval, called * as `(ms, fn)`. The default ADAPTS native `setInterval` (whose signature is `(fn, ms)` — args * reversed) to this `(ms, fn)` shape. Passing native `setInterval` directly would silently never * re-fire (it reads `POLL_MS` as the callback and `tick` as the delay), so only the initial - * `tick()` ran and a tab-swap blanked forever — the JEF-408 bug this default fixes. + * `tick()` ran and a tab-swap blanked forever — the JEF-408 bug this default fixes (ADR-0027). DO + * NOT "clean this up" to pass `setInterval` directly: a number-first handler is coerced to a + * string and eval'd, which the strict CSP (`script-src 'self'`, no `unsafe-eval`) blocks. * @param {(id: number) => void} [opts.clearIntervalImpl] * @returns {() => void} stop */ export function startPolling(opts) { const { - store, tab, + onSnapshot, + onStale, liveRegion, fetchImpl = typeof fetch !== "undefined" ? fetch : undefined, setIntervalImpl = (ms, fn) => setInterval(fn, ms), @@ -75,19 +85,19 @@ export function startPolling(opts) { }); if (!live) return; if (!res.ok) { - store.markStale(); + onStale(); return; } const snapshot = await res.json(); if (!live) return; // Never apply a snapshot mid-selection — it would rip away the text being copied. The next - // tick applies once the selection clears; the store stays LIVE (not stale) meanwhile. + // tick applies once the selection clears; the status stays LIVE (not stale) meanwhile. const region = liveRegion(); if (region && hasLiveSelection(region)) return; - store.applySnapshot(snapshot); + onSnapshot(snapshot); } catch { // Transport failure (offline, DNS, 5xx thrown): keep the last-good render, say we're stale. - if (live) store.markStale(); + if (live) onStale(); } }; diff --git a/engine/web/test/poll.test.js b/engine/web/test/poll.test.js index 7739129..a6489a2 100644 --- a/engine/web/test/poll.test.js +++ b/engine/web/test/poll.test.js @@ -1,11 +1,10 @@ -// Unit tests for the poll engine (ADR-0025 / JEF-397): same-origin URL construction, the -// defer-apply-while-text-selection rule (ported from v3), and stale-not-blank on a failed poll. +// Unit tests for the poll engine (ADR-0025 / JEF-397 / JEF-411): same-origin URL construction, the +// defer-apply-while-text-selection rule (ported from v3), and stale-not-blank on a failed poll. The +// poll takes plain `onSnapshot` / `onStale` callbacks now (no store dependency — JEF-411); tests +// spy those directly. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { snapshotUrl, startPolling, hasLiveSelection, POLL_MS } from "../src/poll.js"; -import { Store } from "../src/store.js"; - -beforeEach(() => sessionStorage.clear()); describe("snapshotUrl", () => { it("is always a relative, same-origin path per tab", () => { @@ -26,45 +25,50 @@ function startOnce(opts) { } describe("startPolling", () => { - it("applies a fetched snapshot to the store (goes live)", async () => { - const store = new Store(); + it("hands a fetched snapshot to onSnapshot (goes live), never onStale", async () => { + const onSnapshot = vi.fn(); + const onStale = vi.fn(); startOnce({ - store, tab: () => "findings", + onSnapshot, + onStale, liveRegion: () => null, fetchImpl: okFetch({ findings: [{ id: "a" }] }), }); - await vi.waitFor(() => expect(store.getState().status).toBe("live")); - expect(store.getState().data).toEqual({ findings: [{ id: "a" }] }); + await vi.waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1)); + expect(onSnapshot).toHaveBeenCalledWith({ findings: [{ id: "a" }] }); + expect(onStale).not.toHaveBeenCalled(); }); - it("marks the store STALE (not blank) on a non-ok response", async () => { - const store = new Store(); - store.applySnapshot({ findings: [{ id: "keep" }] }); // a prior good render + it("calls onStale (not onSnapshot) on a non-ok response", async () => { + const onSnapshot = vi.fn(); + const onStale = vi.fn(); startOnce({ - store, tab: () => "findings", + onSnapshot, + onStale, liveRegion: () => null, fetchImpl: vi.fn().mockResolvedValue({ ok: false, status: 503 }), }); - await vi.waitFor(() => expect(store.getState().status).toBe("stale")); - expect(store.getState().data).toEqual({ findings: [{ id: "keep" }] }); // last-good kept + await vi.waitFor(() => expect(onStale).toHaveBeenCalledTimes(1)); + expect(onSnapshot).not.toHaveBeenCalled(); }); - it("marks stale on a thrown transport error", async () => { - const store = new Store(); - store.applySnapshot({ findings: [] }); + it("calls onStale on a thrown transport error", async () => { + const onStale = vi.fn(); startOnce({ - store, tab: () => "findings", + onSnapshot: vi.fn(), + onStale, liveRegion: () => null, fetchImpl: vi.fn().mockRejectedValue(new Error("offline")), }); - await vi.waitFor(() => expect(store.getState().status).toBe("stale")); + await vi.waitFor(() => expect(onStale).toHaveBeenCalledTimes(1)); }); it("DEFERS applying a snapshot while a selection is anchored in the live region", async () => { - const store = new Store(); + const onSnapshot = vi.fn(); + const onStale = vi.fn(); const region = document.createElement("div"); region.textContent = "some selectable model verdict"; document.body.appendChild(region); @@ -78,15 +82,16 @@ describe("startPolling", () => { expect(hasLiveSelection(region)).toBe(true); startOnce({ - store, tab: () => "findings", + onSnapshot, + onStale, liveRegion: () => region, fetchImpl: okFetch({ findings: [{ id: "x" }] }), }); - // The fetch resolves but the apply is deferred — the store stays first-load, NOT stale. + // The fetch resolves but the apply is deferred — neither callback fires (not live, not stale). await new Promise((r) => setTimeout(r, 5)); - expect(store.getState().status).toBe("first-load"); - expect(store.getState().data).toBeNull(); + expect(onSnapshot).not.toHaveBeenCalled(); + expect(onStale).not.toHaveBeenCalled(); sel.removeAllRanges(); document.body.removeChild(region); @@ -108,8 +113,9 @@ describe("startPolling default interval (JEF-408 regression)", () => { // exactly once (only the initial tick()); the recurring interval was dead. const fetchImpl = okFetch({ findings: [] }); const stop = startPolling({ - store: new Store(), tab: () => "findings", + onSnapshot: () => {}, + onStale: () => {}, liveRegion: () => null, fetchImpl, }); @@ -128,8 +134,9 @@ describe("startPolling default interval (JEF-408 regression)", () => { // operator reported. Assert the FIRST arg to native setInterval is a function, not a number/string. const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); const stop = startPolling({ - store: new Store(), tab: () => "findings", + onSnapshot: () => {}, + onStale: () => {}, liveRegion: () => null, fetchImpl: okFetch({ findings: [] }), // No setIntervalImpl override — the default adapter must call native setInterval as (fn, ms). From f3e4274260593bdee3204ab4aa344a4dfd6f6356 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 12 Jul 2026 14:28:15 -0700 Subject: [PATCH 2/5] refactor(dashboard): App owns shared state via plain useState; delete the store (JEF-411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App holds the only shared client state — activeTab, data, strip, status, lastGoodAt — each a plain useState (no reducer, no Context, no signals). The status transitions are small updaters: a snapshot goes live + persists the strip (keeping the last if omitted — JEF-410), stale never fires before the first snapshot, and a tab swap nulls `data` but never touches `strip` (its own useState, so the header persists across swaps — JEF-410). main.jsx renders ; the hand-rolled store.js is deleted. The poll effect keys on [activeTab] so a swap restarts it (immediate refetch — JEF-408). Honesty is untouched: the strip/empty-state still render server tokens. Tests: app/app-refetch driven via the DOM (5×-pushState intercept, modified click, immediate-refetch-on-swap); a new app-status.test.jsx ports the store's surviving invariants (the three status transitions incl. never-stale-before- first-snapshot; the JEF-410 strip-persists-across-swap AND keeps-last-strip blocks). store.test.js deleted; transitional-states drives the banner via . Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- engine/web/src/app.jsx | 127 ++++++---- engine/web/src/main.jsx | 4 +- engine/web/src/store.js | 230 ------------------- engine/web/test/app-refetch.test.jsx | 40 ++-- engine/web/test/app-status.test.jsx | 108 +++++++++ engine/web/test/app.test.jsx | 38 +-- engine/web/test/store.test.js | 124 ---------- engine/web/test/transitional-states.test.jsx | 76 +++--- 8 files changed, 261 insertions(+), 486 deletions(-) delete mode 100644 engine/web/src/store.js create mode 100644 engine/web/test/app-status.test.jsx delete mode 100644 engine/web/test/store.test.js diff --git a/engine/web/src/app.jsx b/engine/web/src/app.jsx index 46cd27a..e11d017 100644 --- a/engine/web/src/app.jsx +++ b/engine/web/src/app.jsx @@ -1,15 +1,18 @@ -// The dashboard v4 app shell (ADR-0025): the mounted Preact tree below the SERVER-RENDERED status -// strip. The strip stays server-rendered for first-paint honesty (a JS failure must never leave a -// stale green) — this shell renders only the connection banner, the tab nav (progressive- -// enhancement: real `` links intercepted for a client-side view swap), and the active view. -// All five views (Findings / Alerts / Action / Readiness / Admission) are Preact-rendered (JEF-398): -// the engine is Preact-only, so every tab-swap is a local client view swap — no maud fallback. +// The dashboard v4 app shell (ADR-0025 / ADR-0027 / ADR-0028): the mounted Preact tree. `App` owns +// the ONLY shared client state — the active tab, the last-good snapshot, the persistent status +// strip, the connection status, and the freshness clock — each a plain `useState` (JEF-411: no +// store, no reducer, no Context). Everything else (which rows are expanded, which disclosures are +// open) is LOCAL component state, ephemeral by design. +// +// The strip is its OWN useState, decoupled from the per-tab `data` (JEF-410): it persists global +// posture across a tab swap so the header never tears down. Honesty stays server-derived (ADR-0027): +// the client only displays the strip's tokens; it never recomputes "is this green?". // // The connection banner is the only `aria-live="polite"` region (the STRIP HEADLINE — not the // table): first-load says "connecting to the engine…", stale says the load-bearing two-sentence // "Not updating … This is a connection problem, not an all-clear." Live says nothing (no chrome). -import { useEffect, useState } from "preact/hooks"; +import { useCallback, useEffect, useState } from "preact/hooks"; import { startPolling } from "./poll.js"; import { StatusStrip } from "./strip.jsx"; import { FindingsView } from "./findings/table.jsx"; @@ -28,39 +31,67 @@ const TABS = [ /** * @param {object} props - * @param {import("./store.js").Store} props.store the client store. + * @param {string} [props.initialTab] the server-known active tab (from `data-tab`), so the first + * paint's tab matches the document without waiting for a fetch. * @param {() => (Node | null)} [props.liveRegion] resolves the DOM node the selection guard checks * (defaults to this app's root once mounted). */ -export function App({ store, liveRegion }) { - const [, force] = useState(0); - useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]); +export function App({ initialTab = "findings", liveRegion }) { + // The five shared fields, each a plain useState (JEF-411). + const [activeTab, setActiveTab] = useState(initialTab); + const [data, setData] = useState(null); + // The persistent status strip (global cluster posture), its OWN useState decoupled from the + // per-tab `data` so a tab swap (which nulls `data`) never tears the header down (JEF-410). Null + // before the first snapshot (blank is honest — absent is never a green all-clear). + const [strip, setStrip] = useState(null); + const [status, setStatus] = useState("first-load"); + const [lastGoodAt, setLastGoodAt] = useState(null); - const activeTab = store.getState().activeTab; - // Poll the ACTIVE tab; a client tab-swap RESTARTS the poll so the new tab refetches IMMEDIATELY - // (fixing the up-to-5s blank after a swap — JEF-408) rather than waiting for the next interval. - // The selection guard checks this app's own subtree. - useEffect(() => { - const stop = startPolling({ - store, - tab: () => store.getState().activeTab, - liveRegion: liveRegion || (() => document.getElementById("dash-app")), - }); - return stop; - }, [store, liveRegion, activeTab]); + // A successful snapshot: go LIVE and reset the freshness clock. Persist the global posture from + // this snapshot's `strip` (present in every tab's payload); keep the last strip if a snapshot + // omits it, so the header never blanks (JEF-410). + const applySnapshot = useCallback((snap) => { + setData(snap); + setStrip((prev) => (snap && snap.strip ? snap.strip : prev)); + setStatus("live"); + setLastGoodAt(Date.now()); + }, []); + + // Mark the connection stale: keep the last-good snapshot on screen (never blank, never a false + // all-clear). No-op before the first snapshot — "first-load" (connecting…) is honest then. + const markStale = useCallback(() => { + setStatus((s) => (s === "first-load" ? s : "stale")); + }, []); + + // Poll the ACTIVE tab; a client tab-swap RESTARTS the poll (keyed on [activeTab]) so the new tab + // refetches IMMEDIATELY (fixing the up-to-5s blank after a swap — JEF-408) rather than waiting for + // the next interval. `() => activeTab` is correct: the effect restarts per swap, re-closing over + // the fresh value. The selection guard checks this app's own subtree. + useEffect( + () => + startPolling({ + tab: () => activeTab, + onSnapshot: applySnapshot, + onStale: markStale, + liveRegion: liveRegion || (() => document.getElementById("dash-app")), + }), + [activeTab, applySnapshot, markStale, liveRegion], + ); + + // Swap the active tab (client-side view swap). Drop the previous tab's snapshot — each tab has its + // own JSON shape, so rendering the old snapshot under the new view would be wrong — but do NOT + // touch `strip`: it is global posture that persists across the swap (JEF-410). + const swapTab = useCallback((tab) => { + setActiveTab(tab); + setData(null); + }, []); - const state = store.getState(); return ( -
- {/* The persistent status strip — the honesty spine on every view. Read from the store's - decoupled `strip` (global posture), NOT the per-tab `data`, so a tab-swap never tears it - down; it stays mounted and reconciles in place as posture changes. Before the first - snapshot there is no strip, so nothing renders (blank is honest — absent is never a green - all-clear; the ConnectionBanner already says "connecting…"). */} - - - - +
+ + + +
); } @@ -96,7 +127,7 @@ function agoSeconds(at) { * client-side view swap via `history.pushState`. A plain-navigation modifier (ctrl/⌘/middle-click) * is NOT intercepted so open-in-new-tab still works. */ -function TabNav({ activeTab, store }) { +function TabNav({ activeTab, onSwap }) { const onClick = (e, tab) => { if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; // let the browser handle a modified click (new tab, etc.) @@ -105,13 +136,13 @@ function TabNav({ activeTab, store }) { // full navigation, no maud fallback. e.preventDefault(); history.pushState({ tab: tab.id }, "", tab.href); - store.setActiveTab(tab.id); + onSwap(tab.id); }; useEffect(() => { - const onPop = () => store.setActiveTab(tabFromLocation()); + const onPop = () => onSwap(tabFromLocation()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); - }, [store]); + }, [onSwap]); return (

{r.detail}

{r.why}

- {nodes.length > 0 ? : null} + {nodes.length > 0 ? : null} {hasEnable ? (

{isGap ? "enable with" : "configured via"}{" "} diff --git a/engine/web/src/reconcile.js b/engine/web/src/reconcile.js deleted file mode 100644 index 082d9d7..0000000 --- a/engine/web/src/reconcile.js +++ /dev/null @@ -1,78 +0,0 @@ -// The Findings keyed-reconcile engine (ADR-0025 / JEF-397). The heart of the rewrite: instead of -// the v3 innerHTML swap that destroyed focus, `

` state, and scroll every poll, we key -// each row on its STABLE `finding.id` and let Preact patch only what changed — so a present-in- -// both row keeps its exact DOM (focus, open disclosures, selection) untouched. -// -// This module owns only the part Preact's keyed diff does NOT do for us: the GONE-WHILE-OPEN -// TOMBSTONE. When a finding the operator had expanded (or was focused inside) disappears from the -// new snapshot, snapping it out from under them is dishonest and jarring — so we hold a single -// calm tombstone render ("this finding cleared — the model no longer sees this path") keyed on the -// same id, then drop it. A gone finding that was NOT expanded/focused is hard-removed silently. -// -// `computeRows` is a PURE function (snapshot + prior render + interaction state → next render list) -// so it is trivially unit-testable offline — the reconcile logic is where a bug would silently wipe -// operator state, so it is tested without a browser. - -/** - * @typedef {{ id: string }} Finding a finding row (only `id` matters to the reconciler). - * @typedef {{ finding: Finding, tombstone: boolean }} Row a row to render: either a live finding - * or a one-shot tombstone standing in for a just-gone finding. - */ - -/** - * Compute the next Findings render list from the incoming snapshot, the previously-rendered ids, - * and the ids the operator currently cares about (expanded and/or focused). Pure — no DOM, no - * store — so it is unit-tested directly. - * - * Rules (ADR-0025 / JEF-397): - * - Present in the new snapshot → rendered as a live row (Preact keys on `finding.id`, so its DOM - * — focus, open `
`, selection — is preserved in place; a NEW id is simply inserted at - * its already-sorted position and must not steal focus, which the keyed diff guarantees). - * - Gone from the snapshot AND it was expanded/focused → emit a single tombstone row (keyed on the - * same id) exactly ONCE, then never again. The caller purges the id after this render. - * - Gone and NOT expanded/focused → dropped silently (no tombstone). - * - * The snapshot's order is authoritative (the server already sorts by urgency); tombstones are - * appended at the end so a clearing row doesn't shove the live list around. - * - * @param {Finding[]} incoming the new snapshot's findings, in server (urgency) order. - * @param {Set} priorIds the finding ids present in the PREVIOUS render (to detect gone ones). - * @param {Set} keptOpenIds ids the operator is invested in (expanded ∪ focused) — the set - * that earns a gone finding a tombstone instead of a silent drop. - * @param {Set} priorTombstoneIds ids already shown as a tombstone last render — so a - * tombstone lasts exactly one render and never loops. - * @returns {{ rows: Row[], tombstonedNow: Set }} the render list and the ids newly - * tombstoned THIS render (the caller drops+purges them next tick). - */ -export function computeRows(incoming, priorIds, keptOpenIds, priorTombstoneIds) { - /** @type {Row[]} */ - const rows = []; - const presentIds = new Set(); - - for (const finding of incoming) { - presentIds.add(finding.id); - rows.push({ finding, tombstone: false }); - } - - /** @type {Set} */ - const tombstonedNow = new Set(); - for (const id of priorIds) { - if (presentIds.has(id)) continue; // still present — Preact keeps it in place. - if (priorTombstoneIds.has(id)) continue; // already had its one tombstone render — drop it now. - if (!keptOpenIds.has(id)) continue; // gone but unopened — silent hard-remove. - // Gone while the operator was invested in it: one calm tombstone render, keyed on the same id. - rows.push({ finding: { id }, tombstone: true }); - tombstonedNow.add(id); - } - - return { rows, tombstonedNow }; -} - -/** - * The set of finding ids currently present in a snapshot — the `priorIds` for the NEXT reconcile. - * @param {Finding[]} findings - * @returns {Set} - */ -export function idsOf(findings) { - return new Set(findings.map((f) => f.id)); -} diff --git a/engine/web/test/action.test.jsx b/engine/web/test/action.test.jsx index d71f448..632fa1a 100644 --- a/engine/web/test/action.test.jsx +++ b/engine/web/test/action.test.jsx @@ -1,11 +1,10 @@ -// Action view tests (ADR-0025 / JEF-400): a judgement-audit `
` (KEYED component state) -// stays open across a poll, entry rows key on their stable entry (patched in place), the honest -// journal-empty state renders, and an XSS verdict/prompt renders inert. +// Action view tests (ADR-0025 / JEF-400 / JEF-411): a judgement-audit `
` (NATIVE, +// UNCONTROLLED) stays open across a poll, entry rows key on their stable entry (patched in place), +// the honest journal-empty state renders, and an XSS verdict/prompt renders inert. The view is +// `view`-only now (no store — JEF-411); a poll is modelled by re-rendering with a new `view` prop. import { describe, it, expect, beforeEach } from "vitest"; -import { render, fireEvent, cleanup, act } from "@testing-library/preact"; -import { useState, useEffect } from "preact/hooks"; -import { Store } from "../src/store.js"; +import { render, fireEvent, cleanup } from "@testing-library/preact"; import { ActionView } from "../src/action/view.jsx"; import { actionView, wouldAct, judgement } from "./fixtures.js"; @@ -14,13 +13,6 @@ beforeEach(() => { cleanup(); }); -function Harness({ store }) { - const [, force] = useState(0); - useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]); - const data = store.getState().data; - return data ? : null; -} - function openDetails(details) { details.open = true; fireEvent(details, new Event("toggle")); @@ -28,33 +20,33 @@ function openDetails(details) { describe("Action state preservation + keying", () => { it("keeps an opened judgement disclosure open across a poll", () => { - const store = new Store(); - store.applySnapshot(actionView({ judgements: [judgement("web")] })); - const { container } = render(); + const { container, rerender } = render( + , + ); const details = container.querySelector(".judgement-entry details.model-prompt"); expect(details).toBeTruthy(); openDetails(details); - expect(store.isDisclosureOpen("action:judgement-0")).toBe(true); + expect(details.open).toBe(true); - // A poll re-delivers the ring (same entry order); the disclosure must stay open. - act(() => store.applySnapshot(actionView({ judgements: [judgement("web", { verdict: "Cleared" })] }))); + // A poll re-delivers the ring (same entry order); the native disclosure must stay open. + rerender(); const after = container.querySelector(".judgement-entry details.model-prompt"); expect(after.open).toBe(true); }); it("keys a would-act entry on its stable entry and patches in place", () => { - const store = new Store(); - store.applySnapshot(actionView({ "would-act": [wouldAct("web"), wouldAct("api")] })); - const { container } = render(); + const { container, rerender } = render( + , + ); const rows = container.querySelectorAll(".trust-list .trust-entry"); expect(rows.length).toBe(2); rows[0].dataset.probe = "kept"; - act(() => - store.applySnapshot( - actionView({ "would-act": [wouldAct("web", { "last-verdict": "still exploitable" }), wouldAct("api")] }), - ), + rerender( + , ); const after = container.querySelectorAll(".trust-list .trust-entry")[0]; expect(after.dataset.probe).toBe("kept"); @@ -64,13 +56,13 @@ describe("Action state preservation + keying", () => { describe("Action honesty", () => { it("renders the honest journal-empty state (distinct from none-in-window)", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("no decisions journaled yet"); expect(container.textContent).toContain("not an all-clear"); }); it("renders honest 'none in window' when the journal has history but nothing this window", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("none in the last"); }); }); @@ -79,14 +71,14 @@ describe("Action escaping", () => { it("renders an XSS verdict/prompt/entry as inert text", () => { window.__pwned = undefined; const XSS = ''; - const store = new Store(); - store.applySnapshot( - actionView({ - "would-act": [wouldAct(XSS, { "last-verdict": XSS })], - judgements: [judgement(XSS, { verdict: XSS, prompt: XSS, reply: XSS })], - }), + const { container } = render( + , ); - const { container } = render(); openDetails(container.querySelector(".judgement-entry details.model-prompt")); expect(container.querySelector("img")).toBeNull(); expect(window.__pwned).toBeUndefined(); diff --git a/engine/web/test/admission.test.jsx b/engine/web/test/admission.test.jsx index 4752ae9..a8a2338 100644 --- a/engine/web/test/admission.test.jsx +++ b/engine/web/test/admission.test.jsx @@ -1,12 +1,11 @@ -// Admission view tests (ADR-0025 / JEF-400): decision rows key on the `(subject, image, decision)` -// TUPLE so the dedup `count` updates IN PLACE across a poll (no tear), a signing-inventory row's -// expand-in-place detail (KEYED store state) stays open across a poll, the honest empty states -// render, and an XSS subject/image/signer renders inert. +// Admission view tests (ADR-0025 / JEF-400 / JEF-411): decision rows key on the +// `(subject, image, decision)` TUPLE so the dedup `count` updates IN PLACE across a poll (no tear), +// a signing-inventory row's expand-in-place detail (LOCAL useState) stays open across a poll, the +// honest empty states render, and an XSS subject/image/signer renders inert. The view is `view`-only +// now (no store — JEF-411); a poll is modelled by re-rendering with a new `view` prop. import { describe, it, expect, beforeEach } from "vitest"; -import { render, fireEvent, cleanup, act } from "@testing-library/preact"; -import { useState, useEffect } from "preact/hooks"; -import { Store } from "../src/store.js"; +import { render, fireEvent, cleanup } from "@testing-library/preact"; import { AdmissionView } from "../src/admission/view.jsx"; import { decisionKey } from "../src/keys.js"; import { admissionView, decisionRow, signingRow, signingRepo } from "./fixtures.js"; @@ -16,19 +15,12 @@ beforeEach(() => { cleanup(); }); -function Harness({ store }) { - const [, force] = useState(0); - useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]); - const data = store.getState().data; - return data ? : null; -} - describe("Admission tuple keying — count updates in place", () => { it("keeps the same row node and updates the ×count across a poll", () => { - const store = new Store(); const row = decisionRow({ subject: "Deployment/web", image: "registry/web:1", decision: "audit", count: 2 }); - store.applySnapshot(admissionView({ rows: [row], total: 2, audited: 2 })); - const { container } = render(); + const { container, rerender } = render( + , + ); const rowNode = container.querySelector(".decision-row"); expect(rowNode).toBeTruthy(); @@ -36,11 +28,7 @@ describe("Admission tuple keying — count updates in place", () => { expect(container.querySelector(".decision-count").textContent).toContain("2"); // A new pass: the SAME tuple, count now 5 → same node reconciled in place, count bumped. - act(() => - store.applySnapshot( - admissionView({ rows: [{ ...row, count: 5 }], total: 5, audited: 5 }), - ), - ); + rerender(); const after = container.querySelector(".decision-row"); expect(after.dataset.probe).toBe("kept"); // reconciled in place expect(container.querySelector(".decision-count").textContent).toContain("5"); @@ -56,21 +44,20 @@ describe("Admission tuple keying — count updates in place", () => { describe("Admission signing-row expansion survives a poll", () => { it("keeps an expanded signing row open across a poll", () => { - const store = new Store(); const repo = signingRepo("registry/app", [signingRow("img-a"), signingRow("img-b")]); - store.applySnapshot(admissionView({ signing: [repo] })); - const { container } = render(); + const { container, rerender } = render(); const expander = container.querySelector('tr[data-signing="img-a"] .expander'); fireEvent.click(expander); - expect(store.isDisclosureOpen("admission:img-a")).toBe(true); + expect(expander.getAttribute("aria-expanded")).toBe("true"); expect(container.querySelector("#detail-img-a .detail")).toBeTruthy(); - // A poll re-delivers the inventory; the expanded detail must remain mounted (keyed store state). - act(() => - store.applySnapshot( - admissionView({ signing: [signingRepo("registry/app", [signingRow("img-a", { count: 3 }), signingRow("img-b")])] }), - ), + // A poll re-delivers the inventory; the expanded detail must remain mounted (local useState kept + // by the keyed diff on the row's dom-id). + rerender( + , ); expect(container.querySelector("#detail-img-a .detail")).toBeTruthy(); expect(container.querySelector('tr[data-signing="img-a"] .expander').getAttribute("aria-expanded")).toBe("true"); @@ -79,20 +66,20 @@ describe("Admission signing-row expansion survives a poll", () => { describe("Admission honesty", () => { it("renders the honest empty decisions state (never all-clear)", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("no admission decisions recorded yet"); expect(container.textContent).toContain("not an all-clear"); }); it("renders the honest empty signing inventory (never all-clear)", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("no images observed yet"); }); it("keyline-flags a would-deny decision row", () => { - const store = new Store(); - store.applySnapshot(admissionView({ rows: [decisionRow({ decision: "audit", "would-admit": false })], total: 1, audited: 1 })); - const { container } = render(); + const { container } = render( + , + ); expect(container.querySelector(".decision-row-attention")).toBeTruthy(); expect(container.textContent).toContain("would deny"); }); @@ -102,15 +89,15 @@ describe("Admission escaping", () => { it("renders an XSS subject/image/signer identity as inert text", () => { window.__pwned = undefined; const XSS = ''; - const store = new Store(); - store.applySnapshot( - admissionView({ - rows: [decisionRow({ subject: XSS, image: XSS, reason: XSS })], - total: 1, - signing: [signingRepo("registry/app", [signingRow("img-a", { image: XSS, label: XSS, signer: { "identity-short": XSS, "identity-full": XSS, "issuer-badge": XSS, "issuer-full": XSS } })])], - }), + const { container } = render( + , ); - const { container } = render(); fireEvent.click(container.querySelector('tr[data-signing="img-a"] .expander')); expect(container.querySelector("img")).toBeNull(); expect(window.__pwned).toBeUndefined(); diff --git a/engine/web/test/alerts.test.jsx b/engine/web/test/alerts.test.jsx index 93c3980..7c4a1ff 100644 --- a/engine/web/test/alerts.test.jsx +++ b/engine/web/test/alerts.test.jsx @@ -1,12 +1,11 @@ -// Alerts view tests (ADR-0025 / JEF-400): the content-hash reconcile key (an identical alarm -// persisting across passes does NOT flicker; a genuinely new alarm appears as a new node), the +// Alerts view tests (ADR-0025 / JEF-400 / JEF-411): the content-hash reconcile key (an identical +// alarm persisting across passes does NOT flicker; a genuinely new alarm appears as a new node), the // honesty states (LOUD blind caveat vs calm empty — SERVER-DERIVED, the client selects only), and -// escaping (an XSS payload in a signal/workload renders inert). +// escaping (an XSS payload in a signal/workload renders inert). The view was already `view`-only; a +// poll is modelled by re-rendering with a new `view` prop. import { describe, it, expect, beforeEach } from "vitest"; -import { render, cleanup, act } from "@testing-library/preact"; -import { useState, useEffect } from "preact/hooks"; -import { Store } from "../src/store.js"; +import { render, cleanup } from "@testing-library/preact"; import { AlertsView } from "../src/alerts/view.jsx"; import { alertKey } from "../src/keys.js"; import { alert, alertsView } from "./fixtures.js"; @@ -16,20 +15,10 @@ beforeEach(() => { cleanup(); }); -/** A harness re-rendering the Alerts view whenever the store applies a new snapshot (a poll tick). */ -function Harness({ store }) { - const [, force] = useState(0); - useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]); - const data = store.getState().data; - return data ? : null; -} - describe("Alerts content-hash reconcile keying", () => { it("keeps an identical persisting alarm as the SAME node (no flicker) across a poll", () => { - const store = new Store(); const a = alert({ signal: "notable exec: bash", workload: "web" }); - store.applySnapshot(alertsView([a])); - const { container } = render(); + const { container, rerender } = render(); const cardBefore = container.querySelector(".alert-card"); expect(cardBefore).toBeTruthy(); @@ -37,23 +26,21 @@ describe("Alerts content-hash reconcile keying", () => { cardBefore.dataset.probe = "kept"; // A new pass returns the SAME alarm (same kind/signal/workload/on-chain) → same content key. - act(() => store.applySnapshot(alertsView([alert({ signal: "notable exec: bash", workload: "web" })]))); + rerender(); const cardAfter = container.querySelector(".alert-card"); expect(cardAfter.dataset.probe).toBe("kept"); // reconciled in place, not torn down }); it("renders a genuinely new alarm as a NEW node", () => { - const store = new Store(); - store.applySnapshot(alertsView([alert({ signal: "notable exec: bash" })])); - const { container } = render(); + const { container, rerender } = render(); expect(container.querySelectorAll(".alert-card").length).toBe(1); // A different signal → a different content hash → a second, distinct node. - act(() => - store.applySnapshot( - alertsView([alert({ signal: "notable exec: bash" }), alert({ signal: "contacted cloud-metadata" })]), - ), + rerender( + , ); expect(container.querySelectorAll(".alert-card").length).toBe(2); }); diff --git a/engine/web/test/escaping.test.jsx b/engine/web/test/escaping.test.jsx index 149bb65..9dffffd 100644 --- a/engine/web/test/escaping.test.jsx +++ b/engine/web/test/escaping.test.jsx @@ -1,11 +1,11 @@ -// Client escaping test (ADR-0025 / JEF-397): untrusted strings from the JSON (verdict prose, CVE -// titles, node keys, model prompts) render as TEXT, never as live HTML. Preact auto-escapes all -// interpolated text; `dangerouslySetInnerHTML` is banned in src/ (the JEF-396 guard). This asserts -// the guarantee holds end-to-end: an XSS-laden snapshot produces escaped DOM, no injected element. +// Client escaping test (ADR-0025 / JEF-397 / JEF-411): untrusted strings from the JSON (verdict +// prose, CVE titles, node keys, model prompts) render as TEXT, never as live HTML. Preact +// auto-escapes all interpolated text; `dangerouslySetInnerHTML` is banned in src/ (the JEF-396 +// guard). This asserts the guarantee holds end-to-end: an XSS-laden snapshot produces escaped DOM, +// no injected element. import { describe, it, expect, beforeEach } from "vitest"; import { render, fireEvent, cleanup } from "@testing-library/preact"; -import { Store } from "../src/store.js"; import { FindingsView } from "../src/findings/table.jsx"; import { finding, findingsView } from "./fixtures.js"; @@ -19,7 +19,6 @@ const XSS = ''; describe("untrusted text is escaped, never executed", () => { it("escapes an XSS-laden verdict, CVE title, node key, and model prompt", () => { window.__pwned = undefined; - const store = new Store(); const f = finding("evil", { entry: XSS, objective: XSS, @@ -37,8 +36,7 @@ describe("untrusted text is escaped, never executed", () => { paths: [[{ from: XSS, "from-glyph": "x", relation: XSS, to: XSS, "to-glyph": "x", structural: false, "is-cut": false, shared: false }]], cut: null, }); - store.applySnapshot(findingsView([f])); - const { container } = render(); + const { container } = render(); // Expand so the detail (verdict, CVE table, prompt) renders too. fireEvent.click(container.querySelector('tr.row[data-finding="evil"]')); @@ -50,19 +48,4 @@ describe("untrusted text is escaped, never executed", () => { // The payload IS present as literal text (escaped), proving it rendered as data, not markup. expect(container.textContent).toContain(XSS); }); - - it("escapes an XSS-laden tombstone id without injecting an element", async () => { - const store = new Store(); - const evilId = ''; - const f = finding(evilId); - store.applySnapshot(findingsView([f])); - const { container, rerender } = render(); - store.toggleRow(evilId); // mark it open so it earns a tombstone when it clears - rerender(); - - store.applySnapshot(findingsView([])); - rerender(); - expect(container.querySelector("svg")).toBeNull(); - expect(window.__pwned).toBeUndefined(); - }); }); diff --git a/engine/web/test/readiness.test.jsx b/engine/web/test/readiness.test.jsx index 92095f7..54fb129 100644 --- a/engine/web/test/readiness.test.jsx +++ b/engine/web/test/readiness.test.jsx @@ -1,11 +1,10 @@ -// Readiness view tests (ADR-0025 / JEF-400): the per-node `
` disclosure (KEYED component -// state) stays open across a poll, rows key on `id` (patched in place), a blind node is surfaced -// loudly (server-derived state token), and an XSS node name renders inert. +// Readiness view tests (ADR-0025 / JEF-400 / JEF-411): the per-node `
` disclosure (NATIVE, +// UNCONTROLLED) stays open across a poll, rows key on `id` (patched in place), a blind node is +// surfaced loudly (server-derived state token), and an XSS node name renders inert. The view is +// `view`-only now (no store — JEF-411); a poll is modelled by re-rendering with a new `view` prop. import { describe, it, expect, beforeEach } from "vitest"; -import { render, fireEvent, cleanup, act } from "@testing-library/preact"; -import { useState, useEffect } from "preact/hooks"; -import { Store } from "../src/store.js"; +import { render, fireEvent, cleanup } from "@testing-library/preact"; import { ReadinessView } from "../src/readiness/view.jsx"; import { readinessRow, nodeRow, readinessView } from "./fixtures.js"; @@ -14,13 +13,6 @@ beforeEach(() => { cleanup(); }); -function Harness({ store }) { - const [, force] = useState(0); - useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]); - const data = store.getState().data; - return data ? : null; -} - /** Open a native
the way a user would. */ function openDetails(details) { details.open = true; @@ -31,34 +23,34 @@ const rowWithNodes = (id, nodes, over = {}) => readinessRow(id, { nodes, ...over describe("Readiness state preservation + keying", () => { it("keeps an opened per-node breakdown open across a poll", () => { - const store = new Store(); const row = rowWithNodes("runtime-corroboration", [nodeRow("node-1"), nodeRow("node-2")]); - store.applySnapshot(readinessView([row])); - const { container } = render(); + const { container, rerender } = render(); const details = container.querySelector('li[data-input="runtime-corroboration"] details.cov-nodes'); expect(details).toBeTruthy(); openDetails(details); - expect(store.isDisclosureOpen("readiness:runtime-corroboration")).toBe(true); + expect(details.open).toBe(true); - // A poll updates the row's detail; the disclosure must stay open (keyed state survives). - act(() => - store.applySnapshot( - readinessView([rowWithNodes("runtime-corroboration", [nodeRow("node-1"), nodeRow("node-2")], { detail: "2 signals" })]), - ), + // A poll updates the row's detail; the native disclosure must stay open (keyed diff keeps it). + rerender( + , ); const after = container.querySelector('li[data-input="runtime-corroboration"] details.cov-nodes'); expect(after.open).toBe(true); }); it("keys rows on id and patches an updated row in place", () => { - const store = new Store(); - store.applySnapshot(readinessView([readinessRow("model"), readinessRow("kev")])); - const { container } = render(); + const { container, rerender } = render( + , + ); const modelRow = container.querySelector('li[data-input="model"]'); modelRow.dataset.probe = "kept"; - act(() => store.applySnapshot(readinessView([readinessRow("model", { detail: "last call ok" }), readinessRow("kev")]))); + rerender( + , + ); const after = container.querySelector('li[data-input="model"]'); expect(after.dataset.probe).toBe("kept"); expect(after.textContent).toContain("last call ok"); @@ -67,13 +59,11 @@ describe("Readiness state preservation + keying", () => { describe("Readiness honesty (blind nodes)", () => { it("surfaces a blind node loudly (server-derived state token, not a client derivation)", () => { - const store = new Store(); const row = rowWithNodes("runtime-corroboration", [ nodeRow("node-1", { state: "healthy" }), nodeRow("node-2", { state: "blind", detail: "no live sensor" }), ]); - store.applySnapshot(readinessView([row])); - const { container } = render(); + const { container } = render(); const summary = container.querySelector(".cov-nodes-summary"); expect(summary.textContent).toContain("1 blind"); const blindRow = container.querySelector('tr[data-state="blind"]'); @@ -81,10 +71,8 @@ describe("Readiness honesty (blind nodes)", () => { }); it("flags a weakening absent input with the amber-keyline gap class + enable action", () => { - const store = new Store(); const gap = readinessRow("model", { state: "absent", "weakens-decisions": true, enable: "PROTECTOR_MODEL_URL" }); - store.applySnapshot(readinessView([gap])); - const { container } = render(); + const { container } = render(); const row = container.querySelector('li[data-input="model"]'); expect(row.classList.contains("cov-row-gap")).toBe(true); expect(container.querySelector(".cov-enable-action")).toBeTruthy(); @@ -97,9 +85,7 @@ describe("Readiness escaping", () => { window.__pwned = undefined; const XSS = '
'; const row = rowWithNodes("runtime-corroboration", [nodeRow(XSS, { detail: XSS })]); - const store = new Store(); - store.applySnapshot(readinessView([row])); - const { container } = render(); + const { container } = render(); openDetails(container.querySelector("details.cov-nodes")); expect(container.querySelector("img")).toBeNull(); expect(window.__pwned).toBeUndefined(); diff --git a/engine/web/test/reconcile.test.js b/engine/web/test/reconcile.test.js deleted file mode 100644 index e7ad493..0000000 --- a/engine/web/test/reconcile.test.js +++ /dev/null @@ -1,88 +0,0 @@ -// Unit tests for the pure keyed-reconcile engine (ADR-0025 / JEF-397). These pin the tombstone -// lifecycle — the one thing Preact's keyed diff does NOT do for us — without a browser: a -// gone-while-open finding gets exactly ONE tombstone render, a gone-unopened finding is dropped -// silently, and present findings always render live (Preact keeps their DOM in place). - -import { describe, it, expect } from "vitest"; -import { computeRows, idsOf } from "../src/reconcile.js"; - -const F = (id) => ({ id }); - -describe("computeRows", () => { - it("renders every present finding as a live row, in server order", () => { - const { rows, tombstonedNow } = computeRows( - [F("a"), F("b"), F("c")], - new Set(), - new Set(), - new Set(), - ); - expect(rows.map((r) => r.finding.id)).toEqual(["a", "b", "c"]); - expect(rows.every((r) => !r.tombstone)).toBe(true); - expect(tombstonedNow.size).toBe(0); - }); - - it("drops a gone finding SILENTLY when it was not expanded or focused", () => { - // 'b' vanished but nobody had it open — no tombstone, just gone. - const { rows, tombstonedNow } = computeRows( - [F("a"), F("c")], - new Set(["a", "b", "c"]), - new Set(), // nothing kept open - new Set(), - ); - expect(rows.map((r) => r.finding.id)).toEqual(["a", "c"]); - expect(tombstonedNow.size).toBe(0); - }); - - it("emits ONE tombstone for a gone finding that was expanded/focused", () => { - const { rows, tombstonedNow } = computeRows( - [F("a")], - new Set(["a", "b"]), - new Set(["b"]), // 'b' was open when it vanished - new Set(), - ); - const b = rows.find((r) => r.finding.id === "b"); - expect(b).toBeTruthy(); - expect(b.tombstone).toBe(true); - expect(tombstonedNow.has("b")).toBe(true); - }); - - it("drops the tombstone on the NEXT render (one tombstone, then gone)", () => { - // Simulate the follow-up render: 'b' is still gone, still kept-open, but already tombstoned. - const { rows, tombstonedNow } = computeRows( - [F("a")], - new Set(["a", "b"]), - new Set(["b"]), - new Set(["b"]), // already had its one tombstone last render - ); - expect(rows.map((r) => r.finding.id)).toEqual(["a"]); - expect(tombstonedNow.size).toBe(0); - }); - - it("appends tombstones after the live rows so the list doesn't shift", () => { - const { rows } = computeRows( - [F("a"), F("c")], - new Set(["a", "b", "c"]), - new Set(["b"]), - new Set(), - ); - expect(rows.map((r) => r.finding.id)).toEqual(["a", "c", "b"]); - expect(rows[2].tombstone).toBe(true); - }); - - it("keeps a present finding live even if it is in the kept-open set", () => { - const { rows, tombstonedNow } = computeRows( - [F("a")], - new Set(["a"]), - new Set(["a"]), - new Set(), - ); - expect(rows).toEqual([{ finding: { id: "a" }, tombstone: false }]); - expect(tombstonedNow.size).toBe(0); - }); -}); - -describe("idsOf", () => { - it("collects the id set of a findings list", () => { - expect([...idsOf([F("x"), F("y")])].sort()).toEqual(["x", "y"]); - }); -}); diff --git a/engine/web/test/state-preservation.test.jsx b/engine/web/test/state-preservation.test.jsx index 0283e76..10b6563 100644 --- a/engine/web/test/state-preservation.test.jsx +++ b/engine/web/test/state-preservation.test.jsx @@ -1,15 +1,16 @@ -// The ACCEPTANCE HEART of JEF-397 / JEF-351 (ADR-0025): across a data refresh, an expanded row, an -// open native `
` disclosure, AND keyboard focus PERSIST. This is exactly what the v3 -// innerHTML swap destroyed and the keyed reconcile fixes — so it is tested end-to-end in jsdom. +// The ACCEPTANCE HEART of JEF-351 / JEF-411 (ADR-0025 / ADR-0028): across a data refresh, an +// expanded row, an open native `
` disclosure, AND keyboard focus PERSIST. This is exactly +// what the v3 innerHTML swap destroyed and the keyed reconcile fixes — so it is tested end-to-end in +// jsdom. // -// The harness renders the real Findings view keyed on `finding.id`, mutates the DOM the way an -// operator would (expand a row, open the disclosure, focus the expander), then feeds a NEW snapshot -// through the SAME store (a poll tick) and asserts nothing the operator touched was disturbed. +// Post-JEF-411 the expansion is LOCAL component state (a plain `useState` in FindingRow) and the +// "show model prompt" disclosure is a NATIVE, UNCONTROLLED `
`. Neither is persisted; both +// survive a poll purely because Preact's keyed diff (`key={f.id}`) keeps the row's DOM in place. The +// harness re-renders the Findings view with a NEW `view` prop keyed by id (a poll tick), then +// asserts nothing the operator touched was disturbed. import { describe, it, expect, beforeEach } from "vitest"; -import { render, fireEvent, cleanup, act } from "@testing-library/preact"; -import { useState, useEffect } from "preact/hooks"; -import { Store } from "../src/store.js"; +import { render, fireEvent, cleanup } from "@testing-library/preact"; import { FindingsView } from "../src/findings/table.jsx"; import { finding, findingsView } from "./fixtures.js"; @@ -18,38 +19,28 @@ beforeEach(() => { cleanup(); }); -/** A harness that re-renders the Findings view whenever the store applies a new snapshot — i.e. it - * models the live poll driving the reconcile. */ -function Harness({ store }) { - const [, force] = useState(0); - useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]); - const data = store.getState().data; - return data ? : null; -} - -/** Open a native
the way a user would: set `open`, then fire the `toggle` event that the - * component listens for. Wrapped in act via fireEvent's flush. */ +/** Open a native
the way a user would: set `open`, then fire the `toggle` event. */ function openDetails(details) { details.open = true; fireEvent(details, new Event("toggle")); } describe("state preservation across a refresh (the JEF-351 acceptance heart)", () => { - it("keeps an expanded row, an open disclosure, and focus after a poll", async () => { - const store = new Store(); - store.applySnapshot(findingsView([finding("a"), finding("b")])); - const { container } = render(); + it("keeps an expanded row, an open disclosure, and focus after a poll", () => { + const { container, rerender } = render( + , + ); // Expand row 'a' by clicking its row (the whole row is the toggle, like the maud path). const rowA = container.querySelector('tr.row[data-finding="a"]'); fireEvent.click(rowA); - expect(store.isExpanded("a")).toBe(true); + expect(rowA.classList.contains("open")).toBe(true); // The detail panel is now rendered; open its native
"show model prompt". const details = container.querySelector("#detail-a details.model-prompt"); expect(details).toBeTruthy(); openDetails(details); - expect(store.isPromptOpen("a")).toBe(true); + expect(details.open).toBe(true); // Focus the expander button inside row 'a'. const expander = container.querySelector('tr.row[data-finding="a"] .expander'); @@ -57,22 +48,22 @@ describe("state preservation across a refresh (the JEF-351 acceptance heart)", ( expect(document.activeElement).toBe(expander); // A poll lands a NEW snapshot: 'a' unchanged, 'b' changed disposition, a new 'c' inserted. - act(() => - store.applySnapshot( - findingsView([ + rerender( + , ); - // Expansion survived — row 'a' still open, its detail panel still mounted. + // Expansion survived — row 'a' still open (local useState kept by the keyed diff), detail mounted. const rowAafter = container.querySelector('tr.row[data-finding="a"]'); expect(rowAafter.classList.contains("open")).toBe(true); expect(container.querySelector("#detail-a .detail")).toBeTruthy(); - // The open disclosure survived — same DOM node, still open. + // The open native disclosure survived — same DOM node, still open. const detailsAfter = container.querySelector("#detail-a details.model-prompt"); expect(detailsAfter.open).toBe(true); @@ -87,37 +78,20 @@ describe("state preservation across a refresh (the JEF-351 acceptance heart)", ( expect(rowC.classList.contains("open")).toBe(false); }); - it("shows a one-shot tombstone when an expanded finding clears, then drops it", async () => { - const store = new Store(); - store.applySnapshot(findingsView([finding("a"), finding("b")])); - const { container } = render(); - + it("removes a gone finding via the keyed diff (no client tombstone — JEF-411)", () => { + const { container, rerender } = render( + , + ); + // Expand 'a' so we prove even an OPEN row is simply removed when it clears (no farewell row). fireEvent.click(container.querySelector('tr.row[data-finding="a"]')); - expect(store.isExpanded("a")).toBe(true); - - // 'a' clears (the model no longer sees this path) while it was expanded. - act(() => store.applySnapshot(findingsView([finding("b")]))); - - // A calm tombstone stands in for 'a' this render. - const tomb = container.querySelector('tr[data-finding="a"][data-tombstone="true"]'); - expect(tomb).toBeTruthy(); - expect(tomb.textContent).toContain("this finding cleared"); + expect(container.querySelector('tr.row[data-finding="a"]').classList.contains("open")).toBe( + true, + ); - // The purge fires on the next tick; a following poll (no 'a') drops the tombstone entirely. - await act(() => new Promise((r) => setTimeout(r, 0))); - act(() => store.applySnapshot(findingsView([finding("b")]))); + // 'a' clears from the snapshot — Preact's keyed diff drops it; 'b' stays in place. + rerender(); expect(container.querySelector('[data-finding="a"]')).toBeNull(); - expect(store.isExpanded("a")).toBe(false); // its id was purged from the persisted set - }); - - it("hard-removes a cleared finding SILENTLY when it was never opened", () => { - const store = new Store(); - store.applySnapshot(findingsView([finding("a"), finding("b")])); - const { container } = render(); - - // 'b' clears without ever being expanded — no tombstone, just gone. - act(() => store.applySnapshot(findingsView([finding("a")]))); - expect(container.querySelector('[data-finding="b"]')).toBeNull(); expect(container.querySelector('[data-tombstone="true"]')).toBeNull(); + expect(container.querySelector('tr.row[data-finding="b"]')).toBeTruthy(); }); }); From acd237ccdd0d355bb19a5088766a11c5c72a1303 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 12 Jul 2026 14:28:39 -0700 Subject: [PATCH 4/5] =?UTF-8?q?build(dashboard):=20prune=20npm=20deps=20to?= =?UTF-8?q?=20build+test=20only=20=E2=80=94=20zero=20runtime=20deps=20(JEF?= =?UTF-8?q?-411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preact and esbuild-wasm move to devDependencies (both are build/test-only — the running engine include_str!s a compiled bundle and installs nothing). The unused @vitest/mocker (transitive; vi.mock still works via vitest) and the top-level vite pin are dropped; jsdom + @testing-library/preact + vitest stay. The lockfile is regenerated so `npm ci` and the supply-chain guard match — the root manifest now has zero runtime dependencies. Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- engine/web/package-lock.json | 10 ++++------ engine/web/package.json | 8 ++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/engine/web/package-lock.json b/engine/web/package-lock.json index 923eacf..ee9e462 100644 --- a/engine/web/package-lock.json +++ b/engine/web/package-lock.json @@ -8,15 +8,11 @@ "name": "protector-dashboard", "version": "0.0.0", "license": "UNLICENSED", - "dependencies": { - "esbuild-wasm": "0.25.10", - "preact": "10.29.7" - }, "devDependencies": { "@testing-library/preact": "^3.2.4", - "@vitest/mocker": "^3.2.6", + "esbuild-wasm": "0.25.10", "jsdom": "^25.0.1", - "vite": "^6.4.3", + "preact": "10.29.7", "vitest": "^3.2.6" } }, @@ -1771,6 +1767,7 @@ "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.25.10.tgz", "integrity": "sha512-IyyfrTA2iiOh/uhlaJj0aUDgW42lFhr29ZeKouVNOz/8mLyuqWbEuVst+B4RBH18pb3AcOHnaOgyskAbsVOe3A==", + "dev": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" @@ -2641,6 +2638,7 @@ "version": "10.29.7", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", diff --git a/engine/web/package.json b/engine/web/package.json index 718e0a9..b0989e1 100644 --- a/engine/web/package.json +++ b/engine/web/package.json @@ -9,15 +9,11 @@ "build": "node build.mjs", "test": "vitest run" }, - "dependencies": { - "esbuild-wasm": "0.25.10", - "preact": "10.29.7" - }, "devDependencies": { "@testing-library/preact": "^3.2.4", - "@vitest/mocker": "^3.2.6", + "esbuild-wasm": "0.25.10", "jsdom": "^25.0.1", - "vite": "^6.4.3", + "preact": "10.29.7", "vitest": "^3.2.6" } } From 0f202470a397adb9bce217ca5a51dc3403a8755e Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 12 Jul 2026 14:28:39 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs(adr):=200028=20=E2=80=94=20dashboard?= =?UTF-8?q?=20client=20local-state=20simplification=20(JEF-411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the decision: local state by default (App holds the 5 shared fields + the callback-decoupled poll as plain useState, no reducer/Context/deps); expansion is ephemeral (persistence removed); keyed removal replaces the tombstone (a future cleared-cue is server-shipped); the JEF-408 poll/CSP fix + JEF-410 strip persistence + server-derived honesty are retained; deps pruned to build+test only (zero runtime). Extends 0025/0027 (both stand), reaffirms 0016/0019. Update the ADR index. Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- ...board-client-local-state-simplification.md | 84 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 85 insertions(+) create mode 100644 docs/adr/0028-dashboard-client-local-state-simplification.md diff --git a/docs/adr/0028-dashboard-client-local-state-simplification.md b/docs/adr/0028-dashboard-client-local-state-simplification.md new file mode 100644 index 0000000..35e9c4f --- /dev/null +++ b/docs/adr/0028-dashboard-client-local-state-simplification.md @@ -0,0 +1,84 @@ +# 0028. Dashboard client: local state by default — plain useState, no store, zero runtime deps + +- Status: Accepted +- Date: 2026-07-12 +- Extends: [0025](0025-dashboard-v4-preact-client-render.md) and + [0027](0027-dashboard-root-only-shell-client-strip.md) — both stand in full. This ADR refines only + the client's INTERNAL state architecture; nothing about the Preact reconciler, the + view_model/props serde JSON contract, the built-from-source bundle, zero-egress, or the + server-derived honesty tokens changes. +- Reaffirms: [0016](0016-severity-vs-urgency.md) — presentation is a view, never a gate; and the + honesty axes of [0019](0019-dashboard-v3-presentation-architecture.md)/0025/0027 (blind ≠ green). + +## Context + +The v4 client (ADR-0025/0027) shipped with a hand-rolled observable **store** (`store.js`) holding +the whole client state — active tab, last-good snapshot, connection status, AND every expanded +finding row / open disclosure, the last two mirrored to `sessionStorage` so they survived a soft +reload. Two mechanisms grew on top of it: a keyed-reconcile **tombstone** (`reconcile.js`) that held +a gone-while-open finding on screen for one render as a calm farewell row, and a `purge` step to trim +the persisted id sets after a tombstone cleared. + +That is more machinery than the job needs. Preact already keys rows on `finding.id` and diffs them; +a native `
` already owns its own open state; a component's own expansion is exactly what +`useState` is for. The store centralised state that wants to be **local**, the sessionStorage +persistence imposed a constraint (open rows survive a reload) the product never actually required, +and the tombstone re-implemented, by hand, a lifecycle the framework's keyed diff performs for free. +Meanwhile the client carried two npm `dependencies` (`preact`, `esbuild-wasm`) as if they were +runtime deps, when the running engine `include_str!`s a compiled bundle and installs nothing. + +## Decision + +Simplify the client to **local state by default**, plain `useState`/`useEffect` only — no reducer, +no Context, no signals, no new dependency. + +- **`App` owns the only shared state**, each field a plain `useState`: `activeTab`, `data` (the + last-good snapshot), `strip` (global posture — its OWN state, decoupled from `data`), `status` + (`first-load` | `live` | `stale`), and `lastGoodAt`. The store (`store.js`) is deleted. The status + transitions are small updaters: a snapshot goes live + resets the freshness clock + persists the + strip (keeping the last if a snapshot omits it — JEF-410); stale never fires before the first + snapshot; a tab swap nulls `data` but never touches `strip`. +- **The poll is decoupled to callbacks** (`poll.js` takes `{ tab, onSnapshot, onStale, liveRegion, + … }`), so it feeds `App`'s `useState` updaters directly with no store dependency. **The JEF-408 + fix is retained verbatim**: the default interval is `(ms, fn) => setInterval(fn, ms)` (a + function-first handler, never a number coerced to a string and eval'd), the synchronous first + `tick()`, the stale-on-failure paths, and the mid-selection defer guard all stand. The `App` poll + effect keys on `[activeTab]` so a swap restarts it — an immediate refetch. +- **Expansion / disclosure is LOCAL and ephemeral.** A finding row's expansion is a `useState` in + the row; every "show model prompt" / node-breakdown / judgement disclosure is a **native, + uncontrolled `
`**; the Admission signing-row expander is a `useState` per row. None is + persisted. Preact's keyed diff keeps an open row open across a poll for free; the sessionStorage + persistence (open rows survive a reload) is **dropped** — an unnecessary constraint. +- **Keyed removal replaces the tombstone.** A finding that vanishes from a snapshot is removed by + Preact's keyed diff (`key={f.id}`). `reconcile.js` and the client tombstone are deleted, with **no + client replacement** — a future "recently cleared" cue must be **server-shipped** (the honest + cleared-count already carries the signal). +- **Zero runtime npm deps.** `preact` and `esbuild-wasm` move to `devDependencies` (both are + build/test-only — the bundle is compiled and `include_str!`'d, the running engine installs + nothing). The unused `@vitest/mocker` (transitive) and top-level `vite` pin are dropped; + `jsdom` + `@testing-library/preact` + `vitest` stay. The lockfile is regenerated so `npm ci` and + the supply-chain guard match. + +## Consequences + +Easier: + +- One obvious place for shared state (`App`) and the framework's own defaults everywhere else — less + bespoke machinery to read, test, or get wrong. `store.js` + `reconcile.js` (and their tests) are + gone; the views are pure `view`-only renders. +- The honesty contract is untouched and re-pinned: `StatusStrip`/`FindingsEmpty` still render SERVER + tokens (green iff `strip["all-clear"]`), the strip is server-derived, and `strip.test.jsx` + + `honesty.test.jsx` pass unchanged. The client still derives no honesty. +- The dependency surface is honest: the running engine has zero runtime npm deps; the build/test + deps are clearly marked as such. + +Harder / accepted: + +- **Open rows don't survive a reload.** With the sessionStorage persistence dropped, a soft reload + collapses expanded rows and disclosures. Accepted: expansion is an ephemeral read-affordance, not + state worth persisting, and a reload is a deliberate act; the honesty-critical strip is unaffected. +- **A cleared finding disappears without a farewell.** Removing the tombstone means a gone finding + simply drops from the table with no on-screen "this cleared" cue. Accepted as HONEST — the row is + no longer a live proven path, and the strip's cleared-count carries the fact. A gentler + "recently cleared" affordance, if wanted, is a server-shipped concern (the client derives nothing), + not a client-held tombstone. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2397930..26b1a5c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,5 +35,6 @@ Copy [`0000-template.md`](0000-template.md) to start one. | [0025](0025-dashboard-v4-preact-client-render.md) | Dashboard v4: a bundled Preact client reconciling from same-origin read-only JSON — supersedes 0019's maud server-render *mechanism* (its IA + honesty axes survive); view_model/props retained as the serde JSON contract, bundle built-from-source + gitignored, honesty stays server-derived | Accepted (its server-rendered strip/nav superseded in part by 0027 — the body is now root-only) | | [0026](0026-adjudication-judge-qwen3-1.7b.md) | Promote qwen3:1.7b as the adjudication judge (bakeoff: 12/12, the only clean sweep of all three evidence types + every refute; deployed qwen2.5:3b-instruct is 11/12, misses exposed-secret-in-field) — pending Pi latency/RAM validation, strict-JSON on-Pi, the delta-aware prompt path, and in-cluster zero-egress availability | Proposed | | [0027](0027-dashboard-root-only-shell-client-strip.md) | Dashboard: the server emits a ROOT-ONLY shell (`` + `#dash-root`); the status strip + tab nav move to the Preact client — supersedes 0025's server-rendered strip/nav. Honesty preserved (blank ≠ green; the all-clear/watching/`judging-state` tokens stay server-derived). Also fixes the reversed-args `setInterval` bug (dead poll + blank tab-swaps + CSP eval violation) with the CSP kept strict; SSR/hydration deferred | Accepted | +| [0028](0028-dashboard-client-local-state-simplification.md) | Dashboard client: local state by default — `App` holds the 5 shared fields (+ the callback-decoupled poll) as plain `useState`, the hand-rolled store + reconcile tombstone are deleted, expansion/disclosure is local & ephemeral (native `
`; sessionStorage persistence dropped), keyed removal replaces the tombstone (a future cleared-cue is server-shipped), and the npm deps prune to build+test only (zero runtime). Extends 0025/0027 (both stand); the JEF-408 poll/CSP fix + JEF-410 strip persistence + server-derived honesty are retained | Accepted | See also [`../VISION.md`](../VISION.md) for the longer-form narrative this ADR realizes.