fix(webui): sp2 polish, an organization's bare address opens its page and the dashboard keeps the system theme (#1873, lane sp2)

- #/orgs/<id> with no page and no trailing slash now opens #/orgs/<id>/organization instead of the
  system dashboard.
- The system dashboard resolves its own appearance (this browser's choice made
  there, else the default) under mosaic-console-appearance-system; it no
  longer shows the theme of the organization opened last.
- A stopped organization's page shows one bus banner: the page's own down
  banner now carries the restart action, and the shell's "Bus unavailable"
  banner hides on that page (display.js paintBus and app.js busDown).
- The dashboard row for a stopped organization no longer repeats "the bus is
  not running" under "bus stopped"; the last start time still shows.
- Organizations table figures keep to one line (white-space, no font change).
- The Active agents status cell was class "st", which picked up the global
  .st::before "?" glyph; it is now ws-st.
- Tests: red-first browser tests for each item, plus the test sp1 lacked: a
  read still in flight at an organization switch keeps nothing.
- org-switch-browser: a stopped organization's page asserts one banner; the
  shell's "Bus unavailable" is checked on its tasks page instead.
This commit was merged in pull request #1966.
This commit is contained in:
jason.woltje committed 2026-11-15 00:44:18 -06:00
1 parent c7aecbffa9
commit d1b01329bc
6 files changed
+174 -24

No files matched your search

+30 -12
View File
@@ -60,6 +60,10 @@
const darkPreference = window.matchMedia('(prefers-color-scheme: dark)');
const APPEARANCE_KEY = 'mosaic-console-appearance', LAST_KEY = 'mosaic-console-appearance-last';
const orgKey = id => `${APPEARANCE_KEY}:${id}`;
// #1873 SP2: the system dashboard is no organization's page. It shows the system theme: this browser's
// own choice made there, else the default, never the theme of the organization opened last.
const SYSTEM_KEY = 'mosaic-console-appearance-system';
const onSystem = () => !!window.MosaicOrg && !/^#\/orgs\//.test(location.hash) && ['', '/', '/dashboard'].includes(location.hash.replace(/^#/, '').split('?')[0]);
const store = {
read(k) { try { const v = JSON.parse(localStorage.getItem(k)); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } },
write(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { return false; } },
@@ -68,7 +72,10 @@
const orgId = v => typeof v === 'string' && v.length > 0 && v.length <= 64;
// org: null until the Console knows which organization it shows. Then the override lives under that organization's own key;
// before that, and for a Console with no organization, it is the browser-wide record this page has always kept.
let org = null, override = null, themes = null, themesUnavailable = false, current = null, applied = [];
let org = null, override = null, themes = null, themesUnavailable = false, current = null, applied = [], system = false;
const shown = () => (system ? null : org);
// Kept in memory too: a choice storage refused still shows until reload, as an organization's does.
let systemOverride = Brand.appearanceOf(store.read(SYSTEM_KEY));
const readOverride = () => {
if (org) return Brand.appearanceOf(store.read(orgKey(org.id)));
const v = store.read(APPEARANCE_KEY);
@@ -80,7 +87,7 @@
$('theme-switch').setAttribute('aria-checked', String(dark));
}
function paintNotices() {
const w = current.warning, banner = $('theme-warning'), cue = $('org-cue'), label = org?.label;
const w = current.warning, banner = $('theme-warning'), cue = $('org-cue'), label = shown()?.label;
banner.hidden = !w;
if (w) {
const asked = String(w.theme).slice(0, 64), kept = w.source === 'browser' ? 'The choice is kept.' : 'The saved value is unchanged.';
@@ -93,11 +100,12 @@
$('theme-clear').textContent = label ? `Use ${label}’s theme` : 'Use the default theme';
}
// Colour is never the only cue to which organization this is: whenever the theme is not the built-in one, the name is written out.
cue.hidden = !(org && (current.theme !== Brand.DEFAULT_THEME || w));
cue.hidden = !(shown() && (current.theme !== Brand.DEFAULT_THEME || w));
if (!cue.hidden) cue.textContent = w ? `${label} · Harbor shown instead of “${String(w.theme).slice(0, 64)}”` : `${label} · ${nameOf(current.theme)} theme · ${current.themeSource === 'browser' ? 'your choice in this browser' : 'set by the organization'}`;
}
function paint() {
current = Brand.resolveAppearance({ themes, unavailable: themesUnavailable, saved: org?.saved ?? null, override });
system = onSystem();
current = Brand.resolveAppearance({ themes, unavailable: themesUnavailable, saved: shown()?.saved ?? null, override: system ? systemOverride : override });
const root = document.documentElement;
root.dataset.palette = current.theme;
if (current.mode === 'system') delete root.dataset.mode; else root.dataset.mode = current.mode;
@@ -128,15 +136,16 @@
ready: null,
// The installed, valid themes as the server listed them, or null until /api/themes has answered.
themes: () => themes && themes.map(t => ({ id: t.id, displayName: t.displayName, tokens: t.tokens })),
state: () => ({ theme: current.theme, mode: current.mode, requested: current.requested, themeSource: current.themeSource, modeSource: current.modeSource, warning: current.warning, provisional: current.provisional, organization: org && { id: org.id, label: org.label }, saved: org?.saved ?? null, override: override && { ...override } }),
state: () => { const o = shown(), v = system ? systemOverride : override; return { theme: current.theme, mode: current.mode, requested: current.requested, themeSource: current.themeSource, modeSource: current.modeSource, warning: current.warning, provisional: current.provisional, organization: o && { id: o.id, label: o.label }, saved: o?.saved ?? null, override: v && { ...v } }; },
// Writes this browser's override for the organization (a theme, a mode, or both). false when storage refused it; the page still shows it until reload.
set(patch) {
if (onSystem()) { const next = Brand.appearanceOf({ ...(systemOverride || {}), ...patch }), ok = next ? store.write(SYSTEM_KEY, next) : true; systemOverride = next; showAppearance(ok); return ok; }
const next = Brand.appearanceOf({ ...(override || {}), ...patch });
const ok = next ? (org ? store.write(orgKey(org.id), next) : store.write(APPEARANCE_KEY, next)) : true;
override = next; showAppearance(ok); return ok;
},
// Removes the override: the organization's saved value, or else the default, shows again.
clear() { const ok = org ? store.remove(orgKey(org.id)) : store.remove(APPEARANCE_KEY); override = null; showAppearance(ok); return ok; },
clear() { if (onSystem()) { const ok = store.remove(SYSTEM_KEY); systemOverride = null; showAppearance(ok); return ok; } const ok = org ? store.remove(orgKey(org.id)) : store.remove(APPEARANCE_KEY); override = null; showAppearance(ok); return ok; },
// The seam for the organization switcher (stream S5): call it with the organization now shown and its saved appearance.
organization(id, saved, label) {
if (!orgId(id)) return;
@@ -163,6 +172,10 @@
if (org && !org.confirmed) { org.saved = null; showAppearance(); }
};
document.addEventListener('mosaic-org-switch', () => { readBusiness(); });
// The dashboard and an organization page share the shell: moving between them repaints the theme.
const repaintScope = () => { if (onSystem() !== system) showAppearance(); };
window.addEventListener('hashchange', repaintScope);
window.MosaicOrg?.ready?.then(repaintScope);
window.MosaicAppearance.ready = (async () => {
await Promise.allSettled([
(async () => {
@@ -496,10 +509,14 @@
// #1873 SP1: the dashboard lists every organization's bus on its own rows; this banner is an organization page's.
if (window.MosaicOrg && window.MosaicOrg.route().split('?')[0] === '/dashboard') { box.hidden = true; return; }
// An answer about an organization that is no longer open is dropped: the new one's read draws the banner.
const asked = window.MosaicOrg?.id ?? null, stale = () => (window.MosaicOrg?.id ?? null) !== asked;
// So is one drawn for a page left while it was read (#1873 SP2: the organization page draws none).
const where = () => window.MosaicOrg?.route().split('?')[0] === '/organization';
const asked = window.MosaicOrg?.id ?? null, onPage = where(), stale = () => (window.MosaicOrg?.id ?? null) !== asked || where() !== onPage;
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === asked);
if (stale()) return;
// #1873 SP2: the organization's own page says it in its down banner, with the restart action: one banner, not two.
if (org?.busHealthy === false && onPage) { box.hidden = true; return; }
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
}
try {
@@ -552,13 +569,14 @@
document.addEventListener('mosaic-org-switch', onOrgSwitch);
// An address with no organization picks one after the first list read; its own setting applies then.
window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); });
// The banner hides on the system dashboard and shows on an organization's pages, so a move across
// that line redraws it. Other moves inside one organization read nothing (the switch path reads its own).
let onDashboard = null;
// The banner hides on the system dashboard, gives way to the organization page's own down banner,
// and shows on the organization's other pages, so a move across those lines redraws it. Other moves
// inside one organization read nothing (the switch path reads its own).
let busPlace = null;
window.addEventListener('hashchange', () => {
if (!window.MosaicOrg) return;
const now = window.MosaicOrg.route().split('?')[0] === '/dashboard';
if (now !== onDashboard) { onDashboard = now; busDown(); }
const path = window.MosaicOrg.route().split('?')[0], now = ['/dashboard', '/organization'].includes(path) ? path : 'page';
if (now !== busPlace) { busPlace = now; busDown(); }
});
$('refresh').onclick = refresh;
$('mobile-pause').onclick = () => $('pause').click();
+8 -4
View File
@@ -19,9 +19,12 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
const tail = hash => { const m = /^#\/orgs\/[^/]+(.*)$/.exec(hash); return m ? (m[1] || '/') : hash.startsWith('#/') ? hash.slice(1) : '/'; };
const route = () => tail(location.hash);
// The bare address and the old per-organization dashboard address are the system dashboard;
// an organization page without the prefix gets the current organization's.
// an organization page without the prefix gets the current organization's. #1873 SP2: an
// organization's own bare address (#/orgs/<id>, no trailing slash) is that organization's page;
// #/orgs/<id>/ stays the old dashboard address.
const normalize = (hash, seg = current) => {
const path = tail(hash), bare = path.split('?')[0];
const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1];
if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`;
if (bare === '/' || bare === '/dashboard') return `#/dashboard${path.slice(bare.length)}`;
return orgOf(hash) || !seg ? hash : `#/orgs/${seg}${path}`;
};
@@ -48,11 +51,12 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
}).catch(() => { listError = window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
const readList = () => readRows().then(rows => rows ?? []);
// The bus banner belongs to an organization page; the dashboard lists every bus on its own rows.
// #1873 SP2: the organization's own page draws its down banner with the restart action, so this one hides there.
const paintBus = () => {
const banner = document.getElementById('bus-down');
if (!banner) return;
const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid);
if (route().split('?')[0] === '/dashboard' || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; }
const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid), path = route().split('?')[0];
if (path === '/dashboard' || (path === '/organization' && org?.busHealthy === false) || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; }
if (org?.busHealthy === false) { banner.textContent = 'Bus unavailable'; banner.hidden = false; banner.dataset.orgBus = '1'; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); }
};
// Idempotent: each read replaces the banner, and removes it on recovery.
+3 -1
View File
@@ -599,6 +599,8 @@ html[data-organizations] .ws-orgtbl .who .ws-dot{font-size:12px;color:var(--mute
html[data-organizations] .ws-orgtbl .who .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-orgtbl .who .why{flex:1 0 100%;font-size:12px;color:var(--danger);overflow-wrap:anywhere}
html[data-organizations] .ws-orgtbl tr.off .ws-dot{color:var(--danger)}
/* #1873 SP2: a figure keeps its words on one line ("4 agents (0 working)", "none waiting"); the organization cell takes the wrap. */
html[data-organizations] .ws-orgtbl td:not(.who){white-space:nowrap}
html[data-organizations] .ws-dash [data-org-down]{margin:0 0 14px}
html[data-organizations] .ws-dash [data-org-down] .why{display:block;margin-top:4px;color:var(--muted)}
html[data-organizations] .ws-orgcards{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
@@ -638,7 +640,7 @@ html[data-organizations] .ws-needs .ws-chip{grid-row:1/3;grid-column:3}
html[data-organizations] .ws-more-line{margin:6px 0 0}
html[data-organizations] .ws-sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
html[data-organizations] .ws-sess td.who .ws-orgref{vertical-align:middle}
html[data-organizations] .ws-sess td.st{white-space:nowrap}
html[data-organizations] .ws-sess td.ws-st{white-space:nowrap}
/* Filters and tables on Projects and Inbox. */
html[data-organizations] .ws-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
html[data-organizations] .ws-toolbar .ws-right{align-items:center}
+10 -5
View File
@@ -234,14 +234,17 @@
// repeated; the last start time, when read, still shows.
const restates = reason => /^the bus is not (running|answering)\.?$/i.test(reason.trim());
const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : '');
function downBanner(org, row) {
// #1873 SP2: this is the page's only bus banner (the shell's "Bus unavailable" hides on this page), so
// it carries the restart action where the Console allows it. A side-by-side pane has no restart.
function downBanner(org, row, pane = false) {
const stopped = org.busStopped === true;
const plain = row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason && restates(row.reason);
const why = plain ? '' : downWhy(row);
const tail = plain
? (row.boot?.at ? ` <span class="why">Last start ${esc(when(row.boot.at))}.</span>` : '')
: why ? ` <span class="why">Reason: ${esc(why)}.</span>` : ' <span class="why">No reason was read.</span>';
return `<div class="banner err" role="status" data-org-down><div><b>${stopped ? 'The bus is not running.' : 'The bus is not answering.'}</b> Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}</div></div>`;
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart the bus') || '';
return `<div class="banner err" role="status" data-org-down><div><b>${stopped ? 'The bus is not running.' : 'The bus is not answering.'}</b> Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}</div>${restart ? `<div class="actions">${restart}</div>` : ''}</div>`;
}
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
@@ -310,7 +313,7 @@
return `<article class="ws ws-dash" data-org-dashboard>
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/dashboard">Dashboard</a><span aria-hidden="true">›</span><span>${name}</span></nav>
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
${org.busHealthy ? '' : downBanner(org, downRow)}
${org.busHealthy ? '' : downBanner(org, downRow, pane)}
${docrow}
<div class="ws-split">
<section aria-label="Measures and sessions">
@@ -615,7 +618,9 @@
const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` <small class="muted">(${working} working)</small>`}`;
const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none;
const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none;
const why = r.state === 'ok' ? '' : `<span class="why">${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
// #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once.
const reason = r.state !== 'ok' && typeof r.reason === 'string' && r.reason && !restates(r.reason) ? r.reason : '';
const why = r.state === 'ok' || !(reason || r.boot?.at) ? '' : `<span class="why">${esc(reason)}${r.boot?.at ? `${reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
return `<tr class="ws-o${r.state === 'ok' ? '' : ' off'}" style="${o.style(id)}" data-ws-orgrow="${esc(id)}" data-state="${esc(r.state)}"><td class="who"><a href="${esc(orgHref(id))}/organization" data-focus-key="${esc(`orgrow:${id}`)}">${av(o.meta(id))}<span class="nm">${esc(o.name(id))}</span></a>${dot(cls, glyph, word, true)}${why}</td><td>${agentsCell}</td><td class="hide-m">${projectsCell}</td><td>${needsCell}</td></tr>`;
}).join('');
const orgList = o.rows.length ? `<div class="ws-tbl"><table class="ws-orgtbl" data-ws-orgs><thead><tr><th scope="col">Organization</th><th scope="col">Agents</th><th scope="col" class="hide-m">Projects</th><th scope="col">Needs you</th></tr></thead><tbody>${orgTable}</tbody></table></div>` : '<p class="ws-panel tight note" data-ws-orgs>No organizations yet</p>';
@@ -641,7 +646,7 @@
const who = s => (s.kind === 'thread' ? s.instance || s.thread || 'thread' : s.role || 'agent');
const what = s => s.project?.name || s.task?.title || '';
const st = s => { const [cls, glyph, word] = s.kind === 'thread' ? ['s-working', '●', 'live'] : ACTIVITY[s.state] || ['s-unknown', '?', s.state || 'not reported']; return `${dot(cls, glyph, word === 'waiting on you' ? 'waiting' : word, true)}${s.state === 'waiting' ? ' <span class="muted">on you</span>' : s.since ? ` <span class="muted">${esc(span(s.since))}</span>` : ''}`; };
const sessTable = now.length ? `<div class="ws-tbl"><table class="ws-sess" data-ws-sessions><thead><tr><th scope="col">Agent and project</th><th scope="col">Status</th></tr></thead><tbody>${now.slice(0, 8).map(s => `<tr><td class="who"><span class="ws-orgref">${av(o.meta(s.org), 'xs')}<span class="ws-sr">${esc(o.name(s.org))}: </span>${esc(who(s))}</span>${what(s) ? `<span class="muted"> · ${esc(what(s))}</span>` : ''}</td><td class="st">${st(s)}</td></tr>`).join('')}</tbody></table></div>${now.length > 8 ? `<p class="note ws-more-line">${now.length - 8} more on Agents</p>` : ''}` : `<p class="ws-panel tight note" data-ws-sessions>${noneRead(o, ['agents', 'claims']) ? 'Sessions not read' : `No session working right now${idle ? `; ${plural(idle, 'idle session')}` : ''}`}</p>`;
const sessTable = now.length ? `<div class="ws-tbl"><table class="ws-sess" data-ws-sessions><thead><tr><th scope="col">Agent and project</th><th scope="col">Status</th></tr></thead><tbody>${now.slice(0, 8).map(s => `<tr><td class="who"><span class="ws-orgref">${av(o.meta(s.org), 'xs')}<span class="ws-sr">${esc(o.name(s.org))}: </span>${esc(who(s))}</span>${what(s) ? `<span class="muted"> · ${esc(what(s))}</span>` : ''}</td><td class="ws-st">${st(s)}</td></tr>`).join('')}</tbody></table></div>${now.length > 8 ? `<p class="note ws-more-line">${now.length - 8} more on Agents</p>` : ''}` : `<p class="ws-panel tight note" data-ws-sessions>${noneRead(o, ['agents', 'claims']) ? 'Sessions not read' : `No session working right now${idle ? `; ${plural(idle, 'idle session')}` : ''}`}</p>`;
const total = t ? num(t.projects?.active) + num(t.projects?.archived) : null;
const allRead = !o.unread.length;
@@ -158,7 +158,13 @@ test('sidebar lists organizations and navigating changes the route and header',
}
assert.equal(switched, true, 'switch completed in place');
assert.equal(await b.evaluate('window.__sameDocument'), true, 'the switch did not reload the page');
assert.match(await b.evaluate(`document.querySelector('body').textContent`), /Bus unavailable/);
// #1873 SP2: the organization's own page says the bus is down once, in its own banner; the shell's banner gives way there.
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-down]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('down banner missing')})()`);
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), true, 'the organization page shows one bus banner');
// On the organization's other pages the shell's banner still says it, and stays after the boot-status read.
await b.evaluate(`location.hash = '#/orgs/beta/tasks'`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(!document.querySelector('#bus-down').hidden)return;await new Promise(r=>setTimeout(r,30))}throw Error('offline banner missing')})()`);
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), false, 'offline banner remains visible after the boot-status read');
assert.match(await b.evaluate(`document.querySelector('#bus-down').textContent`), /Bus unavailable/);
@@ -56,7 +56,7 @@ async function fixture({ empty = false, bootless = false } = {}) {
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
@@ -310,3 +310,118 @@ test('a file save still in flight at a switch: its outcome is not announced on t
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization');
assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/);
});
// SP2 item 1: an organization's own bare address is its page, not the system dashboard.
test('a bare #/orgs/<id> opens that organization\'s page', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/beta' });
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
// In place too, from another organization's page.
await b.evaluate('location.hash = "#/orgs/acme"');
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
// The old per-organization dashboard address is still the system dashboard.
await b.evaluate('location.hash = "#/orgs/acme/dashboard"');
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
});
// SP2 item 2: the system dashboard shows the system theme, never the organization opened last.
test('the dashboard keeps the system theme after an organization with its own theme was opened', async t => {
const { b, base, wait } = await setup(t, { path: '/#/orgs/acme/organization' });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio" && document.documentElement.dataset.palette === "cobalt"');
await b.evaluate('location.hash = "#/dashboard"');
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await wait('document.documentElement.dataset.palette === "harbor"');
assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null);
assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true);
// A fresh load of the bare address, with acme stored as the last organization shown, starts on the system theme.
await b.navigate(`${base}/`);
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
await new Promise(r => setTimeout(r, 500));
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
// A choice made on the dashboard is the system's own: acme keeps its theme, and the dashboard keeps the choice.
await b.evaluate('window.MosaicAppearance.set({ mode: "light" })');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light');
await b.evaluate('location.hash = "#/orgs/acme/organization"');
await wait('document.documentElement.dataset.palette === "cobalt"');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
await b.evaluate('location.hash = "#/dashboard"');
await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"');
});
// SP2 item 3: a stopped organization's page shows one bus banner, with the restart action and what can't be read.
test('a stopped organization\'s page shows one bus banner, with the restart action', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/(orgs\\/[^/]+\\/)?config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, orgSetup: true }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', init });
await wait('document.querySelector("[data-org-dashboard] [data-org-down] [data-bus-restart]")');
// Both shell painters (display.js on the list read, app.js on its own read) have had their turn.
await b.evaluate('window.MosaicOrg.list');
await new Promise(r => setTimeout(r, 800));
const banners = 'Array.from(document.querySelectorAll(".banner")).filter(e => !e.hidden && e.getClientRects().length && /bus/i.test(e.textContent)).map(e => e.innerText.replace(/\\s+/g, " ").trim())';
const shown = await b.evaluate(banners);
assert.equal(shown.length, 1, shown.join(' | '));
assert.match(shown[0], /The bus is not running\. Nothing about Gamma Home's agents or packs can be read until it starts\./);
assert.match(shown[0], /Restart the bus/);
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
// The organization's other pages keep the shell's banner, with its restart.
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
await wait('!document.getElementById("bus-down").hidden && /Bus unavailable/.test(document.getElementById("bus-down").textContent)');
// And back on the organization page it gives way again.
await b.evaluate('location.hash = "#/orgs/gamma/organization"');
await wait('document.querySelector("[data-org-dashboard] [data-org-down]") && document.getElementById("bus-down").hidden');
});
// SP2 item 4: the dashboard row for a stopped organization says it once.
test('a stopped organization\'s dashboard row does not repeat its state as the reason', async t => {
const { b, wait } = await setup(t, { bootless: true });
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await wait(`${rowText('gamma')}.includes("bus stopped")`);
const gamma = await b.evaluate(rowText('gamma'));
assert.doesNotMatch(gamma, /the bus is not running/i, gamma);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=gamma] .why")'), null);
});
// SP2 item 6: no status-badge glyph ("?") is drawn before the live dot in the dashboard's Active agents.
test('the dashboard\'s active agents draw no stray glyph before the status dot', async t => {
const { b, wait } = await setup(t);
await wait('document.querySelector("[data-ws-sessions] tbody tr")');
const cells = await b.evaluate('Array.from(document.querySelectorAll("[data-ws-sessions] tbody td")).map(td => getComputedStyle(td, "::before").content)');
assert.ok(cells.length >= 2, String(cells));
for (const c of cells) assert.ok(c === 'none' || c === 'normal', `generated content ${c}`);
});
// SP2 item 7 (the test SP1 lacks): a read still in flight when the organization switches keeps nothing.
// acme's settings read is held across a switch to beta and then answers with a figure only it has (99);
// beta's own read then fails, so beta's page draws from what was kept. Nothing of acme's may be there.
test('a read still in flight at a switch: its answer is not kept for the next organization', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `window.__acme = []; window.__beta = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (s === '/api/orgs/acme/settings' && !options.method && window.__holdAcme) { await new Promise(r => window.__acme.push(r));
const r = await original(url, options), body = await r.json(); x = { ...body, launch: { ...body.launch, limits: { total: 99 } } }; return ${json}; }
if (s === '/api/orgs/beta/settings' && !options.method && window.__holdBeta) { await new Promise(r => window.__beta.push(r));
return new Response(JSON.stringify({ error: 'unavailable', message: 'beta settings read failed' }), { status: 500, headers: { 'content-type': 'application/json' } }); }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4"`);
// A fresh read of acme's settings starts and is held.
await b.evaluate('window.__holdAcme = true; location.hash = "#/orgs/acme/settings/tokens"');
await wait('window.__acme.length >= 1');
await b.evaluate('window.__holdBeta = true; location.hash = "#/orgs/beta/settings/limits"');
await wait('location.hash === "#/orgs/beta/settings/limits" && window.__beta.length >= 1');
// acme answers after the switch; then beta's read fails.
await b.evaluate('window.__acme.forEach(r => r())');
await new Promise(r => setTimeout(r, 400));
await b.evaluate('window.__beta.forEach(r => r())');
await wait('document.querySelector("#bus-view .banner")');
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value ?? null`), null, 'acme\'s held answer was drawn on beta');
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .s1-stale")'), false);
assert.match(await b.evaluate('document.getElementById("bus-view").innerText'), /beta settings read failed/);
});