Files
stack/packages/webui/tests/org-archive-browser.test.mjs
jason.woltje fdd6658a65 fix(webui): refresh the Settings bus control on every org list read
E2E5 review P2. An open Settings page kept Done after the bus died
because only opening the organization reread bus health. display.js
now announces each fresh list read, org-create.js redraws the setup
steps when the selected org's bus state flips (choices kept, no draft
guard), and app.js reads the list before bus-status so a failed
bus-status read no longer skips it. Regression: healthy, Refresh down
shows Start, Refresh up hides it; red on 8a8edc44b.
2026-11-09 13:53:19 -06:00

175 lines
15 KiB
JavaScript

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');
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`), true);
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`);
});