app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
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);
}