Files
stack/packages/tasks/tests/adapter.test.mjs
T
jason.woltjeandClaude Opus 5.5 e918a915a8 tasks, bus, harness, seat: the approved requirement ids as data (#1584)
Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on
manifest 95611274 (21 paths, base b8539480). The approved PRD's
requirement ids live in packages/tasks/requirements.json. task_create
refuses an id outside it, and the PM session gets a list_requirements
tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES
(decision 127). Sage gate on c047d028 plus the patch, 23:50:16Z to
23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33,
business 60, control-board 124, queue 150, conversation 182, discord 178,
ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh
exited 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:55:49 -05:00

202 lines
8.1 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, appendFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { world, sleep } from './world.mjs';
import { tasksAdapter, TIMEOUT } from '../src/index.mjs';
const day = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
const credentialEvents = (w) =>
w.store
.all("SELECT kind, body FROM events WHERE kind LIKE 'credential.%' ORDER BY seq")
.map((e) => [e.kind, JSON.parse(e.body)]);
// Timers the test fires by hand.
const manual = () => {
const set = [];
return {
set,
setInterval: (fn, ms) => {
const t = { fn, ms, cleared: false, unref() {} };
set.push(t);
return t;
},
clearInterval: (t) => {
t.cleared = true;
},
};
};
test('the boot config is checked before anything starts', async (t) => {
const w = await world(t);
const make = (tracker, edit = (b) => b) =>
tasksAdapter({ trackers: { demo: tracker }, fetch: w.fake.fetch, autostart: false })({
broker: w.broker,
credentials: w.credentials,
businesses: { demo: edit(structuredClone(w.businesses.demo)) },
});
const good = { baseUrl: 'http://vikunja.test', project: w.project };
for (const bad of [
{ ...good, baseUrl: 'http://vikunja.test/api/v2' },
{ ...good, baseUrl: 'ftp://vikunja.test' },
{ ...good, baseUrl: 'http://user:[email protected]' },
{ ...good, project: 0 },
{ ...good, project: '1' },
{ ...good, pollSeconds: 5 },
{ ...good, reconcileMinutes: 0 },
null,
])
await assert.rejects(make(bad), (e) => e.code === 'tracker-config', JSON.stringify(bad));
for (const edit of [
(b) => ((b.tracker.sync.botId = 0), b),
(b) => ((b.roles.coder.definition = 'pm'), b),
(b) => (delete b.roles.pm.tracker, b),
(b) => ((b.roles.coder.tracker.botId = -1), b),
(b) => ((b.tracker.labels.bad = 0), b),
])
await assert.rejects(make(good, edit), (e) => e.code === 'tracker-config');
await assert.rejects(
tasksAdapter({ trackers: { other: good } })({ broker: w.broker, credentials: w.credentials, businesses: w.businesses }),
(e) => e.code === 'tracker-config',
);
const a = await make({ ...good, pollSeconds: 10 });
assert.equal(a.timeout, TIMEOUT);
assert.deepEqual(a.status(), [
{ business: 'demo', state: 'starting', refused: null, version: null, untested: null, lastPoll: null, lastReconcile: null },
]);
await a.close();
});
test('a business with no tracker entry refuses task verbs', async (t) => {
const w = await world(t);
const a = await tasksAdapter({ trackers: {}, autostart: false })({
broker: w.broker,
credentials: w.credentials,
businesses: w.businesses,
});
await assert.rejects(
w.broker.requestTask(w.agent('pm'), { verb: 'task.close', args: { task_ref: 'vikunja:1/1', verdict: 'v' } }, a.handle),
(e) => e.code === 'tracker-unconfigured',
);
assert.deepEqual(a.status(), []);
});
test('credential.expiring and .expired are recorded once per instance', async (t) => {
const w = await world(t, { expires: { coder: day(3), reviewer: day(10) } });
await w.adapter.start('demo');
await w.adapter.notify('demo');
assert.deepEqual(credentialEvents(w), [['credential.expiring', { service: 'vikunja', instance: 'demo/coder', date: day(3) }]]);
const gone = await world(t, { expires: { pm: day(-1) } });
await gone.adapter.start('demo');
assert.equal(gone.adapter.status()[0].refused, 'credential-expired');
await gone.adapter.notify('demo');
assert.deepEqual(credentialEvents(gone), [['credential.expired', { service: 'vikunja', instance: 'demo/pm', date: day(-1) }]]);
});
test('a token file that changes on disk records credential.changed', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'tasks-cred-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'pm.token');
const w = await world(t, { files: { pm: file } });
await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready');
await w.adapter.notify('demo');
assert.deepEqual(credentialEvents(w), []);
appendFileSync(file, '\n');
await w.adapter.notify('demo');
await w.adapter.notify('demo');
assert.deepEqual(credentialEvents(w), [['credential.changed', { service: 'vikunja', instance: 'demo/pm' }]]);
// The file's path and contents stay out of the event.
assert.ok(!JSON.stringify(credentialEvents(w)).includes(dir));
});
test('autostart polls, reconciles and retries a startup the tracker was down for', async (t) => {
const timers = manual();
let down = true;
let w;
w = await world(t, {
adapter: { autostart: true, timers },
tracker: { pollSeconds: 15, reconcileMinutes: 30 },
fetch: (...a) => (down ? Promise.reject(new TypeError('fetch failed')) : w.fake.fetch(...a)),
});
await w.adapter.ready;
assert.equal(w.adapter.status()[0].refused, 'tracker-unavailable');
assert.deepEqual(
timers.set.map((x) => x.ms),
[15000, 1800000],
);
const [poll, hourly] = timers.set;
down = false;
poll.fn();
await w.adapter.notify('demo');
assert.equal(w.adapter.status()[0].state, 'ready');
await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'made in the UI' });
// Startup's reconcile recorded nothing yet; this tick finds the new task.
await sleep(20);
poll.fn();
await w.adapter.notify('demo');
assert.equal(w.events('task.changed.external').length, 1);
hourly.fn();
await w.adapter.notify('demo');
assert.notEqual(w.adapter.status()[0].lastReconcile, null);
await w.adapter.close();
assert.ok(timers.set.every((x) => x.cleared));
});
test('a refusal a restart must clear is not retried by the poll', async (t) => {
const timers = manual();
const w = await world(t, { adapter: { autostart: true, timers }, scopes: { sync: '*' } });
await w.adapter.ready;
assert.equal(w.adapter.status()[0].refused, 'scope-too-broad');
const before = w.fake.requests.length;
timers.set[0].fn();
timers.set[1].fn();
await w.adapter.notify('demo');
assert.equal(w.fake.requests.length, before);
assert.deepEqual(w.log, ['tasks demo startup scope-too-broad']);
});
test('a poll that fires while two are queued is dropped', async (t) => {
const timers = manual();
const w = await world(t, { adapter: { autostart: true, timers } });
await w.adapter.ready;
const boards = () => w.fake.requests.filter((r) => r.method === 'GET' && r.path.endsWith('/buckets/tasks')).length;
let before = boards();
await w.adapter.tick('demo');
const perTick = boards() - before;
assert.ok(perTick > 0);
before = boards();
// One tick runs and one waits; the other three are dropped.
for (let i = 0; i < 5; i++) timers.set[0].fn();
await w.adapter.notify('demo');
assert.equal(boards() - before, 2 * perTick);
assert.deepEqual(w.log, []);
});
test('close waits for a running verb and refuses one that has not started', async (t) => {
let hold, entered;
const reached = new Promise((ok) => (entered = ok));
let w;
w = await world(t, {
fetch: async (url, init) => {
if (hold && init?.method === 'POST' && url.endsWith(`/projects/${w.project}/tasks`)) {
entered();
await hold;
}
return w.fake.fetch(url, init);
},
});
await w.adapter.start('demo');
const pm = w.agent('pm');
let release;
hold = new Promise((ok) => (release = ok));
const running = w.call(pm, 'task.create', { title: 'x', request: w.instruction(), requirement: 'REQ-TASK-1' });
await reached;
const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-TASK-1' });
let done = false;
const closing = w.adapter.close().then(() => (done = true));
await sleep(20);
assert.equal(done, false);
release();
await closing;
assert.ok((await running).task_ref);
await assert.rejects(queued, (e) => e.code === 'tracker-closed');
await assert.rejects(
w.call(pm, 'task.create', { title: 'z', request: w.instruction(), requirement: 'REQ-TASK-1' }),
(e) => e.code === 'tracker-closed',
);
assert.equal(w.events('task.created').length, 1);
assert.deepEqual(w.log, []);
});