With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.
Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.
Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.
Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
210 lines
10 KiB
JavaScript
210 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { world, sleep, SCOPES } from './world.mjs';
|
|
import { taskFields } from '../src/digest.mjs';
|
|
import { FakeVikunja } from '../src/fake.mjs';
|
|
const recorded = JSON.parse(readFileSync(new URL('./fixtures/v2-shapes.json', import.meta.url)));
|
|
delete recorded.$comment;
|
|
// Each recording, by the fake's route name and status. 401 bodies come from the auth layer on any route.
|
|
const KEY = {
|
|
'GET /projects/{p}/views': 'views 200',
|
|
'GET /projects/{p}/views/{k}/buckets': 'buckets 200',
|
|
'GET /labels (pm)': 'labels 200',
|
|
'GET board (sync)': 'board 200',
|
|
'GET /tasks/{missing}': 'read 404',
|
|
'401 scope': '* 401',
|
|
'401 expired': '* 401',
|
|
'POST /tasks/{t}/comments': 'comment 201',
|
|
'GET /tasks/{t}/comments': 'comments 200',
|
|
'POST /tasks/{t}/relations': 'relate 201',
|
|
'DELETE unassigned assignee': 'unassign 204',
|
|
'DELETE absent label': 'labelRemove 403',
|
|
'GET /projects/{p}': 'project 200',
|
|
'POST /projects/{p}/tasks': 'create 201',
|
|
'PATCH /tasks/{t}': 'patch 200',
|
|
'PATCH bucket_id': 'patch 200',
|
|
'PUT move': 'move 200',
|
|
'PUT move into done': 'move 200',
|
|
'GET /tasks/{t}': 'read 200',
|
|
'GET cursor': 'list 200',
|
|
'GET reconcile': 'list 200',
|
|
'GET task moved away': 'read 403',
|
|
'GET deleted task': 'read 404',
|
|
'PATCH bucket_id (b)': 'patch 200',
|
|
'POST /tasks/{t}/assignees': 'assign 201',
|
|
'DELETE /tasks/{t}/assignees/{u}': 'unassign 500',
|
|
'POST /tasks/{t}/labels': 'labelAdd 201',
|
|
'DELETE /tasks/{t}/labels/{l}': 'labelRemove 204',
|
|
'DELETE /tasks/{t}/relations': 'unrelate 404',
|
|
'GET /tasks/{t} 403': 'read 403',
|
|
'GET /projects/{p} 403': 'project 403',
|
|
'GET /tasks/{t} related': 'read 200',
|
|
'GET cursor related': 'list 200',
|
|
'GET board related': 'board 200',
|
|
};
|
|
// The scenario below does not produce these; a test elsewhere or the probe notes cover each.
|
|
const UNPRODUCED = new Set([
|
|
// A 500 on removing an assignee is a recorded Vikunja fault the fake raises only through fault().
|
|
'unassign 500',
|
|
// S3 never removes a relation that is absent.
|
|
'unrelate 404',
|
|
]);
|
|
const samples = {};
|
|
for (const [name, r] of Object.entries(recorded)) {
|
|
assert.ok(KEY[name], `recording ${name} has no route`);
|
|
assert.equal(String(r.status), KEY[name].split(' ')[1], name);
|
|
(samples[KEY[name]] ??= []).push(r.body);
|
|
}
|
|
// A user or task object has one shape whatever the route, though one recording may hold a null
|
|
// where another holds a value: Vikunja leaves bot_owner_id out for a person, and sends null for a
|
|
// task with no assignees. So users and tasks are checked against every one any recording sent.
|
|
// The cost: a key one route adds, like comment_count or max_permission, passes on any route.
|
|
const isUser = (v) => 'username' in v;
|
|
const isTask = (v) => 'project_id' in v && 'done' in v && 'percent_done' in v;
|
|
const users = [],
|
|
tasks = [];
|
|
const collect = (v) => {
|
|
if (Array.isArray(v)) v.forEach(collect);
|
|
else if (v && typeof v === 'object') {
|
|
if (isUser(v)) users.push(v);
|
|
if (isTask(v)) tasks.push(v);
|
|
Object.values(v).forEach(collect);
|
|
}
|
|
};
|
|
collect(Object.values(recorded).map((r) => r.body));
|
|
// Every key the fake sends must be one Vikunja sent at that place, with the same JSON type. The fake
|
|
// may leave keys out, and may send null where Vikunja sent a value: Vikunja sends null for empty lists.
|
|
function compare(fake, real, at, problems) {
|
|
if (fake === null || fake === undefined) return;
|
|
if (typeof fake === 'object' && !Array.isArray(fake)) {
|
|
if (isUser(fake)) real = users;
|
|
else if (isTask(fake)) real = tasks;
|
|
}
|
|
const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
|
|
const seen = real.filter((v) => v !== null && v !== undefined);
|
|
if (!seen.length) return problems.push(`${at}: Vikunja sent no value here`);
|
|
if (!seen.some((v) => kind(v) === kind(fake))) return problems.push(`${at}: ${kind(fake)}, Vikunja sent ${kind(seen[0])}`);
|
|
if (Array.isArray(fake)) {
|
|
const items = seen.filter(Array.isArray).flat();
|
|
for (const x of fake) compare(x, items.length ? items : [null], at + '[]', problems);
|
|
} else if (typeof fake === 'object') {
|
|
const objects = seen.filter((v) => kind(v) === 'object');
|
|
for (const [k, v] of Object.entries(fake)) {
|
|
if (k === '$schema') continue;
|
|
if (!objects.some((o) => k in o)) problems.push(`${at}.${k}: Vikunja sent no such key`);
|
|
else compare(v, objects.map((o) => o[k]), `${at}.${k}`, problems);
|
|
}
|
|
}
|
|
}
|
|
test('the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent', async (t) => {
|
|
const seen = [];
|
|
let w;
|
|
const capture = async (url, init) => {
|
|
const r = await w.fake.fetch(url, init);
|
|
const route = w.fake.requests.at(-1);
|
|
const text = await r.clone().text();
|
|
seen.push({ route, body: text ? JSON.parse(text) : null });
|
|
return r;
|
|
};
|
|
w = await world(t, { fetch: capture });
|
|
// The route name, from the fake's table, for each recorded request.
|
|
const name = (r) => w.fake.routeName(r.method, r.path);
|
|
await w.adapter.start('demo');
|
|
const pm = w.agent('pm'),
|
|
coder = w.agent('coder');
|
|
const a = await w.call(pm, 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' });
|
|
const b = await w.call(pm, 'task.create', {
|
|
title: 'b',
|
|
request: w.instruction(),
|
|
requirement: 'REQ-S-1',
|
|
labels: [w.label],
|
|
relations: [{ kind: 'blocked', task_ref: a.task_ref }],
|
|
});
|
|
await w.call(pm, 'task.assign', { task_ref: a.task_ref, role: 'coder' });
|
|
await w.call(coder, 'task.update.assigned', { task_ref: a.task_ref, state: 'in-progress', percent_done: 0.5, comment: 'c' });
|
|
await w.call(pm, 'task.schedule', { task_ref: b.task_ref, labels: { remove: [w.label] }, due_date: '2026-11-01T00:00:00.000Z' });
|
|
await w.call(pm, 'task.reassign', { task_ref: a.task_ref, role: 'reviewer' });
|
|
await w.call(pm, 'task.close', { task_ref: b.task_ref, verdict: 'v' });
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'person' });
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
await w.adapter.reconcile('demo');
|
|
// The label probe's 403 and the sync bot's view of a task in a project not shared with it.
|
|
await capture(`http://vikunja.test/api/v2/tasks/1/labels/${w.label}`, {
|
|
method: 'DELETE',
|
|
headers: { authorization: 'Bearer ' + w.tokens.pm },
|
|
});
|
|
const hidden = w.fake.project('Launchpad', w.owner);
|
|
w.fake.moveToProject(1, hidden);
|
|
await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: null }).catch(() => {});
|
|
await capture(`http://vikunja.test/api/v2/projects/${hidden}`, { headers: { authorization: 'Bearer ' + w.tokens.sync } });
|
|
assert.ok(seen.length > 0);
|
|
const key = (r) => `${r.status === 401 ? '*' : name(r)} ${r.status}`;
|
|
for (const { route, body } of seen) {
|
|
const k = key(route);
|
|
// The startup version read has no recording to compare against.
|
|
if (!samples[k] && k.startsWith('info ')) continue;
|
|
assert.ok(samples[k], `the fake answered ${route.method} ${route.path} with ${route.status}, which Vikunja never sent`);
|
|
const problems = [];
|
|
compare(body, samples[k], k, problems);
|
|
assert.deepEqual(problems, [], `${route.method} ${route.path}`);
|
|
}
|
|
const covered = new Set(seen.map((s) => key(s.route)));
|
|
for (const key of new Set(Object.values(KEY)))
|
|
if (!UNPRODUCED.has(key)) assert.ok(covered.has(key), `the scenario never produced ${key}`);
|
|
});
|
|
// No recording holds a GET /projects list. Its envelope is the one every list sent, and each item
|
|
// is checked against the recorded GET /projects/{p} object, which has parent_project_id and is_archived.
|
|
test('the project list holds recorded project objects in the recorded list envelope', async (t) => {
|
|
const w = await world(t, { scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } } });
|
|
const child = w.fake.project('Child', w.owner, { parent: w.project });
|
|
w.fake.pseudoProject(w.bots.sync);
|
|
const r = await w.fake.fetch('http://vikunja.test/api/v2/projects?per_page=50&page=1', {
|
|
headers: { authorization: 'Bearer ' + w.tokens.sync },
|
|
});
|
|
assert.equal(r.status, 200);
|
|
const body = await r.json();
|
|
const problems = [];
|
|
compare({ ...body, items: [] }, samples['views 200'], 'projects', problems);
|
|
for (const p of body.items) compare(p, samples['project 200'], 'projects[]', problems);
|
|
assert.deepEqual(problems, []);
|
|
assert.deepEqual(
|
|
body.items.map((p) => [p.id, p.parent_project_id, p.is_archived]),
|
|
[
|
|
[w.project, 0, false],
|
|
[child, w.project, false],
|
|
[-1, 0, false],
|
|
],
|
|
);
|
|
});
|
|
test('the recorded task bodies pass the checks S3 applies to every read', () => {
|
|
const tasks = [];
|
|
for (const [name, r] of Object.entries(recorded)) {
|
|
const b = r.body;
|
|
if (r.status >= 400 || !b || typeof b !== 'object') continue;
|
|
if ('project_id' in b && 'done' in b) tasks.push([name, b]);
|
|
if (b.task) tasks.push([name + ' .task', b.task]);
|
|
for (const x of b.items ?? []) {
|
|
if ('done' in x) tasks.push([name + ' item', x]);
|
|
for (const y of x.tasks ?? []) tasks.push([name + ' board task', y]);
|
|
}
|
|
}
|
|
assert.ok(tasks.length >= 8, `${tasks.length} task bodies`);
|
|
for (const [name, task] of tasks) assert.doesNotThrow(() => taskFields(task, 1), name);
|
|
assert.ok(tasks.some(([, x]) => Number.isSafeInteger(x.comment_count)), 'reconcile reads comment_count');
|
|
});
|
|
test('the client works against the fake over real HTTP with the platform fetch', async (t) => {
|
|
const fake = new FakeVikunja();
|
|
const server = await fake.listen();
|
|
t.after(server.close);
|
|
const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } });
|
|
await w.adapter.start('demo');
|
|
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
|
|
const r = await w.call(w.agent('pm'), 'task.create', { title: 'over http', request: w.instruction(), requirement: 'REQ-S-1' });
|
|
assert.equal(r.task_ref, `vikunja:${w.project}/1`);
|
|
await w.ui('PATCH', '/tasks/1', { title: 'edited' });
|
|
await sleep(20);
|
|
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 1, events: 1 });
|
|
});
|