#/dashboard is the system landing route: one row per organization with its bus state, agent count (organization files), project count and needs-you count, each only when a read gave it, and active agents. Opening an organization from the sidebar or a dashboard row is a route inside the same page: no reload, a Dashboard / <organization> breadcrumb, and later /api reads scoped to it through the existing fetch wrapper, whose organization is now a variable the router sets (mosaic-org-switch event). #/orgs/<id>/... stays the address. An organization whose bus is down says so on the dashboard and on its page, with the reason the workspace read gives.
177 lines
15 KiB
JavaScript
177 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()`);
|
|
// #1873 SP1: the breadcrumb, not the header, names the open organization.
|
|
let switched = false;
|
|
for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && (document.querySelector('#breadcrumbs [data-crumb-org]')?.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 });
|
|
});
|