E2E5 review P2. An open Settings page kept Done after the bus died
because only opening the organization reread bus health. display.js
now announces each fresh list read, org-create.js redraws the setup
steps when the selected org's bus state flips (choices kept, no draft
guard), and app.js reads the list before bus-status so a failed
bus-status read no longer skips it. Regression: healthy, Refresh down
shows Start, Refresh up hides it; red on 8a8edc44b.
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 { organizationConsole } from '../src/organizations.mjs';
|
|
import * as service from '../../organization/src/org-service.mjs';
|
|
import * as registry from '../../organization/src/org-registry.mjs';
|
|
import { orgPaths } from '../../cli/src/config.mjs';
|
|
import { busStopForDelete } from '../../cli/src/org-bus.mjs';
|
|
import { browser } from './browser.mjs';
|
|
import { close } from './fixture.mjs';
|
|
|
|
async function fixture(t, ids = ['alpha', 'beta'], { health = null } = {}) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'archive-browser-'));
|
|
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 busStatus = async () => ({ healthy: health ? health.up : false });
|
|
const organizations = organizationConsole({ ctx, listOrgs: registry.listOrgs, resolveOrg: registry.resolveOrg, showOrg: service.showOrg, orgPaths, busStatus, options: ({ id }) => ({ reads: { business: async () => ({ business: { id, projects: ['work'] } }), settings: async () => ({}) }, bus: {}, send: async () => ({}) }) });
|
|
const admin = createOrgAdmin({ service, registry, bus: { busStatus, 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: () => {} });
|
|
const b = await browser({ machine: false }); t.after(async () => { await b.close(); await close(server); await rm(dir, { recursive: true, force: true }); });
|
|
const wait = async expression => { for (let i = 0; i < 400; 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}`)}`); }
|
|
async function shots(state, selector) {
|
|
if (!process.env.ORG_ARCHIVE_SHOTS) return;
|
|
await mkdir(process.env.ORG_ARCHIVE_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_ARCHIVE_SHOTS, `${state}-${width}-${mode}.png`));
|
|
}
|
|
}
|
|
return { b, wait, open, shots, ctx, port: server.address().port };
|
|
}
|
|
|
|
test('archive and restore a non-current organization', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('beta');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click()`);
|
|
assert.equal(await f.b.evaluate(`document.activeElement.id`), 'org-archive-confirm');
|
|
await f.shots('archive', '#org-archive');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-cancel').click()`);
|
|
assert.equal(await f.b.evaluate(`document.activeElement.id`), 'org-archive-open');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
|
|
await f.wait(`!document.querySelector('#org-archived').hidden`);
|
|
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'archived');
|
|
assert.match(await f.b.evaluate(`document.querySelector('#org-open-id').selectedOptions[0].textContent`), /archived/);
|
|
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows=>rows.map(row=>row.id))`), ['alpha']);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-save').disabled`), true);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-json-text').disabled`), true);
|
|
await f.shots('archived', '#org-archived');
|
|
const chat = await f.b.evaluate(`fetch('/api/orgs/beta/chat/projects').then(async r=>({status:r.status,body:await r.json()}))`);
|
|
assert.equal(chat.status, 409); assert.equal(chat.body.error, 'org-archived');
|
|
await f.b.evaluate(`document.querySelector('#org-restore').click()`);
|
|
await f.wait(`!document.querySelector('#org-archive-success').hidden`);
|
|
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
|
|
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows=>rows.map(row=>row.id))`), ['alpha', 'beta']);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-save').disabled`), false);
|
|
await f.shots('restored', '#org-archive-success');
|
|
});
|
|
|
|
test('archive does not announce success for an unexpected response', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('beta');
|
|
await f.b.evaluate(`const original=fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/archive')?Promise.resolve(new Response(JSON.stringify({outcome:'done',step:'bus'}),{status:200})):original(input,init);document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
|
|
await f.wait(`!!document.querySelector('#org-archive-error').textContent`);
|
|
assert.match(await f.b.evaluate(`document.querySelector('#org-archive-error').textContent`), /outcome is unknown/i);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-final').textContent`), 'Archive');
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-archived').hidden`), true);
|
|
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
|
|
});
|
|
|
|
test('archiving the current organization switches to the remaining one', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('alpha');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
|
|
await f.wait(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`);
|
|
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'alpha').state, 'archived');
|
|
});
|
|
|
|
test('opening another editor resets the archive confirmation and error', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('beta');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-error').textContent='old error';document.querySelector('#org-archive-final').disabled=true`);
|
|
await f.open('alpha');
|
|
assert.deepEqual(await f.b.evaluate(`({trigger:document.querySelector('#org-archive-open').hidden,panel:document.querySelector('#org-archive-confirm').hidden,error:document.querySelector('#org-archive-error').textContent,disabled:document.querySelector('#org-archive-final').disabled})`), {trigger:false,panel:true,error:'',disabled:false});
|
|
});
|
|
|
|
test('restore does not announce success for an unexpected response', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('beta');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
|
|
await f.wait(`!document.querySelector('#org-archived').hidden`);
|
|
await f.b.evaluate(`const original=fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/restore')?Promise.resolve(new Response(JSON.stringify({outcome:'done',step:'archive'}),{status:200})):original(input,init);document.querySelector('#org-restore').click()`);
|
|
await f.wait(`!!document.querySelector('#org-archive-error').textContent`);
|
|
assert.match(await f.b.evaluate(`document.querySelector('#org-archive-error').textContent`), /outcome is unknown/i);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-success').hidden`), true);
|
|
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'archived');
|
|
});
|
|
|
|
|
|
test('archiving the last active organization shows the no-organization state', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t, ['alpha']); await f.open('alpha');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
|
|
await f.wait(`!window.MosaicOrg && document.querySelector('#org-open-id')?.value === 'alpha'`);
|
|
assert.equal((await registry.listOrgs(f.ctx))[0].state, 'archived');
|
|
assert.match(await f.b.evaluate(`document.querySelector('#org-open-id').selectedOptions[0].textContent`), /archived/);
|
|
await f.open('alpha'); await f.b.evaluate(`document.querySelector('#org-restore').click()`);
|
|
await f.wait(`window.MosaicOrg?.id === 'alpha'`);
|
|
assert.equal((await registry.listOrgs(f.ctx))[0].state, 'active');
|
|
});
|
|
|
|
test('fresh archived organization page offers owner restore while writes stay refused', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('beta');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
|
|
await f.wait(`!document.querySelector('#org-archived').hidden`);
|
|
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
|
|
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`), false);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`), false);
|
|
assert.match(await f.b.evaluate(`document.querySelector('#org-archived').innerText`), /mosaic org restore beta --if-revision \d+/);
|
|
const refused = await f.b.evaluate(`Promise.all(['/api/orgs/beta/chat/projects','/api/orgs/beta/chat/threads/00000000-0000-0000-0000-000000000000/messages'].map(url=>fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:'{}'}).then(r=>r.status)))`);
|
|
assert.deepEqual(refused, [409, 409]);
|
|
await f.b.evaluate(`document.querySelector('#org-restore').click()`);
|
|
await f.wait(`!document.querySelector('#org-archive-success').hidden`);
|
|
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
|
|
});
|
|
|
|
test('owner can start a down bus after setup recorded done and sees recovery guidance on refusal', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t);
|
|
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
|
|
await f.open('alpha');
|
|
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`), true);
|
|
await f.b.evaluate(`document.querySelector('[data-step="bus"] [data-run="bus"]').click()`);
|
|
await f.wait(`document.querySelector('[data-step="bus"]').innerText.includes('stale bus host lock')`);
|
|
assert.match(await f.b.evaluate(`document.querySelector('[data-step="bus"]').innerText`), /mosaic bus recover alpha/);
|
|
});
|
|
|
|
// E2E5 review P2: the list read by Refresh, not only opening the organization, decides whether Start shows.
|
|
test('an open Settings page offers Start when a refreshed org list reads the bus down after setup recorded done', { timeout: 45000 }, async t => {
|
|
const health = { up: true };
|
|
const f = await fixture(t, ['alpha', 'beta'], { health });
|
|
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});return old(input,init);};})()`);
|
|
await f.open('alpha');
|
|
await f.wait(`document.querySelector('[data-step="bus"]')?.textContent.includes('Done')`);
|
|
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]')`), false);
|
|
health.up = false;
|
|
await f.b.evaluate(`document.querySelector('#refresh').click()`);
|
|
await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`);
|
|
health.up = true;
|
|
await f.b.evaluate(`document.querySelector('#refresh').click()`);
|
|
await f.wait(`!document.querySelector('[data-step="bus"] [data-run="bus"]')`);
|
|
});
|
|
|
|
for (const capability of [{ user:true, orgSetup:false }, { tester:true, orgSetup:false }, { orgSetup:false }]) {
|
|
test(`archived restore stays disabled without owner capability ${JSON.stringify(capability)}`, { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('beta');
|
|
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
|
|
await f.wait(`!document.querySelector('#org-archived').hidden`);
|
|
await f.b.call('Page.addScriptToEvaluateOnNewDocument', {source:`{const old=window.fetch;window.fetch=(input,init)=>input instanceof URL && input.pathname==='/api/config'?Promise.resolve(new Response(JSON.stringify(${JSON.stringify(capability)}),{status:200})):old(input,init)}`});
|
|
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
|
|
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`),true);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`),true);
|
|
});
|
|
}
|
|
|
|
test('archive displays progress while its request is in flight', { timeout: 45000 }, async t => {
|
|
const f = await fixture(t); await f.open('beta');
|
|
await f.b.evaluate(`{const old=window.fetch;window.fetch=(input,init)=>String(input).endsWith('/archive')?new Promise(resolve=>window.releaseArchive=()=>old(input,init).then(resolve)):old(input,init);document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()}`);
|
|
assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-final').textContent`),'Archiving…');
|
|
await f.b.evaluate(`window.releaseArchive()`); await f.wait(`!document.querySelector('#org-archived').hidden`);
|
|
});
|