packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).
Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).
Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.
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-S-1' });
|
|
await reached;
|
|
const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-S-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-S-1' }),
|
|
(e) => e.code === 'tracker-closed',
|
|
);
|
|
assert.equal(w.events('task.created').length, 1);
|
|
assert.deepEqual(w.log, []);
|
|
});
|