Org polish: lock-free reads, visible org errors, no-board label (P19 #867, #868, #869)

#867: organization reads no longer take the registry writer lock; they
retry while the lock exists or the registry changed during the read.
#868: a broken organization is listed as invalid with a path-masked
reason instead of disappearing; the Board, header and picker show it,
and /api/orgs 503 carries the reason. Orgs the principal may not see and
archived orgs still give an empty list.
#869: a Console with no board says "No board", not "Board: null".
#866 (save-path validation at bus admission) is left out: it refused
documents the bus accepted before and needs a migration story first.

Every bus, business, cli and webui file run serially; failures are only
the known EISDIR node_modules-link files.
This commit is contained in:
jason.woltje committed 2026-11-08 15:27:19 -06:00
1 parent 606911834e
commit 0e9b2c505d
10 files changed
+166 -38

No files matched your search

+15 -1
View File
@@ -131,7 +131,21 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
const doc = JSON.parse(raw); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
}
async function readCurrent(ctx, id, opts) { return withRegistry(ctx, () => readCurrentUnlocked(ctx, id, opts), { write: false }); }
// Readers do not take the writer lock. Require unchanged registry snapshots and
// no in-progress publication on either side of the read; retry a raced write.
async function readCurrent(ctx, id, opts) {
const locked = async () => { try { await fs.lstat(join(ctx.configDir, 'org-runtime.lock')); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
for (let n = 0; n < 200; n++) {
if (await locked()) { await new Promise(r => setTimeout(r, 10)); continue; }
const before = JSON.stringify(await readRegistry(ctx));
let result, error;
try { result = await readCurrentUnlocked(ctx, id, opts); } catch (e) { error = e; }
const after = JSON.stringify(await readRegistry(ctx));
if (!(await locked()) && before === after) { if (error) throw error; return result; }
await new Promise(r => setTimeout(r, 10));
}
fail('org-locked', 'registry changed or was locked during organization read');
}
export async function createOrg(ctx, id, input) {
authorize(ctx); checkId(id); if (input?.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner', '$.human');
const doc = normalizeDoc(ctx, id, input); await checked(doc);
@@ -51,6 +51,19 @@ test('broker organization writer updates the registry revision and remains reada
assert.deepEqual(after.doc.plugins.channel, ['cli']); assert.equal(after.revision, before.revision + 1);
});
test('reading beta does not contend with an alpha document writer', async () => {
const c = context();
for (const id of ['alpha', 'beta']) await createOrg(c, id, await templateRender(id, { fixture: 'local' }));
const writer = organizationWriter({ dir: c.configDir, rolesDir: REPO_ROLES });
const { document, revision } = writer.read('alpha'); document.plugins.channel = ['cli'];
let attempted = false, error;
const attempt = () => { if (attempted) return; attempted = true; try { writer.write('alpha', document, { revision, actor: c.principal.sub }); } catch (e) { error = e; } };
const watcher = watch(c.configDir, (_event, name) => { if (name === 'org-runtime.lock') attempt(); });
try { await showOrg(c, 'beta'); attempt(); } finally { watcher.close(); }
if (error) throw error;
assert.deepEqual((await showOrg(c, 'alpha')).doc.plugins.channel, ['cli']);
});
test('setup never overwrites a broker write committed while git initializes', async () => {
const c = context(); await createOrg(c, 'alpha', await templateRender('alpha', { fixture: 'local' }));
const writer = organizationWriter({ dir: c.configDir, rolesDir: REPO_ROLES });
+21 -12
View File
@@ -1,3 +1,4 @@
import { plainMessage } from './org-admin.mjs';
// The registry and service are injected here so their single, shared implementations remain
// the authority. The browser supplies only an id; roots and sockets come from verified metadata.
export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths, busStatus, options }) {
@@ -10,19 +11,27 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat
if (org.state !== 'active') throw new Error('org-unavailable');
return { org, shown, context };
}
return Object.freeze({
async list(session) {
const entries = await listOrgs(ctx), result = [];
for (const entry of entries) {
if (entry.state !== 'active') continue;
let verified;
try { verified = await admitted(entry.id, session); } catch { continue; }
let busHealthy = false;
try { busHealthy = (await busStatus(ctx, entry.id)).healthy === true; } catch { /* offline metadata remains visible */ }
result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy });
async function list(session) {
const entries = await listOrgs(ctx), result = [];
for (const entry of entries) {
if (entry.state !== 'active') continue;
let verified;
try { verified = await admitted(entry.id, session); }
catch (error) {
// Do not reveal entries to an unauthorized principal. An admitted
// owner's broken registry or document must not look like no orgs.
if (error?.code === 'org-forbidden' || error?.message === 'org-unavailable') continue;
result.push({ id: entry.id, label: entry.id, invalid: true, reason: plainMessage(ctx, error?.message || 'organization admission refused') });
continue;
}
return result;
},
let busHealthy = false;
try { busHealthy = (await busStatus(ctx, entry.id)).healthy === true; } catch { /* offline metadata remains visible */ }
result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy });
}
return result;
}
return Object.freeze({
list,
async resolve(id, session) {
const { org, shown, context } = await admitted(id, session);
let busHealthy = false;
+1 -1
View File
@@ -600,5 +600,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); $('local-sign-out').hidden = true; $('avatar-name').textContent = 'Operator'; $('avatar-button').querySelector('.avatar-initial').textContent = 'O'; } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { board = config.board || 'unknown'; $('board-url').textContent = config.board ? `Board: ${board}` : 'No board'; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); $('local-sign-out').hidden = true; $('avatar-name').textContent = 'Operator'; $('avatar-button').querySelector('.avatar-initial').textContent = 'O'; } }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
+14 -6
View File
@@ -565,16 +565,21 @@
// role, so it shows only where a send can pass (`send` in /api/config). A refusal keeps nothing;
// any other failure keeps the last read, labelled with its time, as the bus pages do.
let heldErr = null, heldHtml = null, focusSend = false;
let heldOrganizations = { empty: false, invalid: [], error: null };
function heldTable(claims) {
const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
function paintHeld(err, noOrganizations = false) {
function paintHeld(err, noOrganizations, invalidOrganizations, organizationError) {
heldErr = err;
if (noOrganizations !== undefined) heldOrganizations = { empty: noOrganizations, invalid: invalidOrganizations, error: organizationError };
({ empty: noOrganizations, invalid: invalidOrganizations, error: organizationError } = heldOrganizations);
const box = $('held'), r = last.get('agents');
let html;
if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.');
if (organizationError) html = `<div class="banner err" role="alert"><p>Organization registry could not be read: ${txt(organizationError)}</p>${retry}</div>`;
else if (invalidOrganizations.length) html = `<div class="banner err" role="alert">${invalidOrganizations.map(row => `<p>${txt(row.id)}: invalid — ${txt(row.reason)}</p>`).join('')}${retry}</div>`;
else if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.');
else if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
else if (!r) return; // not read yet: the skeleton stays
else {
@@ -583,7 +588,7 @@
${claims.length ? heldTable(claims) : empty('No role is held on the bus right now.', 'A role shows here while a run holds its claim on the bus. Machine sessions are under Show machine sessions.')}
<p class="small muted">Read from the bus ${when(r.at)}.</p>`;
}
$('held-count').textContent = err && (refusal(err) || !r) ? '–' : String(r.rows.length);
$('held-count').textContent = organizationError || invalidOrganizations.length || (err && (refusal(err) || !r)) || !r ? '–' : String(r.rows.length);
if (html === heldHtml) return; // unchanged: no repeated alert, focus stays put
// Keep focus on the same control across the 10 s repaint.
const a = document.activeElement, k = box.contains(a) ? { role: a.dataset?.messageRole, retry: a.hasAttribute?.('data-retry'), wrap: a.classList?.contains('table-wrap') } : null;
@@ -703,7 +708,7 @@
async function businessView(get) {
const b = (await get.api('business')).business || {};
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}>${txt(org.label || org.id)}${org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
const launch = b.launch || {};
const authKnown = instances.some(i => i.authority);
@@ -1459,7 +1464,7 @@
});
const initialOrganizations = await window.MosaicInitialOrganizations;
if (g !== gen) return;
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null), !window.MosaicOrg && Array.isArray(initialOrganizations) && initialOrganizations.length === 0);
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null), !window.MosaicOrg && Array.isArray(initialOrganizations) && initialOrganizations.length === 0, !window.MosaicOrg && Array.isArray(initialOrganizations) ? initialOrganizations.filter(row => row.invalid) : [], !window.MosaicOrg ? window.MosaicOrganizationError : null);
sections(path); schedule();
return;
}
@@ -1531,7 +1536,10 @@
if (e.target.closest('#cmdk')) { pkOpen(); return; }
const message = e.target.closest('#held [data-message-role]');
if (message) { draft.to = message.dataset.messageRole; focusSend = true; location.hash = '#/inbox'; return; }
if (e.target.closest('#bus-view [data-retry], #held [data-retry]')) render(false);
if (e.target.closest('#bus-view [data-retry], #held [data-retry]')) {
if (!window.MosaicOrg && window.MosaicRefreshOrganizations) window.MosaicRefreshOrganizations();
render(false);
}
});
// ---------------------------------------------------------------- toast
+29 -11
View File
@@ -7,17 +7,29 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
const id = match?.[1] ?? null;
if (!id) {
if (!document.documentElement.hasAttribute('data-organizations')) return;
const initialOrganizations = nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(r => r.ok ? r.json() : null).catch(() => null);
window.MosaicInitialOrganizations = initialOrganizations;
initialOrganizations.then(async rows => {
if (!Array.isArray(rows) || !rows.length || location.hash.startsWith('#/orgs/')) return;
let preferred;
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
const selected = rows.find(row => row.id === preferred) ?? rows[0];
const tail = location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
history.replaceState(null, '', `#/orgs/${encodeURIComponent(selected.id)}${tail}`);
location.reload();
}).catch(() => {});
const readInitialOrganizations = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.ok) { window.MosaicOrganizationError = null; return r.json(); }
const body = await r.json().catch(() => ({}));
window.MosaicOrganizationError = body.message || 'Organization registry could not be read';
return null;
}).catch(() => { window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
const refreshInitial = () => {
const initialOrganizations = window.MosaicInitialOrganizations = readInitialOrganizations();
initialOrganizations.then(async rows => {
if (!Array.isArray(rows) || !rows.length || location.hash.startsWith('#/orgs/')) return;
rows = rows.filter(row => !row.invalid);
if (!rows.length) return;
let preferred;
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
const selected = rows.find(row => row.id === preferred) ?? rows[0];
const tail = location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
history.replaceState(null, '', `#/orgs/${encodeURIComponent(selected.id)}${tail}`);
location.reload();
}).catch(() => {});
return initialOrganizations;
};
window.MosaicRefreshOrganizations = refreshInitial;
refreshInitial();
return;
}
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
@@ -30,6 +42,12 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
let list = readList();
const refreshList = () => (list = readList());
const paintHeader = rows => {
const invalid = rows.filter(row => row.invalid);
if (invalid.length) {
const banner = document.createElement('div'); banner.className = 'banner err'; banner.setAttribute('role', 'alert');
banner.textContent = invalid.map(row => `${row.id}: invalid — ${row.reason}`).join('; ');
document.body.prepend(banner);
}
const org = rows.find(row => row.id === decodeURIComponent(id));
const mark = document.querySelector('.wordmark');
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
+2 -2
View File
@@ -17,7 +17,7 @@ import { themesHTTP } from './themes-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { orgAdminHTTP, defaultOrgAdmin, plainMessage } from './org-admin.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -831,7 +831,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (orgList && !hook) {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
try { return json(res, 200, await organizations.list(currentSession)); }
catch { return json(res, 503, { error: 'org-unavailable' }); }
catch (error) { return json(res, 503, { error: 'org-unavailable', message: plainMessage(null, error?.message || 'Organization registry could not be read') }); }
}
if (orgList && hook) orgList = false;
let selected;
+1 -1
View File
@@ -6,7 +6,7 @@ test('organization adapter rechecks authority and verifies roots before selectin
const checks = [], ctx = { configDir: '/cfg', dataRoot: '/data', instance: 'test' };
const adapter = bus.organizationConsole({ ctx,
listOrgs: async () => [{ id: 'alpha', state: 'active' }, { id: 'beta', state: 'active' }, { id: 'reserved', state: 'reserved' }],
showOrg: async (context, id) => { checks.push([id, context.principal.sub]); if (id === 'beta') throw Error('denied'); return { doc: { appearance: { theme: 'harbor' } } }; },
showOrg: async (context, id) => { checks.push([id, context.principal.sub]); if (id === 'beta') throw Object.assign(Error('denied'), { code: 'org-forbidden' }); return { doc: { appearance: { theme: 'harbor' } } }; },
resolveOrg: async (_, id) => ({ id, root: `/verified/${id}`, state: 'active' }),
orgPaths: root => ({ socket: `${root}/bus/broker.sock` }),
busStatus: async (_, id) => ({ healthy: id === 'alpha' }),
+36 -2
View File
@@ -1,11 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { consoleReads } from '../src/reads.mjs';
import { recordBootRefused, recordBootOk } from '../../cli/src/boot-status.mjs';
import { createOrg, templateRender } from '../../business/src/org-service.mjs';
import { createOrg, templateRender, showOrg, archiveOrg } from '../../business/src/org-service.mjs';
import { busStopForDelete } from '../../cli/src/org-bus.mjs';
import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs';
import { organizationConsole } from '../src/organizations.mjs';
test('admission errors are visible while a healthy organization remains usable', async t => {
const root = mkdtempSync(join(tmpdir(), 'org-admission-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const ctx = { configDir, dataRoot, instance: 'readtest', principal };
for (const id of ['alpha', 'beta']) await createOrg(ctx, id, await templateRender(id));
const file = join(configDir, 'org-runtime.json'), registry = JSON.parse(readFileSync(file, 'utf8'));
registry.orgs.alpha.digest = '0'.repeat(64); writeFileSync(file, JSON.stringify(registry));
const console = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}), busStatus: async () => ({ healthy: true }), options: ({ id }) => ({ id }) });
const rows = await console.list(principal);
assert.equal(rows.find(row => row.id === 'alpha')?.invalid, true);
assert.match(rows.find(row => row.id === 'alpha')?.reason, /document and registry disagree/);
assert.equal((await console.resolve('beta', principal)).id, 'beta');
});
test('an owner whose only organization is archived gets an empty list, not an error', async t => {
const root = mkdtempSync(join(tmpdir(), 'org-archived-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const ctx = { configDir, dataRoot, instance: 'readtest', principal };
await createOrg(ctx, 'alpha', await templateRender('alpha'));
const { revision } = await showOrg(ctx, 'alpha');
await archiveOrg(ctx, 'alpha', { ifRevision: revision, busStop: busStopForDelete });
const console = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}), busStatus: async () => ({ healthy: true }), options: ({ id }) => ({ id }) });
assert.deepEqual(await console.list(principal), []);
});
test('offline boot status comes from the selected organization root', async t => {
const root = mkdtempSync(join(tmpdir(), 'org-reads-'));
@@ -6,8 +6,36 @@ import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
test('organization header and footer never display null board values', async t => {
const organizations = { list: async () => [{ id: 'alpha', label: 'alpha', busHealthy: true }], resolve: async () => ({ reads: {}, bus: {} }) };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
for (const [width, height] of [[1400, 900], [390, 844]]) {
await b.viewport(width, height);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(/Board:|No board/.test(document.querySelector('#board-url').textContent))return;await new Promise(r=>setTimeout(r,30))}throw Error('footer not loaded')})()`);
const text = await b.evaluate(`document.querySelector('header').textContent + ' ' + document.querySelector('footer').textContent`);
assert.doesNotMatch(text, /\b(?:null|undefined)\b/i);
}
});
test('an invalid organization is explained instead of appearing as an empty registry', async t => {
const organizations = { list: async () => [{ id: 'alpha', label: 'alpha', invalid: true, reason: 'organization document and registry disagree' }], resolve: async () => { throw Error('unavailable'); } };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
for (const [width, height] of [[1400, 900], [390, 844]]) {
await b.viewport(width, height);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held [role=alert]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('admission error missing')})()`);
const body = await b.evaluate('document.body.textContent');
assert.match(body, /alpha.*invalid.*document and registry disagree/is);
assert.doesNotMatch(body, /No organizations yet/);
}
});
test('fresh dashboard points to organization setup while real list errors remain errors', async t => {
for (const broken of [false, true]) {
for (let broken of [false, true]) {
const organizations = { list: async () => { if (broken) throw Error('registry down'); return []; }, resolve: async () => null };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, board: 'http://127.0.0.1:9', log: () => {} });
const b = await browser({ machine: false });
@@ -15,7 +43,11 @@ test('fresh dashboard points to organization setup while real list errors remain
await b.navigate(`http://127.0.0.1:${server.address().port}/#/`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held .banner, #held a[href="#/settings/organizations"]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('held roles did not settle')})()`);
const held = await b.evaluate(`({ text: document.querySelector('#held').textContent, error: !!document.querySelector('#held .banner.err, #held .banner.ref'), link: !!document.querySelector('#held a[href="#/settings/organizations"]') })`);
if (broken) assert.equal(held.error, true, 'a real registry error remains visible');
if (broken) {
assert.equal(held.error, true, 'a real registry error remains visible'); assert.match(held.text, /registry down/);
broken = false; await b.evaluate(`document.querySelector('#held [data-retry]').click()`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held a[href="#/settings/organizations"]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('registry retry did not recover')})()`);
}
else { assert.match(held.text, /No organizations yet/); assert.equal(held.link, true); assert.equal(held.error, false); }
} finally { await b.close(); await close(server); }
}