diff --git a/config/test-suites.ts b/config/test-suites.ts index 3a16ed1a..c440ce65 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -63,6 +63,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception 'test/local-model-ui.browser.test.ts', 'test/home-breakpoint-swap.browser.test.ts', + 'test/log-viewer-reconnect.browser.test.ts', ]; /** diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 662bfefe..2c503f9d 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -6163,6 +6163,7 @@ Object.assign(CodemanApp.prototype, { CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`) ); + let connected = false; eventSource.onmessage = (e) => { const data = JSON.parse(e.data); const body = document.getElementById(`log-viewer-body-${windowId}`); @@ -6170,6 +6171,7 @@ Object.assign(CodemanApp.prototype, { switch (data.type) { case 'connected': + connected = true; body.innerHTML = ''; break; case 'data': @@ -6189,6 +6191,7 @@ Object.assign(CodemanApp.prototype, { this.updateLogViewerStatus(windowId, 'disconnected', 'ended'); break; case 'error': + if (!connected) eventSource.close(); body.innerHTML += `
${escapeHtml(data.error)}
`; this.updateLogViewerStatus(windowId, 'error', 'error'); break; diff --git a/test/log-viewer-reconnect.browser.test.ts b/test/log-viewer-reconnect.browser.test.ts new file mode 100644 index 00000000..9f60f5fb --- /dev/null +++ b/test/log-viewer-reconnect.browser.test.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const id = 'log-reconnect-fixture', + path = '/workspace/demo.log'; +describe('log viewer pre-connect failures', () => { + let server: WebServer, browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + it.each([ + 'Path must be within working directory or allowed log directories', + 'File does not exist', + 'File not found or not accessible', + 'File too large', + 'Maximum 5 concurrent streams per session', + 'Failed to spawn tail process', + ])('does not reconnect after %s before connected', async (error) => { + const page = await browser.newPage(), + errors: string[] = []; + let requests = 0; + page.on('pageerror', (e) => errors.push(e.message)); + await page.route(`**/api/sessions/${id}/tail-file?**`, async (route) => { + requests++; + return route.fulfill({ + status: 200, + contentType: 'text/event-stream', + // retry: 200 makes a reconnect, if one came, show up within 200 ms. + body: `retry: 200\ndata: ${JSON.stringify({ type: 'error', error })}\n\n`, + }); + }); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.openLogViewerWindow); + await page.waitForTimeout(3500); + await page.evaluate( + ({ id, path }) => { + const app = (window as any).app; + app.sessions.set(id, { id, mode: 'shell', workingDir: '/workspace' }); + app.openLogViewerWindow(path, id); + }, + { id, path } + ); + await expect.poll(() => page.locator('.log-viewer-window').innerText()).toContain(error); + await page.waitForTimeout(1000); + expect(requests).toBe(1); + expect(errors).toEqual([]); + } finally { + await page.close(); + } + }); +});