Brand board revisions, the five dashboard mockups with their verify
script and evidence, the slice 1 S5 design note (SLICE1-VIEWS.md, rev 3
plus the S2c section 10 update), the Console mockup of inbox, tasks,
agents and trail (mockups/slice1/) and checks/slice1-verify.mjs (16
checks; check 16 pinned to bus at d27042fa). Also the CHAT-03 progress
log and the 2026-09-26 dirty-tree manifest. Design and evidence only;
no package code. Row 40 (#1522), committed on its own at Sage's
direction, 2026-10-09.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
431 lines
37 KiB
JavaScript
431 lines
37 KiB
JavaScript
// Verification for the slice 1 S5 mockup (mockups/slice1/). Run: node checks/slice1-verify.mjs
|
||
// Writes screenshots and a JSON report under evidence/slice1/. Uses the installed Chromium over CDP; no npm.
|
||
import { browser } from './browser.mjs';
|
||
import { mkdir, writeFile, readFile, mkdtemp, rm } from 'node:fs/promises';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { createHash } from 'node:crypto';
|
||
import { DatabaseSync } from 'node:sqlite';
|
||
|
||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||
const page = 'file://' + join(root, 'mockups', 'slice1', 'index.html');
|
||
const out = join(root, 'evidence', 'slice1');
|
||
await mkdir(out, { recursive: true });
|
||
|
||
const id = n => `0d1a${String(n).padStart(4, '0')}-5e7c-4b1a-9c3e-1f2a3b4c5d6e`;
|
||
const D = { launch: id(1), prd: id(2), prdOld: id(3), conflict: id(4), scope: id(5), merged: id(6) }; // as in mockups/slice1/data.js
|
||
const ref = n => `vikunja%3A3%2F${n}`;
|
||
const routes = ['#/', '#/inbox', '#/inbox?view=roles', '#/inbox?view=recent', `#/inbox/${D.launch}`, `#/inbox/${D.prd}`, `#/inbox/${D.merged}`, '#/tasks', `#/tasks/${ref(41)}`, `#/tasks/${ref(47)}`, `#/tasks/${ref(48)}`, '#/agents', `#/trail/task/${ref(41)}`, `#/trail/task/${ref(44)}?kind=decision`, `#/trail/decision/${D.prd}`, '#/does-not-exist', `#/inbox/${id(99)}`];
|
||
const widths = [320, 390, 768, 960, 1440, 1920, 2560, 3440];
|
||
const modes = ['light', 'dim', 'dark'];
|
||
const report = { generated: new Date().toISOString(), checks: [] };
|
||
let failures = 0;
|
||
function record(name, status, detail = '') { report.checks.push({ name, status, detail }); if (status === 'fail') failures++; const mark = { pass: 'PASS', fail: 'FAIL', 'not verified': 'N/V ' }[status]; console.log(`${mark} ${name}${detail ? ' : ' + detail : ''}`); }
|
||
const check = (name, problems, ok = '') => record(name, problems.length ? 'fail' : 'pass', problems.slice(0, 8).join(' | ') || ok);
|
||
|
||
const b = await browser();
|
||
// Count every way a page could send something. The mockup must never call any of them.
|
||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__errs=[];window.__net=[];
|
||
window.addEventListener("error",e=>window.__errs.push(e.message+" @"+(e.filename||"").split("/").pop()+":"+e.lineno));
|
||
window.addEventListener("unhandledrejection",e=>window.__errs.push("rejection: "+e.reason));
|
||
const f=window.fetch;window.fetch=(...a)=>{window.__net.push("fetch "+a[0]);return f(...a)};
|
||
const o=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(...a){window.__net.push("xhr "+a[1]);return o.apply(this,a)};
|
||
navigator.sendBeacon=(...a)=>{window.__net.push("beacon "+a[0]);return false};
|
||
const W=window.WebSocket;window.WebSocket=function(u){window.__net.push("ws "+u);return new W(u)};
|
||
window.__clip=[];` });
|
||
const settle = ms => b.evaluate(`new Promise(r=>setTimeout(r,${ms}))`);
|
||
const go = async hash => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await settle(50); };
|
||
const load = async (query = '') => { await b.navigate(page + query); await settle(60); };
|
||
const probe = () => b.evaluate(`JSON.stringify({errs:window.__errs, net:window.__net, sw:document.documentElement.scrollWidth, cw:document.documentElement.clientWidth, main:document.getElementById('main').innerText.length, h1:document.querySelector('#main h1')?.textContent||''})`).then(JSON.parse);
|
||
const text = () => b.evaluate(`document.getElementById('main').innerText`);
|
||
const shot = name => b.screenshot(join(out, name + '.png'));
|
||
|
||
// 1. Every route at every width: renders, no script errors, no horizontal overflow, nothing sent.
|
||
{
|
||
const problems = [];
|
||
for (const w of widths) {
|
||
await b.viewport(w, w < 700 ? 844 : 1000); await load();
|
||
for (const r of routes) {
|
||
await go(r); const p = await probe();
|
||
if (p.errs.length) problems.push(`${w} ${r} errors: ${p.errs.join('; ')}`);
|
||
if (p.net.length) problems.push(`${w} ${r} sent: ${p.net.join('; ')}`);
|
||
if (p.sw > p.cw) problems.push(`${w} ${r} overflow ${p.sw}>${p.cw}`);
|
||
if (p.main < 40) problems.push(`${w} ${r} empty main`);
|
||
if ((r === '#/does-not-exist' || r.endsWith(id(99))) && !/not found/i.test(p.h1)) problems.push(`${w} ${r} not-found page missing`);
|
||
const name = r.slice(2).replace(/[^a-z0-9]+/gi, '-').replace(/-+$/, '') || 'board';
|
||
if (w === 1440 && !r.includes('does-not') && !r.endsWith(id(99))) await shot(`${name.slice(0, 60)}-1440`);
|
||
if (w === 390 && ['#/inbox', `#/inbox/${D.launch}`, '#/tasks', '#/agents', `#/trail/task/${ref(41)}`].includes(r)) await shot(`${name.slice(0, 60)}-390`);
|
||
}
|
||
}
|
||
check(`${routes.length} routes × ${widths.length} widths render without script errors, network calls or horizontal overflow`, problems);
|
||
}
|
||
|
||
// 2. Palettes × modes: tokens apply and sampled text/UI pairs meet WCAG contrast on each view.
|
||
await b.viewport(1440, 1000);
|
||
{
|
||
await load();
|
||
const palettes = await b.evaluate('window.Brand.palettes.map(p=>p.id)');
|
||
const contrast = `(() => {
|
||
const parse = c => { const m = c.match(/[\\d.]+/g).map(Number); return c.startsWith('color(') ? [m[0] * 255, m[1] * 255, m[2] * 255, m.length > 3 ? m[3] : 1] : [m[0], m[1], m[2], m.length > 3 ? m[3] : 1]; };
|
||
const lum = c => { const m = parse(c); const f = v => { v /= 255; return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; }; return .2126 * f(m[0]) + .7152 * f(m[1]) + .0722 * f(m[2]); };
|
||
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||
const blend = (fg, bg) => { const f = parse(fg), g = parse(bg); const a = f[3]; if (a >= 1) return fg; return 'rgb(' + [0,1,2].map(i => Math.round(f[i] * a + g[i] * (1 - a))).join(', ') + ')'; };
|
||
const bgFlat = el => { let e = el; const layers = []; while (e) { const bg = getComputedStyle(e).backgroundColor; if (bg && bg !== 'transparent' && !bg.startsWith('rgba(0, 0, 0, 0)')) layers.push(bg); e = e.parentElement; } layers.push(getComputedStyle(document.documentElement).backgroundColor); let r = layers.pop(); while (layers.length) r = blend(layers.pop(), r); return r; };
|
||
const sel = ['#main h1', '#status', '.muted', '.source', '.badge-ok', '.badge-attn', '.badge-danger', '.badge-muted', '.btn', '.chip[aria-current=true]', '.chip:not([aria-current])', '.count', 'th', 'code', '.s1-dec-open', '.s1-section[aria-current=page]', '.s1-section:not([aria-current])', '.s1-tkind', '.s1-twhen', '.feat', '#main a', '.s1-key', '.tree-title'];
|
||
const out = [];
|
||
for (const s of sel) { const el = document.querySelector(s); if (!el) continue; const cs = getComputedStyle(el); const size = parseFloat(cs.fontSize); const large = size >= 24 || (size >= 18.66 && parseInt(cs.fontWeight) >= 700); const r = ratio(cs.color, bgFlat(el)); out.push({ s, r: +r.toFixed(2), need: large ? 3 : 4.5, ok: r >= (large ? 3 : 4.5) }); }
|
||
return out;
|
||
})()`;
|
||
const views = ['#/inbox', `#/inbox/${D.prd}`, '#/tasks', '#/agents', `#/trail/task/${ref(41)}`];
|
||
const fails = [], all = [];
|
||
for (const p of palettes) for (const m of modes) {
|
||
await load(`?palette=${p}&mode=${m}`);
|
||
const applied = await b.evaluate(`document.documentElement.dataset.palette+'/'+document.documentElement.dataset.mode`);
|
||
if (applied !== `${p}/${m}`) fails.push(`${p}/${m} not applied (${applied})`);
|
||
for (const v of views) {
|
||
await go(v);
|
||
for (const r of await b.evaluate(contrast)) { all.push({ p, m, v, ...r }); if (!r.ok) fails.push(`${p}/${m} ${v} ${r.s} ${r.r}:1 < ${r.need}`); }
|
||
}
|
||
if ((p === 'harbor' && m !== 'light') || (p === 'grove' && m === 'dark')) { await go('#/inbox'); await shot(`palette-${p}-${m}-inbox`); }
|
||
}
|
||
all.sort((a, b) => a.r - b.r); report.contrastLowest = all.slice(0, 12);
|
||
check(`${palettes.length} palettes × 3 modes apply; ${all.length} sampled text/UI pairs across 5 views meet WCAG contrast`, fails, `lowest ${all[0].r}:1 (${all[0].p}/${all[0].m} ${all[0].v} ${all[0].s})`);
|
||
}
|
||
|
||
// 3. Agent-authored text is inert: no element or handler from it, controls and bidi marks visible, Markdown as source.
|
||
{
|
||
const problems = [];
|
||
await load();
|
||
for (const r of ['#/inbox', `#/inbox/${D.prd}`, `#/trail/decision/${D.prd}`]) {
|
||
await go(r);
|
||
const st = await b.evaluate(`JSON.stringify({ img: document.querySelectorAll('#main img').length, script: document.querySelectorAll('#main script').length, pwned: window.__pwned || 0, onerr: !!document.querySelector('#main [onerror]') })`).then(JSON.parse);
|
||
if (st.img || st.script || st.pwned || st.onerr) problems.push(`${r} created markup from text: ${JSON.stringify(st)}`);
|
||
}
|
||
await go(`#/inbox/${D.prd}`);
|
||
const t = await text();
|
||
for (const need of ['<img src=x onerror="window.__pwned=1">', '<script>window.__pwned=2</script>', '[U+202E]exe.txt[U+202C]', '␇', '**"every 30 seconds"**']) if (!t.includes(need)) problems.push(`decision text lacks ${JSON.stringify(need)}`);
|
||
if (/|\u0007/.test(t)) problems.push('raw bidi or bell character reached the page');
|
||
await go('#/tasks');
|
||
if (!(await text()).includes('#43 `mosaic inbox` and `mosaic decide`')) problems.push('task title Markdown not shown as source');
|
||
check('agent- and tracker-authored text renders inert (no markup, visible controls and bidi marks, Markdown as source)', problems);
|
||
}
|
||
|
||
// 4. Read-only: no form or text field, the only buttons are Copy and Read again, Copy writes only the
|
||
// clipboard with the exact command, and nothing is sent anywhere.
|
||
{
|
||
const problems = [];
|
||
await load();
|
||
await b.evaluate(`navigator.clipboard.writeText = t => { window.__clip.push(t); return Promise.resolve(); }`);
|
||
let copies = 0;
|
||
for (const r of routes) {
|
||
await go(r);
|
||
const st = await b.evaluate(`JSON.stringify({ forms: document.querySelectorAll('form').length, fields: document.querySelectorAll('#main input, #main textarea, #main select').length, buttons: [...document.querySelectorAll('#main button')].filter(x => !x.hasAttribute('data-copy') && !x.hasAttribute('data-retry')).map(x => x.textContent), post: document.documentElement.outerHTML.match(/method=.?post/i) ? 1 : 0 })`).then(JSON.parse);
|
||
if (st.forms || st.fields || st.buttons.length || st.post) problems.push(`${r}: ${JSON.stringify(st)}`);
|
||
}
|
||
for (const d of [D.launch, D.prd]) {
|
||
await go(`#/inbox/${d}`);
|
||
const cmds = await b.evaluate(`[...document.querySelectorAll('[data-copy]')].map(x => x.dataset.copy)`);
|
||
const keys = await b.evaluate(`window.S1App.reads().decision(${JSON.stringify(d)}).options.map(o => o.key)`);
|
||
if (JSON.stringify(cmds) !== JSON.stringify(keys.map(k => `mosaic decide ${d} ${k}`))) problems.push(`${d} commands ${JSON.stringify(cmds)}`);
|
||
await b.evaluate(`window.__clip = []; [...document.querySelectorAll('[data-copy]')].forEach(x => x.click())`); await settle(30);
|
||
const clip = await b.evaluate('window.__clip');
|
||
if (JSON.stringify(clip) !== JSON.stringify(cmds)) problems.push(`${d} clipboard got ${JSON.stringify(clip)}`);
|
||
copies += clip.length;
|
||
if (!/Copied: mosaic decide/.test(await b.evaluate(`document.getElementById('copy-status').textContent`))) problems.push('copy status not shown');
|
||
}
|
||
// Clipboard refused: the command is selected instead, and the page says so.
|
||
await b.evaluate(`navigator.clipboard.writeText = () => Promise.reject(new Error('denied'))`);
|
||
await b.evaluate(`document.querySelector('[data-copy]').click()`); await settle(30);
|
||
const fb = await b.evaluate(`JSON.stringify({ sel: String(getSelection()), msg: document.getElementById('copy-status').textContent })`).then(JSON.parse);
|
||
if (!fb.sel.startsWith('mosaic decide ') || !/press Ctrl\+C/.test(fb.msg)) problems.push('clipboard fallback: ' + JSON.stringify(fb));
|
||
// Closed decisions show the outcome, no commands.
|
||
for (const d of [D.merged, D.prdOld]) { await go(`#/inbox/${d}`); if (await b.evaluate(`document.querySelectorAll('[data-copy]').length`)) problems.push(`closed decision ${d} still offers commands`); }
|
||
const net = await b.evaluate('window.__net');
|
||
if (net.length) problems.push('sent: ' + net.join('; '));
|
||
const src = (await readFile(join(root, 'mockups', 'slice1', 'app.js'), 'utf8')) + (await readFile(join(root, 'mockups', 'slice1', 'index.html'), 'utf8'));
|
||
for (const bad of ['fetch(', 'XMLHttpRequest', 'sendBeacon', 'WebSocket', '<form', 'localStorage.setItem']) if (src.includes(bad)) problems.push(`source contains ${bad}`);
|
||
check(`read-only: no forms, fields or write buttons on ${routes.length} routes; ${copies} Copy clicks wrote only the exact command to the clipboard; fallback selects it; closed decisions offer none; nothing sent`, problems);
|
||
}
|
||
|
||
// 5. Inbox rules from the data model: open human-routed first by blocking then oldest; closed ones only in Recent;
|
||
// cross-role ones grouped by arbiter; the supersedes chain and DM state show; the approval context the broker
|
||
// recorded at decision.raise shows on cards and options, never inferred from the recommendation.
|
||
{
|
||
const problems = [];
|
||
await load(); await go('#/inbox');
|
||
const order = await b.evaluate(`[...document.querySelectorAll('.s1-dec-open')].map(a => decodeURIComponent(a.getAttribute('href').split('/').pop()))`);
|
||
if (JSON.stringify(order) !== JSON.stringify([D.launch, D.prd])) problems.push('For you order ' + JSON.stringify(order));
|
||
if (await b.evaluate(`!document.querySelector('.s1-dec.is-attn .badge-attn') || !/DM sent/.test(document.querySelector('.s1-dec.is-attn').innerText)`)) problems.push('blocking card lacks badge or DM state');
|
||
const nav = await b.evaluate(`document.querySelector('.s1-section[aria-current=page]').innerText`);
|
||
if (!/Inbox\s*2/.test(nav) || !/1 blocking/.test(nav)) problems.push('section count: ' + nav);
|
||
await go('#/inbox?view=roles');
|
||
const roles = await b.evaluate(`[...document.querySelectorAll('#main h2')].map(h => h.textContent.replace(/\\s+/g, ' ').trim())`);
|
||
if (JSON.stringify(roles) !== JSON.stringify(['For pm 1', 'For cto 1'])) problems.push('roles groups ' + JSON.stringify(roles));
|
||
await go('#/inbox?view=recent');
|
||
const recent = await text();
|
||
if (!/chose “yes”/.test(recent) || !/withdrawn/.test(recent)) problems.push('Recent lacks the resolved or the withdrawn decision');
|
||
await go(`#/inbox/${D.prd}`);
|
||
const det = await text();
|
||
if (!/Earlier versions/.test(det) || !det.includes(D.prdOld.slice(0, 8)) || !/withdrawn/.test(det)) problems.push('supersedes chain missing');
|
||
if (!/not needed \(not blocking\)/.test(det)) problems.push('DM line for non-blocking decision');
|
||
await go('#/inbox');
|
||
const auth = await b.evaluate(`[...document.querySelectorAll('.s1-dec .s1-auth')].map(p => p.textContent)`);
|
||
if (JSON.stringify(auth) !== JSON.stringify(['Choosing “yes” approves role.launch on coder.', 'Choosing “a” approves prd.approve on REQ-TASK-2.'])) problems.push('card approval context ' + JSON.stringify(auth));
|
||
// #48's conflict: the recommendation is "queue", the recorded approvalChoice is "vikunja".
|
||
await go(`#/inbox/${D.conflict}`);
|
||
const opts = await b.evaluate(`JSON.stringify([...document.querySelectorAll('.s1-opt')].map(o => [o.querySelector('.s1-key').textContent, [...o.querySelectorAll('.badge')].map(x => x.textContent)]))`).then(JSON.parse);
|
||
if (JSON.stringify(opts) !== JSON.stringify([['queue', ['recommended']], ['vikunja', ['approves task.close']]])) problems.push('approval badge does not follow the recorded approvalChoice: ' + JSON.stringify(opts));
|
||
if (!/Choosing “vikunja” approves task\.close on vikunja:3\/48\./.test(await text())) problems.push('decision page lacks the approval line');
|
||
for (const v of ['roles', 'recent']) { await go(`#/inbox?view=${v}`); if (!/not in the Q1 module/.test(await text())) problems.push(`${v} view does not say its source is outside the Q1 module`); }
|
||
await go(`#/inbox/${D.launch}`);
|
||
if (!/Seen by jason via cli/.test(await text())) problems.push('seen history missing');
|
||
check('inbox: blocking then oldest, closed only in Recent, roles grouped by arbiter, DM state, seen history, supersedes chain, recorded approval context on cards and options, non-Q1 views labelled', problems);
|
||
}
|
||
|
||
// 6. Tasks: bucket groups from the latest snapshot; markers for external change, never-self, conflict and missing; freshness line.
|
||
{
|
||
const problems = [];
|
||
await load(); await go('#/tasks');
|
||
const groups = await b.evaluate(`JSON.stringify(window.S1App.reads().tasks().groups.map(g => [g.bucket, g.rows.map(r => r.index)]))`).then(JSON.parse);
|
||
const want = [['todo', [46]], ['in-progress', [42, 43]], ['in-review', [41]], ['blocked', [44]], ['done', [48, 45]]];
|
||
if (JSON.stringify(groups) !== JSON.stringify(want)) problems.push('groups ' + JSON.stringify(groups));
|
||
const t = await text();
|
||
for (const need of ['not created by the stack', 'changed in Vikunja: bucket', 'missing: not-found', 'not built yet']) if (!t.includes(need)) problems.push('missing marker text: ' + need);
|
||
if (await b.evaluate(`document.querySelector('details.s1-done').open`)) problems.push('done group open by default');
|
||
await b.evaluate(`document.querySelector('details.s1-done').open = true`);
|
||
const conflict = await b.evaluate(`document.querySelector('a.s1-marker')?.getAttribute('href')`);
|
||
if (conflict !== `#/inbox/${D.conflict}`) problems.push('conflict marker link ' + conflict);
|
||
// #44 has only self snapshots: no marker. #42's latest row is a stale board read: not external (the v3
|
||
// read_at rule), and not shown either, so #42 stays in in-progress (checked by the groups above).
|
||
for (const n of [44, 42]) { const x = await b.evaluate(`JSON.stringify(window.S1App.reads().task('vikunja:3/${n}').external)`); if (x !== 'null') problems.push(`#${n} marked external: ${x}`); }
|
||
await go(`#/tasks/${ref(42)}`);
|
||
if (!/stale read, not shown/.test(await b.evaluate(`document.querySelector('.s1-snaps').textContent`))) problems.push('#42 snapshot list does not label the stale read');
|
||
await go('#/tasks');
|
||
await go(`#/tasks/${ref(48)}`);
|
||
const t48 = await text();
|
||
if (!/queue says in-review, Vikunja says done/.test(t48)) problems.push('#48 detail lacks conflict');
|
||
if (await b.evaluate(`document.querySelector('a[target=_blank]')?.getAttribute('href')`) !== 'http://127.0.0.1:3456/tasks/48') problems.push('Open in Vikunja link');
|
||
check('tasks: groups by bucket from the latest snapshot, markers (changed in Vikunja, not created by the stack, conflict, missing), done collapsed, freshness line, Vikunja link', problems);
|
||
}
|
||
|
||
// 7. Agents: limits, launch state, holders, advisory T3 claims, released role, credential notices without values.
|
||
{
|
||
const problems = [];
|
||
await load(); await go('#/agents');
|
||
const t = await text();
|
||
for (const need of ['Opus 2 of 4, Sonnet 1 of 4', 'launching allowed', 'advisory', 'expires 2026-10-13', 'pm may launch coder and reviewer', 'not in the Q1 module']) if (!t.includes(need)) problems.push('missing: ' + need);
|
||
// The module returns current claims only, so the released reviewer shows as not held.
|
||
const rev = await b.evaluate(`[...document.querySelectorAll('.s1-agents tbody tr')].find(r => r.querySelector('th').firstChild.textContent === 'reviewer')?.innerText || ''`);
|
||
if (!/not held/.test(rev)) problems.push('released reviewer: ' + rev);
|
||
const advisory = await b.evaluate(`[...document.querySelectorAll('.s1-agents tbody tr')].filter(r => r.querySelector('.badge-muted')?.textContent === 'advisory').map(r => r.querySelector('th').firstChild.textContent)`);
|
||
if (JSON.stringify(advisory) !== '["cto"]') problems.push('advisory on ' + JSON.stringify(advisory));
|
||
if (/\.token|secrets\/|Bearer |-----BEGIN|[A-Za-z0-9]{32,}/.test(t)) problems.push('credential-shaped text on the Agents page');
|
||
check('agents: live counts against limits, launch state, holders, advisory only for the T3 claim, released role not held, credential dates without values or paths, non-Q1 sources labelled', problems);
|
||
}
|
||
|
||
// 8. Trail: oldest first, filters partition the rows, the decision trail includes earlier versions, the Q3 request join labelled.
|
||
{
|
||
const problems = [];
|
||
await load(); await go(`#/trail/task/${ref(41)}`);
|
||
const times = await b.evaluate(`[...document.querySelectorAll('.s1-trail time')].map(t => t.getAttribute('datetime'))`);
|
||
if (times.join() !== [...times].sort().join()) problems.push('not oldest first');
|
||
const counts = await b.evaluate(`[...document.querySelectorAll('#main .chips .chip')].map(c => +c.querySelector('.count').textContent)`);
|
||
if (counts[0] !== counts.slice(1).reduce((a, c) => a + c, 0) + await b.evaluate(`window.S1App.reads().trail('task','vikunja:3/41').filter(r => r.group === 'other').length`)) problems.push('filter counts do not add up: ' + counts);
|
||
const kinds = await b.evaluate(`[...document.querySelectorAll('.s1-tkind')].map(k => k.textContent)`);
|
||
for (const k of ['human.input', 'task.created', 'decision.raised', 'decision.resolved', 'session.launched', 'review.verdict']) if (!kinds.includes(k)) problems.push('task trail lacks ' + k);
|
||
if (!/the request task\.created names/.test(await text())) problems.push('Q3 request join not labelled');
|
||
// The request has no task subject; task.created names it (schema v3a onward). Only that request joins.
|
||
const t41 = await text();
|
||
if (!t41.includes('Build the bus store next.')) problems.push('#41 trail lacks the request its task.created names');
|
||
if (t41.includes('Write the identity runbook first.')) problems.push('#41 trail shows another task\'s request');
|
||
await go(`#/trail/task/${ref(43)}`);
|
||
if (!(await text()).includes('Then the read verbs')) problems.push('#43 trail lacks its shared request');
|
||
await go(`#/trail/task/${ref(41)}?kind=launch`);
|
||
const launchKinds = await b.evaluate(`[...new Set([...document.querySelectorAll('.s1-tkind')].map(k => k.textContent))]`);
|
||
if (launchKinds.some(k => !k.startsWith('session.') && !k.startsWith('launch.') && !k.startsWith('claim.'))) problems.push('launch filter shows ' + launchKinds);
|
||
await go(`#/trail/decision/${D.prd}`);
|
||
if (!/decision\.withdrawn/.test(await text())) problems.push('decision trail lacks the withdrawn earlier version');
|
||
// The decision trail merges one read per version, so it sorts again; check that order too.
|
||
const dTimes = await b.evaluate(`[...document.querySelectorAll('.s1-trail time')].map(t => t.getAttribute('datetime'))`);
|
||
if (dTimes.length < 2 || dTimes.join() !== [...dTimes].sort().join()) problems.push('decision trail not oldest first');
|
||
check('trail: oldest first, filters partition rows, task trail spans request to verdict, decision trail includes earlier versions, Q3 request join labelled', problems);
|
||
}
|
||
|
||
// 9. States: loading, empty, failed read (keeps data, warns, recovers), broker refusal (shows no data).
|
||
{
|
||
const problems = [];
|
||
await load('?state=loading'); if (!/Reading from the broker/.test(await text())) problems.push('loading');
|
||
await load('?state=empty');
|
||
const empties = { '#/inbox': /Nothing is waiting on you/, '#/inbox?view=roles': /No open decisions between roles/, '#/inbox?view=recent': /No decisions closed yet/, '#/tasks': /No tasks in this business yet/, '#/agents': /not held/ };
|
||
for (const [r, re] of Object.entries(empties)) { await go(r); if (!re.test(await text())) problems.push('empty ' + r); }
|
||
await load('?state=error'); await go('#/tasks');
|
||
const err = await text();
|
||
if (!/last read failed/.test(err) || !/Nothing was changed/.test(err) || !/#41 Bus store/.test(err)) problems.push('failed read does not keep data with a warning');
|
||
await shot('state-error-tasks-1440');
|
||
await b.evaluate(`document.querySelector('[data-retry]').click()`); await settle(40);
|
||
if (/last read failed/.test(await text())) problems.push('Read again did not recover');
|
||
await load('?state=refused'); await go('#/inbox');
|
||
const ref_ = await text();
|
||
if (!/refused the read/.test(ref_) || /Launch a second coder/.test(ref_) || await b.evaluate(`document.querySelectorAll('#main table, #main .s1-dec').length`)) problems.push('refusal shows data or lacks reason');
|
||
await shot('state-refused-1440');
|
||
check('states: loading, empty for every view, failed read keeps data and recovers, broker refusal shows no data', problems);
|
||
}
|
||
|
||
// 10. Keyboard: skip link first, Enter focuses main, navigation moves focus to the new h1, focus rings on controls.
|
||
{
|
||
const problems = [];
|
||
await load('#/inbox');
|
||
await b.key('Tab');
|
||
if (!/skip/.test(await b.evaluate(`document.activeElement.className`))) problems.push('first Tab is not the skip link');
|
||
await b.key('Enter'); await settle(30);
|
||
if (await b.evaluate(`document.activeElement.id`) !== 'main') problems.push('skip link did not focus main');
|
||
// The route must stay in the URL, so a reload or bookmark after skipping still opens this view.
|
||
if (await b.evaluate(`location.hash`) !== '#/inbox') problems.push('skip link replaced the route in the URL: ' + await b.evaluate(`location.hash`));
|
||
await b.key('Tab');
|
||
if (!await b.evaluate(`document.getElementById('main').contains(document.activeElement)`)) problems.push('Tab after skip did not enter main');
|
||
await b.evaluate(`document.querySelector('.s1-dec-open').focus()`); await b.key('Enter'); await settle(60);
|
||
const after = await b.evaluate(`document.activeElement.tagName + ' ' + location.hash`);
|
||
if (!after.startsWith('H1 #/inbox/')) problems.push('Enter on a decision did not move focus to its h1: ' + after);
|
||
await b.key('Tab'); await b.key('Tab');
|
||
const ring = await b.evaluate(`(() => { const el = document.activeElement; const cs = getComputedStyle(el); return el.matches(':focus-visible') ? cs.outlineStyle + ' ' + cs.outlineWidth : 'no focus-visible on ' + el.tagName; })()`);
|
||
if (!/solid|auto/.test(ring) || parseFloat(ring.split(' ')[1]) < 2) problems.push('focus ring: ' + ring);
|
||
check('keyboard: skip link keeps the route, main focus, focus moves to the new page heading, visible focus rings', problems);
|
||
}
|
||
|
||
// 15. The fixture is valid schema v3b data, and the schema's own views agree with what the page shows.
|
||
// Every row the page reads goes into the real schema, in time order, so its CHECKs and triggers judge
|
||
// the fixture. Then urgent_inbox, task_external_changes, task_current and tasks_open are compared with
|
||
// reads(). Current task state comes from task_current (lead decisions 59 and 60), never the raw latest
|
||
// row; the page must mark as stale exactly the rows task_current skipped.
|
||
{
|
||
const problems = [];
|
||
const schemaPath = join(root, '..', '..', '..', 'darkwing', 'work', 'slice1-proto', 'schema-v3b.sql');
|
||
const schema = await readFile(schemaPath, 'utf8');
|
||
const sha = createHash('sha256').update(schema).digest('hex');
|
||
if (!sha.startsWith('179ffe35')) problems.push('schema-v3b.sql changed: ' + sha.slice(0, 8));
|
||
await load();
|
||
const S = JSON.parse(await b.evaluate(`JSON.stringify(window.S1)`));
|
||
const db = new DatabaseSync(':memory:');
|
||
db.exec(schema);
|
||
const order = ['decisions', 'messages', 'deliveries', 'decision_events', 'role_claims', 'events', 'task_snapshots'];
|
||
const cols = Object.fromEntries(order.map(t => [t, db.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name).filter(c => c !== 'seq')]));
|
||
const rows = order.flatMap((t, ti) => S[t].map(r => ({ t, ti, r }))).sort((a, b) => a.r.at.localeCompare(b.r.at) || a.ti - b.ti);
|
||
for (const { t, r } of rows) {
|
||
const extra = Object.keys(r).filter(k => k !== 'seq' && k !== 'proposedJoin' && !cols[t].includes(k));
|
||
if (extra.length) problems.push(`${t} row has columns the schema lacks: ${extra}`);
|
||
try { db.prepare(`INSERT INTO ${t} (${cols[t].join(',')}) VALUES (${cols[t].map(() => '?').join(',')})`).run(...cols[t].map(c => r[c] ?? null)); }
|
||
catch (e) { problems.push(`${t} ${r.id || r.task_ref || r.role || r.decision || ''} at ${r.at}: ${e.message}`); }
|
||
}
|
||
// Inserting in time order assigns seq; the fixture's own seq must agree with it.
|
||
for (const t of order) {
|
||
const key = { decisions: 'id', messages: 'id', events: 'id' }[t];
|
||
const db_ = db.prepare(`SELECT ${key || 'at'} AS k FROM ${t} ORDER BY seq`).all().map(x => x.k);
|
||
const fx = [...S[t]].sort((a, b) => a.seq - b.seq).map(x => key ? x[key] : x.at);
|
||
if (db_.join() !== fx.join()) problems.push(`${t}: fixture seq order differs from time order`);
|
||
}
|
||
const R = JSON.parse(await b.evaluate(`JSON.stringify((() => { const r = window.S1App.reads(); const ts = r.tasks().groups.flatMap(g => g.rows); return {
|
||
blocking: r.inbox().human.filter(d => d.blocking && !d.closed).map(d => d.id).sort(),
|
||
external: ts.filter(t => t.external).map(t => t.ref).sort(),
|
||
missing: r.tasks().missing.map(t => t.ref),
|
||
open: ts.filter(t => !t.done).map(t => t.ref + ' ' + t.bucket).sort(),
|
||
current: [...ts, ...r.tasks().missing].map(t => { const d = r.task(t.ref); return { ref: t.ref, bucket: d.missing ? 'gone' : d.bucket, stale: d.snapshots.filter(x => x.stale).map(x => x.at) }; }) }; })())`));
|
||
const urgent = db.prepare('SELECT id FROM urgent_inbox ORDER BY id').all().map(x => x.id);
|
||
if (urgent.join() !== R.blocking.join()) problems.push(`urgent_inbox ${urgent} vs page ${R.blocking}`);
|
||
// The view also lists a tombstone (its latest row is a poll); the page shows that task as missing instead.
|
||
const ext = db.prepare('SELECT task_ref FROM task_external_changes ORDER BY task_ref').all().map(x => x.task_ref).filter(r => !R.missing.includes(r));
|
||
if (ext.join() !== R.external.join()) problems.push(`task_external_changes ${ext} vs page ${R.external}`);
|
||
const open = db.prepare('SELECT task_ref, bucket FROM tasks_open').all().map(x => `${x.task_ref} ${S.lookups.buckets[x.bucket]}`).sort();
|
||
if (open.join() !== R.open.join()) problems.push(`tasks_open ${JSON.stringify(open)} vs page ${JSON.stringify(R.open)}`);
|
||
const page = Object.fromEntries(R.current.map(t => [t.ref, t]));
|
||
const cur = db.prepare('SELECT task_ref, seq, fields FROM task_current ORDER BY task_ref').all();
|
||
if (cur.map(c => c.task_ref).join() !== Object.keys(page).sort().join()) problems.push(`task_current refs ${cur.map(c => c.task_ref)} vs page ${Object.keys(page).sort()}`);
|
||
for (const c of cur) {
|
||
const f = JSON.parse(c.fields), p = page[c.task_ref];
|
||
if (!p) continue;
|
||
const bucket = f.gone ? 'gone' : S.lookups.buckets[f.bucket];
|
||
if (bucket !== p.bucket) problems.push(`${c.task_ref}: task_current says ${bucket}, page says ${p.bucket}`);
|
||
const skipped = db.prepare('SELECT at FROM task_snapshots WHERE task_ref = ? AND seq > ? ORDER BY seq').all(c.task_ref, c.seq).map(x => x.at);
|
||
if (skipped.join() !== p.stale.join()) problems.push(`${c.task_ref}: task_current skipped ${skipped}, page marks stale ${p.stale}`);
|
||
}
|
||
const marked = R.current.filter(t => t.stale.length).map(t => t.ref);
|
||
if (marked.join() !== 'vikunja:3/42') problems.push(`stale reads marked on ${marked}, expected only vikunja:3/42`);
|
||
db.close();
|
||
check('fixture: every row inserts into schema v3b (179ffe35) in time order; urgent_inbox, task_external_changes, task_current and tasks_open match the page; stale marks are exactly the rows task_current skips (#42 only)', problems, `${rows.length} rows`);
|
||
}
|
||
|
||
// 16. The Q1 stand-in returns what the real broker returns. q1.js ports the four reader verbs; this
|
||
// runs the reader from packages/bus as committed in BUS_COMMIT (S2c, row 44) over the same rows in a
|
||
// throwaway data root, and compares inbox, tasks, agents and the trail of every task, decision and
|
||
// message. The package is extracted from the commit with git archive, so working-tree edits don't count.
|
||
// Interim: Rocko's pinned example fixture replaces it (lead decision 56, Q1). The repository is only read.
|
||
// DEWEY_S1_REPO points at the repository when the check runs from a copy outside it (mutation scratch).
|
||
const BUS_COMMIT = 'd27042fa';
|
||
{
|
||
const repo = process.env.DEWEY_S1_REPO || join(root, '..', '..', '..', '..');
|
||
let tree = null;
|
||
try { tree = execFileSync('git', ['-C', repo, 'archive', '--format=tar', BUS_COMMIT, 'packages/bus/package.json', 'packages/bus/schema.sql', 'packages/bus/src'], { maxBuffer: 1 << 26 }); } catch { /* recorded below */ }
|
||
if (!tree) record('the Q1 reader at the S2 commit', 'not verified', `git archive ${BUS_COMMIT} failed in ${repo}`);
|
||
else {
|
||
const problems = [];
|
||
const dir = await mkdtemp(join(tmpdir(), 'dewey-s1-bus-')), code = join(dir, 'code'), data = join(dir, 'data');
|
||
let store, count = 0, pins = '', subjects = [];
|
||
try {
|
||
await mkdir(code, { mode: 0o700 }); await mkdir(data, { mode: 0o700 });
|
||
execFileSync('tar', ['-x', '-C', code], { input: tree });
|
||
const bus = join(code, 'packages', 'bus');
|
||
const pin = async f => createHash('sha256').update(await readFile(join(bus, f))).digest('hex').slice(0, 8);
|
||
pins = `commit ${BUS_COMMIT}: broker.mjs ${await pin('src/broker.mjs')}, store.mjs ${await pin('src/store.mjs')}, schema.sql ${await pin('schema.sql')}`;
|
||
await load();
|
||
const S = JSON.parse(await b.evaluate(`JSON.stringify(window.S1)`));
|
||
subjects = [...new Set([...S.task_snapshots.map(s => s.task_ref), ...S.decisions.map(d => d.id), ...S.messages.map(m => m.id)])];
|
||
const { Store } = await import(pathToFileURL(join(bus, 'src', 'store.mjs')));
|
||
const { Broker } = await import(pathToFileURL(join(bus, 'src', 'broker.mjs')));
|
||
store = new Store(data);
|
||
const order = ['decisions', 'messages', 'deliveries', 'decision_events', 'role_claims', 'events', 'task_snapshots'];
|
||
const cols = Object.fromEntries(order.map(t => [t, store.all(`PRAGMA table_info(${t})`).map(c => c.name).filter(c => c !== 'seq')]));
|
||
const rows = order.flatMap((t, ti) => S[t].map(r => ({ t, ti, r }))).sort((a, b) => a.r.at.localeCompare(b.r.at) || a.ti - b.ti);
|
||
store.transaction(() => { for (const { t, r } of rows) store.run(`INSERT INTO ${t} (${cols[t].join(',')}) VALUES (${cols[t].map(() => '?').join(',')})`, ...cols[t].map(c => r[c] ?? null)); });
|
||
const role = { authority: { withinRole: [], crossRole: [] } }, B = S.lookups.business;
|
||
const broker = new Broker({ store, businesses: { [B]: { id: B, human: S.lookups.human, roles: { pm: role, cto: role, coder: role, reviewer: role }, arbiters: { technical: 'cto', delivery: 'pm' } } } });
|
||
const cap = broker.bindReader({ business: B });
|
||
const want = { inbox: broker.request(cap, { verb: 'inbox' }), tasks: broker.request(cap, { verb: 'tasks' }), agents: broker.request(cap, { verb: 'agents' }) };
|
||
for (const x of subjects) want[`trail ${x}`] = broker.request(cap, { verb: 'trail', args: { subject: x } });
|
||
const got = JSON.parse(await b.evaluate(`JSON.stringify((() => { const q = S1Q1.views(window.S1), out = { inbox: q.inbox(), tasks: q.tasks(), agents: q.agents() }; for (const x of ${JSON.stringify(subjects)}) out['trail ' + x] = q.trail(x); return out; })())`));
|
||
// Key order and absent-versus-null columns don't count; values and row order do.
|
||
const canon = v => JSON.stringify(v, (k, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().filter(n => x[n] !== null && x[n] !== undefined).map(n => [n, x[n]])) : x);
|
||
for (const [name, a] of Object.entries(want)) {
|
||
const g = got[name] || []; count += a.length;
|
||
if (canon(a) === canon(g)) continue;
|
||
const i = Math.max(0, a.findIndex((x, j) => canon(x) !== canon(g[j])));
|
||
problems.push(`${name}: candidate ${a.length} rows, stand-in ${g.length}; first difference at ${i}: ${canon(a[i]).slice(0, 200)} vs ${canon(g[i]).slice(0, 200)}`);
|
||
}
|
||
if (!want.inbox.length || !want.tasks.length || !want.agents.length) problems.push('a candidate read came back empty');
|
||
} catch (e) { problems.push(`candidate reader failed: ${e.code || ''} ${e.message}`); }
|
||
finally { store?.close(); await rm(dir, { recursive: true, force: true }); }
|
||
check(`Q1 stand-in: q1.js returns what the packages/bus reader returns (${pins}) for inbox, tasks, agents and ${subjects.length} trails`, problems, `${count} rows compared`);
|
||
}
|
||
}
|
||
|
||
// 11. Long content and 200% text at 320 px stay within the viewport.
|
||
{
|
||
const problems = [];
|
||
await b.viewport(320, 700); await load();
|
||
await b.evaluate(`(() => { const d = window.S1.decisions.find(x => x.id === ${JSON.stringify(D.launch)}); d.question = 'Launch a second coder instance so the poller can move '.repeat(6) + 'https://example.invalid/a/very/long/unbroken/path/segment/that/must/wrap/0123456789abcdef0123456789abcdef'; window.S1App.render(); })()`);
|
||
for (const r of ['#/inbox', `#/inbox/${D.launch}`, `#/trail/task/${ref(44)}`]) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow with long text`); }
|
||
await b.evaluate(`document.documentElement.style.fontSize = '30px'`); await settle(30);
|
||
for (const r of ['#/inbox', `#/inbox/${D.launch}`, '#/tasks', '#/agents', `#/trail/task/${ref(41)}`]) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow at 200% text`); }
|
||
await go(`#/inbox/${D.launch}`); await shot('text-200-decision-320');
|
||
check('long unbroken content and 200% text at 320 px stay within the viewport (tables scroll inside their region)', problems);
|
||
}
|
||
|
||
record('forced-colors rendering', 'not verified', 'slice1.css adds forced-colors rules; headless Chromium here does not emulate them');
|
||
record('screen reader behaviour', 'not verified', 'focus moves to the page heading and #announce is updated; no screen reader was run');
|
||
|
||
await b.close();
|
||
report.summary = { total: report.checks.length, failed: failures, notVerified: report.checks.filter(c => c.status === 'not verified').length };
|
||
await writeFile(join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||
console.log(`\n${report.summary.total} checks, ${failures} failed, ${report.summary.notVerified} not verified. Report: evidence/slice1/report.json`);
|
||
process.exit(failures ? 1 : 0);
|