Compare commits

..
4 Commits
Author SHA1 Message Date
jason.woltje 73df1a687f docs: BUILD-LOG for organizations landings #1876-#1881 (#1873) 2026-11-09 14:00:54 -06:00
jason.woltje fdd6658a65 fix(webui): refresh the Settings bus control on every org list read
E2E5 review P2. An open Settings page kept Done after the bus died
because only opening the organization reread bus health. display.js
now announces each fresh list read, org-create.js redraws the setup
steps when the selected org's bus state flips (choices kept, no draft
guard), and app.js reads the list before bus-status so a failed
bus-status read no longer skips it. Regression: healthy, Refresh down
shows Start, Refresh up hides it; red on 8a8edc44b.
2026-11-09 13:53:19 -06:00
jason.woltje 8a8edc44b6 Merge refactor 2bed0aebe into E2E5 fixes 2026-11-09 13:30:24 -06:00
jason.woltje d6eff452e3 fix(webui): recover archived organizations and console controls 2026-11-09 13:29:23 -06:00
13 changed files with 180 additions and 16 deletions

No files matched your search

+2
View File
@@ -6339,3 +6339,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-07 UTC | Organizations S1 — implementation evidence | Organization-root bus and launcher paths, guarded lifecycle and Console launch/stop were implemented locally. Focused Node 26 tests and host regressions passed; the fresh-organization notifier choice, shared-proxy S2 integration, and remaining instance-root consumers are not complete. Exact commands, inventory, failures and boundaries: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. No push or deployment. Gitea issue creation was excluded by the no-network and no-external-communication limits.
2026-11-09 UTC | Unified workspace (#1873) — direction recorded | Before: the 11-07 rulings had one Console switch between organizations from Settings, tasks only in each org queue, and an agent bound to one organization (REQ-USER-1). After: Jason's 11-09 direction and answers recorded in docs/plans/2026-11-09_unified-workspace-owner-direction.md (all orgs visible at once, per-org task stores with a Vikunja option and an aggregated Kanban, root/org/project agents with one cross-org assistant, system type plug-ins, self-service restart and model switch). PRD 1.20 draft 1 written for his exact-text approval; the PRD itself is unchanged. The unpushed 11-07 rulings record (ad8140065) lands with it.
2026-11-09 UTC | Organizations friction round, landings #1876-#1881 (#1873) | Before: refactor 00ef0bd30. A Console launched by a relative path showed Request status unavailable, the overview printed the north star as raw markdown, packages/business and the business command spellings were the only names, Quick Setup ended with no route to a north star, an archived org could not be restored from a fresh page, and a dead bus after setup had no browser control. After, each through one Codex Sol xhigh review round plus a lead or builder fix round: #1876 re-execs a relative Console launch with its absolute script path (build 744753d7e, reproduced 121/0; review APPROVE 0/0/1). #1877 F1 renders the north star as sanitized markdown with a short preview (review APPROVE 0/0/1; live retest 4/0 twice). #1878 rename stage B moves packages/business to packages/organization and adds the org command spellings, keeping the business ones and their messages (builder 6828/6, see report; review CHANGES 0/1/0, fixed in e19db4009). #1879 F2 adds the onboarding interview after Quick Setup and on the project page, with human-only north-star approval (`mosaic project north-star --approve`) and no new agent grant (builder 411/0; review CHANGES 0/1/1: a refused start left its button disabled, fixed in 5937a977f along with the P3 stale approval capability). #1880 lands the U0 unified workspace samples, approved by Jason with the four-tile logo kept; the module names stay undecided at his request. #1881 E2E round 5 fixes restore from a fresh page, show an owner bus Start whenever the org list reads the bus down, say room changes are unavailable on org Consoles and recover the composer (builder 258/0; review CHANGES 0/1/0, 231/0: Settings kept Done after the bus died because only opening the org reread health). Lead fix fdd6658a6: every org list read is announced and the open setup redraws on a flip, and app.js reads the list before bus-status so a failed bus-status read cannot skip it; regression red on 8a8edc44b, 18 touched webui files 199/0 (Node 26.12.1, one file at a time). uiorg redeployed at fdd6658a6, 19:53Z. Open follow-ups: a down bus offers both the F3 banner Restart and the Settings Start, focus can move when setup redraws, and a Console stop also stops the org buses it started. Reports: /mnt/storage/scratch/mosaic-lead/reports/org-{reqget-2,f1,rename-b,f2,u0-design,e2e5}-*.md.
+6 -4
View File
@@ -486,12 +486,14 @@
// good load, from the status `mosaic bus start` left. textContent only. Nothing here retries.
async function busDown() {
const box = $('bus-down');
// E2E5 review P2: the organization list is read first and on its own, so a failed bus-status read
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; return; }
}
try {
const s = await api('/api/bus-status');
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; return; }
}
if (!s.down) { box.hidden = true; return; }
const b = s.boot;
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
+19
View File
@@ -1470,6 +1470,25 @@
if (path === '/business') { location.replace('#/settings/business'); return; }
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
// Re-read the scoped capability as the Console and bus recover. A failed read
// withdraws the control; the chat renderer retains the draft across repaint.
if (window.MosaicOrg && !isTester) {
try {
const response = await fetch('/api/config', { cache: 'no-store', redirect: 'manual' });
if (!current()) return;
if (response.type === 'opaqueredirect') { signOut(); return; }
if (response.status === 401) { signOutSso(); return; }
const config = response.ok ? await response.json() : null;
if (!current()) return;
if (response.ok) window.MosaicIcons.succeeded(); else window.MosaicIcons.failed();
chatSend = config?.chatSend === true;
window.MosaicOrgBusStopped = config?.devOwner === true && config?.orgSetup === true && config?.chatSend === false;
} catch {
if (!current()) return;
window.MosaicIcons.failed(); chatSend = false; window.MosaicOrgBusStopped = false;
}
if (!current()) return;
}
// Row 177 (#1675): the tester Console writes to its own threads.
const focusedHeading = document.activeElement === $('bus-view').querySelector('h1');
await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: (window.MosaicOrg ? chatSend : canSend) || isTester, tester: isTester, navigated });
+3
View File
@@ -75,3 +75,6 @@
.chat-room-picker label { display: block; padding-block: .45rem; }
.chat-audience { margin: .25rem 0; }
.chat-projects .panel { border-radius: 4px; }
#chat-send-form textarea:disabled, #chat-room-send-form textarea:disabled { opacity: .55; cursor: not-allowed; }
#chat-send-form button:disabled, #chat-room-send-form button:disabled { opacity: .5; cursor: not-allowed; }
+3 -3
View File
@@ -319,7 +319,7 @@
}
const roomName = instance => current.members?.find(m => m.instance === instance)?.name || instance;
function roomOpenControl(project) {
if (window.MosaicOrg) return '<p>Room changes are unavailable.</p>';
if (window.MosaicOrg) return '<p>Room changes are unavailable on this Console.</p>';
if (!roomOwner()) return '';
if (current.projects.some(p => p.id === project && p.archived)) return `<p><a href="${roomPath(esc(project))}">Read room</a></p>`;
const f = form('room-open:' + project);
@@ -417,7 +417,7 @@
if (s.receiptIds?.every(id => s.rows.some(m => m.ids.includes(id)))) { s.receipt = 'Message shown in the room.'; s.receiptIds = null; }
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = roomError(err);
current.error = window.MosaicOrg && err.code === 'room-not-open' ? 'Room changes are unavailable on this Console.' : roomError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; current.projects = []; }
@@ -630,7 +630,7 @@
} else {
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
html += '</div>';
// Drafts already live in the focused textarea; their changing text is not a
// reason for a background read to replace the control.
+7 -1
View File
@@ -41,7 +41,13 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// A failed read keeps its reason, so the header can say why the list is empty.
let listError = null;
const readList = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.ok) { const rows = await r.json(); listError = null; return Array.isArray(rows) ? rows : []; }
if (r.ok) {
const body = await r.json(); listError = null;
const rows = Array.isArray(body) ? body : [];
// E2E5 review P2: an open panel (Settings > Organizations) follows every fresh list read, not only its own.
document.dispatchEvent(new CustomEvent('mosaic-org-list', { detail: { rows } }));
return rows;
}
const body = await r.json().catch(() => ({}));
listError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read';
return [];
+2 -1
View File
@@ -4,7 +4,8 @@
.org-admin h4{font-size:14px;font-weight:700;margin:0 0 8px;color:var(--strong)}
.org-help{display:block;font-size:13px;color:var(--muted);margin:4px 0 0}
.org-field-err{display:block;font-size:13px;color:var(--danger);margin:4px 0 0}
.org-field-err code,.org-errors code,.org-verb code{overflow-wrap:anywhere}
.org-field-err code,.org-errors code{overflow-wrap:anywhere}
.org-verb code{overflow-wrap:normal;word-break:normal}
.org-start-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:start}
.org-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:center}
.org-inline label{grid-column:1/-1;font-size:14px;font-weight:600;color:var(--strong)}
+27 -5
View File
@@ -75,6 +75,7 @@
<section id="org-archived" class="panel" tabindex="-1" hidden aria-labelledby="org-archived-title">
<h3 id="org-archived-title">Organization archived</h3>
<p>Editing and setup are unavailable while archived. Files remain in place; restoring does not start the bus.</p>
<p id="org-restore-verb" class="org-verb"></p>
<button type="button" id="org-restore" data-write>Restore organization</button>
</section>
<p id="org-archive-error" class="org-field-err" role="alert"></p>
@@ -247,6 +248,7 @@
editorDirty = false; setupDirty.clear(); $('org-new-id').value = '';
$('org-editor').hidden = false; $('org-delete').hidden = mode !== 'edit'; resetDelete(); resetArchive(); $('org-delete-success').hidden = true;
$('org-archive').hidden = mode !== 'edit' || state !== 'active'; $('org-archived').hidden = mode !== 'edit' || state !== 'archived';
$('org-restore-verb').textContent = state === 'archived' ? `Same as mosaic org restore ${id} --if-revision ${revision}` : '';
$('org-editor-title').textContent = mode === 'create' ? `New organization: ${id}` : `Organization: ${id}`;
$('org-editor-verb').innerHTML = verb ? `Same as <code>${txt(verb)}</code>` : '';
$('org-save').textContent = mode === 'create' ? 'Save organization' : 'Save changes';
@@ -416,6 +418,9 @@
if (!alive) return;
if (r.status !== 200) { st.setup = null; $('org-setup').hidden = false; $('org-steps').innerHTML = `<li class="panel org-step"><p class="org-field-err">Setup could not be read back (${txt(r.body.error || r.status)}). ${txt(r.body.message || '')}</p><button type="button" class="org-secondary" data-readback>Read back again</button></li>`; return; }
st.setup = r.body;
const orgs = window.MosaicOrg ? await window.MosaicOrg.readList() : [];
if (!alive) return;
st.busDown = orgs.find(org => org.id === id)?.busHealthy === false;
for (const key of [...st.unknown]) if (stepState(key)?.state !== 'unknown') st.unknown.delete(key);
renderSetup();
}
@@ -431,7 +436,7 @@
const keys = stepKeys(); let blocked = false, firstOpen = null;
$('org-setup').hidden = false;
$('org-steps').innerHTML = keys.map((key, i) => {
const s = { ...stepState(key) }; if (st.unknown.has(key) && s.state !== 'done') s.state = 'unknown';
const s = { ...stepState(key) }; if (st.unknown.has(key)) s.state = 'unknown';
if (s.lastError && s.state !== 'done') s.state = s.state === 'unknown' ? 'unknown' : 'failed';
const [stateLabel, cls] = STATE[s.state] || STATE.unknown;
const ready = !blocked && s.state !== 'done' && s.state !== 'unavailable';
@@ -455,7 +460,7 @@
<label class="org-risk"${remote ? ' hidden' : ''}><input type="checkbox" data-risk${c.risk ? ' checked' : ''}${!remote && s.lastError && !c.risk ? ' aria-invalid="true"' : ''}> I accept that a local-only history has no copy anywhere else and is lost with this disk.</label>
<div class="settings-actions"><button type="button" data-run="${txt(key)}">${busy ? 'Working… <span class="settings-spinner" aria-hidden="true"></span>' : key === 'queue' ? 'Set up checkout' : 'Set up repository'}</button></div>
</fieldset>` : ''}
${s.state !== 'done' && s.state !== 'unknown' && key === 'bus' ? `<div class="settings-actions"><button type="button" data-run="bus" ${ready && canWrite && !busy ? '' : 'disabled'}>${busy ? 'Starting… <span class="settings-spinner" aria-hidden="true"></span>' : 'Start the bus'}</button>${ready ? '' : '<span class="org-help">Finish the steps above first.</span>'}</div>` : ''}
${key === 'bus' && canWrite && !st.unknown.has(key) && (st.busDown || (s.state !== 'done' && s.state !== 'unknown')) ? `<div class="settings-actions"><button type="button" data-run="bus" ${(ready || st.busDown) && canWrite && !busy ? '' : 'disabled'}>${busy ? 'Starting… <span class="settings-spinner" aria-hidden="true"></span>' : 'Start the bus'}</button>${ready || st.busDown ? '' : '<span class="org-help">Finish the steps above first.</span>'}</div>` : ''}
</li>`;
}).join('');
for (const card of root.querySelectorAll('.org-step')) if (st.choice.has(card.dataset.step) && card.querySelector('.org-git')) showVerb(card);
@@ -491,6 +496,7 @@
else if (r.body.error === 'readback-failed') { st.unknown.add(key); s.lastError = r.body.message; }
else if (r.body.errors?.length) s.lastError = r.body.errors.map(e => `${e.field}: ${e.message}`).join(' ');
else s.lastError = r.body.message || `Refused (${r.body.error || r.status}).`;
if (key === 'bus' && r.status >= 400 && r.status < 500) s.lastError += ` If the host lock is stale, run mosaic bus recover ${id}.`;
status(`Step incomplete: ${s.lastError}`);
renderSetup(); root.querySelector(`[data-step="${CSS.escape(key)}"] h4`)?.setAttribute('tabindex', '-1'); root.querySelector(`[data-step="${CSS.escape(key)}"] h4`)?.focus();
}
@@ -531,7 +537,7 @@
function resetArchive() {
$('org-archive-confirm').hidden = true; $('org-archive-open').hidden = false;
$('org-archive-error').textContent = ''; $('org-archive-success').hidden = true;
$('org-archive-final').disabled = !canWrite; $('org-restore').disabled = !canWrite;
$('org-archive-final').disabled = !canWrite; $('org-archive-final').textContent = 'Archive'; $('org-restore').disabled = !canWrite;
}
async function refreshOrganizations() {
const rows = window.MosaicOrg ? await window.MosaicOrg.refreshList() : [];
@@ -545,7 +551,7 @@
}
async function runLifecycle(step) {
const id = st.id, revision = st.revision, button = $(step === 'archive' ? 'org-archive-final' : 'org-restore');
button.disabled = true; $('org-archive-error').textContent = '';
button.disabled = true; if (step === 'archive') button.textContent = 'Archiving…'; $('org-archive-error').textContent = '';
try {
const reply = await api('POST', `/api/orgs/${encodeURIComponent(id)}/${step}`, { revision });
if (!alive) return;
@@ -559,6 +565,7 @@
} else $('org-archived').focus();
status(`Organization ${step === 'archive' ? 'archived' : 'restored'}.`);
} catch {
if (step === 'archive') button.textContent = 'Archive';
$('org-archive-error').textContent = `The outcome is unknown. Inspect the organization before retrying: mosaic org ${step} ${id} --if-revision ${revision}.`;
}
}
@@ -587,8 +594,23 @@
} catch { $('org-delete-error').textContent = 'The outcome is unknown. Inspect the receipt and resume with the CLI.'; $('org-delete-progress').innerHTML = `<p>Resume with <code>${txt(`mosaic org delete ${id} --confirm ${id}`)}</code></p>`; }
}
// E2E5 review P2: a list read anywhere on the page (Refresh, the header, the bus banner) updates the
// bus Start affordance of the open organization. Only a change re-renders, and the typed setup choices
// are kept first, so a draft survives.
function onOrgList(e) {
if (!alive || !st.setup || !st.id || !Array.isArray(e.detail?.rows)) return;
const down = e.detail.rows.find(org => org.id === st.id)?.busHealthy === false;
if (down === Boolean(st.busDown)) return;
for (const card of root.querySelectorAll('.org-step')) {
if (!card.querySelector('.org-git')) continue;
const mode = card.querySelector('input[type=radio]:checked')?.value;
st.choice.set(card.dataset.step, { mode, remote: card.querySelector('[data-remote]')?.value ?? '', risk: !!card.querySelector('[data-risk]')?.checked });
}
st.busDown = down; renderSetup();
}
// ---- wiring -----------------------------------------------------------
const handlers = [
[document, 'mosaic-org-list', onOrgList],
[$('org-archive-open'), 'click', () => { $('org-archive-open').hidden = true; $('org-archive-confirm').hidden = false; $('org-archive-confirm').focus(); }],
[$('org-archive-cancel'), 'click', () => { resetArchive(); $('org-archive-open').focus(); }],
[$('org-archive-final'), 'click', () => runLifecycle('archive')],
@@ -621,7 +643,7 @@
[$('org-steps'), 'input', e => { const card = e.target.closest('.org-step'); if (card && e.target.matches('[data-remote]')) { keepChoice(card); showVerb(card); } }],
];
for (const [el, ev, fn] of handlers) el.addEventListener(ev, fn);
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(async config => {
fetch(new URL('/api/config', location.href), { cache: 'no-store' }).then(r => r.json()).then(async config => {
if (!alive) return;
canWrite = (config.orgSetup === true || config.send === true) && config.tester !== true && config.user !== true;
root.querySelectorAll('[data-write]').forEach(el => { el.disabled = !canWrite; });
@@ -329,6 +329,13 @@ async function userPage(t, config) {
return server.address().port;
}
test('a granted user opening an archived organization route has no enabled lifecycle controls', async t => {
const b = await browser(); t.after(() => b.close());
const port = await userPage(t, { user: true, send: true });
await b.navigate(`http://127.0.0.1:${port}/#/orgs/beta/settings/organizations?org=beta`);
assert.equal(await b.evaluate(`!!document.querySelector('#org-restore:not(:disabled), #org-delete-open:not(:disabled), #org-delete-final:not(:disabled)')`), false);
});
test('R6 G7 the hub label and plain peer links render from config on both pages', async t => {
const links = 'JSON.stringify({label:document.querySelector("#hub-label")?.textContent,hidden:document.querySelector("#federation").hidden,peers:[...document.querySelectorAll("#federation a")].map(a=>[a.getAttribute("href"),a.textContent,a.attributes.length])})';
const expected = { label: 'This Console: hub', hidden: false, peers: [['https://factory.example.test', 'Software factory', 2]] };
@@ -14,12 +14,12 @@ 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']) {
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: false });
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: () => {} });
@@ -69,6 +69,7 @@ test('archive does not announce success for an unexpected response', { timeout:
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');
});
@@ -109,3 +110,65 @@ test('archiving the last active organization shows the no-organization state', {
await f.wait(`window.MosaicOrg?.id === 'alpha'`);
assert.equal((await registry.listOrgs(f.ctx))[0].state, 'active');
});
test('fresh archived organization page offers owner restore while writes stay refused', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`), false);
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`), false);
assert.match(await f.b.evaluate(`document.querySelector('#org-archived').innerText`), /mosaic org restore beta --if-revision \d+/);
const refused = await f.b.evaluate(`Promise.all(['/api/orgs/beta/chat/projects','/api/orgs/beta/chat/threads/00000000-0000-0000-0000-000000000000/messages'].map(url=>fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:'{}'}).then(r=>r.status)))`);
assert.deepEqual(refused, [409, 409]);
await f.b.evaluate(`document.querySelector('#org-restore').click()`);
await f.wait(`!document.querySelector('#org-archive-success').hidden`);
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
});
test('owner can start a down bus after setup recorded done and sees recovery guidance on refusal', { timeout: 45000 }, async t => {
const f = await fixture(t);
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
await f.open('alpha');
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`), true);
await f.b.evaluate(`document.querySelector('[data-step="bus"] [data-run="bus"]').click()`);
await f.wait(`document.querySelector('[data-step="bus"]').innerText.includes('stale bus host lock')`);
assert.match(await f.b.evaluate(`document.querySelector('[data-step="bus"]').innerText`), /mosaic bus recover alpha/);
});
// E2E5 review P2: the list read by Refresh, not only opening the organization, decides whether Start shows.
test('an open Settings page offers Start when a refreshed org list reads the bus down after setup recorded done', { timeout: 45000 }, async t => {
const health = { up: true };
const f = await fixture(t, ['alpha', 'beta'], { health });
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});return old(input,init);};})()`);
await f.open('alpha');
await f.wait(`document.querySelector('[data-step="bus"]')?.textContent.includes('Done')`);
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]')`), false);
health.up = false;
await f.b.evaluate(`document.querySelector('#refresh').click()`);
await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`);
health.up = true;
await f.b.evaluate(`document.querySelector('#refresh').click()`);
await f.wait(`!document.querySelector('[data-step="bus"] [data-run="bus"]')`);
});
for (const capability of [{ user:true, orgSetup:false }, { tester:true, orgSetup:false }, { orgSetup:false }]) {
test(`archived restore stays disabled without owner capability ${JSON.stringify(capability)}`, { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.call('Page.addScriptToEvaluateOnNewDocument', {source:`{const old=window.fetch;window.fetch=(input,init)=>input instanceof URL && input.pathname==='/api/config'?Promise.resolve(new Response(JSON.stringify(${JSON.stringify(capability)}),{status:200})):old(input,init)}`});
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`),true);
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`),true);
});
}
test('archive displays progress while its request is in flight', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`{const old=window.fetch;window.fetch=(input,init)=>String(input).endsWith('/archive')?new Promise(resolve=>window.releaseArchive=()=>old(input,init).then(resolve)):old(input,init);document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()}`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-final').textContent`),'Archiving…');
await f.b.evaluate(`window.releaseArchive()`); await f.wait(`!document.querySelector('#org-archived').hidden`);
});
@@ -121,3 +121,14 @@ test('changed requirement content retains the disclosure and focused summary',as
const result=await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');return {open:d.open,focused:document.activeElement===d.querySelector('summary')}});
assert.deepEqual(result,{open:true,focused:true});
});
test('closed room on an organization Console offers no unusable open action', async t => {
const {b,render,wait}=await setup(t,'/projects/rooms/'+P);
await b.evaluate(`window.MosaicOrg={id:'alpha',route:()=>'/projects/rooms/${P}'};window.reviewClosed=true`);
await render('/projects/rooms/'+P);
await wait(`document.querySelector('.chat-room')`);
const result=await b.evaluate(`({text:document.querySelector('.chat-area').innerText,open:!!document.querySelector('[data-room-open-inline],#chat-room-open')})`);
assert.match(result.text,/Room changes are unavailable on this Console\./);
assert.doesNotMatch(result.text,/Open the room before sending/);
assert.equal(result.open,false);
});
@@ -266,3 +266,18 @@ test('a stopped organization bus has truthful chat guidance', async t => {
assert.match(text, /bus is stopped.*Start the bus/i);
assert.doesNotMatch(text, /This Console only reads/);
});
test('organization conversation recovers send capability and preserves its draft after an outage', async t => {
const { fixture } = await import('./fixture.mjs');
const { P, T, stub } = await import('./helpers/projects-review-fixture.mjs');
const f=await fixture(); t.after(()=>f.close());
const b=await browser({machine:false}); t.after(()=>b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub + `\nwindow.MosaicOrg={id:'alpha',route:()=>'/projects/threads/${T}',list:Promise.resolve([]),readList:async()=>[]};window.configUp=false;{const old=fetch;window.fetch=(url,init)=>String(url)==='/api/config'?Promise.resolve(new Response(JSON.stringify({chatSend:window.configUp,orgSetup:true,devOwner:true,sso:true}),{status:200})):old(url,init)}`});
await b.navigate(f.base+'/#/projects/threads/'+T);
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expr)})})()`);
await wait(`document.querySelector('#chat-body')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
await b.evaluate(`const el=document.querySelector('#chat-body');el.value='draft kept';el.dispatchEvent(new Event('input',{bubbles:true}));window.configUp=true;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('#chat-body').disabled`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'draft kept');
});
@@ -163,3 +163,16 @@ test('sidebar sprite is requested again after a failed read and recovery', async
const after = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
assert.notEqual(after,before);
});
test('sidebar sprite is retried after an outage redraw before reads recover', async t => {
const {b,wait}=await setup(t,'/projects');
await wait(`document.querySelector('#sections use[href*="icons.svg"]')`);
const before=await b.evaluate(`document.querySelector('#sections use').getAttribute('href')`);
await b.evaluate(`{const old=window.fetch;window.netDown=true;window.fetch=(url,init)=>window.netDown && String(url).startsWith('/api/')?Promise.reject(new TypeError('offline')):old(url,init);document.querySelector('#refresh').click()}`);
await wait(`document.querySelector('.chat-error')`);
await b.evaluate(`document.querySelector('#refresh').click()`);
await b.evaluate(`new Promise(r=>setTimeout(r,100))`);
await b.evaluate(`window.netDown=false;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('.chat-error') && document.querySelector('#sections use[href*="?retry="]')`);
assert.notEqual(await b.evaluate(`document.querySelector('#sections use').getAttribute('href')`),before);
});