diff --git a/Makefile b/Makefile index 2eb6d45..1cf55ce 100644 --- a/Makefile +++ b/Makefile @@ -262,7 +262,8 @@ e2e-visual: ## Screenshots of the built viewer in every theme against the commit npx playwright test --project visual $(VISUAL_FLAGS) .PHONY: e2e-visual-update -e2e-visual-update: ## Take the screenshots again after a change of the look that is meant +e2e-visual-update: build ## Take the screenshots again after a change of the look that is meant + # Built first: references taken from a stale binary would not match the code $(MAKE) e2e-visual VISUAL_FLAGS=--update-snapshots .PHONY: ui-build diff --git a/ui/e2e/__screenshots__/showcase.png b/ui/e2e/__screenshots__/showcase.png index c6bb113..f4dcfb5 100644 Binary files a/ui/e2e/__screenshots__/showcase.png and b/ui/e2e/__screenshots__/showcase.png differ diff --git a/ui/e2e/viewer.spec.ts b/ui/e2e/viewer.spec.ts index b1e3487..17e3989 100644 --- a/ui/e2e/viewer.spec.ts +++ b/ui/e2e/viewer.spec.ts @@ -178,6 +178,8 @@ test('errors of a page come in as a child of its PHP request', async ({ page, re const parent = feed.items.find((item) => item.url?.includes('/catalog/item')); expect(parent).toBeDefined(); const ingest = `http://127.0.0.1:${Number(process.env.E2E_PORT ?? 18840) + 1}`; + // Every run names its page apart: the viewer keeps the children of runs before it + const run = Date.now().toString(36); // The shop page as PHP would print it: Debugger::jsTag(), then a script that fails. A real local // server: Chrome lets a page reach a local receiver only from the same address space. @@ -190,7 +192,7 @@ test('errors of a page come in as a child of its PHP request', async ({ page, re await new Promise((done) => shop.listen(0, '127.0.0.1', done)); const { port } = shop.address() as AddressInfo; try { - await page.goto(`http://127.0.0.1:${port}/catalog/item?id=42`); + await page.goto(`http://127.0.0.1:${port}/catalog/item?id=42&run=${run}`); await page.waitForTimeout(300); await page.goto('/'); // leaving the page sends the rest with a beacon } finally { @@ -198,7 +200,7 @@ test('errors of a page come in as a child of its PHP request', async ({ page, re } const requests = page.getByRole('region', { name: 'Requests' }); - const child = requests.getByRole('button', { name: /GET \/catalog\/item\?id=42.*1 error/ }); + const child = requests.getByRole('button', { name: new RegExp(`run=${run}.*1 error`) }); await expect(child).toBeVisible({ timeout: 10_000 }); await child.click(); await expect( diff --git a/ui/src/lib/api/client.test.ts b/ui/src/lib/api/client.test.ts index 0d6eca1..54b37e8 100644 --- a/ui/src/lib/api/client.test.ts +++ b/ui/src/lib/api/client.test.ts @@ -241,6 +241,26 @@ describe('createHttpTransport', () => { expect(source.close).toHaveBeenCalled(); }); + it('tells subscribers to reload once the stream first opens', () => { + // What happened between the first load and the connection has no Last-Event-ID to come back by + const listeners = new Map void>(); + const source: EventSourceLike = { + addEventListener: (name, listener) => listeners.set(name, listener), + close: vi.fn(), + }; + const transport = createHttpTransport({ + baseUrl: '', + fetch: vi.fn(), + createEventSource: () => source, + }); + const received: unknown[] = []; + transport.subscribe((event) => received.push(event)); + listeners.get('open')?.(new MessageEvent('open')); + // A reconnection sends Last-Event-ID and the server sends what was missed + listeners.get('open')?.(new MessageEvent('open')); + expect(received).toEqual([{ event: 'resync' }]); + }); + it('shares one stream between subscribers and closes it after the last one leaves', () => { const listeners = new Map void>(); const source: EventSourceLike = { diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 13ef757..3bdabcd 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -313,14 +313,25 @@ export function createHttpTransport(options: HttpTransportOptions): Transport { subscribers.add(onEvent); if (!source) { const opened = createEventSource(`${options.baseUrl}/api/v1/stream`); + const deliver = (event: StreamEvent) => { + for (const subscriber of [...subscribers]) { + subscriber(event); + } + }; for (const name of streamEventNames) { - opened.addEventListener(name, (message) => { - const event = JSON.parse(String(message.data)) as StreamEvent; - for (const subscriber of [...subscribers]) { - subscriber(event); - } - }); + opened.addEventListener(name, (message) => + deliver(JSON.parse(String(message.data)) as StreamEvent), + ); } + // Events between the first load and the first connection have no Last-Event-ID to come back + // by: the subscribers load again once. Reconnections get the missed events from the server. + let connected = false; + opened.addEventListener('open', () => { + if (!connected) { + connected = true; + deliver({ event: 'resync' }); + } + }); source = opened; } return () => {