From d6eff452e3133a84ee3c6b84f243f3d17fd62675 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 9 Nov 2026 13:29:23 -0600 Subject: [PATCH 1/2] fix(webui): recover archived organizations and console controls --- packages/webui/src/public/bus.js | 19 ++++++++ packages/webui/src/public/chat.css | 3 ++ packages/webui/src/public/chat.js | 6 +-- packages/webui/src/public/org-create.css | 3 +- packages/webui/src/public/org-create.js | 17 +++++-- packages/webui/tests/granted-user.test.mjs | 7 +++ .../webui/tests/org-archive-browser.test.mjs | 47 +++++++++++++++++++ .../webui/tests/org-live-fix-browser.test.mjs | 11 +++++ .../webui/tests/org-switch-browser.test.mjs | 15 ++++++ .../webui/tests/s7-fidelity-browser.test.mjs | 13 +++++ 10 files changed, 132 insertions(+), 9 deletions(-) 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/org-create.css b/packages/webui/src/public/org-create.css index 2017ae829..0dd863c1f 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 a41258340..fcc15a94d 100644 --- a/packages/webui/src/public/org-create.js +++ b/packages/webui/src/public/org-create.js @@ -75,6 +75,7 @@ @@ -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'; @@ -415,6 +417,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(); } @@ -430,7 +435,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'; @@ -454,7 +459,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); @@ -490,6 +495,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(); } @@ -530,7 +536,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() : []; @@ -544,7 +550,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; @@ -558,6 +564,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}.`; } } @@ -618,7 +625,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 0c9bcdabe..f143e41ec 100644 --- a/packages/webui/tests/org-archive-browser.test.mjs +++ b/packages/webui/tests/org-archive-browser.test.mjs @@ -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,49 @@ 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/); +}); + +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); +}); -- 2.54.0 From fdd6658a65e12f132db3390184777534aee9f032 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 9 Nov 2026 13:53:19 -0600 Subject: [PATCH 2/2] 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. --- packages/webui/src/public/app.js | 10 ++++++---- packages/webui/src/public/display.js | 8 +++++++- packages/webui/src/public/org-create.js | 15 ++++++++++++++ .../webui/tests/org-archive-browser.test.mjs | 20 +++++++++++++++++-- 4 files changed, 46 insertions(+), 7 deletions(-) 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/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.js b/packages/webui/src/public/org-create.js index 5cf47951a..d3554f4e2 100644 --- a/packages/webui/src/public/org-create.js +++ b/packages/webui/src/public/org-create.js @@ -594,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')], diff --git a/packages/webui/tests/org-archive-browser.test.mjs b/packages/webui/tests/org-archive-browser.test.mjs index 1a0bdbe6a..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: () => {} }); @@ -137,6 +137,22 @@ test('owner can start a down bus after setup recorded done and sees recovery gui 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'); -- 2.54.0