import test from 'node:test'; import assert from 'node:assert/strict'; import { world, sleep } from './world.mjs'; const ready = async (t, edit) => { const w = await world(t, edit); await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready'); return w; }; const create = (w, title = 'a task') => w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title, request: w.instruction(), requirement: 'REQ-S-1' }); const external = (w, subject) => w.events('task.changed.external').filter((e) => subject === undefined || e.subject === subject); // A fake whose clock runs an hour behind, so every task sits outside the cursor window and only the // board or a task read can see it. const behind = { fake: { now: () => Date.now() - 3600000 } }; test('an edit in the UI is recorded once, with the fields that changed', async (t) => { const w = await ready(t); const { task_ref } = await create(w); await w.ui('PATCH', '/tasks/1', { title: 'renamed', priority: 2 }); await sleep(20); assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 1, events: 1 }); const [e] = external(w, task_ref); assert.deepEqual(e.body.changed, ['title', 'priority']); assert.equal(e.body.via, 'cursor'); assert.equal(e.actor_role, null); const s = w.snapshots(task_ref).at(-1); assert.equal(s.source, 'poll'); assert.equal(s.fields.title, 'renamed'); assert.equal(s.digest, e.body.digest); // The overlap window reads the task again; the digest matches, so nothing new is recorded. await sleep(20); assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); assert.equal(w.broker.taskView('demo', 'current', task_ref).digest, e.body.digest); }); test('a move between open buckets is seen on the board, though updated does not change', async (t) => { const w = await ready(t, behind); const { task_ref } = await create(w); const updated = w.fake.task(1).updated; await w.ui('PUT', `/projects/${w.project}/views/${w.installed.view}/buckets/${w.installed.buckets.blocked}/tasks`, { task_id: 1 }); assert.equal(w.fake.task(1).updated, updated); await sleep(20); await w.adapter.tick('demo'); const [e] = external(w, task_ref); assert.equal(e.body.via, 'board'); assert.deepEqual(e.body.changed, ['bucket']); assert.deepEqual( w.broker.taskView('demo', 'open').map((r) => ({ ...r })), [{ task_ref, bucket: w.installed.buckets.blocked }], ); }); test("a person's comment is counted and a bot's is not", async (t) => { const w = await ready(t); const { task_ref } = await create(w); await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' }); await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, comment: 'from the coder' }); await w.ui('POST', '/tasks/1/comments', { comment: 'from a person' }); await sleep(20); await w.adapter.tick('demo'); const [e] = external(w, task_ref); assert.equal(e.body.comments, 1); assert.deepEqual(e.body.changed, []); // A comment changes no digest field, so it is an event without a snapshot. assert.equal(w.snapshots(task_ref).at(-1).source, 'self'); await sleep(20); await w.adapter.tick('demo'); assert.equal(external(w, task_ref).length, 1); }); test('the hourly reconcile catches a comment through comment_count', async (t) => { const w = await ready(t); const { task_ref } = await create(w); await sleep(20); await w.adapter.reconcile('demo'); await w.ui('POST', '/tasks/1/comments', { comment: 'late' }); await sleep(20); await w.adapter.reconcile('demo'); const [e] = external(w, task_ref); assert.equal(e.body.via, 'reconcile'); assert.equal(e.body.comments, 1); // The tick after it reads the same comment list and finds nothing newer. await sleep(20); await w.adapter.tick('demo'); assert.equal(external(w, task_ref).length, 1); }); test('a task closed in the UI leaves the open view with its done bucket', async (t) => { for (const edit of [{}, behind]) { const w = await ready(t, edit); const { task_ref } = await create(w); await w.ui('PATCH', '/tasks/1', { done: true }); await sleep(20); await w.adapter.tick('demo'); const [e] = external(w, task_ref); // The cursor sees a recent close; an old one is found by reading the task the board dropped. assert.equal(e.body.via, edit.fake ? 'task' : 'cursor'); assert.deepEqual(e.body.changed.sort(), ['bucket', 'done']); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, w.installed.buckets.done); assert.deepEqual(w.broker.taskView('demo', 'open'), []); await sleep(20); assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); } }); test('a task that leaves the board is recorded as deleted, moved or out of reach', async (t) => { const w = await ready(t); const shared = w.fake.project('Shared', w.owner); w.fake.share(shared, w.bots.sync, 0); const hidden = w.fake.project('Launchpad', w.owner); const refs = []; for (const title of ['deleted', 'moved', 'hidden']) refs.push((await create(w, title)).task_ref); await w.ui('DELETE', '/tasks/1'); w.fake.moveToProject(2, shared); w.fake.moveToProject(3, hidden); await sleep(20); await w.adapter.tick('demo'); assert.deepEqual( w.events('task.missing').map((e) => [e.subject, e.body]), [ [refs[0], { reason: 'not-found' }], [refs[1], { reason: 'moved', project: shared }], [refs[2], { reason: 'no-access' }], ], ); assert.deepEqual(w.snapshots(refs[1]).at(-1).fields, { gone: 'moved', project: shared }); // The hidden project's title never reaches the bus. assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('Launchpad')); assert.deepEqual(w.broker.taskView('demo', 'open'), []); await sleep(20); assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 }); await assert.rejects(w.call(w.caps.pm, 'task.assign', { task_ref: refs[0], role: 'coder' }), (e) => e.code === 'task-not-found'); }); test('a poll that read before a verb wrote does not overwrite the verb', async (t) => { let skew = 0; const w = await ready(t, { adapter: { clock: () => Date.now() + skew } }); const { task_ref } = await create(w); // The broker's clock can run a few ms ahead of the poll's; a read at or before the self snapshot // is not newer evidence, and the overlap window rereads it next tick. const at = w.broker.taskView('demo', 'current', task_ref).at; await w.ui('PATCH', '/tasks/1', { title: 'raced' }); skew = Date.parse(at) - 1 - Date.now(); await w.adapter.tick('demo'); skew = 0; assert.equal(external(w, task_ref).length, 0); await sleep(20); await w.adapter.tick('demo'); assert.deepEqual(external(w, task_ref)[0].body.changed, ['title']); }); test('a tracker fault during a tick is reported and the next tick catches up', async (t) => { const w = await ready(t); await create(w); await w.ui('PATCH', '/tasks/1', { title: 'while down' }); w.fake.fault('GET', /\/buckets\/tasks$/, 503); await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-unavailable'); await sleep(20); await w.adapter.tick('demo'); assert.equal(external(w).length, 1); }); test('a malformed answer refuses the tick with tracker-shape', async (t) => { const w = await ready(t); await create(w); // A 200 whose body is an error object, not the board. w.fake.fault('GET', /\/buckets\/tasks$/, 200, { apply: true }); await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-shape'); }); test('no token value reaches the database, the log or a refusal', async (t) => { const { readdirSync, readFileSync, statSync } = await import('node:fs'); const { join } = await import('node:path'); const w = await ready(t); const { task_ref } = await create(w); await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' }); await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, state: 'in-progress', comment: 'x' }); await w.ui('PATCH', '/tasks/1', { title: 'y' }); w.fake.fault('PUT', /\/tasks$/, 500); await assert.rejects(w.call(w.caps.coder, 'task.update.assigned', { task_ref, state: 'blocked' })); await sleep(20); await w.adapter.tick('demo'); await w.adapter.reconcile('demo'); w.store.all('PRAGMA wal_checkpoint(TRUNCATE)'); const files = []; const walk = (d) => { for (const f of readdirSync(d)) { const p = join(d, f); if (statSync(p).isDirectory()) walk(p); else files.push(p); } }; walk(w.root); assert.ok(files.length > 0); const values = Object.values(w.tokens); for (const f of files) { const bytes = readFileSync(f, 'latin1'); for (const v of values) assert.ok(!bytes.includes(v), `a token value is in ${f}`); } for (const v of values) assert.ok(!JSON.stringify(w.log).includes(v)); // A token pasted into a verb argument is refused by the broker before the adapter sees it. await assert.rejects( w.call(w.caps.coder, 'task.update.assigned', { task_ref, comment: w.tokens.coder }), (e) => e.code !== undefined && !e.message.includes(w.tokens.coder), ); }); // The first look at a task counts only comments inside the window, so a restart doesn't replay // every comment ever made as new. test('the first look at a task counts only comments inside the window', async (t) => { let shift = -2 * 3600000; const w = await ready(t, { fake: { now: () => Date.now() + shift } }); const { task_ref } = await create(w); await w.ui('POST', '/tasks/1/comments', { comment: 'two hours ago' }); shift = 0; await w.ui('POST', '/tasks/1/comments', { comment: 'just now' }); await sleep(20); await w.adapter.tick('demo'); const [e] = external(w, task_ref); assert.equal(e.body.comments, 1); });