Files
stack/packages/webui/tests/org-delete-browser.test.mjs
jason.woltje 8710cc0e1e feat(webui): U2 unified workspace sidebar, org dashboard and agents grid (#1873)
Finishes the U2 WIP (37b0891a3) to samples 01, 03 and 08 of the
2026-11-09 unified workspace set:
- workspace.js: sidebar Organizations and Projects groups (collapsible,
  state remembered), the org dashboard, the Agents levels.
- workspace-data.js: every read these screens make, GET no-store,
  redirect manual; a refused read shows unavailable for that part only.
- shell.css: the html[data-organizations] .ws-* block from the sample.
- bus.js: org-mode sidebar order, pinned Settings, no project tree under
  Projects, focus kept across repaint.
- Honest empty states where the Console has no data (KPIs, metrics, kind).
- Tests: unified-workspace-browser (10), picker/delete/switch browser tests
  follow the sidebar, browser.test script pin.
2026-11-10 11:32:19 -06:00

176 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']);
});
}
// U2 (#1873): the sidebar's Organizations group replaced the organization switcher.
test('review: deleting a non-current org refreshes the sidebar organization list', 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/organization'`);
await f.wait(`document.querySelectorAll('[data-workspace-org]').length === 1`);
assert.deepEqual(await f.b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(e=>e.dataset.workspaceOrg)`), ['alpha']);
assert.equal(await f.b.evaluate(`document.querySelector('[data-workspace-org="alpha"] a').getAttribute('aria-current')`), 'page');
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 });
});