import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { createOrgAdmin } from '../src/org-admin.mjs'; import { organizationConsole } from '../src/organizations.mjs'; import * as service from '../../organization/src/org-service.mjs'; import * as registry from '../../organization/src/org-registry.mjs'; import { orgPaths } from '../../cli/src/config.mjs'; import { busStopForDelete } from '../../cli/src/org-bus.mjs'; import { CliError } from '../../cli/src/errors.mjs'; import { actionTrail } from '../src/action-trail.mjs'; import { browser } from './browser.mjs'; import { systemTypesFake } from './helpers/system-types-fake.mjs'; import { close } from './fixture.mjs'; // E1: `bus` adds verbs to the admin's bus (busStart for a restore); `trail` gives the Console its action trail; // `extra` adds to each organization's transports (the dashboard's bus reads and System types). async function fixture(t, ids = ['alpha', 'beta'], { health = null, bus = {}, trail = false, extra = () => ({}) } = {}) { const dir = await mkdtemp(join(tmpdir(), 'archive-browser-')); const configDir = join(dir, 'config'), dataRoot = join(dir, 'data'); await mkdir(configDir, { mode: 0o700 }); await mkdir(dataRoot, { mode: 0o700 }); const ctx = { configDir, dataRoot, instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; for (const id of ids) await service.createOrg(ctx, id, await service.templateRender(id)); const busStatus = async () => ({ healthy: health ? health.up : false }); const organizations = organizationConsole({ ctx, listOrgs: registry.listOrgs, resolveOrg: registry.resolveOrg, showOrg: service.showOrg, orgPaths, busStatus, options: ({ id }) => ({ reads: { business: async () => ({ business: { id, projects: ['work'] } }), settings: async () => ({}) }, bus: {}, send: async () => ({}), ...extra(id) }) }); const admin = createOrgAdmin({ service, registry, bus: { busStatus, busStopForDelete, ...bus }, context: async () => ({ configDir, dataRoot, instance: 'test' }) }); const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {}, ...(trail ? { trail: actionTrail(join(dir, 'console')) } : {}) }); const b = await browser({ machine: false }); t.after(async () => { await b.close(); await close(server); await rm(dir, { recursive: true, force: true }); }); const wait = async expression => { for (let i = 0; i < 400; i++) { try { if (await b.evaluate(expression)) return; } catch {} await new Promise(r => setTimeout(r, 30)); } throw Error(expression); }; await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/organizations`); await wait(`document.querySelector('#org-open-id')?.value === 'alpha'`); async function open(id) { await b.evaluate(`document.querySelector('#org-open-id').value=${JSON.stringify(id)};document.querySelector('#org-open-form button').click()`); await wait(`document.querySelector('#org-editor-title')?.textContent === ${JSON.stringify(`Organization: ${id}`)}`); } async function shots(state, selector) { if (!process.env.ORG_ARCHIVE_SHOTS) return; await mkdir(process.env.ORG_ARCHIVE_SHOTS, { recursive: true }); for (const [width, height] of [[1400, 900], [390, 844]]) for (const mode of ['light', 'dark']) { await b.viewport(width, height); await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(mode)};document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'start'})`); await b.screenshot(join(process.env.ORG_ARCHIVE_SHOTS, `${state}-${width}-${mode}.png`)); } } return { b, wait, open, shots, ctx, port: server.address().port, dir }; } test('archive and restore a non-current organization', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`document.querySelector('#org-archive-open').click()`); assert.equal(await f.b.evaluate(`document.activeElement.id`), 'org-archive-confirm'); await f.shots('archive', '#org-archive'); await f.b.evaluate(`document.querySelector('#org-archive-cancel').click()`); assert.equal(await f.b.evaluate(`document.activeElement.id`), 'org-archive-open'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!document.querySelector('#org-archived').hidden`); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'archived'); assert.match(await f.b.evaluate(`document.querySelector('#org-open-id').selectedOptions[0].textContent`), /archived/); assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows=>rows.map(row=>row.id))`), ['alpha']); assert.equal(await f.b.evaluate(`document.querySelector('#org-save').disabled`), true); assert.equal(await f.b.evaluate(`document.querySelector('#org-json-text').disabled`), true); await f.shots('archived', '#org-archived'); const chat = await f.b.evaluate(`fetch('/api/orgs/beta/chat/projects').then(async r=>({status:r.status,body:await r.json()}))`); assert.equal(chat.status, 409); assert.equal(chat.body.error, 'org-archived'); await f.b.evaluate(`document.querySelector('#org-restore').click()`); await f.wait(`!document.querySelector('#org-archive-success').hidden`); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active'); assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows=>rows.map(row=>row.id))`), ['alpha', 'beta']); assert.equal(await f.b.evaluate(`document.querySelector('#org-save').disabled`), false); await f.shots('restored', '#org-archive-success'); }); test('archive does not announce success for an unexpected response', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`const original=fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/archive')?Promise.resolve(new Response(JSON.stringify({outcome:'done',step:'bus'}),{status:200})):original(input,init);document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!!document.querySelector('#org-archive-error').textContent`); assert.match(await f.b.evaluate(`document.querySelector('#org-archive-error').textContent`), /outcome is unknown/i); assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-final').textContent`), 'Archive'); assert.equal(await f.b.evaluate(`document.querySelector('#org-archived').hidden`), true); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active'); }); test('archiving the current organization switches to the remaining one', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('alpha'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'alpha').state, 'archived'); }); test('opening another editor resets the archive confirmation and error', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-error').textContent='old error';document.querySelector('#org-archive-final').disabled=true`); await f.open('alpha'); assert.deepEqual(await f.b.evaluate(`({trigger:document.querySelector('#org-archive-open').hidden,panel:document.querySelector('#org-archive-confirm').hidden,error:document.querySelector('#org-archive-error').textContent,disabled:document.querySelector('#org-archive-final').disabled})`), {trigger:false,panel:true,error:'',disabled:false}); }); // E1 (P1-4): one Restore restores the organization and starts its bus; the receipt names both. const startingBus = (health, fails = 0) => { const starts = []; return { starts, busStart: async (ctx, id, opts) => { starts.push([id, opts]); if (starts.length <= fails) throw new CliError('bus failed to start; see the bus log', 3); health.up = true; return { org: id, pid: 4242, socket: join(ctx.dataRoot, 'bus.sock'), root: join(ctx.dataRoot, 'org-runtime', id) }; } }; }; const archiveBeta = async f => { await f.open('beta'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!document.querySelector('#org-archived').hidden`); }; test('E1 (P1-4): Restore restores the organization and starts its bus, with one receipt naming both', { timeout: 45000 }, async t => { const health = { up: false }, bus = startingBus(health); const f = await fixture(t, ['alpha', 'beta'], { health, bus, trail: true }); await archiveBeta(f); await f.b.evaluate(`document.querySelector('#org-restore').click()`); await f.wait(`!document.querySelector('#org-archive-success').hidden`); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active'); assert.deepEqual(bus.starts, [['beta', { detach: true, devLocalOwner: true, pm: false }]]); const text = await f.b.evaluate(`document.querySelector('#org-archive-success').innerText`); assert.match(text, /^Organization restored\. Bus started\. It answers\./); assert.match(text, /Same as mosaic bus start beta --dev-local-owner --detach/); assert.doesNotMatch(text, /remains stopped/); assert.equal(await f.b.evaluate(`document.querySelector('[data-restore-bus]')`), null, 'a running bus needs no Restart bus'); assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows=>rows.map(row=>[row.id,row.busHealthy]))`), [['alpha', true], ['beta', true]]); await f.shots('e1-restored-started', '#org-archive-success'); }); test('E1 (P1-4): a bus that does not start after a good restore is said so, and Restart bus starts it', { timeout: 45000 }, async t => { const health = { up: false }, bus = startingBus(health, 1); const f = await fixture(t, ['alpha', 'beta'], { health, bus, trail: true }); await archiveBeta(f); await f.b.evaluate(`document.querySelector('#org-restore').click()`); await f.wait(`!document.querySelector('#org-archive-success').hidden`); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active', 'the restore stands'); assert.match(await f.b.evaluate(`document.querySelector('#org-archive-success').innerText`), /^Organization restored\. The bus did not start: bus failed to start; see the bus log/); assert.equal(await f.b.evaluate(`document.querySelector('[data-restore-bus]').textContent`), 'Restart bus'); await f.shots('e1-restored-bus-failed', '#org-archive-success'); await f.b.evaluate(`document.querySelector('[data-restore-bus]').click()`); await f.wait(`/It answers/.test(document.querySelector('[data-restore-bus-receipt]')?.textContent || '')`); assert.equal(await f.b.evaluate(`document.querySelector('[data-restore-bus-receipt]').textContent`), 'Bus started. It answers.'); assert.equal(await f.b.evaluate(`document.querySelector('[data-restore-bus]').disabled`), true); assert.deepEqual(bus.starts.map(([id]) => id), ['beta', 'beta']); await f.shots('e1-restored-bus-restarted', '#org-archive-success'); }); test('E1 (P1-4): restoring the only organization reloads into it and still shows the restore and bus receipt', { timeout: 45000 }, async t => { const health = { up: false }, bus = startingBus(health); const f = await fixture(t, ['alpha'], { health, bus, trail: true }); await f.open('alpha'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!window.MosaicOrg && document.querySelector('#org-open-id')?.value === 'alpha'`); await f.open('alpha'); await f.b.evaluate(`document.querySelector('#org-restore').click()`); await f.wait(`window.MosaicOrg?.id === 'alpha' && document.querySelector('#org-archive-success')?.hidden === false`); assert.match(await f.b.evaluate(`document.querySelector('#org-archive-success').innerText`), /^Organization restored\. Bus started\. It answers\./); assert.deepEqual(bus.starts.map(([id]) => id), ['alpha']); // The kept receipt is shown once: a later visit does not repeat it. assert.equal(await f.b.evaluate(`Object.keys(sessionStorage).filter(k=>k.startsWith('mosaic-org-restored:')).length`), 0); }); test('E1 (P1-5): after restoring another organization, its dashboard shows the bus running and its System types', { timeout: 45000 }, async t => { const health = { up: false }, bus = startingBus(health), st = await systemTypesFake({ enabled: ['coding'] }); const extra = id => ({ requestRead: st.requestRead, reads: { business: async () => ({ business: { id, human: 'local-dev-owner', arbiters: [], projects: [], vars: {}, instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, }); const f = await fixture(t, ['alpha', 'beta'], { health, bus, trail: true, extra }); await archiveBeta(f); await f.b.evaluate(`document.querySelector('#org-restore').click()`); await f.wait(`/Bus started\. It answers\./.test(document.querySelector('#org-archive-success')?.innerText || '')`); await f.wait(`!!document.querySelector('#workspace-nav [data-workspace-org=beta]')`); await f.b.evaluate(`location.hash = '#/orgs/beta/organization'`); await f.wait(`/bus running, read/.test(document.querySelector('[data-org-dashboard]')?.innerText || '') && !!document.querySelector('[data-org-dashboard] [data-system-type-chips] .ws-chip')`); assert.doesNotMatch(await f.b.evaluate(`document.querySelector('[data-org-dashboard] [data-system-type-chips]').textContent`), /unavailable/); assert.deepEqual(bus.starts.map(([id]) => id), ['beta']); }); test('restore does not announce success for an unexpected response', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!document.querySelector('#org-archived').hidden`); await f.b.evaluate(`const original=fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/restore')?Promise.resolve(new Response(JSON.stringify({outcome:'done',step:'archive'}),{status:200})):original(input,init);document.querySelector('#org-restore').click()`); await f.wait(`!!document.querySelector('#org-archive-error').textContent`); assert.match(await f.b.evaluate(`document.querySelector('#org-archive-error').textContent`), /outcome is unknown/i); assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-success').hidden`), true); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'archived'); }); test('archiving the last active organization shows the no-organization state', { timeout: 45000 }, async t => { const f = await fixture(t, ['alpha']); await f.open('alpha'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!window.MosaicOrg && document.querySelector('#org-open-id')?.value === 'alpha'`); assert.equal((await registry.listOrgs(f.ctx))[0].state, 'archived'); assert.match(await f.b.evaluate(`document.querySelector('#org-open-id').selectedOptions[0].textContent`), /archived/); await f.open('alpha'); await f.b.evaluate(`document.querySelector('#org-restore').click()`); await f.wait(`window.MosaicOrg?.id === 'alpha'`); assert.equal((await registry.listOrgs(f.ctx))[0].state, 'active'); }); test('fresh archived organization page offers owner restore while writes stay refused', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!document.querySelector('#org-archived').hidden`); await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`); await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`); assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`), false); assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`), false); assert.match(await f.b.evaluate(`document.querySelector('#org-archived').innerText`), /mosaic org restore beta --if-revision \d+/); const refused = await f.b.evaluate(`Promise.all(['/api/orgs/beta/chat/projects','/api/orgs/beta/chat/threads/00000000-0000-0000-0000-000000000000/messages'].map(url=>fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:'{}'}).then(r=>r.status)))`); assert.deepEqual(refused, [409, 409]); await f.b.evaluate(`document.querySelector('#org-restore').click()`); await f.wait(`!document.querySelector('#org-archive-success').hidden`); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active'); }); test('owner can start a down bus after setup recorded done and sees recovery guidance on refusal', { timeout: 45000 }, async t => { const f = await fixture(t); await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`); await f.open('alpha'); // The setup steps render after the editor title, once the setup and organization list reads answer. await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`); await f.b.evaluate(`document.querySelector('[data-step="bus"] [data-run="bus"]').click()`); await f.wait(`document.querySelector('[data-step="bus"]').innerText.includes('stale bus host lock')`); assert.match(await f.b.evaluate(`document.querySelector('[data-step="bus"]').innerText`), /mosaic bus recover alpha/); }); // E2E5 review P2: the list read by Refresh, not only opening the organization, decides whether Start shows. test('an open Settings page offers Start when a refreshed org list reads the bus down after setup recorded done', { timeout: 45000 }, async t => { const health = { up: true }; const f = await fixture(t, ['alpha', 'beta'], { health }); await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});return old(input,init);};})()`); await f.open('alpha'); await f.wait(`document.querySelector('[data-step="bus"]')?.textContent.includes('Done')`); assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]')`), false); health.up = false; await f.b.evaluate(`document.querySelector('#refresh').click()`); await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`); health.up = true; await f.b.evaluate(`document.querySelector('#refresh').click()`); await f.wait(`!document.querySelector('[data-step="bus"] [data-run="bus"]')`); }); for (const capability of [{ user:true, orgSetup:false }, { tester:true, orgSetup:false }, { orgSetup:false }]) { test(`archived restore stays disabled without owner capability ${JSON.stringify(capability)}`, { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!document.querySelector('#org-archived').hidden`); await f.b.call('Page.addScriptToEvaluateOnNewDocument', {source:`{const old=window.fetch;window.fetch=(input,init)=>input instanceof URL && input.pathname==='/api/config'?Promise.resolve(new Response(JSON.stringify(${JSON.stringify(capability)}),{status:200})):old(input,init)}`}); await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`); await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`); assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`),true); assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`),true); }); } test('archive displays progress while its request is in flight', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`{const old=window.fetch;window.fetch=(input,init)=>String(input).endsWith('/archive')?new Promise(resolve=>window.releaseArchive=()=>old(input,init).then(resolve)):old(input,init);document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()}`); assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-final').textContent`),'Archiving…'); await f.b.evaluate(`window.releaseArchive()`); await f.wait(`!document.querySelector('#org-archived').hidden`); });