Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on manifest 95611274 (21 paths, baseb8539480). 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 onc047d028plus 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]>
202 lines
8.1 KiB
JavaScript
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, []);
|
|
});
|