// U3 (#1873, PRD 1.20 REQ-WS-1..6): the cross-organization Dashboard, Projects and Inbox pages // (samples 02, 04 and 05). Every bus here is a fake: two running organizations and one stopped, // read through the real GET /api/workspace (workspace-read.mjs) and the real page code. 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(); // acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show. const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10'; const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d'; function fixture({ pollMs, fail = {}, down = [] } = {}) { 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 DATA = { acme: { projects: [ { id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) }, { id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: at(60 * 24 * 20) }, ], tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }], agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }], claims: [], threads: [], inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }], decisions: [], }, beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], claims: [], threads: [], inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }] }], decisions: [], }, }; // fail: { org: [read names] } makes those reads refuse the way a disabled chat channel does. const value = (id, name) => fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); const opts = id => ({ bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') }, chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') }, }); const member = org => ({ id: org.id, doc: { appearance: { theme: org.theme } }, // down: the cross-organization read finds these buses stopped; the page's own reads still answer. status: async () => (org.busHealthy && !down.includes(org.id) ? { 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 { 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 () => [] } }; return { busHealthy: true, reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, ...(pollMs ? { pollMs } : {}), }; } async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) { const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); const base = `http://127.0.0.1:${server.address().port}`; await b.navigate(`${base}/#/orgs/acme${hash}`); return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; } test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => { const { b, wait, text } = await setup(t); await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3'); // The Dashboard nav item is this page. assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/'); assert.equal(await text('#bus-view h1'), 'Dashboard'); // Tiles: needs you, sessions, open tasks, projects, and an honest line for spend. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']); assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i); assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i); assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/); assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/); // Organization cards: two running with their read, the stopped one with its state and the time it was read. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]); assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/); assert.match(await text('[data-ws-orgcard=gamma]'), /down/); assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/); assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization'); assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/); assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/); // Recent project activity across both running organizations, newest first, each linking into its org. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-recent] a")].map(a => a.getAttribute("href"))'), ['#/orgs/acme/projects/p-site', '#/orgs/beta/projects/p-shop', '#/orgs/acme/projects/p-zeta']); assert.match(await text('[data-ws-states]'), /Doing\s*1/); assert.match(await text('[data-ws-states]'), /Done this week\s*0/); // Needs you lists both organizations' decisions, newest raised first as the read orders them. const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])'); assert.equal(needs.length, 2); assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs)); assert.ok(needs.some(([q]) => q === 'Pick a launch price')); // Sessions now: the working and the waiting role, each with its organization. assert.match(await text('[data-ws-sessions]'), /coder/); assert.match(await text('[data-ws-sessions]'), /pm/); // The sidebar Inbox count follows the read across organizations. assert.equal(await b.evaluate('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent'), '2'); // The per-organization board moved to #/board; the processing link goes there. assert.equal(await b.evaluate('document.getElementById("processing-link").getAttribute("href")'), '#/board'); }); test('Projects sorts and filters by each key, remembers the grouping, and rows open the project in its organization', async t => { const { b, wait, text } = await setup(t, { hash: '/projects' }); const names = 'JSON.stringify([...document.querySelectorAll("[data-ws-project]")].map(r => r.dataset.wsProject))'; const settle = async (hash, expected) => { await wait(`location.hash === ${JSON.stringify(hash)} && ${names} === ${JSON.stringify(JSON.stringify(expected))}`); }; await wait('document.querySelector("[data-ws-projects] [data-ws-table]")'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Projects'); // Default: active only, newest activity first, grouped by organization with the stopped one marked. assert.equal(await b.evaluate(names), JSON.stringify(['acme:p-site', 'acme:p-zeta', 'beta:p-shop'])); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-group-org]")].map(r => r.dataset.wsGroupOrg)'), ['acme', 'beta', 'gamma']); assert.match(await text('[data-ws-table] tr.ws-unread'), /Not read.*the bus/); // Rows open the existing project page in their own organization. assert.equal(await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").getAttribute("href")'), '#/orgs/beta/projects/p-shop'); // Sort by name (ascending), then again to reverse it. await b.evaluate('document.querySelector("[data-ws-sort=name]").click()'); await b.evaluate('document.querySelector("[data-ws-group=flat]").click()'); await settle('#/orgs/acme/projects?sort=name', ['beta:p-shop', 'acme:p-site', 'acme:p-zeta']); assert.equal(await b.evaluate('document.querySelector("[data-ws-table] th[aria-sort]").textContent.trim()'), 'Project↑'); await b.evaluate('document.querySelector("[data-ws-sort=name]").click()'); await settle('#/orgs/acme/projects?sort=name&order=desc', ['acme:p-zeta', 'acme:p-site', 'beta:p-shop']); // Sort by organization and by status. await b.evaluate('document.querySelector("[data-ws-sort=org]").click()'); await wait('location.hash === "#/orgs/acme/projects?sort=org" && document.querySelector("[data-ws-sort=org]").getAttribute("aria-pressed") === "true"'); assert.deepEqual(JSON.parse(await b.evaluate(names)).map(k => k.split(':')[0]), ['acme', 'acme', 'beta']); await b.evaluate('document.querySelector("[data-ws-sort=status]").click()'); await wait('location.hash === "#/orgs/acme/projects?sort=status" && document.querySelector("[data-ws-sort=status]").getAttribute("aria-pressed") === "true"'); // Filter by status: archived and both. await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await settle('#/orgs/acme/projects?sort=status&status=archived', ['acme:p-old']); await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "active,archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait('document.querySelectorAll("[data-ws-project]").length === 4'); // Filter by session state: only Website has a decision waiting. await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = "waiting"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-site']))}`); await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait('document.querySelectorAll("[data-ws-project]").length === 4'); // Filter by organization chip, then back to all. await b.evaluate('document.querySelector("[data-ws-org=beta]").click()'); await wait(`${names} === ${JSON.stringify(JSON.stringify(['beta:p-shop']))} && document.querySelector("[data-ws-org=beta]").getAttribute("aria-pressed") === "true"`); assert.match(location(await b.evaluate('location.hash')), /org=beta/); await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()'); await wait('document.querySelectorAll("[data-ws-project]").length === 4'); // Filter by name: typing keeps focus and the caret in the box while the list narrows. await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "zet"; i.dispatchEvent(new Event("input", { bubbles: true })); })()'); await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-zeta']))}`); assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.value, document.activeElement.selectionStart]'), ['proj-name', 'zet', 3]); // Flat mode shows an Organization column and names the stopped organization; the choice is the // sidebar's remembered one and survives a reload. assert.equal(await b.evaluate('document.querySelector("[data-ws-table]").dataset.grouping'), 'flat'); assert.match(await text('[data-ws-unread]'), /Gamma Home down/); assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=flat]").getAttribute("aria-pressed")'), 'true'); assert.equal(await b.evaluate('localStorage.getItem("mosaic-workspace-project-grouping-v1")'), 'flat'); await b.evaluate('location.reload()'); await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "flat" && document.querySelectorAll("[data-ws-project]").length === 1 && document.querySelector("#workspace-nav [data-group=flat]")?.getAttribute("aria-pressed") === "true"'); // The sidebar's toggle switches this page back. // The nav repaints after async reads, so the click retries until the page switches. await wait('(document.querySelector("#workspace-nav [data-group=org]")?.click(), document.querySelector("[data-ws-table]")?.dataset.grouping === "org")'); await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "org" && document.querySelector("[data-ws-group=org]").getAttribute("aria-pressed") === "true"'); }); const location = hash => decodeURIComponent(hash); test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => { const { b, base, wait, text } = await setup(t, { hash: '/inbox' }); await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox'); assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null); const items = await b.evaluate('[...document.querySelectorAll("[data-ws-item]")].map(r => [r.dataset.wsItem, r.innerText])'); assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]); // Each row names its organization, project and session. const acme = items.find(([k]) => k.startsWith('acme'))[1]; assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/); const beta = items.find(([k]) => k.startsWith('beta'))[1]; assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/); assert.match(await text('[data-ws-unread]'), /Gamma Home down/); // Tabs the data can't back say so. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]); assert.match(await text('[data-ws-inbox]'), /not built yet/); // Select the acme decision; the inspector shows it, and the selection stays in the hash. // A pointer click focuses the link before it activates; .click() alone does not. await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`); await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === ${JSON.stringify(`acme:${ACME_DECISION}`)}`); assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), `item:acme:${ACME_DECISION}`); const cmds = await b.evaluate('[...document.querySelectorAll("[data-ws-cmds] [data-copy]")].map(x => [x.dataset.copy, x.previousElementSibling.tagName, x.previousElementSibling.textContent])'); assert.deepEqual(cmds, [[`mosaic decide ${ACME_DECISION} yes --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} yes --org acme`], [`mosaic decide ${ACME_DECISION} no --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} no --org acme`]]); // Copy puts the full command on the clipboard (or selects it when there is none) and says so. await b.evaluate('document.querySelector("[data-ws-cmds] [data-copy]").click()'); await wait('document.getElementById("copy-status")?.textContent.length > 0'); const status = await text('#copy-status'); assert.ok(status.includes(`mosaic decide ${ACME_DECISION} yes --org acme`) || /clipboard is unavailable/.test(status), status); // An answer opens the existing per-organization decision page. assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] .ws-opts a").getAttribute("href")'), `#/orgs/acme/inbox/${ACME_DECISION}`); // Without a signed-in human session and its proof, the per-organization decide path refuses. const refused = await b.evaluate(`(async () => { const r = await fetch('/api/orgs/acme/decisions/${ACME_DECISION}/confirmation', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }) }); return [r.status, (await r.json().catch(() => ({}))).error ?? null]; })()`); assert.ok(refused[0] >= 400 && refused[0] < 500, JSON.stringify(refused)); // The decision page itself offers no answer controls without that proof. await b.navigate(`${base}/#/orgs/acme/inbox/${ACME_DECISION}`); await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")'); assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null); // Filter to one organization. await b.navigate(`${base}/#/orgs/acme/inbox`); await wait('document.querySelectorAll("[data-ws-item]").length === 2'); await b.evaluate('document.querySelector("[data-ws-org=beta]").click()'); await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`); }); test('A poll repaints in place: focus, caret and scroll stay where they were', async t => { const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] }); await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3'); assert.ok(await b.evaluate('window.MosaicWorkspace.pollMs') >= 1000); // Focus a row link, scroll the page, and repaint as a poll does. await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").focus({ preventScroll: true }); (document.scrollingElement.scrollTop = 120); document.getElementById("main").scrollTop = 120;'); const before = await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]'); assert.ok(before.some(v => v > 0), `nothing scrolled: ${before}`); await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); await wait('document.querySelector("[data-ws-table]") !== window.__marker'); assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'prow:beta:p-shop'); assert.deepEqual(await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]'), before); // The search box keeps its caret across a repaint. await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "shop"; i.dispatchEvent(new Event("input", { bubbles: true })); })()'); await wait('location.hash.includes("name=shop") && document.querySelectorAll("[data-ws-project]").length === 1 && document.activeElement?.dataset.focusKey === "proj-name"'); await b.evaluate('document.activeElement.setSelectionRange(2, 2)'); await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); await wait('document.querySelector("[data-ws-table]") !== window.__marker'); assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]); // The Dashboard and the Inbox repaint the same way. await b.evaluate('location.hash = "#/orgs/acme/"'); await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3'); await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()'); await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); await wait('document.querySelector("[data-ws-orgs]") !== window.__marker'); assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta'); // No loading view replaced the page during that repaint. assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null); }); test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => { const { b, wait } = await setup(t, { viewport: [390, 844] }); for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) { await b.evaluate(`location.hash = "#/orgs/acme${hash}"`); await wait(`document.querySelector(${JSON.stringify(ready)})`); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash); } await b.evaluate('document.getElementById("nav-open").click()'); await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"'); assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility !== "hidden" && document.getElementById("console-nav").getBoundingClientRect().right > 0'), true); await b.evaluate('document.getElementById("nav-close").click()'); await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "false"'); // A sidebar link closes the drawer and opens its page. await b.evaluate('document.getElementById("nav-open").click()'); await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"'); await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()'); await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")'); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true); // An inbox row at phone width puts its source on its own line above the question. await b.evaluate('location.hash = "#/orgs/acme/inbox"'); await wait('document.querySelector("[data-ws-item] .ws-msrc")'); assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question'); }); test('A read that an answering organization could not make says not read, never zero', async t => { // beta answers, but its project and inbox reads refuse. const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } }); await wait('document.querySelector("[data-ws-tiles]")'); assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/); assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/); assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/); assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/); assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed'); // Projects, grouped: beta's group says why and shows no count or "No projects match". await b.evaluate('location.hash = "#/orgs/acme/projects"'); await wait('document.querySelector("[data-ws-group-org=beta]")'); assert.equal(await b.evaluate(`document.querySelector('[data-ws-group-org=beta] .ws-n')`), null); const betaRow = await b.evaluate(`document.querySelector('[data-ws-group-org=beta]').nextElementSibling.innerText`); assert.match(betaRow, /Projects not read: the chat channel is off/); assert.match(await text('[data-ws-org=beta] .n'), /not read/i); // Flat: the note names it. await b.evaluate('document.querySelector("[data-ws-group=flat]").click()'); await wait('document.querySelector("[data-ws-miss=projects]")'); assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/); // Inbox: the note names it, and the chip carries no count. await b.evaluate('location.hash = "#/orgs/acme/inbox"'); await wait('document.querySelector("[data-ws-miss=inbox]")'); assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/); assert.match(await text('[data-ws-org=beta] .n'), /not read/i); assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-item]").length'), 1); }); test('When every answering organization failed a read, its tile says Not read instead of a number', async t => { const { wait, text } = await setup(t, { fail: { acme: ['projects'], beta: ['projects'] } }); await wait('document.querySelector("[data-ws-tiles]")'); assert.match(await text('[data-ws-tile=projects]'), /Not read/i); assert.equal(await text('[data-ws-tile=projects] .ws-big'), null); }); // Review P2 (org-u3): a project row's figures come from reads beyond the project list. test('Project rows and per-organization figures say not read when the reads behind them failed', async t => { const { b, wait, text } = await setup(t, { fail: { beta: ['inbox', 'tasks', 'agents', 'claims', 'threads'] }, hash: '/projects' }); await wait('document.querySelector("[data-ws-project=\'beta:p-shop\']")'); const cells = await b.evaluate(`[...document.querySelector("[data-ws-project='beta:p-shop']").cells].map(c => c.innerText.trim())`); // Grouped columns: Project, Status, Sessions, Open tasks, Needs you, Last activity, Git. assert.deepEqual(cells.slice(2, 5), ['not read', 'not read', 'not read'], 'sessions, open tasks and needs you'); assert.match(cells[5], /some reads missing/); const acme = await b.evaluate(`[...document.querySelector("[data-ws-project='acme:p-site']").cells].map(c => c.innerText.trim())`); assert.equal(acme[3], '1'); assert.equal(acme[4], '1'); assert.match(await text('[data-ws-miss=figures]'), /Some figures not read in Beta Works/); // Dashboard: the per-organization figures leave Beta out instead of showing its 0. await b.evaluate('location.hash = "#/orgs/acme/"'); await wait('document.querySelector("[data-ws-tiles]")'); for (const tile of ['needs', 'sessions', 'tasks']) { const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`); assert.doesNotMatch(by, /Beta Works/, tile); assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile); } assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/); }); test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => { const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] }); await wait('document.querySelector("[data-ws-tiles]")'); assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0); assert.match(await text('[data-ws-tiles]'), /No organization answered/); assert.equal(await text('[data-ws-needs]'), 'Decisions not read'); assert.equal(await text('[data-ws-states]'), 'Tasks not read'); assert.equal(await text('[data-ws-recent]'), 'Projects not read'); assert.equal(await text('[data-ws-sessions]'), 'Sessions not read'); await b.evaluate('location.hash = "#/orgs/acme/inbox"'); await wait('document.querySelector("[data-ws-items]")'); assert.equal(await text('[data-ws-items]'), 'Decisions not read'); await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"'); await wait('document.querySelector("[data-ws-table]")'); await b.evaluate('document.querySelector("[data-ws-group=flat]").click()'); await wait('document.querySelector("[data-ws-table][data-grouping=flat]")'); assert.match(await text('[data-ws-table] tbody'), /Projects not read/); }); test('When no organization could read its tasks, Tasks by state shows no Done this week figure', async t => { const { wait, text } = await setup(t, { fail: { acme: ['tasks'], beta: ['tasks'] } }); await wait('document.querySelector("[data-ws-states]")'); assert.equal(await text('[data-ws-states]'), 'Tasks not read'); assert.match(await text('[data-ws-tile=tasks]'), /Not read/); }); // Review P3: the session's organization is in the accessible text, not only in the hidden avatar. test('Sessions now names each session\'s organization for a screen reader', async t => { const { b, wait } = await setup(t); await wait('document.querySelector("[data-ws-sessions] td.who")'); const who = await b.evaluate('[...document.querySelectorAll("[data-ws-sessions] td.who")].map(c => c.textContent)'); assert.ok(who.some(w => /Acme Studio: coder/.test(w)), who.join('|')); assert.ok(who.some(w => /Beta Works: pm/.test(w)), who.join('|')); });