A project row's sessions, open tasks and needs-you figures come from reads beyond the project list; when those failed in the row's organization the cells now say not read instead of none or 0, and a note names the organization. Dashboard per-organization figures and Done this week count only organizations that made the tile's reads, a dashboard with no answering organization shows no totals, and empty lists say not read when no organization could make the read. Sessions now names each session's organization for screen readers. The org-archive test waits for the setup steps, which render after the editor title.
176 lines
15 KiB
JavaScript
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 { 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');
|
|
// The setup steps render after the editor title, once the setup and organization list reads answer.
|
|
await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`);
|
|
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`);
|
|
});
|