From d1b01329bc545c8fc197012ccd8ffe82b6ee4a5e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 00:44:18 -0600 Subject: [PATCH] fix(webui): sp2 polish, an organization's bare address opens its page and the dashboard keeps the system theme (#1873, lane sp2) - #/orgs/ with no page and no trailing slash now opens #/orgs//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. --- packages/webui/src/public/app.js | 42 +++++-- packages/webui/src/public/display.js | 12 +- packages/webui/src/public/shell.css | 4 +- packages/webui/src/public/workspace.js | 15 ++- .../webui/tests/org-switch-browser.test.mjs | 8 +- .../webui/tests/single-pane-browser.test.mjs | 117 +++++++++++++++++- 6 files changed, 174 insertions(+), 24 deletions(-) diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 30992510a..80d74a12b 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -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(); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 459eaf141..18cbac1e5 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -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/, no trailing slash) is that organization's page; + // #/orgs// 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. diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 3517c602e..76f58610e 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -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} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index bfe978cba..26a2e693a 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -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 ? ` Last start ${esc(when(row.boot.at))}.` : '') : why ? ` Reason: ${esc(why)}.` : ' No reason was read.'; - return ``; + const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart the bus') || ''; + return ``; } // 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 `
${av(org, 'lg')}

${name}

${bus}${reload}
${meta}
- ${org.busHealthy ? '' : downBanner(org, downRow)} + ${org.busHealthy ? '' : downBanner(org, downRow, pane)} ${docrow}
@@ -615,7 +618,9 @@ const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` (${working} working)`}`; 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' ? '' : `${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}`; + // #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) ? '' : `${esc(reason)}${r.boot?.at ? `${reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}`; return `${av(o.meta(id))}${esc(o.name(id))}${dot(cls, glyph, word, true)}${why}${agentsCell}${projectsCell}${needsCell}`; }).join(''); const orgList = o.rows.length ? `
${orgTable}
OrganizationAgentsProjectsNeeds you
` : '

No organizations yet

'; @@ -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' ? ' on you' : s.since ? ` ${esc(span(s.since))}` : ''}`; }; - const sessTable = now.length ? `
${now.slice(0, 8).map(s => ``).join('')}
Agent and projectStatus
${av(o.meta(s.org), 'xs')}${esc(o.name(s.org))}: ${esc(who(s))}${what(s) ? ` · ${esc(what(s))}` : ''}${st(s)}
${now.length > 8 ? `

${now.length - 8} more on Agents

` : ''}` : `

${noneRead(o, ['agents', 'claims']) ? 'Sessions not read' : `No session working right now${idle ? `; ${plural(idle, 'idle session')}` : ''}`}

`; + const sessTable = now.length ? `
${now.slice(0, 8).map(s => ``).join('')}
Agent and projectStatus
${av(o.meta(s.org), 'xs')}${esc(o.name(s.org))}: ${esc(who(s))}${what(s) ? ` · ${esc(what(s))}` : ''}${st(s)}
${now.length > 8 ? `

${now.length - 8} more on Agents

` : ''}` : `

${noneRead(o, ['agents', 'claims']) ? 'Sessions not read' : `No session working right now${idle ? `; ${plural(idle, 'idle session')}` : ''}`}

`; const total = t ? num(t.projects?.active) + num(t.projects?.archived) : null; const allRead = !o.unread.length; diff --git a/packages/webui/tests/org-switch-browser.test.mjs b/packages/webui/tests/org-switch-browser.test.mjs index 64dde0657..223721882 100644 --- a/packages/webui/tests/org-switch-browser.test.mjs +++ b/packages/webui/tests/org-switch-browser.test.mjs @@ -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/); diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs index fe9a1f446..a8923ce6f 100644 --- a/packages/webui/tests/single-pane-browser.test.mjs +++ b/packages/webui/tests/single-pane-browser.test.mjs @@ -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/ 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/); +}); -- 2.54.0