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 @@ @@ -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); +});