Files
stack/packages/webui/tests/org-switch-browser.test.mjs

284 lines
24 KiB
JavaScript

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/projects`);
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('Settings lists organizations and switching 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.querySelector('#org-picker'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organization picker 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.deepEqual(await b.evaluate(`[...document.querySelector('#org-picker').options].map(o=>o.value)`), ['alpha', 'beta']);
await b.evaluate(`(()=>{const e=document.querySelector('#org-picker');e.value='beta';e.dispatchEvent(new Event('change',{bubbles:true}))})()`);
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("#org-picker")');
await b.evaluate(`(()=>{const el=document.querySelector('#org-picker');el.value='beta';el.dispatchEvent(new Event('change',{bubbles:true}))})()`);
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(`document.querySelector('#org-picker').value`), '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()`);
await b.navigate(base + '#/orgs/beta/');
await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(window.MosaicOrg?.id === 'beta')return;await new Promise(r=>setTimeout(r,30))}throw Error('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/projects`);
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');
});