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 }); });