// 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('Bad gateway', { 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.'); });