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 { createOrgAdmin } from '../src/org-admin.mjs'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; test('organization header and footer never display null board values', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'alpha', busHealthy: true }], resolve: async () => ({ reads: {}, bus: {} }) }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); for (const [width, height] of [[1400, 900], [390, 844]]) { await b.viewport(width, height); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(/Board:|No board/.test(document.querySelector('#board-url').textContent))return;await new Promise(r=>setTimeout(r,30))}throw Error('footer not loaded')})()`); const text = await b.evaluate(`document.querySelector('header').textContent + ' ' + document.querySelector('footer').textContent`); assert.doesNotMatch(text, /\b(?:null|undefined)\b/i); } }); test('an invalid organization is explained instead of appearing as an empty registry', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'alpha', invalid: true, reason: 'organization document and registry disagree' }], resolve: async () => { throw Error('unavailable'); } }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); for (const [width, height] of [[1400, 900], [390, 844]]) { await b.viewport(width, height); await b.navigate(`http://127.0.0.1:${server.address().port}/#/`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held [role=alert]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('admission error missing')})()`); const body = await b.evaluate('document.body.textContent'); assert.match(body, /alpha.*invalid.*document and registry disagree/is); assert.doesNotMatch(body, /No organizations yet/); } }); test('fresh dashboard points to organization setup while real list errors remain errors', async t => { for (let broken of [false, true]) { const organizations = { list: async () => { if (broken) throw Error('registry down'); return []; }, resolve: async () => null }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, board: 'http://127.0.0.1:9', log: () => {} }); const b = await browser({ machine: false }); try { await b.navigate(`http://127.0.0.1:${server.address().port}/#/`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held .banner, #held a[href="#/settings/organizations"]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('held roles did not settle')})()`); const held = await b.evaluate(`({ text: document.querySelector('#held').textContent, error: !!document.querySelector('#held .banner.err, #held .banner.ref'), link: !!document.querySelector('#held a[href="#/settings/organizations"]') })`); if (broken) { assert.equal(held.error, true, 'a real registry error remains visible'); assert.match(held.text, /^Organization registry could not be readRead again$/, 'the fixed message, once, with no server error text'); broken = false; await b.evaluate(`document.querySelector('#held [data-retry]').click()`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held a[href="#/settings/organizations"]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('registry retry did not recover')})()`); } else { assert.match(held.text, /No organizations yet/); assert.equal(held.link, true); assert.equal(held.error, false); } } finally { await b.close(); await close(server); } } }); test('creating another organization opens its setup, receipt, header and theme', async t => { const makeDoc = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} }, appearance: { theme: id === 'beta' ? 'cobalt' : 'harbor', mode: id === 'beta' ? 'light' : 'dark' } }); const docs = new Map([['alpha', makeDoc('alpha')]]), records = new Map(); const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); }; const service = { orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }], templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }), createOrg: async (_ctx, id, doc) => { docs.set(id, doc); const receipt = { op: 'org.create', org: id, notifications: 'none' }; records.set(id, receipt); return receipt; }, showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }), notificationShow: async (_ctx, id) => records.get(id), queueShow: missing, projectRepoShow: missing, }; const registry = { listOrgs: async () => [...docs.keys()].map(id => ({ id, state: 'active' })) }; const organizations = { list: async () => [...docs.keys()].map(id => ({ id, label: id, busHealthy: true })), resolve: async id => docs.has(id) ? { busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id, projects: ['work'], appearance: docs.get(id).appearance } }), busStatus: async () => ({ live: true }) }, bus: {} } : null, }; const orgAdmin = createOrgAdmin({ service, registry, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/organizations`); await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled'); const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).display !== 'none' && e.getClientRects().length > 0})()`); assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'), false, 'org setup is not shown as read only'); assert.equal(await shown('#org-new-form [data-write]'), true, 'New organization is visible, not only clickable'); await b.evaluate(`(()=>{const e=document.querySelector('#org-new-id');e.value='beta';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`); await wait('document.querySelector("#org-advanced-open") && !document.querySelector("#org-paths").hidden'); await b.evaluate(`document.querySelector('#org-advanced-open').click()`); await wait('document.querySelector("#org-editor") && !document.querySelector("#org-editor").hidden'); assert.equal(await shown('#org-save'), true, 'Save organization is visible, not only clickable'); await b.evaluate(`document.querySelector('#org-save').click()`); let switched = false; for (let i = 0; i < 150 && !switched; i++) { try { switched = await b.evaluate('window.MosaicOrg?.id === "beta" && document.querySelector("#org-setup") && !document.querySelector("#org-setup").hidden'); } catch { /* the organization switch reloads the page */ } if (!switched) await new Promise(r => setTimeout(r, 30)); } assert.ok(switched, 'new organization setup loaded after the switch'); await b.evaluate('window.MosaicAppearance.ready'); assert.match(await b.evaluate('location.hash'), /^#\/orgs\/beta\/settings\/organizations\?org=beta&step=setup/); assert.match(await b.evaluate('document.querySelector("header").textContent'), /beta/); assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'cobalt'); assert.match(await b.evaluate('document.querySelector("#org-receipt").textContent'), /Organization created.*beta/s); }); test('global organization admin requests reach the real routes with an organization selected', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }], resolve: async () => ({ reads: {}, bus: {} }) }; const service = { validateOrg: async () => ({ ok: true, errors: [] }) }; const registry = { listOrgs: async () => [{ id: 'alpha', state: 'active' }] }; const orgAdmin = createOrgAdmin({ service, registry, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`); const result = await b.evaluate(`(async () => { const list = await fetch('/api/org-admin'); const check = await fetch('/api/org-validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'alpha', doc: {} }) }); return { listStatus: list.status, list: await list.json(), checkStatus: check.status, check: await check.json() }; })()`); assert.equal(result.listStatus, 200); assert.deepEqual(result.list.orgs, [{ id: 'alpha', state: 'active' }]); assert.equal(result.checkStatus, 200); assert.equal(result.check.ok, false); }); test('project creation offers repositories from the scoped business read', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }], resolve: async () => ({ busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id: 'alpha', projects: ['work'] } }), busStatus: async () => ({ live: true }) }, bus: { inbox: async () => [], agents: async () => [], messages: async () => ({ messages: [] }), tasks: async () => [] }, chat: { projects: async () => [], threads: async () => [] } }), }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#chat-repo option[value=work]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('repository choice missing')})()`); assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work'); }); test('sidebar lists organizations and navigating changes the route and header', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }], resolve: async id => ['alpha', 'beta'].includes(id) ? { reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => { await new Promise(r => setTimeout(r, 200)); return { live: id === 'alpha', down: false }; } }, bus: { inbox: async () => [], agents: async () => [], messages: async () => ({ messages: [] }), tasks: async () => [] }, } : null, }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, board: 'http://127.0.0.1:9' }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(390, 900); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelectorAll('[data-workspace-org]').length===2 && document.querySelector('#settings-page-title'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organizations did not appear')})()`); assert.match(await b.evaluate(`document.querySelector('header').textContent`), /Alpha/); assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization'); assert.equal(await b.evaluate(`!!document.querySelector('#org-picker')`), false); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(o=>o.dataset.workspaceOrg)`), ['alpha', 'beta']); await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`); let switched = false; for (let i = 0; i < 150 && !switched; i++) { try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch { /* reload replaced the page */ } if (!switched) await new Promise(r => setTimeout(r, 30)); } assert.equal(switched, true, 'switch completed after reload'); assert.match(await b.evaluate(`document.querySelector('body').textContent`), /Bus unavailable/); await new Promise(r => setTimeout(r, 400)); assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), false, 'offline banner remains visible after the boot-status read'); assert.match(await b.evaluate(`document.querySelector('#bus-down').textContent`), /Bus unavailable/); }); test('a chat draft is kept on Stay and discarded on an organization switch', async t => { const project = '10000000-0000-4000-8000-000000000001', thread = '20000000-0000-4000-8000-000000000002'; const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }, { id: 'beta', label: 'Beta', busHealthy: true }], resolve: async id => ({ send: async () => ({}), reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }) }, bus: { inbox: async () => [], agents: async () => [], messages: async () => ({ messages: [] }), tasks: async () => [] }, chat: { projects: async () => [{ id: project, name: 'Project' }], threads: async ({ shared }) => shared ? [] : [{ id: thread, project, name: 'Conversation', instance: 'assistant' }], sessions: async () => [{ id: thread, name: 'default', default: true }], shares: async () => [], messages: async () => [] }, }), }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat/threads/${thread}`); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); await wait('!!document.querySelector("#chat-body")'); assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, #chat-share-form, #chat-clear')`), false, 'unmapped session and sharing controls are unavailable'); await b.evaluate(`(()=>{const el=document.querySelector('#chat-body');el.value='unsent alpha';el.dispatchEvent(new Event('input',{bubbles:true}))})()`); await b.evaluate(`location.hash='#/settings/business'`); await wait('!!document.querySelector("[data-workspace-org=beta] a")'); await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`); await wait('!!document.querySelector("dialog[open] [data-stay]")'); await b.evaluate(`document.querySelector('[data-stay]').click()`); assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\//); assert.equal(await b.evaluate(`window.MosaicOrg.id`), 'alpha'); await b.evaluate(`location.hash='#/chat/threads/${thread}'`); await wait('!!document.querySelector("#chat-body")'); assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`), 'unsent alpha'); await b.evaluate(`location.hash='#/orgs/beta/settings/business'`); await wait('!!document.querySelector("dialog[open] [data-discard]")'); await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {}); let switched = false; for (let i = 0; i < 150 && !switched; i++) { try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); } assert.equal(switched, true); assert.equal(await b.evaluate(`document.querySelector('#chat-body')?.value ?? ''`), ''); }); test('organization switch isolates browser board preferences', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }, { id: 'beta', label: 'Beta', busHealthy: true }], resolve: async id => ({ reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }) }, bus: {} }), }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); const base = `http://127.0.0.1:${server.address().port}/`; await b.navigate(base + '#/orgs/alpha/'); await b.evaluate(`document.querySelector('#show-machine').click()`); assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true); // The switch reloads the page. The navigate helper's load checks can be in flight when that // reload commits, so set the hash in the page and poll one short check at a time. await b.evaluate(`location.hash = '#/orgs/beta/'`); let loaded = false; for (let i = 0; i < 150 && !loaded; i++) { try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {} if (!loaded) await new Promise(r => setTimeout(r, 30)); } assert.equal(loaded, true, 'beta did not load'); assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false); }); test('organization creation draft survives Stay and is discarded on switch', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }, { id: 'beta', label: 'Beta', busHealthy: true }], resolve: async id => ({ reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }) }, bus: {} }), }; const service = { orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }], templateRender: async id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} } }) }; const orgAdmin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`); await b.evaluate(`location.hash='#/orgs/alpha/settings/organizations'`); await wait('!!document.querySelector("#org-new-id")'); await wait('!document.querySelector("#org-new-form [data-write]").disabled'); await b.evaluate(`(()=>{const el=document.querySelector('#org-new-id');el.value='draft';el.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`); await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden'); await b.evaluate(`document.querySelector('#org-advanced-open').click()`); await wait('!document.querySelector("#org-editor").hidden'); assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\/settings\/organizations/); await b.evaluate(`(()=>{const el=document.querySelector('[data-path="$.human"]');el.value='unsaved';el.dispatchEvent(new Event('input',{bubbles:true}))})()`); const before = await b.evaluate(`({ owner: document.querySelector('[data-path="$.human"]').value, json: document.querySelector('#org-json-text').value, hash: location.hash })`); await b.evaluate('history.back()'); await wait('!!document.querySelector("dialog[open] [data-stay]")'); await b.evaluate(`document.querySelector('[data-stay]').click()`); assert.deepEqual(await b.evaluate(`({ owner: document.querySelector('[data-path="$.human"]').value, json: document.querySelector('#org-json-text').value, hash: location.hash })`), before); await b.evaluate(`location.hash='#/orgs/beta/settings/organizations'`); await wait('!!document.querySelector("dialog[open] [data-stay]")'); await b.evaluate(`document.querySelector('[data-stay]').click()`); assert.equal(await b.evaluate(`document.querySelector('[data-path="$.human"]').value`), 'unsaved'); assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\//); await b.evaluate(`location.hash='#/orgs/beta/settings/organizations'`); await wait('!!document.querySelector("dialog[open] [data-discard]")'); await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {}); await wait('window.MosaicOrg?.id === "beta"'); }); test('a stopped organization bus has truthful chat guidance', async t => { const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: false }], resolve: async () => ({ busHealthy: false, reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }), }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`); await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.chat-area')?.innerText.includes('The bus is stopped'))return;await new Promise(r=>setTimeout(r,30))}throw Error('stopped-bus guidance missing')})()`); const text = await b.evaluate(`document.querySelector('.chat-area').innerText`); assert.match(text, /bus is stopped.*Start the bus/i); assert.doesNotMatch(text, /This Console only reads/); }); test('organization conversation recovers send capability and preserves its draft after an outage', async t => { const { fixture } = await import('./fixture.mjs'); const { P, T, stub } = await import('./helpers/projects-review-fixture.mjs'); const f=await fixture(); t.after(()=>f.close()); const b=await browser({machine:false}); t.after(()=>b.close()); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub + `\nwindow.MosaicOrg={id:'alpha',route:()=>'/projects/threads/${T}',list:Promise.resolve([]),readList:async()=>[]};window.configUp=false;{const old=fetch;window.fetch=(url,init)=>String(url)==='/api/config'?Promise.resolve(new Response(JSON.stringify({chatSend:window.configUp,orgSetup:true,devOwner:true,sso:true}),{status:200})):old(url,init)}`}); await b.navigate(f.base+'/#/projects/threads/'+T); const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expr)})})()`); await wait(`document.querySelector('#chat-body')`); assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true); await b.evaluate(`const el=document.querySelector('#chat-body');el.value='draft kept';el.dispatchEvent(new Event('input',{bubbles:true}));window.configUp=true;document.querySelector('#refresh').click()`); await wait(`!document.querySelector('#chat-body').disabled`); assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'draft kept'); });