master
Drew Owens Publish 0.80.0 eb7d34d
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366
// adom-gantt collaboration and performance suite (0.75.1).
//
// Several people on one live plan at once, the way a team meeting uses it: real Chrome tabs,
// each signed in as a different person (a fake Carbon stands in for Adom sign-in, and the
// server runs with --no-loopback so every tab is a real session), all on one server. It
// measures what each laptop pays (main-thread time, long tasks, forced layouts, memory) and
// checks that every tab ends up showing exactly what the server holds, with no edit lost.
//
// Written after a meeting where six people opening the plan froze each other's laptops: every
// save anyone made cost every other open tab 15 to 25 seconds of one CPU core. The budgets
// below are what keeps that from coming back.
//
//   node test/collab_test.mjs                      all scenarios, 6 people, generated plan
//   node test/collab_test.mjs --users 12           a bigger meeting
//   node test/collab_test.mjs --throttle 4         every tab on a CPU four times slower
//   node test/collab_test.mjs --only meeting,soak  some scenarios (join merge meeting presence
//                                                  idle soak conflict reconnect docs fuzz sections
//                                                  subtasks milestones requests)
//   node test/collab_test.mjs --old-page p.html    the 0.77 page the subtasks scenario opens beside
//                                                  this build (default: gantt.html of commit
//                                                  ffdacb4, 0.77.1, from this checkout's git)
//   node test/collab_test.mjs --plan my.json       a real plan (and its -state.json) instead
//   node test/collab_test.mjs --seconds 120        a longer meeting
//   node test/collab_test.mjs --app ../old-build   measure another build (checks that need
//                                                  this build's hooks are skipped)
//   node test/collab_test.mjs --report out.json    every number, for comparing runs
//   node test/collab_test.mjs --only fuzz --seed 9 another random sequence for the fuzz
//
// Budgets scale with --throttle. COLLAB_BUDGET_<NAME>=<n> overrides one (see BUDGETS).
import puppeteer from './node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js';
import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import http from 'node:http';
import net from 'node:net';

const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const arg = (k, d) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : d; };
const flag = k => argv.includes('--' + k);
const USERS = Math.max(2, Math.min(12, +arg('users', 6)));
const SECONDS = +arg('seconds', 45);
const THROTTLE = Math.max(1, +arg('throttle', 1));
const ONLY = arg('only', '') ? arg('only').split(',').map(s => s.trim()) : null;
const APP = resolve(arg('app', join(HERE, '..')));
const PLAN = arg('plan', '');
const REPORT = arg('report', '');
const SEED = +arg('seed', 7000);   // the fuzz's random sequence; each person adds their index
const OLD_PAGE = arg('old-page', '');
const KEEP = flag('keep');
const want = s => !ONLY || ONLY.includes(s);
const sleep = ms => new Promise(r => setTimeout(r, ms));

// ── Budgets: what a tab may spend. Measured on a 2026 laptop at --throttle 1 with six people
// on a 490-row plan, then given room; the build that froze the meeting broke every one of them
// by ten times or more. Each scales with --throttle, and COLLAB_BUDGET_<NAME> overrides it.
// All the tabs share this one machine, and a laptop CPU mixes fast and slow cores, so the
// same work measures up to three times apart from run to run: the budgets are set to catch a
// real regression (the freeze was 25 to 100 times over), not that noise.
const B = (name, v, scales = true) => { const e = process.env['COLLAB_BUDGET_' + name]; return e !== undefined ? +e : scales ? v * THROTTLE : v; };
const BUDGETS = {
  READY_MS: B('READY_MS', 8000),            // a tab, from navigation to ready, with the others open
  STARTUP_LAYOUTS: B('STARTUP_LAYOUTS', 4000, false), // forced layouts while it starts (the freeze had ~70,000)
  MERGE_MS: B('MERGE_MS', 1000),            // main-thread time one other person's save costs a tab
  MERGE_LAYOUTS: B('MERGE_LAYOUTS', 60, false), // forced layouts per merge (the freeze had ~69,000)
  VISIBLE_MS: B('VISIBLE_MS', 3000),        // from a save to every other tab showing it
  MEETING_BUSY: B('MEETING_BUSY', 35, false) * Math.min(THROTTLE, 2), // % of a core, per tab, during the meeting
  LONG_TASK_MS: B('LONG_TASK_MS', 1200),    // the longest single freeze a person may feel
  PRESENCE_BUSY: B('PRESENCE_BUSY', 12, false) * Math.min(THROTTLE, 2), // % a watching tab pays while everyone else clicks around
  CLICK_MS: B('CLICK_MS', 450),             // main-thread time of a click that opens a row's editor
  CLICK_WALL_MS: B('CLICK_WALL_MS', 1200),  // from that click to the editor on screen
  IDLE_BUSY: B('IDLE_BUSY', 2, false),      // % of a core with nothing happening
  CRIT_IDLE_BUSY: B('CRIT_IDLE_BUSY', 5, false), // the same with the critical path on (it was 100%)
  SOAK_HEAP_MB: B('SOAK_HEAP_MB', 8, false),     // growth over the soak, after a forced GC
  SOAK_NODES: B('SOAK_NODES', 2000, false),
  SOAK_LISTENERS: B('SOAK_LISTENERS', 50, false),
  SERVER_P95_MS: B('SERVER_P95_MS', 250, false), // /health latency while the meeting runs
  SERVER_RSS_MB: B('SERVER_RSS_MB', 400, false),
};

// ── Results ──
let pass = 0, fail = 0, skipped = 0, scenario = '';
const failures = [], report = { users: USERS, seconds: SECONDS, throttle: THROTTLE, app: APP, plan: PLAN || 'generated', budgets: BUDGETS, scenarios: {} };
function check(name, ok, detail = '') {
  const tag = scenario ? `[${scenario}] ` : '';
  if (ok) { pass++; console.log(`  ok   ${tag}${name}${detail ? '  (' + detail + ')' : ''}`); }
  else { fail++; failures.push(`${tag}${name} :: ${detail}`); console.log(`  FAIL ${tag}${name}\n       ${detail}`); }
}
function skip(name, why) { skipped++; console.log(`  skip [${scenario}] ${name}: ${why}`); }
const rec = (k, v) => { (report.scenarios[scenario] = report.scenarios[scenario] || {})[k] = v; };
const pct = (num, secs) => +(num / secs * 100).toFixed(1);
const p95 = a => { if (!a.length) return 0; const s = a.slice().sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(s.length * 0.95))]; };
const fmtMs = v => Math.round(v) + ' ms';

// ── A plan shaped like a real launch plan: 50 sections, ~420 rows in the data file, 70 more
// created in the app (the sidecar's `added`, which startup inserts one by one), 5 deleted,
// ~190 dependencies, owners, statuses, notes and tags. Seeded, so every run is the same plan.
function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
const PEOPLE = ['Drew Owens', 'Kale Mer', 'Noah Ash', 'Colby Knox', 'Adonis Ray', 'Caleb Stone', 'Ana Lopez', 'Bo Chen', 'Cy Diaz', 'Di Evans', 'Ed Fox', 'Flo Gray'];
const STATUSES = ['not-started', 'not-started', 'not-started', 'in-progress', 'in-progress', 'complete', 'blocked', 'cancelled'];
const TAGS = ['Launch', 'Fab', 'Order', 'Assembly', 'Post-process', 'Ops'];
function makePlan() {
  const R = rng(20261029), pick = a => a[Math.floor(R() * a.length)];
  const iso = d => d.toISOString().slice(0, 10), base = Date.UTC(2026, 8, 21);
  const day = n => iso(new Date(base + n * 86400000));
  const phases = [], items = [], added = [];
  let uidN = 0; const uid = () => String(++uidN).padStart(5, '0');
  for (let p = 0; p < 50; p++) phases.push({ name: `Area ${p + 1}`, lead: pick(PEOPLE).split(' ')[0], uid: 'ph' + (p + 1), ...(R() < 0.6 ? { tags: [pick(TAGS)] } : {}) });
  let t = 0;
  const row = (p, inSidecar) => {
    const ms = R() < 0.1, s = Math.floor(R() * 120), len = 2 + Math.floor(R() * 35);
    const o = { type: ms ? 'milestone' : 'task', name: `${ms ? 'Gate' : 'Work'} ${p + 1}.${t + 1}: ${pick(['wire', 'order', 'test', 'build', 'review', 'ship', 'tune', 'clean'])} the ${pick(['panel', 'board', 'cell', 'line', 'oven', 'laser', 'kit', 'doc'])}`,
      phase: p, uid: uid(), owner: R() < 0.25 ? '' : pick(PEOPLE), status: pick(STATUSES) };
    if (ms) o.date = day(s); else { o.id = 't' + (++t); o.start = day(s); o.end = day(s + len); }
    if (R() < 0.5) o.description = `Why this matters for area ${p + 1}, in a sentence or two.`;
    if (R() < 0.2) o.tags = [pick(TAGS)];
    (inSidecar ? added : items).push(o);
    return o;
  };
  for (let p = 0; p < 50; p++) { const n = 7 + Math.floor(R() * 3); for (let i = 0; i < n; i++) row(p, false); }
  for (let i = 0; i < 70; i++) row(Math.floor(R() * 50), true);
  // Dependencies: chains inside sections, and some across them.
  const tasks = [...items, ...added].filter(r => r.id);
  let deps = 0;
  tasks.forEach((r, i) => { const prev = tasks[i - 1]; if (prev && prev.phase === r.phase && R() < 0.45) { (r.deps = r.deps || []).push(prev.id); deps++; } });
  for (let i = 0; i < 30; i++) { const a = pick(tasks), b = pick(tasks); if (a !== b && a.start < b.start && !(b.deps || []).includes(a.id)) { (b.deps = b.deps || []).push(a.id); deps++; } }
  const deleted = items.slice(10, 15).map(r => r.uid);
  const all = [...items, ...added].filter(r => !deleted.includes(r.uid));
  const order = [];
  for (let p = 0; p < 50; p++) all.filter(r => r.phase === p).forEach(r => order.push(r.uid));
  const data = { project: { title: 'Collaboration Test Plan', appName: 'collab-plan', nextUid: uidN + 1, view: { mode: 'gantt', grain: 'week' } }, phases: phases.map(p => Object.assign({ start: '', end: '' }, p)), items };
  const state = { v: 4, items: [], phases: phases.map((p, i) => Object.assign({ i, start: '', end: '', tags: p.tags || [] }, p)), order, deleted, added, stateRev: 87 };
  return { data, state, deps, rows: all.length };
}

// ── A fake Carbon: one account per person, all in the adom org ──
async function fakeCarbon() {
  const people = {}; PEOPLE.forEach((n, i) => { people['u' + i] = { id: 'id' + i, name: 'user' + i, display_name: n, orgs: ['adom'] }; });
  const intents = new Map(); let next = 'u0', seq = 0;
  const srv = http.createServer((req, res) => {
    const send = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
    const who = String(req.headers.authorization || '').replace(/^Bearer S-/, '');
    if (req.method === 'POST' && req.url === '/auth/intents') { const t = 'T' + (++seq); intents.set(t, next); send(200, { token: t, auth_url: 'http://example.invalid/a' }); return; }
    const m = req.url.match(/^\/auth\/intents\/([^/]+)\/status$/);
    if (m) { const w = intents.get(m[1]); send(200, w ? { state: 'authenticated', session_token: 'S-' + w } : { state: 'pending' }); return; }
    const p = people[who];
    if (req.url === '/user' && p) { send(200, { id: p.id, name: p.name, display_name: p.display_name }); return; }
    if (req.url === '/user/memberships' && p) { send(200, p.orgs.map(o => ({ is_owner: false, is_public: true, organization: { id: 'o-' + o, name: o } }))); return; }
    send(401, { error: 'no' });
  });
  const port = await new Promise(r => srv.listen(0, '127.0.0.1', () => r(srv.address().port)));
  return { url: `http://127.0.0.1:${port}`, setNext: w => { next = w; }, close: () => srv.close() };
}

// ── The plan server: sign-in on, loopback NOT trusted, so every tab is a real person ──
const TMP = mkdtempSync(join(tmpdir(), 'adom-gantt-collab-'));
const PORTS = (process.env.COLLAB_TEST_PORTS || '8920-8939').split('-').map(Number);
async function freePort() {
  for (let p = PORTS[0]; p <= PORTS[1]; p++) {
    const ok = await new Promise(r => { const t = net.createServer(); t.once('error', () => r(false)); t.listen(p, '127.0.0.1', () => t.close(() => r(true))); });
    if (ok) return p;
  }
  throw new Error(`no free port in ${PORTS[0]}..${PORTS[1]} (set COLLAB_TEST_PORTS=<lo>-<hi>)`);
}
const servers = [];
async function startServer(carbon, opts = {}) {
  const dir = mkdtempSync(join(TMP, 'plan-'));
  const file = join(dir, 'plan.json');
  if (PLAN) {
    copyFileSync(PLAN, file);
    const side = PLAN.replace(/\.json$/, '-state.json');
    if (existsSync(side)) copyFileSync(side, file.replace(/\.json$/, '-state.json'));
  } else {
    const g = makePlan();
    writeFileSync(file, JSON.stringify(g.data, null, 1));
    writeFileSync(file.replace(/\.json$/, '-state.json'), JSON.stringify(g.state));
  }
  const port = await freePort();
  const proc = spawn(process.execPath, [join(APP, 'server.js'), '--data', file, '--port', String(port), '--no-loopback'],
    { cwd: APP, env: Object.assign({}, process.env, { ADOM_CARBON_API: carbon.url }), stdio: ['ignore', 'pipe', 'pipe'] });
  let log = ''; proc.stdout.on('data', d => log += d); proc.stderr.on('data', d => log += d);
  const base = `http://127.0.0.1:${port}`;
  for (let i = 0; ; i++) {
    if (proc.exitCode !== null) throw new Error('server exited: ' + log);
    try { const r = await fetch(base + '/health'); if (r.ok && /serving .* on http:\S+:\d+/.test(log) && log.includes(':' + port)) break; } catch {}
    if (i > 150) throw new Error('server did not come up: ' + log);
    await sleep(100);
  }
  const token = readFileSync(file.replace(/\.json$/, '-agent-token'), 'utf8').trim();
  const S = { base, dir, file, proc, token, log: () => log };
  // The first person is the plan's owner, so the owner-only paths (critical path, Analytics) run.
  await fetch(base + '/access', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ owner: 'user0' }) });
  // Requests (0.80.0) are on by default with sign-in. These scenarios use the owner as an
  // everyday field that people race on, so they turn them off; the `requests` scenario keeps them.
  if (!opts.requests) await fetch(base + '/access', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ requests: 'off' }) });
  servers.push(S);
  return S;
}
async function stopServer(S) { const i = servers.indexOf(S); if (i >= 0) servers.splice(i, 1); try { S.proc.kill('SIGTERM'); } catch {} await sleep(100); }
async function signIn(S, carbon, who) {
  carbon.setNext(who);
  const st = await (await fetch(S.base + '/auth/start', { method: 'POST' })).json();
  const r = await fetch(S.base + '/auth/poll?token=' + st.token);
  await r.json();
  const sid = ((r.headers.get('set-cookie') || '').match(/adom_gantt_sid=([^;]+)/) || [])[1];
  if (!sid) throw new Error('sign-in gave no session for ' + who);
  return sid;
}
const asUser = (S, sid, path, opts = {}) => fetch(S.base + path, Object.assign({}, opts, { headers: Object.assign({ Cookie: 'adom_gantt_sid=' + sid }, opts.headers || {}) }));
const serverRev = async S => (await (await fetch(S.base + '/health')).json()).stateRev;
function serverRss(S) { try { const m = readFileSync(`/proc/${S.proc.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+)/); return m ? +m[1] / 1024 : 0; } catch { return 0; } }

// ── Chrome ──
const CHROME_DIR = join(process.env.HOME || '/home/adom', '.cache/puppeteer/chrome');
const CHROME = process.env.CHROME_PATH || (() => {
  const dirs = readdirSync(CHROME_DIR).filter(d => d.startsWith('linux-')).sort((a, b) => a.split('-')[1].localeCompare(b.split('-')[1], undefined, { numeric: true }));
  return join(CHROME_DIR, dirs.at(-1), 'chrome-linux64', 'chrome');
})();
let BROWSER = null;

// One person: their own browser context (cookies, storage, renderer), a CDP session for the
// metrics, the long tasks the page reports, and every request it makes, counted.
async function openPerson(S, carbon, i, { view = 'gantt', w = 1500, h = 900 } = {}) {
  const sid = await signIn(S, carbon, 'u' + i);
  const ctx = await BROWSER.createBrowserContext();
  const page = await ctx.newPage();
  await page.setViewport({ width: w, height: h });
  await page.setCookie({ name: 'adom_gantt_sid', value: sid, url: S.base + '/' });
  await page.evaluateOnNewDocument(v => {
    window.__lt = [];
    try { new PerformanceObserver(l => l.getEntries().forEach(e => window.__lt.push(Math.round(e.duration)))).observe({ type: 'longtask', buffered: true }); } catch (e) {}
    // The lens this person works in, as a preference they had already chosen.
    try { const k = 'adom-gantt:prefs'; const p = JSON.parse(localStorage.getItem(k) || '{}'); p.view = v; localStorage.setItem(k, JSON.stringify(p)); } catch (e) {}
  }, view);
  const cdp = await page.createCDPSession();
  await cdp.send('Performance.enable', { timeDomain: 'timeTicks' });
  if (THROTTLE > 1) await cdp.send('Emulation.setCPUThrottlingRate', { rate: THROTTLE });
  const P = { i, name: PEOPLE[i], sid, ctx, page, cdp, errors: [], req: { save: 0, state: 0, focus: 0, log: 0 }, view };
  page.on('pageerror', e => P.errors.push(e.message));
  page.on('console', m => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) P.errors.push('console: ' + m.text().slice(0, 200)); });
  page.on('request', r => { const u = r.url().replace(/^https?:\/\/[^/]+/, '').split('?')[0], mth = r.method();
    if (mth === 'POST' && (u === '/save' || u === '/ops')) P.req.save++; else if (mth === 'GET' && u === '/state') P.req.state++;
    else if (mth === 'POST' && u === '/live/focus') P.req.focus++; else if (mth === 'POST' && u === '/log') P.req.log++; });
  return P;
}
async function goReady(P, S) {
  const t0 = Date.now();
  await P.page.goto(S.base + '/', { waitUntil: 'domcontentloaded', timeout: 120000 });
  await P.page.waitForSelector('html[data-ready="1"]', { timeout: 120000 });
  P.readyMs = Date.now() - t0;
  return P.readyMs;
}
async function closePerson(P) { try { await P.ctx.close(); } catch {} }
async function metrics(P) { const { metrics } = await P.cdp.send('Performance.getMetrics'); const o = {}; metrics.forEach(m => { o[m.name] = m.value; }); return o; }
const busySince = (a, b) => ({ task: b.TaskDuration - a.TaskDuration, layouts: b.LayoutCount - a.LayoutCount, script: b.ScriptDuration - a.ScriptDuration });
const hasHooks = P => P.page.evaluate(() => !!(window.__collab && window.__collab.rows));
const merges = P => P.page.evaluate(() => window.__collab ? window.__collab.merges() : 0);
async function longTasks(P, clear = true) { return P.page.evaluate(c => { const a = (window.__lt || []).slice(); if (c) window.__lt.length = 0; return a; }, clear); }

// ── What a person does, through the page the way a person would ──
// Open a task's editor by clicking its bar (scrolled into view first), the gesture every
// edit starts with; Escape closes it again.
async function clickBar(P, id) {
  const pt = await P.page.evaluate(i => {
    const b = document.querySelector(`.bar[data-id="${i}"]`); if (!b || !b.offsetParent) return null;
    b.scrollIntoView({ block: 'center', inline: 'center' });
    const r = b.getBoundingClientRect(); return { x: r.left + Math.min(r.width / 2, 20), y: r.top + r.height / 2 };
  }, id);
  if (!pt) return false;
  await sleep(60);
  await P.page.mouse.click(pt.x, pt.y);
  return true;
}
async function editorOpen(P) { return P.page.evaluate(() => { const e = document.getElementById('editor'); return !!e && e.classList.contains('show') && !!document.getElementById('imApply'); }); }
async function setField(P, sel, v) { await P.page.$eval(sel, (e, v) => { e.value = v; e.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); }, v); }
// One edit: open the row, change its status and its end, Apply. Returns what was written.
async function editRow(P, row, n) {
  if (!(await clickBar(P, row.id))) return null;
  for (let k = 0; k < 20 && !(await editorOpen(P)); k++) await sleep(50);
  if (!(await editorOpen(P))) return null;
  const status = ['not-started', 'in-progress', 'blocked', 'complete'][n % 4];
  const end = shiftDay(row.start, 6 + (n % 9));
  await setField(P, '#imStatus', status);
  await setField(P, '#imEnd', end);
  await P.page.click('#imApply');
  await sleep(120);
  return { uid: row.uid, status, end };
}
const shiftDay = (iso, n) => new Date(Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)) + n * 86400000).toISOString().slice(0, 10);
async function lookAtRow(P, id) { if (await clickBar(P, id)) { await sleep(250 + Math.random() * 300); await P.page.keyboard.press('Escape'); await P.page.keyboard.press('Escape'); } }
async function scrollAround(P) { await P.page.mouse.move(900, 500); for (let k = 0; k < 4; k++) { await P.page.mouse.wheel({ deltaY: (Math.random() < 0.5 ? -1 : 1) * (200 + Math.random() * 500) }); await sleep(60); } }
async function toggleLocalView(P, n) {
  // Views that are the reader's own and never reach anyone else: they must cost nothing remote.
  const k = n % 3;
  if (k === 0 && await P.page.$('#currentChip')) { await P.page.click('#currentChip'); await sleep(300); await P.page.click('#currentChip'); }
  else if (k === 1 && await P.page.$('#btnCollapseAll')) { await P.page.click('#btnCollapseAll'); await sleep(300); await P.page.click('#btnCollapseAll'); }
  else if (await P.page.$('#ganttOwner')) { const o = await P.page.$$eval('#ganttOwner option', os => os.map(o => o.value).filter(Boolean)); if (o.length) { await P.page.select('#ganttOwner', o[n % o.length]); await sleep(300); await P.page.select('#ganttOwner', ''); } }
}

// The rows each person edits: their own, so an edit can only be lost by the app, never by
// two people writing the same row (that case has its own scenario).
async function planRows(S, sid) {
  const plan = await (await asUser(S, sid, '/export/json')).json();
  return (plan.items || []).filter(r => r.type !== 'group');
}

// Everyone has caught up: no pull, save or pending save anywhere, and every tab on the
// server's revision. Returns how long that took.
async function quiesce(S, people, timeout = 30000) {
  const t0 = Date.now(); let stable = 0, lastRev = -1;
  while (Date.now() - t0 < timeout) {
    const rev = await serverRev(S);
    const st = await Promise.all(people.map(P => P.page.evaluate(() => window.__collab ? { busy: window.__collab.busy ? window.__collab.busy() : false, rev: window.__collab.rev() } : { busy: false, rev: -1 }).catch(() => ({ busy: true, rev: -2 }))));
    const settled = st.every(s => !s.busy && (s.rev === rev || s.rev === -1));
    stable = settled && rev === lastRev ? stable + 1 : 0; lastRev = rev;
    if (stable >= 3) return Date.now() - t0;
    await sleep(300);
  }
  return -1;
}
// Every tab holds exactly what the server holds, field by field, row by row.
async function convergence(S, people) {
  const srv = new Map((await planRows(S, people[0].sid)).filter(r => r.uid).map(r => [r.uid, r]));
  const full = await people[0].page.evaluate(() => !!(window.__collab && window.__collab.order));
  // From 0.77.0 the tabs report every field, and the order, so all of it is compared.
  const norm = (r, tree) => JSON.stringify([r.name || '', r.owner || '', r.status || '', r.start || '', r.end || '', r.date || '', r.phase, (r.deps || []).slice().sort(), r.description || r.desc || '',
    ...(full ? [r.note || '', (Array.isArray(r.tags) ? r.tags : []).map(t => String(t).toLowerCase()).sort(), typeof r.effort === 'number' ? r.effort : null] : []),
    ...(tree ? [r.parent || ''] : [])]);
  const srvOrder = [...srv.keys()];
  const diffs = [];
  for (const P of people) {
    const rows = await P.page.evaluate(() => window.__collab.rows());
    const mine = new Map(rows.map(r => [r.uid, r]));
    // From 0.79.0 a tab reports each row's parent too (a 0.77 tab does not, and is compared without).
    const tree = rows.length > 0 && 'parent' in rows[0];
    if (mine.size !== srv.size) diffs.push(`${P.name}: ${mine.size} rows, server ${srv.size}`);
    for (const [u, s] of srv) { const m = mine.get(u); if (!m) { diffs.push(`${P.name}: missing ${u}`); continue; } if (norm(m, tree) !== norm(s, tree)) diffs.push(`${P.name}: ${u} ${norm(m, tree)} vs server ${norm(s, tree)}`); }
    if (full) { const o = (await P.page.evaluate(() => window.__collab.order())).filter(u => srv.has(u)); if (o.join() !== srvOrder.join()) diffs.push(`${P.name}: row order differs from the server's`); }
  }
  return diffs;
}

// ════════════════════════════════════════════════════════════════════════════
// Scenarios
// ════════════════════════════════════════════════════════════════════════════

// The meeting starts: people join one after another. Each tab must get ready within budget
// with the others already open, without forcing a layout storm, and a join must cost the
// tabs already open next to nothing (they only learn who is here).
async function joinScenario(carbon) {
  scenario = 'join';
  const S = await startServer(carbon);
  const people = [];
  const joinCost = [];
  for (let i = 0; i < USERS; i++) {
    const P = await openPerson(S, carbon, i);
    const before = await Promise.all(people.map(metrics));
    const m0 = await metrics(P);
    const t0 = Date.now();
    await goReady(P, S);
    await sleep(800);
    const m1 = await metrics(P);
    P.startup = { readyMs: P.readyMs, taskMs: Math.round((m1.TaskDuration - m0.TaskDuration) * 1000), layouts: m1.LayoutCount - m0.LayoutCount };
    const after = await Promise.all(people.map(metrics));
    const wall = (Date.now() - t0) / 1000;
    people.forEach((Q, k) => joinCost.push(pct(after[k].TaskDuration - before[k].TaskDuration, wall)));
    people.push(P);
  }
  const ready = people.map(P => P.startup.readyMs), layouts = people.map(P => P.startup.layouts);
  rec('startup', people.map(P => ({ name: P.name, ...P.startup })));
  rec('joinCostPctOfOthers', joinCost);
  check(`each of ${USERS} people is ready within ${BUDGETS.READY_MS} ms of opening the plan`, Math.max(...ready) <= BUDGETS.READY_MS, `slowest ${fmtMs(Math.max(...ready))}, median ${fmtMs(ready.slice().sort((a, b) => a - b)[ready.length >> 1])}`);
  check(`no tab forces more than ${BUDGETS.STARTUP_LAYOUTS} layouts while it starts (the freeze forced ~70,000)`, Math.max(...layouts) <= BUDGETS.STARTUP_LAYOUTS, `most ${Math.max(...layouts)}`);
  check('someone joining costs the tabs already open less than 15% of a core', !joinCost.length || Math.max(...joinCost) < 15, `worst ${joinCost.length ? Math.max(...joinCost) : 0}%`);
  const chips = await Promise.all(people.map(P => P.page.evaluate(() => (document.querySelector('#presence .presence-n') || {}).textContent || '')));
  check(`every tab shows all ${USERS} people in the legend`, chips.every(c => c === `${USERS} tabs`), chips.join(' | '));
  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));
  check('no page errors while joining', !errs.length, errs.slice(0, 3).join(' | '));
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// One save, everyone else: each person edits once in turn while the others sit in different
// lenses (chart, table, setup, analytics), so every lens's merge path is measured.
async function mergeScenario(carbon) {
  scenario = 'merge';
  const S = await startServer(carbon);
  // The owner takes Analytics (the team does not have it); the others share the rest.
  const lenses = ['analytics', 'gantt', 'table', 'setup'];
  const people = [];
  for (let i = 0; i < USERS; i++) {
    const P = await openPerson(S, carbon, i, { view: lenses[i % lenses.length] }); await goReady(P, S);
    P.view = await P.page.evaluate(() => document.documentElement.dataset.view);
    people.push(P);
  }
  await sleep(1500);
  if (!(await hasHooks(people[0]))) skip('row-level checks', 'this build has no __collab.rows');
  const rows = (await planRows(S, people[0].sid)).filter(r => r.type === 'task' && r.id && r.start);
  const perLens = {}, visible = [], layouts = [], costs = [];
  for (let k = 0; k < USERS; k++) {
    const editor = people[k];
    if (editor.view !== 'gantt') { await editor.page.click('.vt-btn[data-view="gantt"]'); await sleep(600); }
    const others = people.filter(P => P !== editor);
    const m0 = await Promise.all(others.map(metrics)), mc0 = await Promise.all(others.map(merges));
    const saves0 = others.map(P => P.req.save);
    const row = rows[k * 7 % rows.length];
    const t0 = Date.now();
    const wrote = await editRow(editor, row, k);
    if (!wrote) { check(`${editor.name} could open a row to edit`, false, row.name); continue; }
    // Every other tab shows it.
    const seen = await Promise.all(others.map(async (P, j) => {
      const t = Date.now();
      while (Date.now() - t < 15000) { if ((await merges(P)) > mc0[j]) return Date.now() - t0; await sleep(40); }
      return -1;
    }));
    await sleep(500);
    const m1 = await Promise.all(others.map(metrics));
    others.forEach((P, j) => {
      const d = busySince(m0[j], m1[j]); const ms = Math.round(d.task * 1000);
      costs.push(ms); layouts.push(d.layouts); visible.push(seen[j]);
      (perLens[P.view] = perLens[P.view] || []).push(ms);
    });
    check(`${editor.name}'s edit reaches every other tab`, seen.every(v => v >= 0), seen.join(', '));
    check(`and no other tab saved anything in answer (no save echo)`, others.every((P, j) => P.req.save === saves0[j]), others.map((P, j) => P.req.save - saves0[j]).join(','));
    if (editor.view !== 'gantt') { await editor.page.evaluate(v => { const b = document.querySelector(`.vt-btn[data-view="${v}"]`); if (b && b.offsetParent) b.click(); }, editor.view); await sleep(400); }
  }
  rec('mergeMsPerTab', costs); rec('mergeLayouts', layouts); rec('visibleMs', visible); rec('perLens', perLens);
  check(`a save costs each other tab under ${BUDGETS.MERGE_MS} ms of main thread (the freeze was 15,000+)`, Math.max(...costs) <= BUDGETS.MERGE_MS, `worst ${fmtMs(Math.max(...costs))}, p95 ${fmtMs(p95(costs))}; by lens ${Object.entries(perLens).map(([k, v]) => k + ' ' + Math.max(...v)).join(', ')}`);
  check(`and forces at most ${BUDGETS.MERGE_LAYOUTS} layouts (the freeze forced ~69,000)`, Math.max(...layouts) <= BUDGETS.MERGE_LAYOUTS, `most ${Math.max(...layouts)}`);
  check(`and shows up in every tab within ${BUDGETS.VISIBLE_MS} ms`, visible.every(v => v >= 0 && v <= BUDGETS.VISIBLE_MS), `slowest ${fmtMs(Math.max(...visible))}`);
  const pulls = await Promise.all(people.map(P => P.page.evaluate(() => window.__collab.pulls ? window.__collab.pulls() : -1)));
  if (pulls[0] >= 0) check('each tab pulled the plan no more often than it was told of a save', people.every((P, j) => pulls[j] <= USERS), pulls.join(','));
  if (await hasHooks(people[0])) { await quiesce(S, people); const diffs = await convergence(S, people); check('every tab holds exactly what the server holds', !diffs.length, diffs.slice(0, 4).join(' | ')); }
  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));
  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// The meeting: everyone at once, for --seconds. Each person looks at rows, edits their own,
// scrolls, and flips their own filters and folds. Then every tab must agree with the server,
// and every edit anyone made must be in it.
async function meetingScenario(carbon) {
  scenario = 'meeting';
  const S = await startServer(carbon);
  const people = [];
  for (let i = 0; i < USERS; i++) { const P = await openPerson(S, carbon, i, { view: 'gantt' }); await goReady(P, S); people.push(P); }
  await sleep(1500);
  const hooks = await hasHooks(people[0]);
  const rows = (await planRows(S, people[0].sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));
  const mine = people.map((P, k) => rows.filter((r, j) => j % USERS === k).slice(0, 12));
  const expected = new Map(); let edits = 0, actions = 0;
  // The server is watched while it works: /health latency, and its memory.
  const lat = []; let rssMax = 0, sampling = true;
  const sampler = (async () => { while (sampling) { const t = Date.now(); try { await fetch(S.base + '/health'); lat.push(Date.now() - t); } catch {} rssMax = Math.max(rssMax, serverRss(S)); await sleep(400); } })();
  await Promise.all(people.map(P => longTasks(P)));
  const m0 = await Promise.all(people.map(metrics)), t0 = Date.now();
  await Promise.all(people.map(async (P, k) => {
    let n = 0;
    while (Date.now() - t0 < SECONDS * 1000) {
      const roll = Math.random(); n++; actions++;
      try {
        if (roll < 0.35) await lookAtRow(P, mine[k][n % mine[k].length].id);
        else if (roll < 0.6) { const w = await editRow(P, mine[k][n % mine[k].length], n + k); if (w) { expected.set(w.uid, w); edits++; } }
        else if (roll < 0.8) await scrollAround(P);
        else await toggleLocalView(P, n);
      } catch (e) { P.errors.push('action: ' + e.message.slice(0, 120)); }
      await sleep(1500 + Math.random() * 2500);
    }
  }));
  const wall = (Date.now() - t0) / 1000;
  const m1 = await Promise.all(people.map(metrics));
  const lts = await Promise.all(people.map(P => longTasks(P)));
  sampling = false; await sampler;
  const busy = people.map((P, j) => pct(m1[j].TaskDuration - m0[j].TaskDuration, wall));
  const worstLt = lts.map(a => a.length ? Math.max(...a) : 0);
  rec('busyPct', busy); rec('longestTaskMs', worstLt); rec('edits', edits); rec('actions', actions);
  rec('serverHealthP95', p95(lat)); rec('serverRssMB', +rssMax.toFixed(0));
  console.log(`       ${USERS} people, ${wall.toFixed(0)} s, ${actions} actions, ${edits} edits; busy per tab ${busy.join('% ')}%; longest freeze per tab ${worstLt.join(', ')} ms`);
  check(`every tab stays under ${BUDGETS.MEETING_BUSY}% of a core through the meeting`, Math.max(...busy) <= BUDGETS.MEETING_BUSY, `worst ${Math.max(...busy)}%, mean ${(busy.reduce((a, b) => a + b, 0) / busy.length).toFixed(1)}%`);
  check(`no freeze longer than ${BUDGETS.LONG_TASK_MS} ms in any tab`, Math.max(...worstLt) <= BUDGETS.LONG_TASK_MS, `longest ${fmtMs(Math.max(...worstLt))}`);
  check(`the server answers within ${BUDGETS.SERVER_P95_MS} ms (p95) while it all happens`, p95(lat) <= BUDGETS.SERVER_P95_MS, `p95 ${fmtMs(p95(lat))}, ${lat.length} samples`);
  check(`and stays under ${BUDGETS.SERVER_RSS_MB} MB`, rssMax <= BUDGETS.SERVER_RSS_MB, `${rssMax.toFixed(0)} MB`);
  check('every save was an edit someone made (no save storms or echoes)', people.reduce((a, P) => a + P.req.save, 0) <= edits * 2 + USERS, `${people.reduce((a, P) => a + P.req.save, 0)} saves for ${edits} edits`);
  if (hooks) {
    const q = await quiesce(S, people);
    check('every tab settles once the meeting stops', q >= 0, q >= 0 ? `${q} ms` : 'still busy after 30 s');
    const diffs = await convergence(S, people);
    check('every tab holds exactly what the server holds, row by row', !diffs.length, diffs.slice(0, 4).join(' | '));
    const srv = new Map((await planRows(S, people[0].sid)).map(r => [r.uid, r]));
    const lost = [...expected.values()].filter(w => { const r = srv.get(w.uid); return !r || r.status !== w.status || r.end !== w.end; });
    check(`every one of the ${expected.size} rows edited holds its last edit (no edit lost)`, !lost.length, lost.slice(0, 3).map(w => `${w.uid} wanted ${w.status}/${w.end} got ${JSON.stringify(srv.get(w.uid) && [srv.get(w.uid).status, srv.get(w.uid).end])}`).join(' | '));
  } else skip('convergence and lost edits', 'this build has no __collab.rows');
  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));
  check('no page errors in any tab', !errs.length, errs.slice(0, 3).join(' | '));
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// Everyone clicking around: each selection is announced to the others, who mark the row with
// the person's initials. One person only watches: what the others' clicking costs them is the
// price of presence, and it must be small. The clickers' own clicks are measured too.
async function presenceScenario(carbon) {
  scenario = 'presence';
  const S = await startServer(carbon);
  const people = [];
  for (let i = 0; i < USERS; i++) { const P = await openPerson(S, carbon, i); await goReady(P, S); people.push(P); }
  await sleep(1500);
  const rows = (await planRows(S, people[0].sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));
  const watcher = people[0], clickers = people.slice(1);
  const clicks = [];
  const w0 = await metrics(watcher), t0 = Date.now();
  await Promise.all(clickers.map(async (P, k) => {
    let n = 0;
    while (Date.now() - t0 < 20000) {
      const id = rows[(k * 31 + n++ * 7) % rows.length].id;
      const m0 = await metrics(P), c0 = Date.now();
      if (await clickBar(P, id)) {
        for (let i = 0; i < 40 && !(await editorOpen(P)); i++) await sleep(25);
        const m1 = await metrics(P);
        clicks.push({ wall: Date.now() - c0, task: Math.round((m1.TaskDuration - m0.TaskDuration) * 1000) });
        await sleep(300); await P.page.keyboard.press('Escape'); await P.page.keyboard.press('Escape');
      }
      await sleep(300);
    }
  }));
  const wall = (Date.now() - t0) / 1000, w1 = await metrics(watcher);
  const busy = pct(w1.TaskDuration - w0.TaskDuration, wall);
  const cw = clicks.map(c => c.wall), ct = clicks.map(c => c.task);
  rec('watcherBusyPct', busy); rec('clicks', clicks.length); rec('clickWallP95', p95(cw)); rec('clickTaskP95', p95(ct)); rec('focusPosts', people.map(P => P.req.focus));
  check(`${clickers.length} people clicking around cost a watching tab under ${BUDGETS.PRESENCE_BUSY}% of a core`, busy <= BUDGETS.PRESENCE_BUSY, `${busy}% over ${wall.toFixed(0)} s, ${clicks.length} clicks`);
  check(`a click that opens a row costs its own tab under ${BUDGETS.CLICK_MS} ms (p95)`, p95(ct) <= BUDGETS.CLICK_MS, `p95 ${fmtMs(p95(ct))}, median ${fmtMs(ct.slice().sort((a, b) => a - b)[ct.length >> 1] || 0)}`);
  check(`and puts the editor on screen within ${BUDGETS.CLICK_WALL_MS} ms (p95)`, p95(cw) <= BUDGETS.CLICK_WALL_MS, `p95 ${fmtMs(p95(cw))}`);
  check('each click announced the row once at most (no presence storm)', people.every(P => P.req.focus <= clicks.length * 2 + 2), people.map(P => P.req.focus).join(','));
  // Person 1 holds one row; every other tab marks it with their initials.
  const row = rows[3];
  await clickBar(people[1], row.id); await sleep(1200);
  const marks = await Promise.all(people.map(P => P.page.evaluate(() => [...document.querySelectorAll('.label-row.peer-focus')].map(t => (t.querySelector('.peer-chip') || {}).textContent || '').join(','))));
  check(`a row ${people[1].name} has open shows their initials in every other tab`, marks.every((m, j) => j === 1 ? !m.includes('KM') : m.includes('KM')), marks.join(' | '));
  await people[1].page.keyboard.press('Escape'); await people[1].page.keyboard.press('Escape'); await sleep(1200);
  const cleared = await Promise.all(people.map(P => P.page.evaluate(() => document.querySelectorAll('.label-row.peer-focus').length)));
  check('and letting go clears it everywhere', cleared.every(n => n === 0), cleared.join(','));
  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));
  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// Nothing happening: an open tab must cost nothing, including the owner's with the critical
// path on (its endless glow kept a core at 100% through 0.75.0).
async function idleScenario(carbon) {
  scenario = 'idle';
  const S = await startServer(carbon);
  const people = [];
  for (let i = 0; i < Math.min(USERS, 4); i++) { const P = await openPerson(S, carbon, i); await goReady(P, S); people.push(P); }
  await sleep(2000);
  let m0 = await Promise.all(people.map(metrics)); await sleep(10000); let m1 = await Promise.all(people.map(metrics));
  const busy = people.map((P, j) => pct(m1[j].TaskDuration - m0[j].TaskDuration, 10));
  rec('busyPct', busy);
  check(`an idle tab costs under ${BUDGETS.IDLE_BUSY}% of a core`, Math.max(...busy) <= BUDGETS.IDLE_BUSY, busy.join('% ') + '%');
  const owner = people[0];
  if (await owner.page.$('#btnCrit')) {
    await owner.page.click('#btnCrit'); await sleep(1500);
    m0 = await metrics(owner); await sleep(10000); m1 = await metrics(owner);
    const b = pct(m1.TaskDuration - m0.TaskDuration, 10); rec('critBusyPct', b);
    check(`with the critical path on, idle stays under ${BUDGETS.CRIT_IDLE_BUSY}% (it was 100%)`, b <= BUDGETS.CRIT_IDLE_BUSY, b + '%');
    await owner.page.click('#btnCrit');
  } else skip('critical path idle', 'no Critical button for the owner here');
  // The team cannot reach the Critical button, so a mode they saved before it was hidden
  // must not be on for them.
  const member = people[1];
  await member.page.evaluate(() => { try { const k = 'adom-gantt:prefs'; const p = JSON.parse(localStorage.getItem(k) || '{}'); p.critMode = 'phase'; localStorage.setItem(k, JSON.stringify(p)); } catch (e) {} });
  await member.page.reload({ waitUntil: 'domcontentloaded' }); await member.page.waitForSelector('html[data-ready="1"]', { timeout: 120000 }); await sleep(1500);
  const crit = await member.page.evaluate(() => document.querySelectorAll('.bar.crit, .dep-svg .crit').length);
  check('a teammate with an old critical-path setting does not get it (they cannot turn it off)', crit === 0, `${crit} glowing`);
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// A long session: one tab takes 150 saves from others. After a forced garbage collection its
// memory, its DOM and its listeners must be where they started, give or take.
async function soakScenario(carbon) {
  scenario = 'soak';
  const S = await startServer(carbon);
  const P = await openPerson(S, carbon, 0); await goReady(P, S); await sleep(1500);
  const writer = await signIn(S, carbon, 'u1');
  await P.cdp.send('HeapProfiler.enable');
  const gc = async () => { await P.cdp.send('HeapProfiler.collectGarbage'); await sleep(400); return metrics(P); };
  const g0 = await gc();
  const N = 150; let worst = 0, n = 0;
  for (let i = 0; i < N; i++) {
    const st = await (await asUser(S, writer, '/state')).json(); const rev = st.stateRev; delete st.stateRev; delete st.integrity;
    const it = st.items.find((x, j) => j === i % Math.max(1, st.items.length)) || st.items[0];
    if (!it) { st.items.push({ uid: st.order[i % st.order.length], owner: 'Soak ' + i }); } else it.owner = 'Soak ' + i;
    const b = await merges(P), t0 = Date.now();
    await asUser(S, writer, '/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign(st, { baseRev: rev, client: 'soak' })) });
    while ((await merges(P)) === b && Date.now() - t0 < 10000) await sleep(20);
    worst = Math.max(worst, Date.now() - t0); n++;
  }
  await sleep(800);
  const g1 = await gc();
  const heap = (g1.JSHeapUsedSize - g0.JSHeapUsedSize) / 1048576, nodes = g1.Nodes - g0.Nodes, lis = g1.JSEventListeners - g0.JSEventListeners;
  rec('heapGrowthMB', +heap.toFixed(2)); rec('nodeGrowth', nodes); rec('listenerGrowth', lis); rec('slowestMergeMs', worst);
  check(`${n} merges later the heap grew less than ${BUDGETS.SOAK_HEAP_MB} MB`, heap <= BUDGETS.SOAK_HEAP_MB, `${heap.toFixed(2)} MB`);
  check(`the DOM grew by fewer than ${BUDGETS.SOAK_NODES} nodes`, nodes <= BUDGETS.SOAK_NODES, `${nodes} nodes`);
  check(`and the event listeners by fewer than ${BUDGETS.SOAK_LISTENERS}`, lis <= BUDGETS.SOAK_LISTENERS, `${lis}`);
  check(`no merge took longer than ${BUDGETS.VISIBLE_MS} ms to show`, worst <= BUDGETS.VISIBLE_MS, fmtMs(worst));
  check('no page errors', !P.errors.length, P.errors.slice(0, 3).join(' | '));
  await closePerson(P);
  await stopServer(S);
}

// Two people on the SAME moment: on different rows both edits survive (the second save is
// refused, the tab pulls and saves again); on the SAME row the later save wins that row,
// which is the documented limit until field-level merging, and both tabs still agree.
async function conflictScenario(carbon) {
  scenario = 'conflict';
  const S = await startServer(carbon);
  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1);
  await goReady(A, S); await goReady(Bp, S); await sleep(1500);
  const hooks = await hasHooks(A);
  const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));
  const [r1, r2] = [rows[5], rows[60]];
  const [w1, w2] = await Promise.all([editRow(A, r1, 1), editRow(Bp, r2, 2)]);
  await quiesce(S, [A, Bp]);
  let srv = new Map((await planRows(S, A.sid)).map(r => [r.uid, r]));
  check('two people saving at the same moment on different rows: both edits survive', w1 && w2 && srv.get(r1.uid).status === w1.status && srv.get(r2.uid).status === w2.status,
    JSON.stringify([w1 && w1.status, srv.get(r1.uid).status, w2 && w2.status, srv.get(r2.uid).status]));
  const banners = await Promise.all([A, Bp].map(P => P.page.evaluate(() => document.getElementById('conflictBar').classList.contains('show'))));
  check('and neither of them sees the conflict banner', banners.every(b => !b), banners.join(','));
  const r3 = rows[9];
  const [x1, x2] = await Promise.all([editRow(A, r3, 5), editRow(Bp, r3, 6)]);
  await quiesce(S, [A, Bp]);
  srv = new Map((await planRows(S, A.sid)).map(r => [r.uid, r]));
  const got = srv.get(r3.uid);
  check('the same row from two people at once: the row ends as one of them wrote it, whole', x1 && x2 && [x1, x2].some(w => got.status === w.status && got.end === w.end), JSON.stringify([x1, x2, got && [got.status, got.end]]));
  if (hooks) { const diffs = await convergence(S, [A, Bp]); check('and both tabs agree with the server', !diffs.length, diffs.slice(0, 3).join(' | ')); }
  // A is typing a new end date in the editor when B's save lands. The merge must not put the
  // stored date back over what A typed (it did through 0.75.0, and A's Apply then saved the
  // old date with the new status: an edit lost with every tab agreeing about it).
  if (hooks) {
    const r4 = rows[30], r5 = rows[90];
    if (await clickBar(A, r4.id)) {
      for (let k = 0; k < 20 && !(await editorOpen(A)); k++) await sleep(50);
      const typed = shiftDay(r4.start, 11);
      await setField(A, '#imEnd', typed);
      const b0 = await merges(A);
      await editRow(Bp, r5, 3);
      for (const t = Date.now(); (await merges(A)) === b0 && Date.now() - t < 10000;) await sleep(50);
      const still = await A.page.$eval('#imEnd', e => e.value);
      await A.page.click('#imApply'); await sleep(200);
      await quiesce(S, [A, Bp]);
      const got = new Map((await planRows(S, A.sid)).map(r => [r.uid, r])).get(r4.uid);
      check('a date typed in the editor survives someone else\'s save landing, and Apply saves it', still === typed && got && got.end === typed, JSON.stringify({ typed, inField: still, saved: got && got.end }));
    } else check('the editor opened for the typing check', false, r4.name);
  }
  for (const P of [A, Bp]) await closePerson(P);
  await stopServer(S);
}

// A tab that drops off the live channel (a laptop lid, a flaky connection) misses saves, then
// comes back and catches up on its own.
async function reconnectScenario(carbon) {
  scenario = 'reconnect';
  const S = await startServer(carbon);
  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1);
  await goReady(A, S); await goReady(Bp, S); await sleep(1500);
  if (!(await hasHooks(A))) { skip('reconnect', 'this build has no __collab.rows'); for (const P of [A, Bp]) await closePerson(P); await stopServer(S); return; }
  await Bp.page.evaluate(() => window.__collab.pause());
  const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));
  for (let i = 0; i < 4; i++) { await editRow(A, rows[20 + i], i); await sleep(1000); }
  const behind = await Bp.page.evaluate(() => window.__collab.rev());
  const rev = await serverRev(S);
  check('a tab off the live channel does not see the saves it missed', behind < rev, `${behind} vs ${rev}`);
  const t0 = Date.now();
  await Bp.page.evaluate(() => window.__collab.resume());
  while ((await Bp.page.evaluate(() => window.__collab.rev())) < rev && Date.now() - t0 < 10000) await sleep(50);
  const took = Date.now() - t0;
  check(`back on the channel it catches up within ${BUDGETS.VISIBLE_MS} ms`, took <= BUDGETS.VISIBLE_MS, fmtMs(took));
  await quiesce(S, [A, Bp]);
  const diffs = await convergence(S, [A, Bp]);
  check('and holds what the server holds', !diffs.length, diffs.slice(0, 3).join(' | '));
  for (const P of [A, Bp]) await closePerson(P);
  await stopServer(S);
}


// ════════════════════════════════════════════════════════════════════════════
// Google Docs style (0.77.0): what a tab sends is what it changed, field by field, so two
// people never lose each other's work by touching the same row; undo is your own.
// ════════════════════════════════════════════════════════════════════════════
const hasOps = P => P.page.evaluate(() => !!(window.__collab && window.__collab.set));
const setF = (P, uid, f, v) => P.page.evaluate((u, f, v) => window.__collab.set(u, f, v), uid, f, v);
const tabRow = (P, uid) => P.page.evaluate(u => window.__collab.rows().find(r => r.uid === u) || null, uid);
// This tab has nothing left to send and nothing on its way.
async function sent(people, timeout = 15000) {
  const t0 = Date.now();
  while (Date.now() - t0 < timeout) {
    const st = await Promise.all(people.map(P => P.page.evaluate(() => ({ p: window.__collab.pending(), f: window.__collab.inflight() }))));
    if (st.every(x => !x.p && !x.f)) return true;
    await sleep(100);
  }
  return false;
}
async function docsScenario(carbon) {
  scenario = 'docs';
  const S = await startServer(carbon);
  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1);
  const both = [A, Bp];
  await goReady(A, S); await goReady(Bp, S); await sleep(1500);
  if (!(await hasOps(A))) { skip('docs', 'this build sends whole rows, not fields'); for (const P of both) await closePerson(P); await stopServer(S); return; }
  const pause = () => Promise.all(both.map(P => P.page.evaluate(() => window.__collab.pause())));
  const resume = async () => { await Promise.all(both.map(P => P.page.evaluate(() => window.__collab.resume()))); await quiesce(S, both); };
  const srvRow = async uid => (await planRows(S, A.sid)).find(r => r.uid === uid) || null;
  const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));
  const other = (cur, list) => list.find(x => x !== (cur || '')) || list[0];

  // 1. Two fields of one row, at the same moment: neither tab hears the other first.
  const r1 = rows[3];
  await pause();
  await setF(A, r1.uid, 'owner', 'Kale Mer'); await setF(Bp, r1.uid, 'status', 'blocked');
  await sent(both); await resume();
  let g = await srvRow(r1.uid);
  check('two people change two different fields of one row at the same moment: both are kept', g.owner === 'Kale Mer' && g.status === 'blocked', JSON.stringify([g.owner, g.status]));
  let ta = await tabRow(A, r1.uid), tb = await tabRow(Bp, r1.uid);
  check('and both tabs show both', ta.owner === 'Kale Mer' && tb.owner === 'Kale Mer' && ta.status === 'blocked' && tb.status === 'blocked', JSON.stringify([ta.owner, ta.status, tb.owner, tb.status]));

  // 2. One field, at the same moment: the later save wins it, everywhere.
  const r2 = rows[7];
  await pause();
  await setF(A, r2.uid, 'owner', 'Noah Ash'); await sent([A]);
  await setF(Bp, r2.uid, 'owner', 'Colby Knox'); await sent([Bp]);
  await resume();
  g = await srvRow(r2.uid); ta = await tabRow(A, r2.uid); tb = await tabRow(Bp, r2.uid);
  check('one field changed by two people at once: the later save wins it, in every tab', g.owner === 'Colby Knox' && ta.owner === 'Colby Knox' && tb.owner === 'Colby Knox', JSON.stringify([g.owner, ta.owner, tb.owner]));

  // 3. One field, a moment apart: the person whose value was replaced is told, by name.
  const r3 = rows[9];
  await setF(A, r3.uid, 'owner', 'Bo Chen'); await quiesce(S, both);
  await setF(Bp, r3.uid, 'owner', 'Cy Diaz'); await quiesce(S, both);
  const notes = await A.page.evaluate(() => window.__collab.notices());
  check('a value replaced a moment later is not lost silently: its writer is told who replaced it and what they had', notes.some(n => n.uid === r3.uid && n.f === 'owner' && n.who === 'Kale Mer' && n.was === 'Bo Chen' && n.to === 'Cy Diaz'), JSON.stringify(notes.slice(-3)));
  check('and sees it said on screen', /Kale Mer changed the owner of .* to Cy Diaz just now \(you had Bo Chen\)/.test(await A.page.$eval('#saveInd', e => e.textContent)), await A.page.$eval('#saveInd', e => e.textContent));

  // 4. Lists merge item by item: two people add a different dependency, and a different tag.
  const r4 = rows[12], c4 = await tabRow(A, r4.uid);
  const d1 = rows.find(r => r.uid !== r4.uid && !c4.deps.includes(r.id)).id, d2 = rows.find(r => r.uid !== r4.uid && r.id !== d1 && !c4.deps.includes(r.id)).id;
  await pause();
  await setF(A, r4.uid, 'deps', [...c4.deps, d1]); await setF(Bp, r4.uid, 'deps', [...c4.deps, d2]);
  await setF(A, r4.uid, 'tags', [...c4.tags, 'Alpha']); await setF(Bp, r4.uid, 'tags', [...c4.tags, 'Beta']);
  await sent(both); await resume();
  g = await srvRow(r4.uid);
  check('two people add a different dependency to one row at once: both are kept', [...c4.deps, d1, d2].every(d => (g.deps || []).includes(d)), JSON.stringify(g.deps));
  check('two people add a different tag to one row at once: both are kept', ['alpha', 'beta', ...c4.tags].every(t => (g.tags || []).map(x => x.toLowerCase()).includes(t)), JSON.stringify(g.tags));

  // 5. Undo is your own: it takes back the field you changed and leaves a teammate's later
  //    change to another field of the same row alone, without claiming it kept anything.
  const r5 = rows[20], b5 = await srvRow(r5.uid);
  const st5 = other(b5.status, ['in-progress', 'blocked', 'complete']);
  await setF(A, r5.uid, 'owner', 'Adonis Ray'); await quiesce(S, both);
  await setF(Bp, r5.uid, 'status', st5); await quiesce(S, both);
  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);
  g = await srvRow(r5.uid);
  check('Ctrl+Z puts back your own change (the owner) and keeps a teammate\'s later change to the row (the status)', (g.owner || '') === (b5.owner || '') && g.status === st5, JSON.stringify([b5.owner, g.owner, st5, g.status]));
  check('and does not claim to have kept a field it never changed', !(await A.page.evaluate(() => window.__collab.kept())).length, JSON.stringify(await A.page.evaluate(() => window.__collab.kept())));
  // 6. ...and when the teammate changed the SAME field after you, undo leaves theirs and says so.
  const r6 = rows[25];
  await setF(A, r6.uid, 'owner', 'Ed Fox'); await quiesce(S, both);
  await setF(Bp, r6.uid, 'owner', 'Flo Gray'); await quiesce(S, both);
  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);
  g = await srvRow(r6.uid);
  const kept6 = await A.page.evaluate(() => window.__collab.kept());
  check('undo does not take back a teammate\'s newer value of the field you changed', g.owner === 'Flo Gray', g.owner);
  check('and the toast names whose value it kept', kept6.some(k => k.uid === r6.uid && k.f === 'owner' && k.who === 'Kale Mer') && /kept Kale Mer's newer owner/.test(await A.page.$eval('#undoToast', e => e.textContent)), JSON.stringify(kept6) + ' ' + await A.page.$eval('#undoToast', e => e.textContent));
  // 7. A list item: undo takes out the dependency you added, not the one a teammate added after.
  const r7 = rows[30], c7 = await tabRow(A, r7.uid);
  const e1 = rows.find(r => r.uid !== r7.uid && !c7.deps.includes(r.id)).id, e2 = rows.find(r => r.uid !== r7.uid && r.id !== e1 && !c7.deps.includes(r.id)).id;
  await setF(A, r7.uid, 'deps', [...c7.deps, e1]); await quiesce(S, both);
  const c7b = await tabRow(Bp, r7.uid);
  await setF(Bp, r7.uid, 'deps', [...c7b.deps, e2]); await quiesce(S, both);
  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);
  g = await srvRow(r7.uid);
  check('undo takes out the dependency you added and keeps the one a teammate added after it', !(g.deps || []).includes(e1) && (g.deps || []).includes(e2), JSON.stringify(g.deps));

  // 8. Two people move different rows at the same moment: both moves land.
  const order0 = (await planRows(S, A.sid)).filter(r => r.uid);
  const byPhase = p => order0.filter(r => r.phase === p);
  const pa = [...new Set(order0.map(r => r.phase))].find(p => byPhase(p).length >= 4);
  const pb = [...new Set(order0.map(r => r.phase))].find(p => p !== pa && byPhase(p).length >= 4);
  const X = byPhase(pa), Y = byPhase(pb);
  await pause();
  const mvA = await A.page.evaluate((u, a) => window.__collab.move(u, a), X[3].uid, X[0].uid);
  const mvB = await Bp.page.evaluate((u, a) => window.__collab.move(u, a), Y[3].uid, Y[0].uid);
  await sent(both); await resume();
  let o = (await planRows(S, A.sid)).map(r => r.uid);
  check('two people move different rows at the same moment: both moves land', mvA && mvB && o.indexOf(X[3].uid) === o.indexOf(X[0].uid) + 1 && o.indexOf(Y[3].uid) === o.indexOf(Y[0].uid) + 1, JSON.stringify([mvA, mvB]));
  // and an undo of one move puts back that row only
  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);
  o = (await planRows(S, A.sid)).map(r => r.uid);
  check('undoing your move puts your row back and leaves the teammate\'s move where they put it', o.indexOf(X[3].uid) === o.indexOf(X[2].uid) + 1 && o.indexOf(Y[3].uid) === o.indexOf(Y[0].uid) + 1, JSON.stringify([o.indexOf(X[2].uid), o.indexOf(X[3].uid), o.indexOf(Y[0].uid), o.indexOf(Y[3].uid)]));

  // 9. A row you made, which a teammate has worked on since, is not taken away by your undo.
  await A.page.click('#btnNew'); await sleep(300);
  await A.page.type('#ncName', 'Made by Drew'); await A.page.click('#ncCreate'); await sleep(400);
  await quiesce(S, both);
  const made = (await planRows(S, A.sid)).find(r => r.name === 'Made by Drew');
  check('a row made in one tab reaches the server', !!made);
  if (made) {
    await setF(Bp, made.uid, 'owner', 'Kale Mer'); await quiesce(S, both);
    await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);
    check('undoing the creation of a row a teammate has since changed keeps the row', !!(await srvRow(made.uid)) && (await A.page.evaluate(() => window.__collab.kept())).some(k => k.f === 'row'), JSON.stringify(await A.page.evaluate(() => window.__collab.kept())));
  }

  // 10. An edit from a tab that had not heard of a deletion brings the row back with it.
  const r10 = rows[40];
  await A.page.evaluate(() => window.__collab.pause());
  await Bp.page.evaluate(u => window.__collab.del(u), r10.uid); await sent([Bp]);
  check('the deletion reaches the server', !(await srvRow(r10.uid)));
  await setF(A, r10.uid, 'owner', 'Di Evans'); await sent([A]);
  await resume();
  g = await srvRow(r10.uid); tb = await tabRow(Bp, r10.uid);
  check('an edit made without seeing a deletion brings the row back, with the edit, in every tab', g && g.owner === 'Di Evans' && tb && tb.owner === 'Di Evans', JSON.stringify([g && g.owner, tb && tb.owner]));

  const diffs = await convergence(S, both);
  check('after all of it, both tabs hold exactly what the server holds, the row order included', !diffs.length, diffs.slice(0, 3).join(' | '));
  const errs = both.flatMap(P => P.errors);
  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));
  for (const P of both) await closePerson(P);
  await stopServer(S);
}

// Several people editing the same handful of rows as fast as they can: fields, lists, moves,
// undo and redo, with tabs dropping off the live channel and coming back. Afterwards every
// tab must hold exactly what the server holds, and a field only one person ever wrote must
// end as that person left it: whole-row saving lost exactly those.
// ═════════════════════════════════════════════════════════════════════════════
// Sections (0.77.1), the team plan's incident of 2026-11-03, all three parts of it. A batch
// that created a section and moved rows into it reached open tabs rows first; they refused
// the move and their next saves sent the old section back. Two tabs that had missed a
// change to created rows held them in another section than the server, and corrected the
// server's order against a third tab three times a second, for an hour. And each of those
// corrections rebuilt the legend, which closed the owner list in the hand that had it open.
// ═════════════════════════════════════════════════════════════════════════════
async function sectionsScenario(carbon) {
  scenario = 'sections';
  const S = await startServer(carbon);
  const people = [];
  for (let i = 0; i < 3; i++) people.push(await openPerson(S, carbon, i));
  for (const P of people) await goReady(P, S);
  await sleep(1500);
  const [A, Bp, Cp] = people;
  const can = await A.page.evaluate(() => !!(window.__collab && window.__collab.set && window.__collab.diverge));
  if (!can) { skip('sections', 'this build cannot make a tab miss a change (no __collab.diverge)'); for (const P of people) await closePerson(P); await stopServer(S); return; }
  const H = { Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' };
  const rows = await A.page.evaluate(() => window.__collab.rows());
  const inSec = p => rows.filter(r => r.phase === p && r.start);
  const secs = [...new Set(rows.map(r => r.phase))].filter(p => inSec(p).length >= 4).sort((a, b) => a - b);
  const srvPhase = async () => new Map((await planRows(S, A.sid)).map(r => [r.uid, r.phase]));
  const posts = P => { P.ops = []; P.page.on('request', r => { if (r.method() === 'POST' && r.url().split('?')[0].endsWith('/ops')) P.ops.push(Date.now()); }); };
  people.forEach(posts);

  // 1. One batch creates a section at the end and moves rows into it, rows first.
  const st = await (await fetch(S.base + '/state', { headers: H })).json();
  const ph = st.phases.map(p => ({ name: p.name, start: p.start || '', end: p.end || '', uid: p.uid, color: p.color, tags: p.tags || [] }));
  const N = ph.length; ph.push({ name: 'Created and filled at once', start: '', end: '', color: '#56ccf2', tags: [] });
  const movers = inSec(secs[0]).slice(0, 3).map(r => r.uid);
  const res = await (await fetch(S.base + '/ops', { method: 'POST', headers: H,
    body: JSON.stringify({ client: 'agent', batch: 'sec-1', ops: movers.map(u => ({ op: 'row', uid: u, set: { phase: N } })).concat([{ op: 'phases', phases: ph }]) }) })).json();
  check('a batch that creates a section and moves rows into it is announced sections first', res.ok && (res.ops || [])[0] && res.ops[0].op === 'phases', (res.ops || []).map(o => o.op).join(' > '));
  await quiesce(S, people);
  // Everybody then edits something else, so whatever a tab holds goes out with its save.
  const busy = inSec(secs[1]);
  for (let i = 0; i < 3; i++) await setF(people[i], busy[i].uid, 'status', 'in-progress');
  await quiesce(S, people); await sleep(600);
  let sp = await srvPhase();
  check('the rows moved into the new section stay there through everybody\'s next save (nobody sends the old section back)', movers.every(u => sp.get(u) === N), movers.map(u => sp.get(u)).join(','));
  const shown = await Promise.all(people.map(P => P.page.evaluate(m => m.map(u => (window.__collab.rows().find(r => r.uid === u) || {}).phase), movers)));
  check('and every tab shows them in it', shown.every(l => l.every(p => p === N)), JSON.stringify(shown));

  // 2. Two tabs miss a change: rows held in another section, on the page and in its copy of
  // the server's values. A third tab makes one ordinary move.
  // As on the day: most of a section, not all of it, held two sections further down, so no
  // one order can suit both ways of grouping and each side keeps correcting the other.
  const lost = inSec(secs[1]).slice(2).map(r => r.uid), wrong = secs[3];
  for (const P of [Bp, Cp]) await P.page.evaluate(([u, p]) => window.__collab.diverge(u, p), [lost, wrong]);
  const r0 = await Promise.all([Bp, Cp].map(P => P.page.evaluate(() => window.__collab.resyncs ? window.__collab.resyncs() : 0)));
  const mv = inSec(secs[4] !== undefined ? secs[4] : secs[0]);
  const t0 = Date.now();
  await A.page.evaluate(([u, w]) => window.__collab.move(u, w), [mv[0].uid, mv[2].uid]);
  const per = [];
  for (let k = 0; k < 10; k++) { await sleep(1000); per.push(people.reduce((n, P) => n + P.ops.filter(x => x >= t0 + k * 1000 && x < t0 + (k + 1) * 1000).length, 0)); }
  rec('batchesPerSecondAfterOneMove', per);
  check('one move with two tabs that missed a change: the tabs go quiet, no fight over the order', per.slice(-5).every(n => n === 0), per.join(' '));
  sp = await srvPhase();
  for (const P of [Bp, Cp]) {
    const rs = await P.page.evaluate(() => window.__collab.rows());
    const off = lost.filter(u => (rs.find(r => r.uid === u) || {}).phase !== sp.get(u));
    const r1 = await P.page.evaluate(() => window.__collab.resyncs ? window.__collab.resyncs() : 0);
    check(`${P.name}'s tab notices it disagrees about the sections, fetches the plan and puts the ${lost.length} rows back`, off.length === 0, `${off.length} still elsewhere, catch-ups ${r1 - r0[[Bp, Cp].indexOf(P)]}`);
  }
  check('the server never took the wrong section from them', lost.every(u => sp.get(u) === secs[1]));
  const orders = await Promise.all(people.map(P => P.page.evaluate(() => window.__collab.order().join(','))));
  check('and all three tabs hold the same order', orders.every(o => o === orders[0]));
  check('every tab holds exactly what the server holds', (await convergence(S, people)).length === 0);

  // 3. The owner list stays open in one hand while the others change the plan.
  const listOpen = () => A.page.evaluate(() => !!document.querySelector('.cell-list') && document.activeElement === document.getElementById('ganttOwner'));
  await A.page.click('#ganttOwner'); await sleep(300);
  const before = await listOpen();
  const t1 = inSec(secs[2]);
  await setF(Bp, t1[0].uid, 'status', 'blocked'); await quiesce(S, people);
  await setF(Cp, t1[1].uid, 'tags', ['Moved', 'Here']); await quiesce(S, people);
  await Cp.page.evaluate(([u, w]) => window.__collab.move(u, w), [t1[2].uid, t1[3].uid]); await quiesce(S, people);
  await Bp.page.evaluate(u => window.__collab.del(u), t1[1].uid); await quiesce(S, people);
  await Bp.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);
  check('an open owner list stays open while other people change fields, tags, the order, and delete and restore a row', before && await listOpen());
  await A.page.keyboard.press('Escape');
  const errs = people.flatMap(P => P.errors);
  check('no page errors', errs.length === 0, errs.slice(0, 3).join(' | '));
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// ═════════════════════════════════════════════════════════════════════════════
// Subtasks (0.79.0, issue #5). Each row holds its parent and its own place among its siblings
// (a fractional key), so a move or a nest is one row's fields, last writer wins, and there is
// no shared order to fight over. Two people nest and un-nest at once; two people put one row
// under different tasks; two people put two tasks under each other (a loop); a row is nested
// under a task somebody deletes, in both orders; undo of a nest is your own; and a 0.77 tab
// left open on the new server sees the tree as a flat plan, moves rows in it, and nobody fights.
// ═════════════════════════════════════════════════════════════════════════════
async function openOldPerson(S, carbon, i, oldHtml) {
  const P = await openPerson(S, carbon, i);
  await P.page.setRequestInterception(true);
  P.page.on('request', async req => {
    try {
      const u = new URL(req.url());
      if (req.resourceType() === 'document' && (u.pathname === '/' || u.pathname === '/gantt.html')) {
        // The identity the server would put in this person's page, read off this build's page.
        const html = await (await fetch(S.base + '/', { headers: { Cookie: 'adom_gantt_sid=' + P.sid } })).text();
        const m = html.match(/const RAW_USER = (.*);\n/);
        await req.respond({ status: 200, contentType: 'text/html', headers: { 'Cache-Control': 'no-store' }, body: oldHtml.split('__APP_VERSION__').join('0.77.1').split('"__ADOM_USER__"').join(m ? m[1] : 'null') });
      } else await req.continue();
    } catch (e) { try { await req.continue(); } catch (_) {} }
  });
  P.old = true;
  return P;
}
async function subtasksScenario(carbon) {
  scenario = 'subtasks';
  let oldHtml = '';
  try { oldHtml = OLD_PAGE ? readFileSync(OLD_PAGE, 'utf8') : execFileSync('git', ['-C', APP, 'show', 'ffdacb4:gantt.html'], { encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'] }); } catch (e) { oldHtml = ''; }
  const S = await startServer(carbon);
  const people = [];
  for (let i = 0; i < 3; i++) people.push(await openPerson(S, carbon, i));
  if (oldHtml) people.push(await openOldPerson(S, carbon, 3, oldHtml));
  for (const P of people) await goReady(P, S);
  await sleep(1500);
  const [A, Bp, Cp] = people, O = people[3] || null, news = [A, Bp, Cp];
  const can = await A.page.evaluate(() => !!(window.__collab && window.__collab.nest && window.__collab.tree));
  if (!can) { skip('subtasks', 'this build has no subtasks (no __collab.nest)'); for (const P of people) await closePerson(P); await stopServer(S); return; }
  const rows = await A.page.evaluate(() => window.__collab.rows());
  const tasksIn = p => rows.filter(r => r.phase === p && r.id && r.start);
  const secs = [...new Set(rows.map(r => r.phase))].filter(p => tasksIn(p).length >= 6).sort((a, b) => a - b);
  const srv = async () => new Map((await planRows(S, A.sid)).map(r => [r.uid, r]));
  const parentOf = async u => ((await srv()).get(u) || {}).parent || '';
  const tabParent = (P, u) => P.page.evaluate(x => (window.__collab.tree().find(r => r.uid === x) || {}).parent || '', u);
  const pauseAll = list => Promise.all(list.map(P => P.page.evaluate(() => window.__collab.pause())));
  const resumeAll = async list => { await Promise.all(list.map(P => P.page.evaluate(() => window.__collab.resume()))); await quiesce(S, people); };
  const nest = (P, u, p) => P.page.evaluate(([a, b]) => window.__collab.nest(a, b), [u, p]);
  const unnest = (P, u) => P.page.evaluate(a => window.__collab.unnest(a), u);
  people.forEach(P => { P.ops = []; P.page.on('request', r => { if (r.method() === 'POST' && r.url().split('?')[0].endsWith('/ops')) P.ops.push(Date.now()); }); });
  const quiet = async () => { const t0 = Date.now(); await sleep(3000); return people.reduce((n, P) => n + P.ops.filter(x => x >= t0).length, 0); };
  const X = tasksIn(secs[0]), Y = tasksIn(secs[1]);

  // 1. Two people nest and un-nest at the same moment; a third nests elsewhere.
  check('a task is nested under another', await nest(A, X[1].uid, X[0].uid));
  await quiesce(S, people);
  await pauseAll(news);
  await nest(A, X[2].uid, X[0].uid); await unnest(Bp, X[1].uid); await nest(Cp, X[4].uid, X[3].uid);
  await sent(news); await resumeAll(news);
  let sv = await srv();
  check('two people nest and un-nest at once, a third nests elsewhere: all three land', (sv.get(X[2].uid).parent || '') === X[0].uid && !(sv.get(X[1].uid).parent) && (sv.get(X[4].uid).parent || '') === X[3].uid,
    JSON.stringify([X[2].uid, sv.get(X[2].uid).parent, X[1].uid, sv.get(X[1].uid).parent, X[4].uid, sv.get(X[4].uid).parent]));
  let diffs = await convergence(S, people);
  check('and every tab holds exactly what the server holds, parents and the order included', !diffs.length, diffs.slice(0, 3).join(' | '));

  // 2. One row put under two different tasks at once: the later place wins it whole, everywhere.
  await pauseAll(news);
  await nest(A, X[5].uid, X[0].uid); await sent([A]);
  await nest(Bp, X[5].uid, X[3].uid); await sent([Bp]);
  await resumeAll(news);
  const t2 = await Promise.all(news.map(P => tabParent(P, X[5].uid)));
  check('one row nested under two tasks at once: the later nest wins, in every tab', (await parentOf(X[5].uid)) === X[3].uid && t2.every(p => p === X[3].uid), JSON.stringify([await parentOf(X[5].uid), t2]));

  // 3. Two tasks put under each other at once: one lands, the other is refused, no loop.
  await pauseAll(news);
  await nest(A, X[3].uid, X[0].uid); await nest(Bp, X[0].uid, X[3].uid);
  await sent(news); await resumeAll(news);
  const p0 = await parentOf(X[0].uid), p3 = await parentOf(X[3].uid);
  check('two tasks nested under each other at once: one lands, the server refuses the loop', (p0 === X[3].uid) !== (p3 === X[0].uid), JSON.stringify([p0, p3]));
  diffs = await convergence(S, people);
  check('and every tab agrees which one', !diffs.length, diffs.slice(0, 3).join(' | '));

  // 4a. A row nested under a task somebody deleted a moment before: it takes the task's place.
  const P1 = Y[0], R1 = Y[2], P2 = Y[3], R2 = Y[5];
  await A.page.evaluate(() => window.__collab.pause());
  await Bp.page.evaluate(u => window.__collab.del(u), P1.uid); await sent([Bp]);
  await nest(A, R1.uid, P1.uid); await sent([A]);
  await resumeAll([A]);
  sv = await srv();
  check('nesting under a task somebody had just deleted: the row is kept, where the task was', sv.has(R1.uid) && !sv.has(P1.uid) && !(sv.get(R1.uid).parent) && sv.get(R1.uid).phase === secs[1], JSON.stringify(sv.get(R1.uid)));
  // 4b. ...and the other way round: the nest lands first, then the task is deleted by a tab
  // that had not heard of it. The row is not deleted with it: it takes the task's place.
  await Bp.page.evaluate(() => window.__collab.pause());
  await nest(A, R2.uid, P2.uid); await sent([A]);
  await Bp.page.evaluate(u => window.__collab.del(u), P2.uid); await sent([Bp]);
  await resumeAll([Bp]);
  sv = await srv();
  check('a task deleted by a tab that had not seen a row nested under it: the row is kept, where the task was', sv.has(R2.uid) && !sv.has(P2.uid) && !(sv.get(R2.uid).parent), JSON.stringify(sv.get(R2.uid)));
  // 4c. A task deleted with its subtasks, by a tab that knew them: all of them go, one undo brings them back.
  const Z = tasksIn(secs[2]);
  await nest(Cp, Z[1].uid, Z[0].uid); await quiesce(S, people);
  await Cp.page.evaluate(u => window.__collab.del(u), Z[0].uid); await quiesce(S, people);
  sv = await srv();
  check('deleting a task deletes its subtasks with it (ClickUp)', !sv.has(Z[0].uid) && !sv.has(Z[1].uid));
  await Cp.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);
  sv = await srv();
  check('and one Ctrl+Z brings the task and its subtask back, still nested', sv.has(Z[0].uid) && (sv.get(Z[1].uid) || {}).parent === Z[0].uid, JSON.stringify(sv.get(Z[1].uid)));

  // 5. Undo of a nest is your own.
  const before = (await planRows(S, A.sid)).map(r => r.uid);
  await nest(A, Z[3].uid, Z[2].uid); await quiesce(S, people);
  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);
  const after = (await planRows(S, A.sid)).map(r => r.uid);
  check('Ctrl+Z on a nest puts the row back where it was, in every tab', !(await parentOf(Z[3].uid)) && after.indexOf(Z[3].uid) === before.indexOf(Z[3].uid) && (await Promise.all(news.map(P => tabParent(P, Z[3].uid)))).every(p => !p),
    JSON.stringify([before.indexOf(Z[3].uid), after.indexOf(Z[3].uid)]));
  await nest(A, Z[4].uid, Z[2].uid); await quiesce(S, people);
  await nest(Bp, Z[4].uid, Z[3].uid); await quiesce(S, people);
  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);
  const kept = await A.page.evaluate(() => window.__collab.kept());
  check('Ctrl+Z on a nest that a teammate has moved since leaves their place, and says whose', (await parentOf(Z[4].uid)) === Z[3].uid && kept.some(k => k.uid === Z[4].uid && k.f === 'place' && k.who === PEOPLE[1]), JSON.stringify([await parentOf(Z[4].uid), kept]));

  // 6. Nobody fights.
  const posts = await quiet();
  check('after all of that, nobody sends anything (no tab corrects another)', posts === 0, posts + ' batches in 3 s');
  const rs = await Promise.all(news.map(P => P.page.evaluate(() => window.__collab.resyncs ? window.__collab.resyncs() : 0)));
  rec('resyncs', rs);

  // 7. A 0.77 tab left open on this server.
  if (!O) skip('0.77 tab beside this build', 'no 0.77 page (git show ffdacb4:gantt.html failed; pass --old-page)');
  else {
    const oo = () => O.page.evaluate(() => window.__collab.order());
    const so = async () => (await planRows(S, A.sid)).map(r => r.uid);
    const sOrd = await so(), oOrd = (await oo()).filter(u => sOrd.includes(u));
    check('a 0.77 tab shows the plan in the server\'s order: every subtask right under its task', oOrd.join() === sOrd.join(), JSON.stringify(oOrd.slice(0, 12)));
    // It moves a task with subtasks below the next top-level row: the subtasks go with it.
    const t7 = await A.page.evaluate(() => window.__collab.tree());
    const par = t7.find(r => !r.parent && t7.some(k => k.parent === r.uid) && r.phase === secs[0]);
    const kidsOf = (t, u) => t.filter(r => r.parent === u).map(r => r.uid);
    const sib = par && t7.filter(r => !r.parent && r.phase === par.phase && t7.indexOf(r) > t7.indexOf(par))[0];
    if (par && sib) {
      const k0 = kidsOf(t7, par.uid);
      await O.page.evaluate(([u, a]) => window.__collab.move(u, a), [par.uid, sib.uid]); await quiesce(S, people);
      const o2 = await so(), t8 = await A.page.evaluate(() => window.__collab.tree());
      check('a 0.77 tab moves a task that has subtasks: it lands where that tab put it, its subtasks with it', o2.indexOf(par.uid) > o2.indexOf(sib.uid) && kidsOf(t8, par.uid).join() === k0.join() && k0.every(u => o2.indexOf(u) > o2.indexOf(par.uid)), JSON.stringify([o2.indexOf(sib.uid), o2.indexOf(par.uid), k0, kidsOf(t8, par.uid)]));
      // It moves a subtask to another section by its section field: it leaves its task.
      const kid = k0[0];
      await O.page.evaluate(([u, p]) => window.__collab.set(u, 'phase', p), [kid, secs[1]]); await quiesce(S, people);
      sv = await srv();
      check('a 0.77 tab moves a subtask to another section: it leaves its task and lands in that section', !(sv.get(kid).parent) && sv.get(kid).phase === secs[1], JSON.stringify(sv.get(kid)));
      // It deletes a task with subtasks, knowing nothing of them: they are kept, in its place.
      const t9 = await A.page.evaluate(() => window.__collab.tree());
      const par2 = t9.find(r => t9.some(k => k.parent === r.uid));
      const k2 = par2 ? kidsOf(t9, par2.uid) : [];
      if (par2) {
        await O.page.evaluate(u => window.__collab.del(u), par2.uid); await quiesce(S, people);
        sv = await srv();
        check('a 0.77 tab deletes a task with subtasks: the task goes, its subtasks stay, in its place', !sv.has(par2.uid) && k2.length && k2.every(u => sv.has(u) && (sv.get(u).parent || '') !== par2.uid), JSON.stringify(k2.map(u => sv.get(u))));
      }
      // And a newer tab nests a row while it watches.
      const t10 = await A.page.evaluate(() => window.__collab.tree());
      const host = t10.find(r => !r.parent && r.phase === secs[2] && rows.find(x => x.uid === r.uid && x.id)), guest = t10.filter(r => !r.parent && r.phase === secs[2] && r.uid !== (host || {}).uid && rows.find(x => x.uid === r.uid && x.id)).slice(-1)[0];
      if (host && guest) { await nest(Cp, guest.uid, host.uid); await quiesce(S, people);
        const s3 = await so(), o3 = (await oo()).filter(u => s3.includes(u));
        check('a 0.77 tab sees a row a newer tab nests come straight under its task', o3.join() === s3.join() && s3.indexOf(guest.uid) > s3.indexOf(host.uid), JSON.stringify([s3.indexOf(host.uid), s3.indexOf(guest.uid)])); }
    } else skip('0.77 tab moves', 'no task with subtasks and a sibling after it');
    const p2 = await quiet();
    check('with a 0.77 tab open, nobody fights over the order', p2 === 0, p2 + ' batches in 3 s');
  }
  diffs = await convergence(S, people);
  check('every tab, the 0.77 one included, holds exactly what the server holds', !diffs.length, diffs.slice(0, 3).join(' | '));
  const errs = people.flatMap(P => P.errors);
  check('no page errors', errs.length === 0, errs.slice(0, 3).join(' | '));
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// ═════════════════════════════════════════════════════════════════════════════
// Milestone dependencies (0.78.0). A milestone is a gate now: it waits on work and work waits
// on it, and its links travel as field ops like a task's. Two people link the same gate at the
// same moment, in both directions, and neither link is lost in any tab.
// ═════════════════════════════════════════════════════════════════════════════
// ── Requests (0.80.0): a request, then accept or decline, across tabs ──
// Drew (the plan's owner), Kale (asked) and Noah (watching). The owner changes go in two
// ways: through the page's own path (the Table's owner cell), and straight into the page's
// model the way a tab from before 0.80.0 makes them, which only the server can catch.
async function requestsScenario(carbon) {
  scenario = 'requests';
  const S = await startServer(carbon, { requests: true });
  const A = await openPerson(S, carbon, 0), K = await openPerson(S, carbon, 1), N = await openPerson(S, carbon, 2), all = [A, K, N];
  for (const P of all) await goReady(P, S);
  await sleep(1500);
  if (!(await A.page.evaluate(() => !!window.__req))) { skip('requests', 'this build has no requests'); for (const P of all) await closePerson(P); await stopServer(S); return; }
  const srvRow = async uid => (await planRows(S, A.sid)).find(r => r.uid === uid) || null;
  const srvReqs = async () => ((await (await asUser(S, A.sid, '/requests')).json()).requests) || [];
  const tabReqs = P => P.page.evaluate(() => window.__req.list());
  const badge = P => P.page.evaluate(() => (document.getElementById('reqN') || {}).textContent || '');
  const until = async (fn, ms = 8000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(150); } return fn(); };
  const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.owner === 'Drew Owens' && !/cancel/i.test(r.status || ''));
  const notKale = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.owner && r.owner !== 'Kale Mer' && r.owner !== 'Drew Owens');
  if (rows.length < 2 || notKale.length < 2) { skip('requests', 'the plan has too few rows of Drew\'s'); for (const P of all) await closePerson(P); await stopServer(S); return; }

  // 1. Drew asks Kale through the Table's owner cell.
  const r1 = rows[0];
  await A.page.bringToFront();
  await A.page.evaluate(() => { const b = document.querySelector('.vt-btn[data-view="table"]'); if (b) b.click(); }); await sleep(800);
  const cell = `#tableView tr[data-key="t:${r1.id}"] td[data-field="owner"]`;
  await A.page.evaluate(sel => document.querySelector(sel).scrollIntoView({ block: 'center' }), cell);
  await A.page.click(cell); await sleep(150);
  await A.page.keyboard.down('Control'); await A.page.keyboard.press('KeyA'); await A.page.keyboard.up('Control');
  await A.page.keyboard.type('Kale Mer'); await A.page.keyboard.press('Enter');
  await quiesce(S, all);
  let q = (await srvReqs()).find(x => x.uid === r1.uid && x.state === 'pending');
  check('Drew sets Kale as the owner: the server holds a pending request and the row stays Drew\'s', q && q.to === 'Kale Mer' && (await srvRow(r1.uid)).owner === 'Drew Owens', JSON.stringify([q, (await srvRow(r1.uid)).owner]));
  check('every tab has the request; nobody\'s copy of the row changed', await until(async () => {
    for (const P of all) { const l = await tabReqs(P); const t = await tabRow(P, r1.uid); if (!l.some(x => x.id === q.id && x.state === 'pending') || t.owner !== 'Drew Owens') return false; } return true; }));
  check('Kale\'s badge counts it; Noah\'s does not', await until(async () => (await badge(K)) === '1' && (await badge(N)) === ''), `${await badge(K)} / ${await badge(N)}`);

  // 2. Kale accepts in his inbox: every tab shows him as the owner.
  await K.page.bringToFront(); await K.page.click('#btnReq'); await sleep(300);
  await K.page.type('#reqPanel .rq-in', 'on it');
  await K.page.click('#reqPanel .rq-b.yes');
  await quiesce(S, all);
  q = (await srvReqs()).find(x => x.id === q.id);
  check('Kale accepts: the server has the row as his and the request accepted, with his note', q.state === 'accepted' && q.reply === 'on it' && (await srvRow(r1.uid)).owner === 'Kale Mer', JSON.stringify(q));
  check('every tab shows Kale as the owner and the request accepted', await until(async () => {
    for (const P of all) { const l = await tabReqs(P); const t = await tabRow(P, r1.uid); if (!l.some(x => x.id === q.id && x.state === 'accepted') || t.owner !== 'Kale Mer') return false; } return true; }));
  check('Drew\'s badge counts the answer', await until(async () => (await badge(A)) === '1'), await badge(A));

  // 3. A change straight into the page's model, as a tab from before 0.80.0 makes it: the
  // server asks instead, and the tab puts the old owner back on its own screen.
  const r2 = rows[1];
  await setF(A, r2.uid, 'owner', 'Kale Mer'); await sent([A]); await quiesce(S, all);
  q = (await srvReqs()).find(x => x.uid === r2.uid && x.state === 'pending');
  check('an owner change that skips the page\'s own check is still a request, made by the server', q && q.to === 'Kale Mer' && (await srvRow(r2.uid)).owner === 'Drew Owens', JSON.stringify([q, (await srvRow(r2.uid)).owner]));
  check('and the tab that made it shows the old owner again, with the request', await until(async () => (await tabRow(A, r2.uid)).owner === 'Drew Owens' && (await tabReqs(A)).some(x => x.id === q.id)), JSON.stringify(await tabRow(A, r2.uid)));
  const dirty = await A.page.evaluate(() => window.__collab.dirty());
  check('with nothing left to send (it does not keep sending the owner)', dirty.length === 0 && await A.page.evaluate(() => window.__req.out()) === 0, JSON.stringify(dirty));

  // 4. Kale declines it from his inbox; Drew sees the decline and the row stays his.
  await K.page.bringToFront(); await K.page.click('#btnReq'); await sleep(300);
  await K.page.evaluate(id => { const i = document.querySelector(`#reqPanel .rq-in[data-req="${id}"]`); i.value = 'no room'; document.querySelector(`#reqPanel .rq-b[data-a="decline"][data-req="${id}"]`).click(); }, q.id);
  await quiesce(S, all);
  q = (await srvReqs()).find(x => x.id === q.id);
  check('Kale declines: the row stays Drew\'s, everywhere', q.state === 'declined' && (await srvRow(r2.uid)).owner === 'Drew Owens' && await until(async () => { for (const P of all) if ((await tabRow(P, r2.uid)).owner !== 'Drew Owens') return false; return true; }), JSON.stringify(q));
  check('Drew\'s tab has the decline and its note', await until(async () => (await tabReqs(A)).some(x => x.id === q.id && x.state === 'declined' && x.reply === 'no room')));

  // 5. Noah moves somebody else's task to Kale while Drew asks Kale for another: two requests
  // at the same moment, both pending, nobody's edits lost.
  const [n1, n2] = notKale;
  await Promise.all([setF(N, n1.uid, 'owner', 'Kale Mer'), setF(A, n2.uid, 'owner', 'Kale Mer'), setF(N, n1.uid, 'status', 'blocked')]);
  await sent([A, N]); await quiesce(S, all);
  const pend = (await srvReqs()).filter(x => x.state === 'pending' && (x.uid === n1.uid || x.uid === n2.uid));
  check('two people asking at once: two pending requests, the owners unchanged, the other field kept', pend.length === 2 && (await srvRow(n1.uid)).owner === n1.owner && (await srvRow(n2.uid)).owner === n2.owner && (await srvRow(n1.uid)).status === 'blocked', JSON.stringify(pend.map(x => [x.uid, x.from.name])));
  check('Kale\'s badge counts both', await until(async () => (await badge(K)) === '2'), await badge(K));
  // Every tab converges on the server's requests.
  const want = JSON.stringify((await srvReqs()).map(x => [x.id, x.state]).sort());
  check('every tab holds exactly the server\'s requests', await until(async () => { for (const P of all) { const l = (await tabReqs(P)).map(x => [x.id, x.state]).sort(); if (JSON.stringify(l) !== want) return false; } return true; }));
  // The changelog chain stays whole with the server's lines in it.
  const h = await (await fetch(S.base + '/health')).json();
  check('the changelog chain is whole', h.integrity && h.integrity.ok === true, JSON.stringify(h.integrity));
  for (const P of all) { if (P.errors.length) check(`no page errors in ${P.name}'s tab`, false, P.errors.slice(0, 3).join(' | ')); }
  for (const P of all) await closePerson(P);
  await stopServer(S);
}
async function milestonesScenario(carbon) {
  scenario = 'milestones';
  const S = await startServer(carbon);
  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1), both = [A, Bp];
  await goReady(A, S); await goReady(Bp, S); await sleep(1500);
  if (!(await hasOps(A))) { skip('milestones', 'this build sends whole rows, not fields'); for (const P of both) await closePerson(P); await stopServer(S); return; }
  const pause = () => Promise.all(both.map(P => P.page.evaluate(() => window.__collab.pause())));
  const resume = async () => { await Promise.all(both.map(P => P.page.evaluate(() => window.__collab.resume()))); await quiesce(S, both); };
  const srvRow = async uid => (await planRows(S, A.sid)).find(r => r.uid === uid) || null;
  const rows = await planRows(S, A.sid);
  const gates = rows.filter(r => r.type === 'milestone');
  check('every milestone in the plan has an id, given by the server at startup', gates.length > 3 && gates.every(r => r.id), JSON.stringify(gates.filter(r => !r.id).map(r => r.uid)));
  if (!gates.every(r => r.id)) { for (const P of both) await closePerson(P); await stopServer(S); return; }
  // Links that close no loop: `x` may wait on `y` when nothing `y` waits on, however far back, is `x`.
  const byId = new Map(rows.filter(r => r.id).map(r => [r.id, r]));
  const reaches = (from, to, seen = new Set()) => from === to || (!seen.has(from) && (seen.add(from), ((byId.get(from) || {}).deps || []).some(d => reaches(d, to, seen))));
  const canWait = (x, y) => x.id !== y.id && !(x.deps || []).includes(y.id) && !reaches(y.id, x.id);
  const work = rows.filter(r => r.type === 'task' && r.id && !/cancel/i.test(r.status || ''));
  const g1 = gates[0], g2 = gates[1];
  const [t1, t2] = work.filter(t => canWait(g1, t)).slice(0, 2);
  const t3 = work.find(t => t !== t1 && t !== t2 && canWait(t, g2)), t4 = work.find(t => t !== t1 && t !== t2 && t !== t3 && canWait(g2, t) && !reaches(t.id, t3.id));

  // 1. Two people make one gate wait on different work at the same moment: both links stand.
  const c1 = await tabRow(A, g1.uid);
  await pause();
  await setF(A, g1.uid, 'deps', [...c1.deps, t1.id]); await setF(Bp, g1.uid, 'deps', [...c1.deps, t2.id]);
  await sent(both); await resume();
  let g = await srvRow(g1.uid), ta = await tabRow(A, g1.uid), tb = await tabRow(Bp, g1.uid);
  check('two people make one milestone wait on different work at the same moment: both links are kept', [t1.id, t2.id].every(d => (g.deps || []).includes(d)), JSON.stringify(g.deps));
  check('and both tabs show both', [ta, tb].every(t => t.deps.includes(t1.id) && t.deps.includes(t2.id)), JSON.stringify([ta.deps, tb.deps]));

  // 2. At the same moment one person makes a task wait on a gate and the other makes that gate
  //    wait on other work: both directions stand.
  const c3 = await tabRow(A, t3.uid), c2 = await tabRow(Bp, g2.uid);
  await pause();
  await setF(A, t3.uid, 'deps', [...c3.deps, g2.id]); await setF(Bp, g2.uid, 'deps', [...c2.deps, t4.id]);
  await sent(both); await resume();
  const s3 = await srvRow(t3.uid), s2 = await srvRow(g2.uid);
  check('one person makes a task wait on a milestone while another makes the milestone wait on other work: both links are kept', (s3.deps || []).includes(g2.id) && (s2.deps || []).includes(t4.id), JSON.stringify([s3.deps, s2.deps]));
  const b3 = await tabRow(Bp, t3.uid), a2 = await tabRow(A, g2.uid);
  check('and each tab shows the other person\'s link', b3.deps.includes(g2.id) && a2.deps.includes(t4.id), JSON.stringify([b3.deps, a2.deps]));
  const arrows = await Promise.all(both.map(P => P.page.evaluate((m, t, w) => [!!document.querySelector(`path[data-from="${m}"][data-to="${t}"]`), !!document.querySelector(`path[data-from="${w}"][data-to="${m}"]`)], g2.id, t3.id, t4.id)));
  check('and the arrows out of and into the milestone are drawn in both tabs', arrows.every(x => x[0] && x[1]), JSON.stringify(arrows));

  // 3. Undo is your own on a milestone's list too: it takes out the link you made and keeps
  //    the one a teammate made after it.
  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);
  const u3 = await srvRow(t3.uid), u2 = await srvRow(g2.uid);
  check('undo takes back your link to the milestone and keeps the teammate\'s link from it', !(u3.deps || []).includes(g2.id) && (u2.deps || []).includes(t4.id), JSON.stringify([u3.deps, u2.deps]));

  const diffs = await convergence(S, both);
  check('after all of it, both tabs hold exactly what the server holds, milestone links included', !diffs.length, diffs.slice(0, 3).join(' | '));
  const errs = both.flatMap(P => P.errors);
  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));
  for (const P of both) await closePerson(P);
  await stopServer(S);
}

async function fuzzScenario(carbon) {
  scenario = 'fuzz';
  const S = await startServer(carbon);
  const N = Math.min(USERS, 6), people = [];
  for (let i = 0; i < N; i++) { const P = await openPerson(S, carbon, i); await goReady(P, S); people.push(P); }
  await sleep(1500);
  if (!(await hasOps(people[0]))) { skip('fuzz', 'this build sends whole rows, not fields'); for (const P of people) await closePerson(P); await stopServer(S); return; }
  const all = await planRows(S, people[0].sid);
  const pool = all.filter(r => r.type === 'task' && r.uid && r.phase < 3);
  const ids = all.filter(r => r.id).map(r => r.id);
  const writers = new Map(), lastVal = new Map();
  const DUR = Math.min(SECONDS, 40) * 1000, t0 = Date.now();
  let actions = 0, drops = 0;
  await Promise.all(people.map(async (P, pi) => {
    const R = rng(SEED + pi);
    let n = 0, offline = false;
    while (Date.now() - t0 < DUR) {
      const row = pool[Math.floor(R() * pool.length)], x = R();
      let act;
      if (x < 0.42) {
        const f = ['owner', 'status', 'note', 'desc', 'effort', 'e'][Math.floor(R() * 6)];
        const v = f === 'owner' ? PEOPLE[Math.floor(R() * PEOPLE.length)] : f === 'status' ? STATUSES[Math.floor(R() * STATUSES.length)]
          : f === 'note' ? `n${pi}.${n}` : f === 'desc' ? `d${pi}.${n}` : f === 'effort' ? Math.floor(R() * 40) : shiftDay(row.start, 3 + Math.floor(R() * 20));
        act = { k: 'set', uid: row.uid, f, v };
      } else if (x < 0.56) act = { k: 'list', uid: row.uid, f: 'deps', item: ids[Math.floor(R() * ids.length)] };
      else if (x < 0.68) act = { k: 'list', uid: row.uid, f: 'tags', item: ['Red', 'Green', 'Blue', 'Gold'][Math.floor(R() * 4)] };
      else if (x < 0.8) act = { k: 'undo' };
      else if (x < 0.87) act = { k: 'redo' };
      else if (x < 0.95) act = { k: 'move', uid: row.uid, r: R() };
      else act = { k: 'net' };
      if (act.k === 'net') {
        offline = !offline; if (offline) drops++;
        await P.page.evaluate(o => o ? window.__collab.pause() : window.__collab.resume(), offline);
      } else {
        // The action and the before/after read are one synchronous call, so nobody else's
        // change can land between them and be credited to this person.
        const changed = await P.page.evaluate(a => {
          const C = window.__collab, snap = () => new Map(C.rows().map(r => [r.uid, r]));
          const before = snap();
          if (a.k === 'set') C.set(a.uid, a.f, a.v);
          else if (a.k === 'list') { const r = before.get(a.uid); if (r) { const cur = a.f === 'deps' ? r.deps : r.tags; const has = cur.includes(a.f === 'tags' ? a.item.toLowerCase() : a.item);
            C.set(a.uid, a.f, has ? cur.filter(x => x !== (a.f === 'tags' ? a.item.toLowerCase() : a.item)) : [...cur, a.item]); } }
          else if (a.k === 'undo') C.undo(); else if (a.k === 'redo') C.redo();
          else if (a.k === 'move') { const r = before.get(a.uid); const mates = C.order().filter(u => { const m = before.get(u); return m && r && m.phase === r.phase && u !== a.uid; });
            if (mates.length) C.move(a.uid, mates[Math.floor(a.r * mates.length)]); }
          const after = snap(), out = [];
          for (const [u, r] of after) { const b = before.get(u); if (!b) continue;
            for (const f of ['owner', 'status', 'note', 'desc', 'effort', 'end', 'deps', 'tags']) if (JSON.stringify(b[f]) !== JSON.stringify(r[f])) out.push([u, f, r[f]]); }
          return out;
        }, act);
        for (const [u, f, v] of changed) { const k = u + '|' + f; (writers.get(k) || writers.set(k, new Set()).get(k)).add(pi); lastVal.set(k + '|' + pi, v); }
      }
      actions++; n++;
      await sleep(60 + R() * 350);
    }
    if (offline) await P.page.evaluate(() => window.__collab.resume());
  }));
  const took = await quiesce(S, people, 60000);
  check(`${N} people, ${actions} actions in ${Math.round(DUR / 1000)} s (${drops} drop-offs): everyone settles`, took >= 0, took >= 0 ? fmtMs(took) : 'never settled');
  const diffs = await convergence(S, people);
  check('every tab holds exactly what the server holds: every field, every list, the row order', !diffs.length, diffs.slice(0, 4).join(' | '));
  const srv = new Map((await planRows(S, people[0].sid)).map(r => [r.uid, r]));
  const lost = [];
  let single = 0;
  for (const [k, who] of writers) {
    if (who.size !== 1) continue;
    single++;
    const [u, f] = k.split('|'), pi = [...who][0], want = lastVal.get(k + '|' + pi), r = srv.get(u);
    if (!r) continue;
    const have = f === 'tags' ? (r.tags || []).map(t => t.toLowerCase()).sort() : f === 'deps' ? (r.deps || []).slice().sort() : f === 'desc' ? (r.description || '') : f === 'effort' ? (typeof r.effort === 'number' ? r.effort : null) : (r[f] || '');
    const w = Array.isArray(want) ? want.slice().sort() : (want === undefined ? '' : want);
    if (JSON.stringify(have) !== JSON.stringify(w)) lost.push(`${u}.${f}: ${PEOPLE[pi]} left ${JSON.stringify(w)}, server has ${JSON.stringify(have)}`);
  }
  check(`no edit lost: each of the ${single} fields only one person wrote ends as that person left it`, !lost.length, lost.slice(0, 4).join(' | '));
  const errs = people.flatMap(P => P.errors);
  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));
  const h = await (await fetch(S.base + '/health')).json();
  check('the changelog\'s hash chain is intact', h.integrity ? h.integrity.ok !== false : true, JSON.stringify(h.integrity));
  report.scenarios.fuzz = { seed: SEED, people: N, actions, drops, settleMs: took, singleWriterFields: single };
  for (const P of people) await closePerson(P);
  await stopServer(S);
}

// ════════════════════════════════════════════════════════════════════════════
const t0 = Date.now();
let exitCode = 1;
const carbon = await fakeCarbon();
try {
  BROWSER = await puppeteer.launch({ executablePath: CHROME, headless: 'new', protocolTimeout: 600000,
    args: ['--no-sandbox', '--disable-dev-shm-usage', '--hide-scrollbars'] });
  console.log(`adom-gantt collaboration suite: ${USERS} people, ${PLAN ? basename(PLAN) : 'generated plan'}, CPU ${THROTTLE}x, app ${APP}`);
  if (want('join')) await joinScenario(carbon);
  if (want('merge')) await mergeScenario(carbon);
  if (want('meeting')) await meetingScenario(carbon);
  if (want('presence')) await presenceScenario(carbon);
  if (want('idle')) await idleScenario(carbon);
  if (want('soak')) await soakScenario(carbon);
  if (want('conflict')) await conflictScenario(carbon);
  if (want('reconnect')) await reconnectScenario(carbon);
  if (want('docs')) await docsScenario(carbon);
  if (want('fuzz')) await fuzzScenario(carbon);
  if (want('sections')) await sectionsScenario(carbon);
  if (want('subtasks')) await subtasksScenario(carbon);
  if (want('milestones')) await milestonesScenario(carbon);
  if (want('requests')) await requestsScenario(carbon);
  exitCode = fail ? 1 : 0;
} catch (e) {
  console.error('\nSUITE CRASHED:', e && e.stack || e);
  fail++; failures.push(`[${scenario}] suite crashed: ${e && e.message}`);
} finally {
  for (const S of servers.slice()) await stopServer(S);
  try { await BROWSER?.close(); } catch {}
  carbon.close();
  report.pass = pass; report.fail = fail; report.skipped = skipped; report.failures = failures; report.seconds = Math.round((Date.now() - t0) / 1000);
  if (REPORT) writeFileSync(REPORT, JSON.stringify(report, null, 1));
  if (!KEEP) { try { rmSync(TMP, { recursive: true, force: true }); } catch {} } else console.log('kept', TMP);
  console.log(`\n${pass} passed, ${fail} failed${skipped ? `, ${skipped} skipped` : ''} in ${report.seconds} s`);
  if (failures.length) { console.log('Failures:'); failures.forEach(f => console.log('  - ' + f)); }
  process.exit(exitCode);
}