diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
index e9a98e320..f495564c8 100644
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -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.`));
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 6a8a60561..05f328ce2 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -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 });
diff --git a/packages/webui/src/public/chat.css b/packages/webui/src/public/chat.css
index 2f7b6001a..2b48b16b7 100644
--- a/packages/webui/src/public/chat.css
+++ b/packages/webui/src/public/chat.css
@@ -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; }
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
index d83f0a928..3c6816221 100644
--- a/packages/webui/src/public/chat.js
+++ b/packages/webui/src/public/chat.js
@@ -319,7 +319,7 @@
}
const roomName = instance => current.members?.find(m => m.instance === instance)?.name || instance;
function roomOpenControl(project) {
- if (window.MosaicOrg) return '
Room changes are unavailable.
';
+ if (window.MosaicOrg) return 'Room changes are unavailable on this Console.
';
if (!roomOwner()) return '';
if (current.projects.some(p => p.id === project && p.archived)) return `Read room
`;
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') + 'Projects and their conversations.
' + c.projects.map(p => `
${link('projects', p.id, p.name)}
${p.repo ? `Repository: ${txt(p.repo)}
` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}`).join('') + (c.projects.length ? '' : c.readFailed ? '
Projects unavailable.
' : '
No projects yet.
') + '
Shared with me
' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
- if (!c.canSend) html += window.MosaicOrgBusStopped ? 'The bus is stopped. Use Start the bus to send messages.
' : 'Creating and sending require signed-in send mode. This Console only reads.
';
+ if (!c.canSend) html += window.MosaicOrgBusStopped ? 'The bus is stopped. Use Start the bus on Settings → Organizations to send messages.
' : 'Creating and sending require signed-in send mode. This Console only reads.
';
html += '';
// Drafts already live in the focused textarea; their changing text is not a
// reason for a background read to replace the control.
diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js
index d530094b0..fa5858ca9 100644
--- a/packages/webui/src/public/display.js
+++ b/packages/webui/src/public/display.js
@@ -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 [];
diff --git a/packages/webui/src/public/org-create.css b/packages/webui/src/public/org-create.css
index 58bdbecbb..8d55f2eb9 100644
--- a/packages/webui/src/public/org-create.css
+++ b/packages/webui/src/public/org-create.css
@@ -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)}
diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js
index 8e35dcda7..d3554f4e2 100644
--- a/packages/webui/src/public/org-create.js
+++ b/packages/webui/src/public/org-create.js
@@ -75,6 +75,7 @@
Organization archived
Editing and setup are unavailable while archived. Files remain in place; restoring does not start the bus.
+
@@ -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 ${txt(verb)}` : '';
$('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 = `Setup could not be read back (${txt(r.body.error || r.status)}). ${txt(r.body.message || '')}
`; 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 @@
` : ''}
- ${s.state !== 'done' && s.state !== 'unknown' && key === 'bus' ? `${ready ? '' : 'Finish the steps above first.'}
` : ''}
+ ${key === 'bus' && canWrite && !st.unknown.has(key) && (st.busDown || (s.state !== 'done' && s.state !== 'unknown')) ? `${ready || st.busDown ? '' : 'Finish the steps above first.'}
` : ''}
`;
}).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 = `Resume with ${txt(`mosaic org delete ${id} --confirm ${id}`)}
`; }
}
+ // 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; });
diff --git a/packages/webui/tests/granted-user.test.mjs b/packages/webui/tests/granted-user.test.mjs
index ebacfa0e7..75ac7a1b3 100644
--- a/packages/webui/tests/granted-user.test.mjs
+++ b/packages/webui/tests/granted-user.test.mjs
@@ -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]] };
diff --git a/packages/webui/tests/org-archive-browser.test.mjs b/packages/webui/tests/org-archive-browser.test.mjs
index 2167a2ffd..e2db1ab1d 100644
--- a/packages/webui/tests/org-archive-browser.test.mjs
+++ b/packages/webui/tests/org-archive-browser.test.mjs
@@ -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`);
+});
diff --git a/packages/webui/tests/org-live-fix-browser.test.mjs b/packages/webui/tests/org-live-fix-browser.test.mjs
index 16c280a6c..e665aeb4e 100644
--- a/packages/webui/tests/org-live-fix-browser.test.mjs
+++ b/packages/webui/tests/org-live-fix-browser.test.mjs
@@ -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);
+});
diff --git a/packages/webui/tests/org-switch-browser.test.mjs b/packages/webui/tests/org-switch-browser.test.mjs
index ab5d38ea2..6615947b6 100644
--- a/packages/webui/tests/org-switch-browser.test.mjs
+++ b/packages/webui/tests/org-switch-browser.test.mjs
@@ -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');
+});
diff --git a/packages/webui/tests/s7-fidelity-browser.test.mjs b/packages/webui/tests/s7-fidelity-browser.test.mjs
index 830385bd3..f7bb51e5c 100644
--- a/packages/webui/tests/s7-fidelity-browser.test.mjs
+++ b/packages/webui/tests/s7-fidelity-browser.test.mjs
@@ -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);
+});