#/tasks is a system route that shows every organization's tasks on one board, with the per-organization columns, organization and project chips on each card, and the organization and project filters kept in the address. An organization that did not answer is named first with its reason. A move or edit goes to the row's own organization and is offered only where that organization's own config takes task changes. #/orgs/<id>/tasks is the same board scoped to one organization, and a card opened from the system board links to its task there. A render replaced by a newer one now draws from that newer read instead of throwing on an empty board.
483 lines
37 KiB
JavaScript
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}/#/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.');
|
|
});
|