Files
stack/packages/webui/tests/single-pane-browser.test.mjs
jason.woltje d1b01329bc fix(webui): sp2 polish, an organization's bare address opens its page and the dashboard keeps the system theme (#1873, lane sp2)
- #/orgs/<id> with no page and no trailing slash now opens #/orgs/<id>/organization instead of the
  system dashboard.
- The system dashboard resolves its own appearance (this browser's choice made
  there, else the default) under mosaic-console-appearance-system; it no
  longer shows the theme of the organization opened last.
- A stopped organization's page shows one bus banner: the page's own down
  banner now carries the restart action, and the shell's "Bus unavailable"
  banner hides on that page (display.js paintBus and app.js busDown).
- The dashboard row for a stopped organization no longer repeats "the bus is
  not running" under "bus stopped"; the last start time still shows.
- Organizations table figures keep to one line (white-space, no font change).
- The Active agents status cell was class "st", which picked up the global
  .st::before "?" glyph; it is now ws-st.
- Tests: red-first browser tests for each item, plus the test sp1 lacked: a
  read still in flight at an organization switch keeps nothing.
- org-switch-browser: a stopped organization's page asserts one banner; the
  shell's "Bus unavailable" is checked on its tasks page instead.
2026-11-15 00:44:18 -06:00

428 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SP1 (#1873, single pane slice 1): #/dashboard is the system landing route, an organization page is a
// drill-in route inside the same shell (no reload, the sidebar and top bar stay), and an organization
// whose bus is down says so with its reason. Every bus is a fake: acme and beta answer, gamma's bus is
// stopped. The real GET /api/orgs, /api/workspace and /api/agents/levels serve the page.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
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();
async function fixture({ empty = false, bootless = false } = {}) {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
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, busStopped: true },
];
const ROLES = {
acme: { chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } },
beta: { chief: { definition: 'chief-of-staff' } },
gamma: { chief: { definition: 'chief-of-staff' }, researcher: { definition: 'research' } },
};
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) }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
inbox: [{ id: 'dec-acme-1', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', at: at(10), options: [{ key: 'yes', text: 'Ship it' }] }],
},
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
};
// Launch settings differ per organization so a value carried from one to the other shows.
const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } });
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 }, roles: ROLES[org.id], projects: {} },
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id),
});
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
const organizations = {
list: async () => (empty ? [] : LIST),
members: async () => (empty ? [] : 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 () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, '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, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
};
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
}
async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
const { organizations, agentLevels } = await fixture({ empty, bootless });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
// `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL.
if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}${path}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const rowText = org => `document.querySelector("[data-ws-orgrow=${org}]")?.innerText.replace(/\\s+/g, " ").trim() ?? ""`;
test('the bare address lands on #/dashboard without a reload and lists every organization with only the figures read', async t => {
const { b, wait, text } = await setup(t);
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
// One page load: the landing route is set in place, never by location.reload().
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation")[0].type'), 'navigate');
assert.equal(await text('#bus-view h1'), 'Dashboard');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/dashboard');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(r => r.dataset.wsOrgrow)'), ['acme', 'beta', 'gamma']);
// Agents from the levels read, projects and needs-you from the workspace read.
await wait(`${rowText('acme')}.includes("3 agents")`);
const acme = await b.evaluate(rowText('acme'));
assert.match(acme, /Acme Studio/);
assert.match(acme, /bus running/);
assert.match(acme, /2 projects/);
assert.match(acme, /1 needs you/);
assert.match(await b.evaluate(rowText('beta')), /1 agent\b/);
// gamma: down with its reason; its agent count comes from its file, the bus figures are not read.
const gamma = await b.evaluate(rowText('gamma'));
assert.match(gamma, /bus stopped/);
assert.match(gamma, /stopped by the owner/);
assert.match(gamma, /2 agents/);
assert.doesNotMatch(gamma, /\d+ projects?|\d+ needs you/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a")?.getAttribute("href")'), '#/orgs/beta/organization');
// A system route marks no organization in the sidebar and shows no breadcrumb.
assert.equal(await b.evaluate('document.querySelectorAll("#workspace-nav .sel, #workspace-nav [aria-current]").length'), 0);
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")'), null);
// The top bar names no organization.
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
});
test('an organization opens inside the same shell with a breadcrumb, and its pages keep reading that organization', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await b.evaluate('window.__marker = 1; window.__bar = document.querySelector(".cmdbar"); window.__side = document.getElementById("workspace-nav")');
await b.evaluate('document.querySelector("[data-ws-orgrow=acme] a").click()');
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
assert.equal(await b.evaluate('window.__marker'), 1);
assert.equal(await b.evaluate('window.__bar === document.querySelector(".cmdbar") && window.__side === document.getElementById("workspace-nav")'), true);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")?.innerText.replace(/\\s+/g, " ").trim()'), 'Dashboard › Acme Studio');
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
// The sidebar marks the open organization.
await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
// The sidebar opens another organization in place; its page reads that organization's routes.
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.__marker'), 1);
// A page inside beta scopes its reads to beta.
await b.evaluate('location.hash = "#/agents"');
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('window.__marker'), 1);
// The organization's control board is named as such after the organization, not as a second Dashboard.
await b.evaluate('location.hash = "#/orgs/beta/board"');
await wait('/^Dashboard\\/.+\\/Control board$/.test(document.getElementById("breadcrumbs")?.textContent ?? "")');
assert.equal(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]")?.getAttribute("href")'), '#/orgs/beta/organization');
// The breadcrumb goes back to the system dashboard, still without a reload.
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('document.querySelector(".ws-crumb a")');
await b.evaluate('document.querySelector(".ws-crumb a").click()');
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('window.__marker'), 1);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await text('#bus-view h1'), 'Dashboard');
});
test('an old #/orgs/<id>/... link still opens that page', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/beta/agents' });
await wait('document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
});
test('a stopped organization says so on its page and on its agents, with the reason', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization' });
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
assert.match(down, /The bus is not running/);
assert.match(down, /stopped by the owner/);
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
await wait('document.querySelector("[data-org-agents] [data-org=gamma] [data-agents-why]")');
assert.equal(await b.evaluate('document.querySelector("[data-org=gamma] [data-agents-why]").innerText.trim()'), 'Agents not read: the bus is stopped');
const agents = await b.evaluate('document.querySelector("[data-org-agents]").innerText.replace(/\\s+/g, " ")');
assert.doesNotMatch(agents, /Agents unavailable/);
});
test('at 390 px the dashboard lists the organizations without a horizontal scroll', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
});
// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the
// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for.
test('first run: the wordmark opens the first-run board, not a Not found page', async t => {
const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true });
await wait('document.querySelector("#bus-view h1")');
await b.evaluate('document.querySelector(".wordmark").click()');
// The board route empties the page view; give the router and render time to settle before checking.
await wait('location.hash !== "#/settings/appearance"');
await new Promise(r => setTimeout(r, 800));
assert.equal(await b.evaluate('location.hash'), '#/');
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false);
assert.equal(await b.evaluate('window.MosaicOrg'), undefined);
});
test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => {
const ID = '11111111-2222-4333-8444-555555555555';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `window.__reads = []; window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init });
const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]';
await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`);
await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`);
await wait('window.__reads.length >= 1');
assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']);
assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/);
await b.evaluate('location.hash = "#/orgs/beta/settings/agents"');
await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")');
const seen = await b.evaluate('window.__reads.length');
// Two poll periods pass on beta's page.
await new Promise(r => setTimeout(r, 4500));
const reads = await b.evaluate('window.__reads');
assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' '));
assert.equal(reads.length, seen, 'the poll stopped at the switch');
assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false);
});
test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// acme's settings POST is held until the test releases it, then answers with acme's saved values.
const init = `window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
const submit = '#launch-settings-form button[type=submit]';
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`);
await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`);
await wait('typeof window.__release === "function"');
assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64));
await b.evaluate('location.hash = "#/orgs/beta/settings/limits"');
await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`);
// beta's Save is its own: not held by acme's write.
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
await b.evaluate('window.__release()');
await new Promise(r => setTimeout(r, 700));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7');
assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
});
test('a down banner does not repeat its heading as the reason', async t => {
// No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words.
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true });
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
assert.match(down, /The bus is not running\./);
assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down);
assert.doesNotMatch(down, /Reason:|No reason was read/);
});
test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => {
const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// Every agent request read and write is recorded, whichever organization it names; the bus answers
// are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and
// recorded (the session gone): beta's own page reads beta's instances, which is not the chain.
const init = `window.__chain = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id');
x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; }
if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init });
await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")');
// The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page.
await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()');
await wait('window.__chain.length >= 1');
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('location.hash === "#/orgs/beta/organization"');
// Done when the chain has launched and read the launch request back, or as soon as any of it names beta.
await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000);
const chain = await b.evaluate('window.__chain');
assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' '));
// beta's page draws nothing of acme's chain.
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
});
test('a file save still in flight at a switch: its outcome is not announced on the next organization', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// acme's file reads are stubbed; its save POST is held until the test releases it, then answers with a commit.
const init = `window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true, edit: true }; return ${json}; }
if (s.startsWith('/api/orgs/acme/project-file?')) { x = { path: 'docs/a.md', commit: 'a'.repeat(40), content: '# A\\n', at: new Date().toISOString() }; return ${json}; }
if (options.method === 'POST' && s === '/api/orgs/acme/project-file') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
x = { path: 'docs/a.md', commit: 'c'.repeat(40) }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: `/#/orgs/acme/docs/site/${encodeURIComponent('docs/a.md')}`, init });
await wait('document.querySelector("#doc-form")');
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
await wait('document.querySelector("#doc-text")?.value === "# A\\n"');
await b.evaluate('(() => { const a = document.querySelector("#doc-text"); a.value = "# A\\n\\nmore\\n"; a.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#doc-form").requestSubmit(); })()');
await wait('typeof window.__release === "function"');
assert.equal(await b.evaluate('window.__posts[0]?.base'), 'a'.repeat(40));
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
await b.evaluate('window.__release()');
await new Promise(r => setTimeout(r, 700));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization');
assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/);
});
// SP2 item 1: an organization's own bare address is its page, not the system dashboard.
test('a bare #/orgs/<id> opens that organization\'s page', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/beta' });
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
// In place too, from another organization's page.
await b.evaluate('location.hash = "#/orgs/acme"');
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
// The old per-organization dashboard address is still the system dashboard.
await b.evaluate('location.hash = "#/orgs/acme/dashboard"');
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
});
// SP2 item 2: the system dashboard shows the system theme, never the organization opened last.
test('the dashboard keeps the system theme after an organization with its own theme was opened', async t => {
const { b, base, wait } = await setup(t, { path: '/#/orgs/acme/organization' });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio" && document.documentElement.dataset.palette === "cobalt"');
await b.evaluate('location.hash = "#/dashboard"');
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await wait('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);
// A fresh load of the bare address, with acme stored as the last organization shown, starts on the system theme.
await b.navigate(`${base}/`);
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
await new Promise(r => setTimeout(r, 500));
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
// A choice made on the dashboard is the system's own: acme keeps its theme, and the dashboard keeps the choice.
await b.evaluate('window.MosaicAppearance.set({ mode: "light" })');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light');
await b.evaluate('location.hash = "#/orgs/acme/organization"');
await wait('document.documentElement.dataset.palette === "cobalt"');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
await b.evaluate('location.hash = "#/dashboard"');
await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"');
});
// SP2 item 3: a stopped organization's page shows one bus banner, with the restart action and what can't be read.
test('a stopped organization\'s page shows one bus banner, with the restart action', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/(orgs\\/[^/]+\\/)?config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, orgSetup: true }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', init });
await wait('document.querySelector("[data-org-dashboard] [data-org-down] [data-bus-restart]")');
// Both shell painters (display.js on the list read, app.js on its own read) have had their turn.
await b.evaluate('window.MosaicOrg.list');
await new Promise(r => setTimeout(r, 800));
const banners = 'Array.from(document.querySelectorAll(".banner")).filter(e => !e.hidden && e.getClientRects().length && /bus/i.test(e.textContent)).map(e => e.innerText.replace(/\\s+/g, " ").trim())';
const shown = await b.evaluate(banners);
assert.equal(shown.length, 1, shown.join(' | '));
assert.match(shown[0], /The bus is not running\. Nothing about Gamma Home's agents or packs can be read until it starts\./);
assert.match(shown[0], /Restart the bus/);
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
// The organization's other pages keep the shell's banner, with its restart.
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
await wait('!document.getElementById("bus-down").hidden && /Bus unavailable/.test(document.getElementById("bus-down").textContent)');
// And back on the organization page it gives way again.
await b.evaluate('location.hash = "#/orgs/gamma/organization"');
await wait('document.querySelector("[data-org-dashboard] [data-org-down]") && document.getElementById("bus-down").hidden');
});
// SP2 item 4: the dashboard row for a stopped organization says it once.
test('a stopped organization\'s dashboard row does not repeat its state as the reason', async t => {
const { b, wait } = await setup(t, { bootless: true });
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await wait(`${rowText('gamma')}.includes("bus stopped")`);
const gamma = await b.evaluate(rowText('gamma'));
assert.doesNotMatch(gamma, /the bus is not running/i, gamma);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=gamma] .why")'), null);
});
// SP2 item 6: no status-badge glyph ("?") is drawn before the live dot in the dashboard's Active agents.
test('the dashboard\'s active agents draw no stray glyph before the status dot', async t => {
const { b, wait } = await setup(t);
await wait('document.querySelector("[data-ws-sessions] tbody tr")');
const cells = await b.evaluate('Array.from(document.querySelectorAll("[data-ws-sessions] tbody td")).map(td => getComputedStyle(td, "::before").content)');
assert.ok(cells.length >= 2, String(cells));
for (const c of cells) assert.ok(c === 'none' || c === 'normal', `generated content ${c}`);
});
// SP2 item 7 (the test SP1 lacks): a read still in flight when the organization switches keeps nothing.
// acme's settings read is held across a switch to beta and then answers with a figure only it has (99);
// beta's own read then fails, so beta's page draws from what was kept. Nothing of acme's may be there.
test('a read still in flight at a switch: its answer is not kept for the next organization', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `window.__acme = []; window.__beta = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (s === '/api/orgs/acme/settings' && !options.method && window.__holdAcme) { await new Promise(r => window.__acme.push(r));
const r = await original(url, options), body = await r.json(); x = { ...body, launch: { ...body.launch, limits: { total: 99 } } }; return ${json}; }
if (s === '/api/orgs/beta/settings' && !options.method && window.__holdBeta) { await new Promise(r => window.__beta.push(r));
return new Response(JSON.stringify({ error: 'unavailable', message: 'beta settings read failed' }), { status: 500, headers: { 'content-type': 'application/json' } }); }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4"`);
// A fresh read of acme's settings starts and is held.
await b.evaluate('window.__holdAcme = true; location.hash = "#/orgs/acme/settings/tokens"');
await wait('window.__acme.length >= 1');
await b.evaluate('window.__holdBeta = true; location.hash = "#/orgs/beta/settings/limits"');
await wait('location.hash === "#/orgs/beta/settings/limits" && window.__beta.length >= 1');
// acme answers after the switch; then beta's read fails.
await b.evaluate('window.__acme.forEach(r => r())');
await new Promise(r => setTimeout(r, 400));
await b.evaluate('window.__beta.forEach(r => r())');
await wait('document.querySelector("#bus-view .banner")');
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value ?? null`), null, 'acme\'s held answer was drawn on beta');
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .s1-stale")'), false);
assert.match(await b.evaluate('document.getElementById("bus-view").innerText'), /beta settings read failed/);
});