Files
stack/packages/webui/tests/system-inbox-browser.test.mjs

258 lines
19 KiB
JavaScript

// 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/<id>/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 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');
// 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);
// 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]")`);
// 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/<id>/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);
// 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"');
});