// SP3 (#1873, single pane slice 3): one Inbox for the whole system at #/inbox. Every organization // that answers adds its items to one list, newest first, each row with its organization chip and a // project chip when it has one. Filters by organization and type live in the hash query. An answer // given from the list goes to the row's own organization, never to the one opened last. A stopped // organization is named at the top with its reason. #/orgs//inbox is the same list, filtered. // Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace // and the real per-organization routes 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 ACME_DECISION = 'dec-acme-1'; const BETA_DECISION = 'dec-beta-1'; const DIGEST = 'd'.repeat(64); function fixture({ empty = false } = {}) { 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) }], 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) } }], 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' }] }], }, 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) }], // Eligible for an answer in the Console: the bus marks it with its `console` field. inbox: [{ id: BETA_DECISION, authorization: { action: 'x.y', target: 'shop', approvalChoice: 'high' }, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }], }, }; if (empty) { DATA.acme.inbox = []; DATA.beta.inbox = []; } const value = (id, name) => 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 } }, 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 { 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), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, // The broker's inbox row: the decisions row, so `route_to`, plus `authorization`. 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', route_to: 'human', authorization: d.authorization ?? null })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, }; } // 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 answers decisions, except those named in `noDecide`; // decision POSTs are recorded and answered from a queue, so no real write happens. const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`; const STUB = `(() => { const real = window.fetch.bind(window); window.__posts = []; window.__answers = []; const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); window.fetch = async (url, init = {}) => { const u = String(url); if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) { const c = await (await real(url, init)).json(); const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1]; return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) }); } if (!u.includes('/decisions/')) return real(url, init); window.__posts.push({ url: u, method: init.method, body: init.body }); const a = window.__answers.shift(); if (!a) throw new TypeError('no answer queued'); return answer(a.status ?? 200, a.body); }; })();`; async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false, noDecide = [] } = {}) { const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) }); const base = `http://127.0.0.1:${server.address().port}`; await b.navigate(`${base}/${hash}`); return { b, base, 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 items = 'JSON.stringify([...document.querySelectorAll("[data-ws-item]")].map(r => r.dataset.wsItem))'; test('#/inbox lists both organizations in one list, newest first, each row with its organization and project chips', async t => { const { b, wait, text } = await setup(t); await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`, `acme:${ACME_DECISION}`]))}`); // A system route: the address keeps no organization and the sidebar Inbox is this page. assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/inbox'); assert.equal(await text('#bus-view h1'), 'Inbox'); // SP7: the page name shows once, as the heading. assert.equal(await b.evaluate(trail), null); // Organization chips on every row; the project chip only where the item has a project. assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-orgchip]`), 'Acme Studio'); assert.equal(await text(`[data-ws-item="beta:${BETA_DECISION}"] [data-ws-orgchip]`), 'Beta Works'); assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-projchip]`), 'Website'); assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null); // SP7: the class chip says the decision class in words, from the one map of class words. assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-ws-item]')].map(r => r.querySelector('td.hide-m .ws-chip')?.textContent)`), ['Within role', 'Gated']); // The stopped organization is named at the top, before the list, with its reason. 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("[data-ws-items]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true); // The sidebar Inbox count and the dashboard read the same figure. await wait('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent === "2"'); await b.evaluate('location.hash = "#/dashboard"'); await wait('document.querySelector("[data-ws-tile=needs]")'); assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i); const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => a.getAttribute("href"))'); assert.ok(needs.includes(`#/inbox?d=${encodeURIComponent(`beta:${BETA_DECISION}`)}`), JSON.stringify(needs)); assert.ok(await b.evaluate('[...document.querySelectorAll("[data-ws-home] a")].some(a => a.getAttribute("href") === "#/inbox")')); }); test('the organization and type filters live in the hash and survive a reload', async t => { const { b, base, wait } = await setup(t); await wait('document.querySelectorAll("[data-ws-item]").length === 2'); await b.evaluate('document.querySelector("[data-ws-org=beta]").click()'); await wait(`location.hash.startsWith("#/inbox?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`); const filtered = await b.evaluate('location.hash'); await b.navigate(`${base}/${filtered}`); await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`); // Type: the classes the read carries, each a chip; choosing one narrows the list. await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()'); await wait('document.querySelectorAll("[data-ws-item]").length === 2'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-type]")].map(x => x.dataset.wsType)'), ['', 'gated', 'within-role']); await b.evaluate('document.querySelector("[data-ws-type=gated]").click()'); await wait(`new URLSearchParams(location.hash.split("?")[1]).get("type") === "gated" && ${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))}`); await b.navigate(`${base}/${await b.evaluate('location.hash')}`); await wait(`${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))} && document.querySelector("[data-ws-type=gated]")?.getAttribute("aria-pressed") === "true"`); // Both at once with nothing left says so plainly. await b.evaluate('location.hash = "#/inbox?org=beta&type=gated"'); await wait('document.querySelector("[data-ws-items]")?.textContent === "Nothing matches these filters"'); }); test('answering beta\'s row from the list while acme was opened last reaches beta', async t => { const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true }); await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); await b.evaluate('location.hash = "#/inbox"'); await wait('document.querySelectorAll("[data-ws-item]").length === 2'); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`); await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}" && document.querySelector("#decide-section [data-decide]")`); assert.equal(await text('[data-ws-insp] header .ws-chip'), 'Within role'); // The item also opens in its own organization. assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] [data-ws-open]")?.getAttribute("href")'), `#/orgs/beta/inbox/${BETA_DECISION}`); await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`); await b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].find(x => x.textContent === "High").click()'); await wait('document.querySelector("#decide-section [data-decide=confirm]")'); // The confirm step names who raised the item, from beta's bus row, as the decision page does. const confirm = await text('#decide-section'); assert.match(confirm, /Raised by pm ยท run run/); assert.match(confirm, /Decision routed to: human/); assert.doesNotMatch(confirm, /date unknown/); assert.match(confirm, /Affected object: shop/); await b.evaluate('document.querySelector("#decide-section [data-decide=confirm]").click()'); await wait('/Recorded/.test(document.getElementById("decide-status")?.textContent ?? "")'); const posts = await b.evaluate('window.__posts'); assert.deepEqual(posts.map(p => p.url), [`/api/orgs/beta/decisions/${BETA_DECISION}/confirmation`, `/api/orgs/beta/decisions/${BETA_DECISION}/resolve`]); assert.deepEqual(JSON.parse(posts[1].body), { choice: 'high', confirmation: 'c-1' }); // Still a system route, still scoped to acme for everything else. assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox'); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); }); test('#/orgs//inbox is the same list filtered to that organization', async t => { const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/inbox' }); await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`); assert.match(await text('[data-ws-inbox] .sub'), /Beta Works/); assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] a[href=\'#/inbox\']") !== null'), true); // SP7: a drill-in keeps its trail. await wait(`${trail} === 'Dashboard / Beta Works / Inbox'`); // Selecting a row keeps the organization address. await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`); await wait(`location.hash.startsWith("#/orgs/beta/inbox?") && document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}"`); }); test('with nothing waiting the list says so and still names the stopped organization', async t => { const { wait, text } = await setup(t, { empty: true }); await wait('document.querySelector("[data-ws-items]")'); assert.equal(await text('[data-ws-items]'), 'Nothing needs you'); assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s); }); test('the hire, fire and change form is reached by clicks from #/inbox, one link per answering organization', async t => { const { b, wait } = await setup(t, { stub: true }); await wait('document.querySelector("[data-ws-hire-in]")'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => [a.textContent, a.getAttribute("href")])'), [['Acme Studio', '#/orgs/acme/inbox'], ['Beta Works', '#/orgs/beta/inbox']]); // The system list has no form of its own: a hire goes to one organization's bus. assert.equal(await b.evaluate('document.getElementById("hire-start")'), null); await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].find(a => a.textContent === "Beta Works").click()'); await wait('location.hash === "#/orgs/beta/inbox" && document.getElementById("hire-start")'); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta'); assert.equal(await b.evaluate('document.querySelector("#hire-start input[readonly]")?.value'), 'beta'); }); test('the hire links follow each organization\'s own config, not the organization opened last', async t => { // Acme is opened last and this Console does not decide there; beta's config still says it does. const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['acme'] }); await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); await b.evaluate('location.hash = "#/inbox"'); await wait('document.querySelector("[data-ws-hire-in]")'); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => a.getAttribute("href"))'), ['#/orgs/beta/inbox']); }); test('with no organization deciding here, #/inbox shows no hire line', async t => { const { b, wait } = await setup(t, { stub: true, noDecide: ['acme', 'beta'] }); await wait('document.querySelectorAll("[data-ws-item]").length === 2'); assert.equal(await b.evaluate('document.querySelector("[data-ws-hire-in]")'), null); }); test('the sidebar Inbox count on an organization\'s board is every organization\'s, as #/inbox lists', async t => { const { b, wait } = await setup(t, { hash: '#/orgs/acme/board' }); await wait('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent === "2"'); // Held across a second board render, which reads only acme's inbox. const reads = 'performance.getEntriesByType("resource").filter(e => new URL(e.name).pathname === "/api/orgs/acme/bus/inbox").length'; const before = await b.evaluate(reads); assert.ok(before >= 1, String(before)); await b.evaluate('window.dispatchEvent(new HashChangeEvent("hashchange"))'); await wait(`${reads} > ${before}`); await new Promise(r => setTimeout(r, 200)); assert.equal(await b.evaluate('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent'), '2'); }); // SP2 gave the system dashboard the system theme. #/inbox is a system page too: it shows the system // theme, and a choice made there is the system's own, never written to the organization opened last. test('#/inbox keeps the system theme, and a choice made there stays off the organization opened last', async t => { const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' }); await wait('document.documentElement.dataset.palette === "cobalt"'); await b.evaluate('location.hash = "#/inbox"'); await wait(`${items} !== "[]" && document.documentElement.dataset.palette === "harbor"`); assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null); assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true); await b.evaluate('window.MosaicAppearance.set({ mode: "light" })'); assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light'); assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-appearance:acme")'), null); // The organization's own Inbox is its page: its theme, and not the system's choice. await b.evaluate('location.hash = "#/orgs/acme/inbox"'); await wait('document.documentElement.dataset.palette === "cobalt"'); assert.notEqual(await b.evaluate('document.documentElement.dataset.mode'), 'light'); await b.evaluate('location.hash = "#/inbox"'); await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"'); });