Files
stack/packages/webui/tests/tasks-board-browser.test.mjs

483 lines
37 KiB
JavaScript

// U4b (#1873, PRD 1.20): the task board across organizations (sample 06) in a real browser.
// Three orgs: forge keeps its tasks in the built-in store, ddigital in Vikunja, home's bus is down.
// Every bus is a fake in this file; each org's taskWrite records what reached that org only.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const D = n => String(n).repeat(64);
function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const DOCS = {
ddigital: { appearance: { theme: 'rosewood' }, tracker: { kind: 'vikunja' }, roles: { copywriter: { tracker: { botId: 7 } }, editor: { tracker: { botId: 8 } } } },
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {}, reviewer: {} } },
home: { appearance: { theme: 'grove' } },
};
const PROJECTS = {
ddigital: [{ id: 'spring-launch', repo: 'spring', name: 'Spring launch', at: '2026-11-09T10:00:00.000Z' }],
forge: [{ id: 'api-gateway', repo: 'gateway', name: 'API gateway', at: '2026-11-09T10:00:00.000Z' }, { id: 'billing', repo: 'billing', name: 'Billing', at: '2026-11-08T10:00:00.000Z' }],
};
const store = [
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' },
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null },
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null },
].map(t => ({ ...t, rev: 0 }));
// A task's digest changes with every write to it, as the store's does.
const digest = t => D((t.n + t.rev) % 10);
const TASKS = {
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: t.readOnly ? undefined : digest(t), requirement: 'REQ-API-1',
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
ddigital: () => [
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
{ task_ref: 'vikunja:3/118', project_key: 'spring', priority: 0, at: '2026-11-08T11:00:00.000Z', fields: { title: 'Teaser reel', done: true, assignees: [8], bucket: 14, labels: [] } },
],
};
const writes = [];
const taskWrite = org => async (verb, args) => {
writes.push([org, verb, args]);
if (refuse) throw Object.assign(new Error('refused'), { code: refuse });
const t = store.find(s => `task:${s.n}` === args.task_ref);
if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' });
if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; }
return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) };
};
const options = id => ({
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) },
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
});
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
const down = async () => ({ healthy: false, pid: null, boot: null });
return {
writes, store,
list: async () => ORGS,
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })),
resolve: async id => {
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: taskWrite(id) };
},
};
}
async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
return { b, wait, orgs, url };
}
const cards = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)');
test('the board merges every organization\'s tasks, tags each with its org, and a down org keeps its state', async t => {
const { b } = await open(t);
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'kanban');
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118', 'forge|task:41', 'forge|task:42', 'forge|task:43']);
// Each card carries its org's mark and the id in its tracker's form.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-card")].map(c => [c.dataset.org, c.querySelector(".id").textContent, !!c.querySelector(".ws-av"), c.querySelector(".tb-trk").textContent]).sort()'),
[['ddigital', 'V-112', true, 'Vikunja'], ['ddigital', 'V-118', true, 'Vikunja'], ['forge', '#41', true, 'built-in'], ['forge', '#42', true, 'built-in'], ['forge', '#43', true, 'built-in']]);
// Columns: the store's states, plus one for the Vikunja task whose bucket the bus does not name.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done', 'State not read']);
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-col=todo] [data-tb-task=\'forge|task:41\'] .bot").children].map(e => e.textContent.trim())'), ['API gateway', 'coder', 'P4', 'due 20 Nov']);
// Only built-in store cards can be dragged; a Vikunja card is read only.
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'forge|task:41\']").draggable'), true);
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'ddigital|vikunja:3/112\']").draggable'), false);
// home's bus is down: it shows its state and reason, and the other orgs still render.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-tracker]")].map(e => [e.dataset.tbTracker, e.dataset.state])'), [['ddigital', 'ok'], ['forge', 'ok'], ['home', 'down']]);
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=home]").textContent'), /Home.*bus down.*the bus is not running/s);
assert.match(await b.evaluate('document.querySelector("[data-tb-org=home]").textContent'), /Home\s*unavailable/);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /5 tasks from 2 of 3 organizations.*Vikunja tasks are read only here/);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /not built yet/);
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), false);
});
test('org and project filters narrow the board', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-org=forge]").click()');
await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"');
assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']);
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.');
// The project list follows the org filter.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['forge|task:42']);
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.');
// Back to every org; the project filter stays on forge's project.
await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()');
await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"');
assert.deepEqual(await cards(b), ['forge|task:42']);
// A project of another org, then the assignee filter on a Vikunja role.
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "ddigital:spring-launch"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 2');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=assignee]"); s.value = "copywriter"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['ddigital|vikunja:3/112']);
});
test('the layout toggle is remembered across a reload', async t => {
const { b, wait, url } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
// Load the page again through navigate(), which waits for the load. A location.reload() from evaluate
// returns before the reload commits, so the next wait ran on the old page and the reload killed it
// (as instances-browser found). The same URL with only a hash would not load, so add a query.
await b.navigate(url.replace('/#', '/?reload=1#'));
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
});
test('a phone opens on List with no sideways scroll, and Kanban is one tap away', async t => {
const { b, wait } = await open(t, { width: 390, height: 844 });
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
// One column on a phone.
assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".tb-board")).gridTemplateColumns.split(" ").length'), 1);
});
test('a move writes only to its own org, and a polled repaint keeps focus', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// Vikunja rows have no State control; a store row does.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=ddigital]")'), null);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.focus(); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/moved to Review in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:41\']").value'), 'in-review');
// The search keeps focus and its caret through the poll's repaint.
await b.evaluate('(() => { const q = document.querySelector("[data-tb-q]"); q.focus(); q.value = "inv"; q.dispatchEvent(new Event("input", { bubbles: true })); q.setSelectionRange(1, 1); })()');
await wait('document.querySelectorAll("[data-tb-list] tr.tb-row").length === 1');
// bus.js repaints the view on its own 10 s poll; wait for that repaint to replace the board.
assert.equal(await b.evaluate('(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()'), true);
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement?.selectionStart, document.querySelector("[data-tb-q]").value]'), ['tb-q', 1, 'inv']);
});
test('a write the bus refuses for want of a person is shown, and nothing else is written', async t => {
const { b, wait, orgs } = await open(t, { refuse: 'human-required' });
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:42\']")');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:42\']"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelector("[data-tb-notice][role=alert]")');
assert.match(await b.evaluate('document.querySelector("[data-tb-notice]").textContent'), /#42 was not moved\. The bus takes task changes only from a person at the Console\. Nothing was written\./);
assert.deepEqual(orgs.writes.map(w => w[0]), ['forge']);
// The board still shows the task where the bus has it.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:42\']").value'), 'in-progress');
// The edit dialog shows the same refusal.
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/only from a person/.test(document.querySelector("#tb-dialog [data-tb-form-status]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]);
});
test('a Console without --send shows a read-only board and the write route refuses', async t => {
const { b, orgs } = await open(t, { send: false });
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
assert.equal(await b.evaluate('document.querySelectorAll(".tb-card[draggable=true]").length'), 0);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /board is read only/);
const status = await b.evaluate('fetch("/api/workspace/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "edit", org: "forge", task_ref: "task:41", state: "done" }) }).then(r => r.status)');
assert.equal(status, 403);
assert.deepEqual(orgs.writes, []);
});
// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board.
const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()';
test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
// Another writer renames the task and assigns it; a poll then reads the new version.
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
assert.equal(await b.evaluate(POLLED), true);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
// Save sends the digest and the changes against the task as the dialog showed it.
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
assert.equal(t42.title, 'Invoice export (renamed elsewhere)');
// The dialog reopens on the current task, so the next save is checked against what it now shows.
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
});
// Polish 1 (#1873): after a conflict the dialog reads the current task before it reopens. This holds
// that read: the first workspace read after the write's answer waits until window.__release().
const HOLD_CONFLICT_READ = `(() => {
const real = window.fetch;
window.__armed = false; window.__held = false;
window.fetch = (u, o) => {
const url = String(u);
if (url === '/api/workspace/tasks') return real(u, o).then(r => { window.__armed = true; return r; });
if (url.startsWith('/api/workspace?') && window.__armed && !window.__held) {
window.__held = true;
return new Promise(go => { window.__release = () => real(u, o).then(go); });
}
return real(u, o);
};
})()`;
const SAVE_AS = title => `(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = ${JSON.stringify(title)}; f.requestSubmit(); })()`;
const STATUS = 'document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent';
test('an Archive conflict reopens on the current task and never sends the old digest again', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
await b.evaluate(HOLD_CONFLICT_READ);
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()');
await wait('window.__held');
assert.equal(await b.evaluate(STATUS), 'Reading the current task…');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").disabled'), true);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true);
await b.evaluate(SAVE_AS('A stale Save while Archive refreshes'));
// Even a synthetic click past the disabled control cannot send another stale write.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").dispatchEvent(new MouseEvent("click", { bubbles: true }))');
assert.deepEqual(orgs.writes, [['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }]]);
await b.evaluate('window.__release()');
await wait('/changed after the board read it/.test(' + STATUS + ')');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").textContent'), 'Archive');
// The refreshed task must be confirmed anew; its archive uses only the current version.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()');
assert.equal(orgs.writes.length, 1);
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()');
await wait('/#42 archived in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [
['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }],
['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(3) }],
]);
});
for (const operation of ['Save', 'Archive']) {
test(`${operation}: a conflict onto a read-only task explains the refusal only until the dialog closes`, async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (now read only)', readOnly: true, rev: t42.rev + 1 });
await b.evaluate(operation === 'Save' ? SAVE_AS('Invoice export, CSV')
: 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form")'), null);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog h2").textContent'), 'Invoice export (now read only)');
const status = await b.evaluate(`(() => { const s = document.querySelector('#tb-dialog [data-tb-form-status]');
return s && { role: s.getAttribute('role'), text: s.textContent, styled: s.classList.contains('tb-form-status'), visible: s.getClientRects().length > 0 }; })()`);
assert.ok(status, 'a refused write needs a status in the read-only view');
assert.equal(status.role, 'status');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), 'tb-dialog-status');
assert.equal(await b.evaluate('document.getElementById("tb-dialog-status").textContent'), status.text);
assert.equal(status.styled, true); assert.equal(status.visible, true);
assert.equal(status.text, 'The task changed after the board read it. Nothing was written; the board now shows the current task.');
assert.equal(orgs.writes.length, 1);
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('!document.querySelector("#tb-dialog").open && !document.querySelector("#tb-dialog").innerHTML');
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]")'), null, 'the next ordinary open has no refusal line');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), null);
});
}
test('after a conflict the dialog shows the refusal only with the current task, and takes no save while it reads it', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
await b.evaluate(HOLD_CONFLICT_READ);
await b.evaluate(SAVE_AS('Invoice export, CSV'));
await wait('window.__held');
// The write was refused and the current task is still being read: the form still holds the old task,
// so it does not show the refusal and its Save is off.
assert.equal(await b.evaluate(STATUS), 'Reading the current task…');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export, CSV');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true);
// A save in this window, even one sent past the disabled button, goes nowhere.
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
await b.evaluate('new Promise(r => setTimeout(r, 200))');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
// Once the read answers, the refusal and the current task appear together.
await b.evaluate('window.__release()');
await wait('/changed after the board read it/.test(' + STATUS + ')');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), false);
await b.evaluate(SAVE_AS('Invoice export, CSV'));
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
assert.equal(orgs.writes.length, 2);
});
for (const operation of ['Save', 'Archive']) {
const send = operation === 'Save' ? SAVE_AS('Invoice export, CSV') : 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()';
test(`${operation}: after a conflict a task that can no longer be read leaves the dialog unable to write`, async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
const t42 = orgs.store.find(s => s.n === 42);
t42.rev++;
await b.evaluate(HOLD_CONFLICT_READ);
await b.evaluate(send);
await wait('window.__held');
// The task leaves the store before the dialog's read answers, so neither read finds it.
orgs.store.splice(orgs.store.indexOf(t42), 1);
await b.evaluate('window.__release()');
await wait('/could not be read/.test(' + STATUS + ')');
assert.match(await b.evaluate(STATUS), /^The task changed after the board read it\. Nothing was written, and the current task could not be read; close this form and open the task again\.$/);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog button")].map(x => [x.textContent, x.disabled])'), [[operation === 'Archive' ? 'Archive: press again to confirm' : 'Archive', true], ['Cancel', false], ['Save', true]]);
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
await b.evaluate('new Promise(r => setTimeout(r, 200))');
assert.equal(orgs.writes.length, 1);
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('!document.querySelector("#tb-dialog").open');
});
test(`${operation}: a dialog closed just before the conflict read answers stays closed`, async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
orgs.store.find(s => s.n === 42).rev++;
// Hold the read's body, not the request, so the close and the read's answer land in one task:
// close() clears `open` at once, but the event that empties the dialog runs after the read resumes.
await b.evaluate(`(() => {
const real = window.fetch;
let armed = false, held = false;
window.fetch = (u, o) => {
if (String(u) === '/api/workspace/tasks') return real(u, o).then(r => { armed = true; return r; });
if (String(u).startsWith('/api/workspace?') && armed && !held) {
held = true;
return real(u, o).then(async r => {
const body = await r.json();
r.json = () => new Promise(go => { window.__releaseJson = () => go(structuredClone(body)); });
return r;
});
}
return real(u, o);
};
})()`);
await b.evaluate(send);
await wait('typeof window.__releaseJson === "function"');
await b.evaluate('document.querySelector("#tb-dialog").close(); window.__releaseJson()');
await b.evaluate('new Promise(r => setTimeout(r, 250))');
assert.deepEqual(await b.evaluate('[document.querySelector("#tb-dialog").open, document.querySelector("#tb-dialog").innerHTML]'), [false, '']);
assert.equal(orgs.writes.length, 1);
});
}
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// Hold the next workspace read until focus has moved from the search to a row's State control.
await b.evaluate(`(() => {
const real = window.fetch;
window.__held = null;
window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held
? new Promise(go => { window.__held = () => real(u, o).then(go); })
: real(u, o);
document.querySelector("[data-tb-q]").focus();
})()`);
// bus.js polls every 10 s, longer than waitFor allows.
assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true);
await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())');
await b.evaluate('window.__held()');
await wait('document.querySelector("[data-tasks-board]") !== window.__board');
await b.evaluate('new Promise(r => setTimeout(r, 100))');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43');
});
test('an org whose tasks read fails shows its tasks as not read, not as none', async t => {
const { b } = await open(t, { tasksFail: ['forge'] });
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']);
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s);
assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/);
// No org that can take a write is left, so New task is off.
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
});
test('a board with no tasks read at all says so instead of showing an empty board', async t => {
const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] });
assert.deepEqual(await cards(b), []);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./);
});
test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// The write reaches the bus; a proxy then replaces the answer with a page that is not JSON.
await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('<html>Bad gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)');
const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent');
assert.doesNotMatch(text, /was not moved|Nothing was written/);
assert.match(text, /^#41: /);
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
// The board reads again and shows where the bus has the task.
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"');
});
test('a filter change asks the server for the matching tasks', async t => {
const { b, wait } = await open(t);
await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__reads.length');
const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]'));
assert.equal(q.get('project'), 'forge:billing');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['forge|task:42']);
});
test('a conflict on a task another writer moved out of the board\'s filter reopens on the current task', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "in-progress"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
// Another writer finishes the task, so the board's In progress read no longer holds it.
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { state: 'done', rev: t42.rev + 1 });
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('document.querySelector("#tb-dialog form")?.state.value === "done"');
assert.match(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]").textContent'), /changed after the board read it/);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), true);
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
});
test('an empty result names the organizations whose tasks were not read', async t => {
const { b, wait } = await open(t, { tasksFail: ['ddigital'] });
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "blocked"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/No tasks match/.test(document.querySelector("[data-tb-results]").textContent)');
assert.equal(await b.evaluate('document.querySelector("[data-tb-results] .ws-panel").textContent'),
'No tasks match these filters in the organizations that answered. Tasks from Distinctly Digital, Home could not be read.');
});