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 { browser } from './browser.mjs'; import { close } from './fixture.mjs'; async function fixture(t, ids = ['alpha', 'beta'], { health = null } = {}) { 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 () => ({}) }) }); const admin = createOrgAdmin({ service, registry, bus: { busStatus, busStopForDelete }, 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: () => {} }); 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 }; } 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}); }); 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`); });