// SP4 (#1873, single pane slice 4): one task board for the whole system at #/tasks. Every // organization that answers adds its tasks to one board, in the per-organization page's columns, // each card with its organization chip and its project chip. The organization and project filters // live in the hash query. A move goes to the row's own organization, offered only where that // organization's own config takes task changes, never judged by the organization opened last. // A stopped organization is named at the top with its reason. #/orgs//tasks is the same board, // scoped to one organization, and a card opened from the system board opens there. // Every bus is a fake: acme and beta keep their tasks in the built-in store, gamma's bus is stopped. // The real GET /api/workspace and the real POST /api/workspace/tasks serve the page. 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<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const D = n => String(n).repeat(64); function fixture({ empty = false, archived = [] } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, { id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false }, ]; const PROJECTS = { acme: [{ id: 'site', repo: 'site', name: 'Website', archived: false, at: at(30) }], beta: [{ id: 'shop', repo: 'shop', name: 'Shop', archived: false, at: at(60) }, { id: 'ads', repo: 'ads', name: 'Ads', archived: false, at: at(90) }], }; const STORE = { acme: [{ n: 1, title: 'Hero copy', state: 'todo', group: 'site', assignees: ['coder'] }], beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }], }; if (empty) { STORE.acme = []; STORE.beta = []; } for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`); const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1', fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } })); const writes = []; const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; }; const opts = id => ({ bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => tasks(id), messages: async () => ({ messages: [], unread: 0 }) }, chat: { projects: async () => PROJECTS[id] || [], threads: async () => [] }, }); const member = org => ({ id: org.id, doc: { appearance: { theme: org.theme }, roles: { coder: {} } }, status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), options: () => opts(org.id), }); return { writes, list: async () => LIST, members: async () => LIST.map(member), resolve: async id => { const org = LIST.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, human: 'jason', projects: PROJECTS[id].map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, ...opts(id), bus: { ...opts(id).bus, instances: async () => ({ business: id, instances: {} }) }, taskWrite: taskWrite(id), }; }, }; } // Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. // Every organization's config says this Console takes task changes, except those named in `noWrite`. // The real server sets orgSetup for the whole Console, so this per-organization difference is faked: // it proves the board asks each row's own organization, which matters where one config read fails. const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)}; (() => { const real = window.fetch.bind(window); window.fetch = async (url, init = {}) => { const u = String(url); if (!/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) return real(url, init); const c = await (await real(url, init)).json(); const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1]; return new Response(JSON.stringify({ ...c, orgSetup: c.orgSetup === true && !window.__noWrite.includes(org) }), { status: 200, headers: { 'content-type': 'application/json' } }); }; })();`; async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) { const orgs = fixture({ empty, archived }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close()); await b.viewport(...viewport); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB(noWrite) }); const base = `http://127.0.0.1:${server.address().port}`; await b.navigate(`${base}/${hash}`); return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; } const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)'; const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())'; const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3'])); const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3'])); test('#/tasks shows both organizations\' tasks on one board, each card with its organization and project chips', async t => { const { b, wait, text } = await setup(t); await wait(`${cards} === ${ALL}`); // A system route: the address keeps no organization and the sidebar Tasks is this page. assert.equal(await b.evaluate('location.hash'), '#/tasks'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks'); assert.equal(await text('#bus-view h1'), 'Tasks'); // SP7: the page name shows once, as the heading. assert.equal(await b.evaluate(trail), null); // The per-organization page's columns. assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']); // One chip names the organization, with its avatar in place of the dot. assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="acme|task:1"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Acme Studio'); assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="beta|task:2"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Beta Works'); assert.equal(await text('[data-tb-task="acme|task:1"] [data-ws-projchip]'), 'Website'); assert.equal(await text('[data-tb-task="beta|task:3"] [data-ws-projchip]'), 'Ads'); assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true); // The first load draws the board once, with no "last read failed" banner from a render the next one replaced. assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null); }); test('the organization and project filters live in the address and survive a reload', async t => { const { b, base, wait } = await setup(t); await wait(`${cards} === ${ALL}`); await b.evaluate('document.querySelector("[data-tb-org=beta]").click()'); await wait(`location.hash === "#/tasks?org=beta" && ${cards} === ${BETA}`); await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "beta:shop"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait(`location.hash === "#/tasks?org=beta&project=beta%3Ashop" && ${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`); await b.navigate(`${base}/?reload=1#/tasks?org=beta&project=beta%3Ashop`); await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`); assert.equal(await b.evaluate('document.querySelector("[data-tb-org=beta]").getAttribute("aria-pressed")'), 'true'); assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop'); // A hand-edited value that is not an organization id or a project is ignored, not read as a filter. await b.navigate(`${base}/?reload=2#/tasks?org=Not%20An%20Id&project=nope`); await wait(`${cards} === ${ALL}`); assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true'); // An organization or project that is not on the list falls back to all, and the address drops it. await b.navigate(`${base}/?reload=3#/tasks?org=gone&project=beta%3Agone`); await wait(`${cards} === ${ALL} && location.hash === "#/tasks"`); }); test('a move on beta\'s row while acme was opened last reaches beta, and acme\'s own config keeps its rows read only', async t => { const { b, wait, orgs } = await setup(t, { hash: '#/orgs/acme/organization', noWrite: ['acme'] }); await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); await b.evaluate('location.hash = "#/tasks"'); await wait(`${cards} === ${ALL}`); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); await b.evaluate('document.querySelector("[data-tb-layout=list]").click()'); await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")'); // Acme's config takes no task changes, so its row has no State control and its card does not drag. assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=acme]")'), null); await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait('/moved to Review in Beta Works/.test(document.querySelector("[data-tb-notice]").textContent)'); assert.deepEqual(orgs.writes, [['beta', 'tasks.edit', { task_ref: 'task:2', expect: D(2), state: 'in-review' }]]); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); }); test('a stopped organization is named at the top of the board with its reason', async t => { const { b, wait, text } = await setup(t); await wait(`${cards} === ${ALL}`); const note = await text('[data-ws-unread]'); assert.match(note, /Gamma Home/); assert.match(note, /stopped by the owner/); assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector(".tb-toolbar")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true); }); test('with no tasks anywhere the board says so and still names the stopped organization', async t => { const { b, wait, text } = await setup(t, { empty: true }); await wait('/No tasks yet/.test(document.querySelector("[data-tb-results]")?.textContent || "")'); assert.match(await text('[data-tb-results]'), /Gamma Home could not be read/); assert.match(await text('[data-ws-unread]'), /Gamma Home/); }); test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organization', async t => { const { b, base, wait } = await setup(t, { hash: '#/orgs/beta/tasks' }); await wait(`${cards} === ${BETA}`); assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks'); assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta'); // SP7: a drill-in keeps its trail. await wait(`${trail} === 'Dashboard / Beta Works / Tasks'`); // No filter is on, so no "match the filters" line: the scope is not a filter. assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null); assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null); assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /Tasks from Beta Works only/); assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .sub a")?.getAttribute("href")'), '#/tasks'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks'); // The sidebar Tasks link opens the system board. await b.evaluate('document.querySelector("#sections a[href=\'#/tasks\']").click()'); await wait(`location.hash === "#/tasks" && ${cards} === ${ALL}`); // An address that names no possible organization is not the system board. await b.navigate(`${base}/?reload=1#/orgs/Not%20An%20Id/tasks`); await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")'); assert.equal(await b.evaluate(cards), '[]'); }); test('a card opened from the system board opens the task in its organization', async t => { const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' }); await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); await b.evaluate('location.hash = "#/tasks"'); await wait(`${cards} === ${ALL}`); await b.evaluate('document.querySelector("[data-tb-open=\'beta|task:3\']").click()'); await wait('document.querySelector("#tb-dialog[open] [data-tb-goto]")'); assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").getAttribute("href")'), '#/orgs/beta/tasks?task=task%3A3'); assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").textContent'), 'Open in Beta Works'); await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").click()'); await wait('location.hash.startsWith("#/orgs/beta/tasks") && window.MosaicOrg.id === "beta" && document.querySelector("[data-tasks-board]")?.dataset.tbScope === "beta"'); await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")?.dataset.tbKey === "beta|task:3"'); // The scoped page offers no second hop to the organization it is already in. assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]")'), null); // Closing the task takes it off the address, so a reload does not open it again. await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()'); await wait('location.hash === "#/orgs/beta/tasks" && !document.querySelector("#tb-dialog").open'); }); // Holds each task board read for ?hold= ms (or window.__hold, set later) and counts the reads that // started and the ones that came back, so a test can act while a read is in flight. A task write is // held for window.__holdW ms and counted the same way. const HOLD = `window.__hold = Number(new URLSearchParams(location.search).get('hold')) || 0; window.__started = 0; window.__landed = 0; window.__holdW = 0; window.__wStarted = 0; window.__wLanded = 0; (() => { const real = window.fetch.bind(window); window.fetch = async (url, init = {}) => { if (String(url) === '/api/workspace/tasks' && init.method === 'POST' && window.__holdW) { window.__wStarted++; await new Promise(r => setTimeout(r, window.__holdW)); try { return await real(url, init); } finally { window.__wLanded++; } } if (!/^\\/api\\/workspace\\?sections=tasks(?:%2C|,)projects/.test(String(url))) return real(url, init); window.__started++; if (window.__hold) await new Promise(r => setTimeout(r, window.__hold)); try { return await real(url, init); } finally { window.__landed++; } }; })();`; const settle = b => b.evaluate('new Promise(r => setTimeout(r, 200))'); test('a project filter stays in the address while its organization does not answer', async t => { const { b, orgs, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop' }); const SHOP = JSON.stringify(JSON.stringify(['beta|task:2'])); await wait(`${cards} === ${SHOP}`); const list = await orgs.list(); list[1].busHealthy = false; await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); await wait('/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")'); await settle(b); assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop'); list[1].busHealthy = true; await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); await wait(`${cards} === ${SHOP} && !/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")`); assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop'); assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop'); }); test('a gone filter is cleaned from the tasks address only, never from a page opened while it was read', async t => { const { b, base, wait } = await setup(t); await wait(`${cards} === ${ALL}`); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD }); await b.navigate(`${base}/?hold=800#/tasks?org=gone`); await wait('window.__started >= 1'); await b.evaluate('location.hash = "#/inbox?org=acme"'); await wait('window.__landed >= 1'); await settle(b); assert.equal(await b.evaluate('location.hash'), '#/inbox?org=acme'); }); test('a read in flight does not paint the board over the page for an address no organization matches', async t => { const { b, base, wait } = await setup(t); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD }); await b.navigate(`${base}/?reload=1#/tasks`); await wait(`${cards} === ${ALL}`); await b.evaluate('window.__hold = 800; window.__started = 0; window.__landed = 0'); await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "todo"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait('window.__started >= 1'); await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"'); await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")'); await wait('window.__landed >= window.__started'); await settle(b); assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/); assert.equal(await b.evaluate(cards), '[]'); }); test('a move still writing when the not-on-your-list page opens does not paint the board over it', async t => { const { b, base, wait } = await setup(t); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD }); await b.navigate(`${base}/?reload=1#/tasks`); await wait(`${cards} === ${ALL}`); await b.evaluate('document.querySelector("[data-tb-layout=list]").click()'); await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")'); await b.evaluate('window.__holdW = 800; window.__started = 0; window.__landed = 0'); await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait('window.__wStarted >= 1'); await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"'); await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")'); await wait('window.__wLanded >= 1 && window.__landed >= window.__started'); await settle(b); assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/); assert.equal(await b.evaluate(cards), '[]'); }); test('an archived project keeps its filter while it has tasks, and while another filter matches none of them', async t => { const { b, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop', archived: ['beta:shop'] }); await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`); await settle(b); assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop'); // No shop task is done: the read comes back empty because of the state filter, not because shop is gone. await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait(`${cards} === "[]" && /No tasks match/.test(document.querySelector("[data-tb-results]")?.textContent || "")`); await settle(b); assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop'); });