Files
stack/packages/webui/tests/org-delete-browser.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -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 * as service from '../../organization/src/org-service.mjs';
import * as registry from '../../organization/src/org-registry.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { busStopForDelete } from '../../cli/src/org-bus.mjs';
test('two confirmations delete an organization and remove it from the list', { timeout: 45000 }, async t => {
const dir = await mkdtemp(join(tmpdir(), 'delete-'));
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' } };
await service.createOrg(ctx, 'alpha', await service.templateRender('alpha'));
const bus = { busStatus: async () => ({ healthy: false }), busStopForDelete };
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
// Remove the directory last: hooks run in the order they are registered, and a removal that
// fails while the server still writes there would skip the server and browser closes (P19 #865).
t.after(() => rm(dir, { recursive: true, force: true }));
async function shots(state, selector) {
if (!process.env.ORG_DELETE_SHOTS || (process.env.ORG_DELETE_SHOT_STATES && !process.env.ORG_DELETE_SHOT_STATES.split(',').includes(state))) return;
await mkdir(process.env.ORG_DELETE_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_DELETE_SHOTS, `${state}-${width}-${mode}.png`));
}
}
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;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}/#/settings/organizations`);
await wait(`document.querySelector('#org-open-id')?.value === 'alpha'`);
await b.evaluate(`document.querySelector('#org-open-form button').click()`);
await wait(`document.querySelector('#org-delete-open') && !document.querySelector('#org-delete').hidden`);
await b.evaluate(`document.querySelector('#org-delete-open').click()`);
await wait(`!document.querySelector('#org-delete-first').hidden`);
assert.equal(await b.evaluate(`document.activeElement.id`), 'org-delete-first');
await shots('confirmation-1', '#org-delete');
await b.evaluate(`document.querySelector('#org-delete-cancel').click()`);
assert.equal(await b.evaluate(`document.activeElement.id`), 'org-delete-open');
await b.evaluate(`document.querySelector('#org-delete-open').click();document.querySelector('#org-delete-continue').click()`);
assert.equal(await b.evaluate(`document.querySelector('#org-delete-final').disabled`), true);
await shots('confirmation-2', '#org-delete');
await b.evaluate(`const i=document.querySelector('#org-delete-confirm'); i.value='alpha'; i.dispatchEvent(new Event('input',{bubbles:true}))`);
assert.equal(await b.evaluate(`document.querySelector('#org-delete-final').disabled`), false);
await b.evaluate(`document.querySelector('#org-delete-final').click()`);
await wait(`!document.querySelector('#org-delete-success').hidden`);
assert.equal(await b.evaluate(`document.querySelector('#org-open-id').value`), '');
assert.equal(await b.evaluate(`document.querySelector('#org-archive').hidden && document.querySelector('#org-archived').hidden`), true);
await shots('success', '#org-delete-success');
assert.deepEqual(await registry.listOrgs(ctx), []);
});
test('deleting the current organization switches the Console to a remaining organization', { timeout: 45000 }, async t => {
const dir = await mkdtemp(join(tmpdir(), 'delete-switch-'));
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 ['alpha', 'beta']) await service.createOrg(ctx, id, await service.templateRender(id));
const organizations = {
list: async () => (await registry.listOrgs(ctx)).filter(e => e.state === 'active').map(e => ({ id: e.id, label: e.id, busHealthy: false })),
resolve: async id => (await registry.listOrgs(ctx)).some(e => e.id === id && e.state === 'active') ? { reads: { business: async () => ({ business: { id, projects: ['work'] } }) }, bus: {}, send: async () => ({}) } : null,
};
const admin = createOrgAdmin({ service, registry, bus: { busStatus: async () => ({ healthy: false }), 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: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
// Remove the directory last: hooks run in the order they are registered, and a removal that
// fails while the server still writes there would skip the server and browser closes (P19 #865).
t.after(() => rm(dir, { recursive: true, force: true }));
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<400;i++){try{if(${expression})return}catch{}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-open-id')?.value === 'alpha'`);
await b.evaluate(`document.querySelector('#org-open-form button').click()`);
await wait(`document.querySelector('#org-delete') && !document.querySelector('#org-delete').hidden`);
await b.evaluate(`document.querySelector('#org-delete-open').click();document.querySelector('#org-delete-continue').click();const i=document.querySelector('#org-delete-confirm');i.value='alpha';i.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-delete-final').click()`);
let switched = false;
for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); }
assert.equal(switched, true); assert.deepEqual((await registry.listOrgs(ctx)).map(e => e.id), ['beta']);
});
async function reviewFixture(t, ids = ['alpha', 'beta']) {
const dir = await mkdtemp(join(tmpdir(), 'review-delete-'));
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 organizations = {
list: async () => (await registry.listOrgs(ctx)).filter(e => e.state === 'active').map(e => ({ id: e.id, label: e.id, busHealthy: false })),
resolve: async id => (await registry.listOrgs(ctx)).some(e => e.id === id && e.state === 'active') ? { reads: { business: async () => ({ business: { id, projects: ['work'] } }), settings: async () => ({}) }, bus: {}, send: async () => ({}) } : null,
};
const admin = createOrgAdmin({ service, registry, bus: { busStatus: async () => ({ healthy: false }), 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: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
// Remove the directory last: hooks run in the order they are registered, and a removal that
// fails while the server still writes there would skip the server and browser closes (P19 #865).
t.after(() => rm(dir, { recursive: true, force: true }));
const wait = async expression => { for (let i=0;i<300;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}`)} && !document.querySelector('#org-delete').hidden`);
}
async function confirm(id) {
await open(id);
await b.evaluate(`document.querySelector('#org-delete-open').click();document.querySelector('#org-delete-continue').click();const i=document.querySelector('#org-delete-confirm');i.value=${JSON.stringify(id)};i.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-delete-final').click()`);
await wait(`!document.querySelector('#org-delete-success').hidden || !!document.querySelector('#org-delete-error').textContent`);
}
return { b, wait, open, confirm, ctx };
}
test('review: reject a 200 response without the delete outcome and step', async t => {
const f = await reviewFixture(t);
await f.b.evaluate(`const originalFetch=window.fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/delete') ? Promise.resolve(new Response(JSON.stringify({outcome:'unknown',step:'bus'}),{status:200,headers:{'Content-Type':'application/json'}})) : originalFetch(input,init)`);
await f.confirm('beta');
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha','beta']);
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-success').hidden`), true, 'uncertain response must not announce successful deletion');
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-error').textContent`), /outcome is unknown/i);
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-progress').textContent`), /mosaic org delete beta --confirm beta/);
});
for (const [status, body] of [[201, { outcome: 'done', step: 'delete' }], [503, { message: 'Unavailable' }], [200, { outcome: 'done', step: 'bus' }], [200, {}]]) {
test(`delete treats status ${status} and ${JSON.stringify(body)} as unknown`, async t => {
const f = await reviewFixture(t);
await f.b.evaluate(`const originalFetch=window.fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/delete') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(body))},{status:${status},headers:{'Content-Type':'application/json'}})) : originalFetch(input,init)`);
await f.confirm('beta');
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-success').hidden`), true);
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-error').textContent`), /outcome is unknown/i);
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-progress').textContent`), /mosaic org delete beta --confirm beta/);
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e => e.id), ['alpha', 'beta']);
});
}
test('review: deleting a non-current org refreshes the organization switcher', async t => {
const f = await reviewFixture(t);
await f.confirm('beta');
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha']);
await f.b.evaluate(`location.hash='#/orgs/alpha/settings/business'`);
await f.wait(`!!document.querySelector('#org-picker')`);
assert.deepEqual(await f.b.evaluate(`[...document.querySelector('#org-picker').options].map(e=>e.value)`), ['alpha']);
assert.equal(await f.b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows => rows.map(row => row.id))`), ['alpha']);
});
test('review: another org can be deleted after non-current deletion without reloading', async t => {
const f = await reviewFixture(t);
await f.confirm('beta');
await f.open('alpha');
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').hidden`), false, 'opening another org must restore its delete trigger');
});
test('opening another editor clears every deletion control', async t => {
const f = await reviewFixture(t);
await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-delete-open').click(); document.querySelector('#org-delete-continue').click(); const i=document.querySelector('#org-delete-confirm'); i.value='beta'; i.dispatchEvent(new Event('input',{bubbles:true})); document.querySelector('#org-delete-error').textContent='old error'; document.querySelector('#org-delete-progress').textContent='old progress'`);
await f.open('alpha');
assert.deepEqual(await f.b.evaluate(`({trigger:document.querySelector('#org-delete-open').hidden, first:document.querySelector('#org-delete-first').hidden, second:document.querySelector('#org-delete-second').hidden, value:document.querySelector('#org-delete-confirm').value, error:document.querySelector('#org-delete-error').textContent, progress:document.querySelector('#org-delete-progress').textContent, disabled:document.querySelector('#org-delete-final').disabled})`), {trigger:false,first:true,second:true,value:'',error:'',progress:'',disabled:true});
});
test('both confirmation panels have room around their content', async t => {
const f = await reviewFixture(t); await f.b.viewport(390, 844); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-delete-open').click()`);
const padded = await f.b.evaluate(`parseFloat(getComputedStyle(document.querySelector('#org-delete-first')).paddingLeft) > 0`);
await f.b.evaluate(`document.querySelector('#org-delete-continue').click()`);
const gap = await f.b.evaluate(`document.querySelector('#org-delete-second .settings-actions').getBoundingClientRect().top - document.querySelector('#org-delete-confirm').getBoundingClientRect().bottom > 0`);
assert.deepEqual({ padded, gap }, { padded: true, gap: true });
});