diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 0cc0d46b..729dfd59 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -33829,12 +33829,30 @@ function handleAuthStatusChangeForChatTab(detail) { publishRealtimeConnectionTelemetry('auth_status_changed'); } +export async function retryConversationReadsAfterConnectionRecovery() { + if (pendingChatOrganisationSwitchId !== null) return; + const organisationGeneration = chatOrganisationGeneration; + await refreshChatSessionTabs(); + if (!isChatOrganisationRequestCurrent(organisationGeneration)) return; + // Do not replay a send or replace a live answer. Retry only a failed/missing + // transcript; the composer, attachments and current selection stay in place. + if (!activeHistoryRequest && !getLiveChatRequestForSession(activeChatSessionId) + && (historyLoadState.degraded || displayedHistorySessionId !== activeChatSessionId)) { + await loadChatHistory({ scrollToBottom: false, preserveScroll: true }); + } +} + export function initializeChatTab() { if (chatTabInitialised) { console.warn('[chatTab] initializeChatTab called more than once; skipping duplicate initialisation.'); return; } chatTabInitialised = true; + document.addEventListener('von:connectionRestored', () => { + void retryConversationReadsAfterConnectionRecovery().catch(error => { + console.warn('[chatTab] Connection recovered; conversation retry failed:', error); + }); + }); const turnModelSelect = document.getElementById('turnModelSelect'); if (turnModelSelect) { turnModelPicker = createTurnModelPicker({ diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index f4836ec9..d711650e 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -12,6 +12,7 @@ import { escapeHtml } from './markdownUtils.js'; import './suppressTooltips.js'; import { activateTab, loadTabData, setupTabNavigation } from './tabNavigation.js'; import { installOutageView } from '../outage/app.js'; +import { fetchVonHealth, healthErrorKind } from '../outage/health.js'; import { preserveOutageDraft } from '../outage/draft.js'; import { evaluateServerHealthState } from './utils/serverHealthState.js'; import { handleSelectConceptByIdDetail } from './utils/selectConceptByIdHandler.js'; @@ -998,8 +999,8 @@ function startHealthPolling() { ? `cannot reach Von (checked ${lastCheckLabel})` : 'cannot reach Von'; const baseTitle = lastSuccessLabel - ? `Von server is unreachable | Last healthy response ${lastSuccessLabel} ago` - : 'Von server is unreachable'; + ? `Connection to Von is unconfirmed | Last healthy response ${lastSuccessLabel} ago` + : 'Connection to Von is unconfirmed'; uptimeSpan.title = copyHint ? `${baseTitle} | ${copyHint}` : baseTitle; if (uptimeContainer) uptimeContainer.classList.add('pid-error'); } else if (serverHealthUiState === 'waiting') { @@ -1131,13 +1132,9 @@ function startHealthPolling() { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); - const res = await fetch('/health', { cache: 'no-store', signal: controller.signal }); - clearTimeout(timeout); - if (!res.ok) throw new Error('HTTP ' + res.status); - const data = await res.json(); - if (data?.status !== 'healthy' || typeof data.start_time !== 'string') { - throw new Error('Invalid Von health response'); - } + let data; + try { data = await fetchVonHealth({ signal: controller.signal }); } + finally { clearTimeout(timeout); } const recoveringFromOutage = serverHealthUiState === 'down'; lastHealthCheckCompletedAtMs = Date.now(); hasSeenSuccessfulHealthPoll = true; @@ -2336,10 +2333,7 @@ function startHealthPolling() { firstFailureAtMs = Date.now(); } const nowMs = Date.now(); - const isHttpError = typeof e?.message === 'string' && e.message.startsWith('HTTP '); - const errorKind = e?.name === 'AbortError' - ? 'timeout' - : (isHttpError ? 'http' : 'network_or_unknown'); + const errorKind = healthErrorKind(e); const errorDetail = typeof e?.message === 'string' ? e.message : String(e || ''); diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index bc9f3f69..553d8fa0 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -1,5 +1,6 @@ jest.mock('../workflowStudioAccess.js', () => ({ canUseWorkflowStudio: jest.fn(() => true) })); import { + retryConversationReadsAfterConnectionRecovery, __testOnly_buildThinkingProgressPresentation, __testOnly_buildThinkingCardProgressViewModel, __testOnly_buildRetainedThinkingCardRequestFromDebugData, @@ -2731,6 +2732,36 @@ describe('loadChatHistory degraded handling', () => { __testOnly_setActiveChatSession(null, null); }); + test('connection recovery retries failed reads for the same conversation without altering the draft or sending', async () => { + document.body.insertAdjacentHTML('beforeend', '
'); + let available = false; + global.fetch = jest.fn(async (url) => { + if (!available) throw new TypeError('Failed to fetch'); + if (url.startsWith('/von/history/sessions')) return { ok: true, json: async () => ({ + sessions: [{ session_id: 'session-1', session_name: 'Session 1', message_count: 1 }], + active_session_id: 'session-1' + }) }; + if (url.startsWith('/von/history?')) return { ok: true, json: async () => ({ + history: [{ role: 'user', content: 'Recovered conversation' }], + segments_returned: 1, total_segments: 1 + }) }; + return { ok: true, json: async () => ({}) }; + }); + expect(await __testOnly_loadChatHistory()).toBe(false); + available = true; + await retryConversationReadsAfterConnectionRecovery(); + expect(document.getElementById('scrollableField').textContent).toContain('Recovered conversation'); + expect(document.getElementById('promptInput').value).toBe('Keep this unsent text'); + expect(fetch.mock.calls.some(([url]) => url.startsWith('/von/history/sessions'))).toBe(true); + const historyReads = fetch.mock.calls.filter(([url]) => url.startsWith('/von/history?')); + expect(historyReads).toHaveLength(2); + expect(historyReads.every(([url]) => new URL(url, 'http://localhost').searchParams.get('session_id') === 'session-1')).toBe(true); + expect(fetch.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true); + // Repeated successful health checks must not replace a successfully loaded transcript. + await retryConversationReadsAfterConnectionRecovery(); + expect(fetch.mock.calls.filter(([url]) => url.startsWith('/von/history?'))).toHaveLength(2); + }); + test('preserves rendered history and surfaces a banner when the backend reports degraded history', async () => { __testOnly_setDisplayedHistorySession('session-1'); global.fetch = jest.fn((url) => { diff --git a/src/frontend/web/von_interface/static/outage/app.js b/src/frontend/web/von_interface/static/outage/app.js index ff5e4761..88d98b4b 100644 --- a/src/frontend/web/von_interface/static/outage/app.js +++ b/src/frontend/web/von_interface/static/outage/app.js @@ -1,11 +1,12 @@ import { maintenanceMessage, readMaintenance } from './status.js'; +import { connectionMessage } from './health.js'; // The existing health poll owns the decision; this module only presents it. export function installOutageView() { const dialog = document.createElement('dialog'); dialog.className = 'von-outage'; dialog.setAttribute('aria-labelledby', 'vonOutageTitle'); - dialog.innerHTML = '

Cannot reach Von

Sorry, Von cannot connect right now.

Your open work stays in place. We’ll reconnect automatically. An interrupted message will not be sent again; check its result before resending.

'; + dialog.innerHTML = '

Connection to Von interrupted

Sign in again in a new tab

Complete sign-in with the same account, then return to this tab and choose Check status / Retry. Keep this tab open: your conversation, unsent text and attachments stay here.

We’ll reconnect automatically. An interrupted message will not be sent again; check its result before resending.

'; document.body.append(dialog); let lastFocus = null; let state = 'waiting'; @@ -13,10 +14,10 @@ export function installOutageView() { let statusInFlight = false; let status = { record: null, cached: true }; let lastChecked = null; + let lastErrorKind = null; + let signInAttempted = false; const updateText = () => { - dialog.querySelector('[data-connection]').textContent = navigator.onLine === false - ? 'Your device reports that it is offline. Check your connection; Von’s status is unknown.' - : 'Cannot reach Von. The cause is unknown.'; + dialog.querySelector('[data-connection]').textContent = connectionMessage(lastErrorKind); dialog.querySelector('[data-maintenance]').textContent = maintenanceMessage(status.record, status); dialog.querySelector('[data-checked]').textContent = lastChecked ? `Last checked: ${new Date(lastChecked).toLocaleTimeString()}` : 'No check completed yet.'; }; @@ -35,11 +36,16 @@ export function installOutageView() { const detail = event.detail || {}; const previous = state; state = detail.state; + lastErrorKind = detail.lastErrorKind || detail.latestErrorKind || null; lastChecked = Date.now(); updateText(); if (state === 'healthy') { dismissed = false; close(); + if (previous === 'down' || signInAttempted) { + signInAttempted = false; + document.dispatchEvent(new CustomEvent('von:connectionRestored')); + } } else if (state === 'down' && !detail.isThinkingActive && !dismissed && !dialog.open) { lastFocus = document.activeElement; dialog.showModal(); @@ -51,12 +57,22 @@ export function installOutageView() { void refreshStatus(); }); dialog.querySelector('[data-return]').addEventListener('click', () => { dismissed = true; close(); }); + // A normal user-initiated navigation can complete an edge sign-in that fetch + // cannot. Never navigate/reload the working tab or pass draft data to the new one. + dialog.querySelector('[data-sign-in]').addEventListener('click', () => { signInAttempted = true; }); dialog.addEventListener('cancel', event => { event.preventDefault(); dismissed = true; close(); }); window.addEventListener('online', () => { document.dispatchEvent(new CustomEvent('von:requestHealthCheck')); if (state === 'down') void refreshStatus(); }); window.addEventListener('offline', updateText); + const checkOnReturn = () => { + if (document.visibilityState !== 'hidden' && (state === 'down' || signInAttempted)) { + document.dispatchEvent(new CustomEvent('von:requestHealthCheck')); + } + }; + window.addEventListener('focus', checkOnReturn); + document.addEventListener('visibilitychange', checkOnReturn); // Fetch before an outage so the last-known attributed record can survive it. void refreshStatus(); setInterval(() => { if (dialog.open) updateText(); }, 10000); diff --git a/src/frontend/web/von_interface/static/outage/health.js b/src/frontend/web/von_interface/static/outage/health.js new file mode 100644 index 00000000..c29b4f58 --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/health.js @@ -0,0 +1,38 @@ +// A failed health request describes this browser's connection, not necessarily +// backend downtime. In particular, Access redirects can be opaque to fetch. +export async function fetchVonHealth({ signal } = {}) { + const response = await fetch('/health', { + cache: 'no-store', credentials: 'same-origin', redirect: 'manual', signal + }); + const fail = (kind, message) => { throw Object.assign(new Error(message), { healthErrorKind: kind }); }; + if (response.type === 'opaqueredirect' || response.redirected || + (response.status >= 300 && response.status < 400)) { + fail('redirect', 'Health request was redirected'); + } + if (response.status === 401 || response.status === 403) { + fail('access', `HTTP ${response.status}`); + } + if (!response.ok) fail('http', `HTTP ${response.status}`); + if (!response.headers.get('content-type')?.includes('application/json')) { + fail('unexpected_response', 'Expected a Von health response'); + } + let data; + try { data = await response.json(); } + catch { fail('unexpected_response', 'Invalid Von health response'); } + if (data?.status !== 'healthy' || typeof data.start_time !== 'string') { + fail('unexpected_response', 'Invalid Von health response'); + } + return data; +} + +export function healthErrorKind(error) { + return error?.healthErrorKind || (error?.name === 'AbortError' ? 'timeout' : 'network_or_unknown'); +} + +export function connectionMessage(kind, online = navigator.onLine) { + if (online === false) return 'Your device reports that it is offline. Check your connection; Von’s status is unknown.'; + if (kind === 'redirect') return 'The connection was redirected. You may need to sign in again; this does not confirm a Von outage.'; + if (kind === 'access') return 'Access was refused. Try signing in again. If access is still refused, check your account’s access to Von.'; + if (kind === 'http') return 'Von or its gateway returned an error. We’ll keep checking the connection.'; + return 'The connection could not be checked. Your sign-in may have expired, or there may be a network or server problem.'; +} diff --git a/src/frontend/web/von_interface/static/outage/offline.html b/src/frontend/web/von_interface/static/outage/offline.html index f10c2ac4..b62e76a4 100644 --- a/src/frontend/web/von_interface/static/outage/offline.html +++ b/src/frontend/web/von_interface/static/outage/offline.html @@ -1,3 +1,3 @@ Von — reconnecting -

Cannot reach Von

Sorry, Von cannot connect right now.

The cause is unknown. We’ll keep checking.

Return time unknown.

No check completed yet.

This page reconnects automatically. It will never resend a message. If a previous send was interrupted, check the conversation before sending again.

+

Connection to Von interrupted

Your sign-in may have expired, or there may be a network or server problem. We’ll keep checking.

Sign in again in a new tab

Complete sign-in with the same account, then return to this tab and choose Check status / Retry.

Return time unknown.

No check completed yet.

This page reconnects automatically. It will never resend a message. If a previous send was interrupted, check the conversation before sending again.

diff --git a/src/frontend/web/von_interface/static/outage/offline.js b/src/frontend/web/von_interface/static/outage/offline.js index 761b05db..d6a153fa 100644 --- a/src/frontend/web/von_interface/static/outage/offline.js +++ b/src/frontend/web/von_interface/static/outage/offline.js @@ -1,7 +1,9 @@ import { maintenanceMessage, readMaintenance } from './status.js'; +import { fetchVonHealth, healthErrorKind, connectionMessage } from './health.js'; let inFlight = false; let failures = 0; let timer; +let lastErrorKind = null; async function check() { if (inFlight) return; inFlight = true; @@ -14,8 +16,8 @@ async function check() { document.getElementById('maintenance').textContent = maintenanceMessage(value.record, value); }); try { - const response = await fetch('/health', { cache: 'no-store', redirect: 'error', signal: controller.signal }); - const health = response.ok ? await response.json() : null; + const health = await fetchVonHealth({ signal: controller.signal }); + lastErrorKind = null; const seeds = health?.runtime_authority?.startup_seed_materialisations; // Explicitly skipped startup work (including AgentTest) is not a pending // readiness failure. Still require healthy transport and the real app below. @@ -31,14 +33,12 @@ async function check() { return; } } - } catch { /* A proxy error or device-offline event does not identify the server-side cause. */ } + } catch (error) { lastErrorKind = healthErrorKind(error); } finally { clearTimeout(timeout); await status; document.getElementById('checked').textContent = `Last checked: ${new Date().toLocaleTimeString()}`; - document.getElementById('connection').textContent = navigator.onLine === false - ? 'Your device reports that it is offline. Check your connection; Von’s status is unknown.' - : 'Cannot reach Von. The cause is unknown. We’ll keep checking.'; + document.getElementById('connection').textContent = connectionMessage(lastErrorKind); button.disabled = false; inFlight = false; failures++; @@ -47,4 +47,5 @@ async function check() { } document.getElementById('retry').addEventListener('click', check); window.addEventListener('online', check); +window.addEventListener('focus', check); void check(); diff --git a/src/frontend/web/von_interface/static/outage/outage.css b/src/frontend/web/von_interface/static/outage/outage.css index 66085ad6..5534754c 100644 --- a/src/frontend/web/von_interface/static/outage/outage.css +++ b/src/frontend/web/von_interface/static/outage/outage.css @@ -4,7 +4,7 @@ body.von-outage { margin: 0; min-height: 100dvh; padding: max(24px, env(safe-are .von-outage::backdrop { background: #f4f7f8; } .von-outage main, .von-outage section { max-width: 34rem; margin: max(1rem, 8dvh) auto; overflow-wrap: anywhere; } .von-outage h1 { font-size: clamp(1.6rem, 6vw, 2.4rem); line-height: 1.2; } -.von-outage button { font: inherit; cursor: pointer; min-height: 48px; padding: .6rem 1rem; border: 1px solid #536c77; border-radius: .6rem; margin: .25rem .4rem .25rem 0; background: #fff; color: #172b35; } -.von-outage button:focus-visible { outline: 3px solid #077eaa; outline-offset: 3px; } +.von-outage button, .von-outage a { display: inline-block; box-sizing: border-box; font: inherit; cursor: pointer; min-height: 48px; padding: .6rem 1rem; border: 1px solid #536c77; border-radius: .6rem; margin: .25rem .4rem .25rem 0; background: #fff; color: #172b35; } +.von-outage button:focus-visible, .von-outage a:focus-visible { outline: 3px solid #077eaa; outline-offset: 3px; } .von-outage small { display: block; } -@media (prefers-color-scheme: dark) { .von-outage, .von-outage::backdrop { background: #18252c; color: #eef4f5; } .von-outage button { background: #2b424e; color: #eef4f5; } } +@media (prefers-color-scheme: dark) { .von-outage, .von-outage::backdrop { background: #18252c; color: #eef4f5; } .von-outage button, .von-outage a { background: #2b424e; color: #eef4f5; } } diff --git a/src/frontend/web/von_interface/static/outage/service-worker.js b/src/frontend/web/von_interface/static/outage/service-worker.js index 43ed0750..844297a7 100644 --- a/src/frontend/web/von_interface/static/outage/service-worker.js +++ b/src/frontend/web/von_interface/static/outage/service-worker.js @@ -1,8 +1,10 @@ // Cache only this public, dependency-free outage shell. Never cache application HTML, // API responses, navigation history, health payloads, credentials or uploaded files. -const CACHE = 'von-outage-v1'; +// Install the complete dependency bundle separately. A failed upgrade must not +// overwrite the active shell with imports its old worker cannot serve offline. +const CACHE = 'von-outage-v1-sign-in'; const STATIC_ROOT = '/static/'; -const ASSETS = ['/static/outage/offline.html', '/static/outage/offline.js', '/static/outage/status.js', '/static/outage/outage.css']; +const ASSETS = ['/static/outage/offline.html', '/static/outage/offline.js', '/static/outage/status.js', '/static/outage/outage.css', '/static/outage/health.js']; self.addEventListener('install', event => { event.waitUntil((async () => { const cache = await caches.open(CACHE); @@ -24,7 +26,7 @@ self.addEventListener('activate', event => { event.waitUntil((async () => { await self.clients.claim(); for (const name of await caches.keys()) { - if (name.startsWith('von-outage-v1-') && name !== CACHE) await caches.delete(name); + if ((name === 'von-outage-v1' || name.startsWith('von-outage-v1-')) && name !== CACHE) await caches.delete(name); } })()); }); diff --git a/tests/browser/outageComponents.cjs b/tests/browser/outageComponents.cjs index 7613a94b..d771ee60 100644 --- a/tests/browser/outageComponents.cjs +++ b/tests/browser/outageComponents.cjs @@ -11,15 +11,19 @@ assert(evidence, 'Provide evidence directory'); fs.mkdirSync(evidence, { recursive: true }); let available = true; let effects = 0; +let accessGate = false; +let signInBase; +let base; let status = { schema_version: 'von_maintenance.v1', release_id: 'component-fixture', agent: 'Codex DGX fixture', state: 'planned', reason: 'Controlled maintenance fixture', planned_start: new Date().toISOString(), estimated_ready_at: new Date(Date.now() + 180000).toISOString(), updated_at: new Date().toISOString(), expires_at: new Date(Date.now() + 1800000).toISOString() }; -const app = `
`; const server = http.createServer((req, res) => { @@ -39,6 +52,12 @@ const server = http.createServer((req, res) => { if (pathname === '/von-status/maintenance.json') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(status)); return; } + if (pathname === '/fixture-sign-in-complete') { + res.writeHead(302, { 'Set-Cookie': 'fixture_access=valid; HttpOnly; SameSite=Lax; Path=/', Location: '/von/' }); res.end(); return; + } + if (accessGate && !req.headers.cookie?.includes('fixture_access=valid')) { + res.writeHead(302, { Location: signInBase }); res.end(); return; + } if (!available) { res.writeHead(503); res.end('Backend unavailable'); return; } if (pathname === '/health') { res.setHeader('Content-Type', 'application/json'); @@ -46,6 +65,9 @@ const server = http.createServer((req, res) => { runtime_authority: { startup_seed_materialisations: { ready: false, state: 'skipped' } } })); return; } + if (pathname === '/von/history') { + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ text: 'Recovered fixture conversation' })); return; + } if (pathname === '/von/') { res.setHeader('Content-Type', 'text/html'); res.end(app); return; } let file; if (pathname === '/von/service-worker.js') file = path.join(root, 'service-worker.js'); @@ -57,9 +79,17 @@ const server = http.createServer((req, res) => { res.setHeader('X-Fixture-Private-Header', 'must-not-be-cached'); res.end(fs.readFileSync(file)); }); +// A separate origin without CORS headers reproduces Access-style redirect fetch +// failure. This is a disposable HTTP/cookie fixture, not Cloudflare or Google OAuth. +const signInServer = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(`

Fixture sign-in

Continue with fixture account`); +}); (async () => { + await new Promise(resolve => signInServer.listen(0, '127.0.0.1', resolve)); + signInBase = `http://127.0.0.1:${signInServer.address().port}`; await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - const base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch(); try { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); @@ -87,6 +117,8 @@ const server = http.createServer((req, res) => { assert.equal(workerLifecycle.script, `${base}/von/service-worker.js`); assert.equal(workerLifecycle.pushStateCleared, true); const draft = page.locator('#promptInput'); + assert.equal(await page.evaluate(() => window.checkHealth()), null); + await expect(page.locator('dialog')).not.toBeVisible(); await draft.fill('Unsent local acceptance draft'); await page.evaluate(() => window.health({ hasSeenSuccessfulHealthPoll: true, failureCount: 1, firstFailureAtMs: Date.now() })); await expect(page.locator('dialog')).not.toBeVisible(); @@ -99,6 +131,7 @@ const server = http.createServer((req, res) => { for (const [label, width, height] of [['portrait',390,844], ['keyboard',390,350], ['landscape',844,390]]) { await page.setViewportSize({ width, height }); await page.locator('[data-retry]').click(); + await expect(page.locator('[data-connection]')).toContainText('gateway returned an error'); await expect(draft).toHaveValue('Unsent local acceptance draft'); assert(await page.evaluate(() => document.querySelector('dialog').scrollWidth <= innerWidth)); await page.screenshot({ path: path.join(evidence, `${label}.png`) }); @@ -109,17 +142,54 @@ const server = http.createServer((req, res) => { await expect(page.locator('dialog')).not.toBeVisible(); await expect(draft).toBeFocused(); await expect(draft).toHaveValue('Unsent local acceptance draft'); + // Invalidate only this isolated browser's fixture cookie. No real profile, + // credentials, production logout or model prompts are involved. + accessGate = true; + await context.clearCookies(); + await page.locator('#attachment').setInputFiles({ name: 'unsent.txt', mimeType: 'text/plain', buffer: Buffer.from('Unsent attachment') }); + const baselineFailure = await page.evaluate(async () => { + try { await fetch('/health'); return 'unexpected success'; } catch (error) { return error.name; } + }); + assert.equal(baselineFailure, 'TypeError'); + assert.equal(await page.evaluate(() => window.checkHealth()), 'redirect'); + await expect(page.locator('[data-connection]')).toContainText('may need to sign in again'); + await expect(page.locator('[data-connection]')).toContainText('does not confirm a Von outage'); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: path.join(evidence, 'access-expired.png') }); + await page.setViewportSize({ width: 1280, height: 900 }); + assert(await page.evaluate(() => document.querySelector('dialog').scrollWidth <= innerWidth)); + await page.screenshot({ path: path.join(evidence, 'access-expired-desktop.png') }); + const originalUrl = page.url(); + const newTabPromise = context.waitForEvent('page'); + await page.getByRole('link', { name: 'Sign in again in a new tab' }).click(); + const signInTab = await newTabPromise; + await expect(signInTab.getByRole('heading')).toHaveText('Fixture sign-in'); + assert.equal(await signInTab.evaluate(() => window.opener), null); + await signInTab.getByRole('link', { name: 'Continue with fixture account' }).click(); + await expect(signInTab.locator('#promptInput')).toBeVisible(); + await page.bringToFront(); + // Explicit retry also works where the browser does not emit a focus event. + if (await page.locator('dialog').isVisible()) await page.locator('[data-retry]').click(); + await expect(page.locator('dialog')).not.toBeVisible(); + await expect(page.locator('#conversation')).toHaveText('Recovered fixture conversation'); + await expect(page.locator('#conversation')).toHaveAttribute('data-session', 'fixture-conversation'); + await expect(draft).toHaveValue('Unsent local acceptance draft'); + assert.equal(await page.locator('#attachment').evaluate(input => input.files[0].name), 'unsent.txt'); + assert.equal(page.url(), originalUrl); + await page.screenshot({ path: path.join(evidence, 'access-recovered.png') }); + await signInTab.close(); + accessGate = false; // Reload/reopen acceptance is separate from in-memory draft retention. available = false; await page.reload(); - await expect(page.locator('h1')).toHaveText('Cannot reach Von'); + await expect(page.locator('h1')).toHaveText('Connection to Von interrupted'); await expect(page.locator('#checked')).toContainText('Last checked'); await context.setOffline(true); await page.locator('#retry').click(); await expect(page.locator('#connection')).toContainText('device reports that it is offline'); const reopened = await context.newPage(); await reopened.goto(`${base}/von/`); - await expect(reopened.locator('h1')).toHaveText('Cannot reach Von'); + await expect(reopened.locator('h1')).toHaveText('Connection to Von interrupted'); const cached = await page.evaluate(async () => { const entries = []; for (const name of await caches.keys()) for (const request of await (await caches.open(name)).keys()) { @@ -129,17 +199,28 @@ const server = http.createServer((req, res) => { } return entries.sort(); }); - assert.deepEqual(cached, ['/static/outage/offline.html','/static/outage/offline.js','/static/outage/outage.css','/static/outage/status.js']); + assert.deepEqual(cached, ['/static/outage/health.js','/static/outage/offline.html','/static/outage/offline.js','/static/outage/outage.css','/static/outage/status.js']); available = true; + accessGate = true; + await context.clearCookies(); await context.setOffline(false); + await page.locator('#retry').click(); + await expect(page.locator('#connection')).toContainText('may need to sign in again'); + const shellSignInPromise = context.waitForEvent('page'); + await page.getByRole('link', { name: 'Sign in again in a new tab' }).click(); + const shellSignIn = await shellSignInPromise; + await shellSignIn.getByRole('link', { name: 'Continue with fixture account' }).click(); + await expect(shellSignIn.locator('#promptInput')).toBeVisible(); + await page.bringToFront(); + if (await page.locator('#retry').isVisible()) await page.locator('#retry').click(); await expect(page.locator('#promptInput')).toBeVisible(); await page.locator('#vonRecoveredDraft summary').click(); await expect(page.getByLabel('Recovered unsent draft')).toHaveValue('Unsent local acceptance draft'); await expect(page.locator('#promptInput')).toHaveValue(''); assert.equal(effects, 0); - const result = { surface: 'component/network fixture', viewports: 3, workerLifecycle, transientNoOverlay: true, thinkingNoOverlay: true, + const result = { surface: 'component/network fixture with cross-origin sign-in and isolated cookie', baselineFailure, opaqueRedirectDetected: true, signInNewTab: true, offlineShellSignIn: true, attachmentRetained: true, conversationRetained: true, viewports: 4, workerLifecycle, transientNoOverlay: true, thinkingNoOverlay: true, openDraftRetained: true, reloadDraftRecovery: true, focusRestored: true, offlineReloadAndReopen: true, reconnect: true, cachedPaths: cached, effects }; fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result)); - } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); } -})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); + } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); await new Promise(resolve => signInServer.close(resolve)); } +})().catch(error => { console.error(error); server.close(); signInServer.close(); process.exitCode = 1; }); diff --git a/tests/frontend/outageHealth.test.js b/tests/frontend/outageHealth.test.js new file mode 100644 index 00000000..fccf90f7 --- /dev/null +++ b/tests/frontend/outageHealth.test.js @@ -0,0 +1,38 @@ +import { fetchVonHealth, healthErrorKind, connectionMessage } from '../../src/frontend/web/von_interface/static/outage/health.js'; + +afterEach(() => { delete global.fetch; }); + +test('healthy JSON is read without following an edge redirect or caching the response', async () => { + const data = { status: 'healthy', start_time: '2026-09-16T00:00:00Z' }; + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, + headers: { get: () => 'application/json' }, json: async () => data }); + expect(await fetchVonHealth()).toEqual(data); + expect(fetch).toHaveBeenCalledWith('/health', expect.objectContaining({ + redirect: 'manual', cache: 'no-store', credentials: 'same-origin' + })); +}); + +test.each([ + [{ type: 'opaqueredirect', status: 0 }, 'redirect'], + [{ status: 302 }, 'redirect'], + [{ status: 401 }, 'access'], + [{ status: 403 }, 'access'], + [{ status: 503 }, 'http'], + [{ ok: true, status: 200, headers: { get: () => 'text/html' } }, 'unexpected_response'], + [{ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({}) }, 'unexpected_response'], + [{ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => { throw new SyntaxError(); } }, 'unexpected_response'], +])('unhealthy responses retain the observed cause without asserting expired authentication', async (response, kind) => { + global.fetch = jest.fn().mockResolvedValue(response); + await expect(fetchVonHealth()).rejects.toMatchObject({ healthErrorKind: kind }); +}); + +test('opaque fetch failure remains uncertain; offline and HTTP failures have appropriate copy', () => { + expect(healthErrorKind(new TypeError('Failed to fetch'))).toBe('network_or_unknown'); + expect(connectionMessage('network_or_unknown', true)).toContain('may have expired'); + expect(connectionMessage('network_or_unknown', true)).toContain('network or server problem'); + expect(connectionMessage('redirect', true)).toContain('does not confirm a Von outage'); + expect(connectionMessage('access', true)).toContain('Access was refused'); + expect(connectionMessage('http', true)).toContain('gateway returned an error'); + expect(connectionMessage('redirect', false)).toContain('device reports that it is offline'); + expect(healthErrorKind({ name: 'AbortError' })).toBe('timeout'); +}); diff --git a/tests/frontend/outageWorkerUpgrade.test.js b/tests/frontend/outageWorkerUpgrade.test.js new file mode 100644 index 00000000..9ec1d253 --- /dev/null +++ b/tests/frontend/outageWorkerUpgrade.test.js @@ -0,0 +1,44 @@ +/** @jest-environment node */ +const fs = require('node:fs'); +const vm = require('node:vm'); + +test('failed offline-shell upgrade leaves the active bundle intact; activation retires it only after complete installation', async () => { + const oldCache = new Map([['/static/outage/offline.js', 'old working shell']]); + const cacheMaps = new Map([['von-outage-v1', oldCache]]); + const caches = { + open: async name => { + if (!cacheMaps.has(name)) cacheMaps.set(name, new Map()); + return { put: async (key, value) => cacheMaps.get(name).set(key, value) }; + }, + keys: async () => [...cacheMaps.keys()], + delete: async name => cacheMaps.delete(name) + }; + const handlers = {}; + const self = { addEventListener: (name, handler) => { handlers[name] = handler; }, + skipWaiting: jest.fn(), clients: { claim: jest.fn() } }; + let dependencyAvailable = false; + const fetch = jest.fn(async url => { + if (url.endsWith('/health.js') && !dependencyAvailable) throw new TypeError('Interrupted update'); + const contentType = url.endsWith('.js') ? 'application/javascript' : url.endsWith('.css') ? 'text/css' : 'text/html'; + return new Response('new public asset', { headers: { 'Content-Type': contentType, 'Set-Cookie': 'must not cache' } }); + }); + vm.runInNewContext(fs.readFileSync('src/frontend/web/von_interface/static/outage/service-worker.js', 'utf8'), { + self, caches, fetch, Response, URL + }); + const run = event => new Promise((resolve, reject) => { + handlers[event]({ waitUntil: promise => promise.then(resolve, reject) }); + }); + await expect(run('install')).rejects.toThrow('Interrupted update'); + expect(oldCache.get('/static/outage/offline.js')).toBe('old working shell'); + expect(self.skipWaiting).not.toHaveBeenCalled(); + + dependencyAvailable = true; + await run('install'); + expect(cacheMaps.has('von-outage-v1')).toBe(true); + await run('activate'); + expect(cacheMaps.has('von-outage-v1')).toBe(false); + const [installed] = [...cacheMaps.values()]; + expect(installed.has('/static/outage/health.js')).toBe(true); + expect(await installed.get('/static/outage/offline.js').text()).toBe('new public asset'); + expect([...installed.values()].every(response => !response.headers.has('Set-Cookie'))).toBe(true); +});