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.
175 lines
15 KiB
JavaScript
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 });
|
|
});
|