diff --git a/relay/test/setup.ts b/relay/test/setup.ts new file mode 100644 index 0000000..5338f7e --- /dev/null +++ b/relay/test/setup.ts @@ -0,0 +1,18 @@ +// The hub logs one `{"evt":"channel_closed",...}` JSON line per retired +// client, on purpose: it is the per-channel counter feed for Workers Logs +// (src/hub.ts, retireClient). Under vitest that purpose does not exist, and +// the suites retire enough clients to bury a CI log in seventy-odd of them — +// so the line is dropped here, at the printing edge, and nowhere else. +// +// Only this line, by its prefix: anything else the Worker says still prints, +// because an unexpected log in a test run is signal. And the test that proves +// the counters ("... logs the counters", test/hub.test.ts) keeps working +// unchanged: its vi.spyOn(console, 'log') wraps this filter, so it records +// the call and its arguments whether or not anything reaches the terminal. + +const passThrough = console.log.bind(console) + +console.log = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].startsWith('{"evt":"channel_closed"')) return + passThrough(...args) +} diff --git a/relay/vitest.config.ts b/relay/vitest.config.ts index 8aba76b..e1816f9 100644 --- a/relay/vitest.config.ts +++ b/relay/vitest.config.ts @@ -43,5 +43,10 @@ export default defineConfig({ }, }), ], - test: { include: ['test/**/*.test.ts'] }, + test: { + include: ['test/**/*.test.ts'], + // Drops the hub's per-channel Workers Logs line from test output — the + // line itself is deliberate production logging. See test/setup.ts. + setupFiles: ['test/setup.ts'], + }, }) diff --git a/web/src/components/app-shell.test.tsx b/web/src/components/app-shell.test.tsx index a80d3e3..14cdc42 100644 --- a/web/src/components/app-shell.test.tsx +++ b/web/src/components/app-shell.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FlueClientContext } from '@/client/provider' import { renderWithRouter } from '@/testing/render' @@ -186,8 +186,12 @@ describe('AppShell', () => { // state for a line nobody is waiting on. expect(screen.queryByText(/^flue /)).toBeNull() - last().open() - last().emitControl({ type: 'welcome', daemonId: 'local', host: 'macbook', ver: '0.4.1' }) + // FakeSocket delivers synchronously, so the welcome's setState lands right + // here — inside act, or as an act warning for every listening component. + act(() => { + last().open() + last().emitControl({ type: 'welcome', daemonId: 'local', host: 'macbook', ver: '0.4.1' }) + }) const line = await screen.findByText('flue 0.4.1') // The daemon's releases, not a tag assembled from the string above — a @@ -199,7 +203,9 @@ describe('AppShell', () => { // And it follows a reconnect, since a welcome is a claim about the daemon // and the daemon may have been restarted into a new build under the tab. - last().emitControl({ type: 'welcome', daemonId: 'local', host: 'macbook', ver: '0.5.0' }) + act(() => { + last().emitControl({ type: 'welcome', daemonId: 'local', host: 'macbook', ver: '0.5.0' }) + }) expect(await screen.findByText('flue 0.5.0')).toBeTruthy() }) diff --git a/web/src/components/update-notice.test.tsx b/web/src/components/update-notice.test.tsx index 18d4eb3..0491f0d 100644 --- a/web/src/components/update-notice.test.tsx +++ b/web/src/components/update-notice.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -63,8 +63,12 @@ function mount() { , ) - last().open() - last().emitControl({ type: 'welcome', daemonId: 'local', host: 'macbook', ver: '0.4.1' }) + // FakeSocket delivers synchronously, so the welcome's setState lands right + // here — inside act, or as an act warning from every test in the file. + act(() => { + last().open() + last().emitControl({ type: 'welcome', daemonId: 'local', host: 'macbook', ver: '0.4.1' }) + }) return view } diff --git a/web/src/router.test.tsx b/web/src/router.test.tsx index 64e2cc3..53aa3b4 100644 --- a/web/src/router.test.tsx +++ b/web/src/router.test.tsx @@ -32,11 +32,18 @@ async function renderAt(path: string) { const router = createFlueRouter() await router.load() const { client, sockets } = fakeClient() - const view = render( - - - , - ) + // Async act, not a bare render: mounting RouterProvider re-runs + // router.load() from Transitioner's mount effect, and its continuations + // update the router stores a microtask after RTL's synchronous act exits — + // an act warning per mounted Match on a runner slow enough to print them. + let view!: ReturnType + await act(async () => { + view = render( + + + , + ) + }) return { ...view, client, sockets } } @@ -56,11 +63,15 @@ async function renderFleet(path: string) { { id: 'local', name: '', client: local.client }, { id: 'attic-pi', name: 'Attic Pi', client: attic.client }, ]) - const view = render( - - - , - ) + // The same async act as renderAt, for the same post-mount load. + let view!: ReturnType + await act(async () => { + view = render( + + + , + ) + }) return { ...view, local, attic } } @@ -74,7 +85,12 @@ async function renderPicker(path: string) { window.history.replaceState(null, '', path) const router = createFlueRouter({ picker: true }) await router.load() - return render() + // The same async act as renderAt, for the same post-mount load. + let view!: ReturnType + await act(async () => { + view = render() + }) + return view } /** @@ -104,7 +120,10 @@ async function renderBare(path: string, picker = false): Promise { window.history.replaceState(null, '', path) const router = createFlueRouter({ picker }) await router.load() - render() + // The same async act as renderAt, for the same post-mount load. + await act(async () => { + render() + }) return urls } @@ -360,11 +379,16 @@ describe('createFlueRouter', () => { [{ id: 'local', name: '', client: local.client }], () => Promise.resolve([{ id: 'attic-pi', name: 'Attic Pi', client: attic.client }]), ) - const { container } = render( - - - , - ) + // The same async act as renderAt, for the same post-mount load. The fleet + // has not been welcomed yet, so nothing here settles the adoption early. + let container!: HTMLElement + await act(async () => { + container = render( + + + , + ).container + }) // Before the welcome, not paired is all this browser can truthfully say. expect(screen.getByRole('status').textContent).toContain('Machine not paired on this browser') diff --git a/web/src/routes/pair.test.tsx b/web/src/routes/pair.test.tsx index 642a63c..ec2d4cb 100644 --- a/web/src/routes/pair.test.tsx +++ b/web/src/routes/pair.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { RouterProvider } from '@tanstack/react-router' import { IDBFactory } from 'fake-indexeddb' @@ -79,7 +79,15 @@ async function renderPair(search = '') { window.history.replaceState(null, '', `/pair${search}`) const router = createFlueRouter() await router.load() - return render() + // Async act, because mounting RouterProvider re-runs router.load() from + // Transitioner's mount effect, and its continuations update the router + // stores a microtask after RTL's synchronous act exits — an act warning per + // mounted Match on a runner slow enough to print them. + let view!: ReturnType + await act(async () => { + view = render() + }) + return view } const pairButton = () => screen.getByRole('button', { name: 'Pair' }) @@ -468,7 +476,12 @@ async function renderRelayPair(search = '') { }) const router = createFlueRouter() await router.load() - return render() + // The same async act as renderPair, for the same post-mount load. + let view!: ReturnType + await act(async () => { + view = render() + }) + return view } describe('PairRoute on a relay origin', () => { diff --git a/web/src/routes/sessions.test.tsx b/web/src/routes/sessions.test.tsx index a2b4eb3..a6138ae 100644 --- a/web/src/routes/sessions.test.tsx +++ b/web/src/routes/sessions.test.tsx @@ -97,13 +97,21 @@ async function mountSessions({ open = true, strict = false, solo = false } = {}) // tree and every query misses. await router.load() - const view = render( - - - - - , - ) + // Async act, because mounting RouterProvider re-runs router.load() from + // Transitioner's mount effect, and its continuations update the router + // stores a microtask after RTL's synchronous act exits — an act warning per + // mounted Match on a runner slow enough to print them. No socket is open + // yet, so nothing else can settle early in here. + let view!: ReturnType + await act(async () => { + view = render( + + + + + , + ) + }) const sock = local.sockets[0]! if (open) act(() => sock.open()) @@ -629,7 +637,11 @@ describe('SessionsRoute', () => { // `spawn` carries no metadata, so the tag can only be applied once the // session exists — which is the `attached` that answers it. expect(sock.ofType('update')).toEqual([]) - act(() => sock.emitControl(attached({ ref: 3, id: 'fresh1', reqId: 1 }))) + // Async, unlike the emits above: this `attached` answers a spawn, so it + // also starts the navigation to the new session, and the router's + // continuations land a microtask after a synchronous act exits. The + // update frame itself is sent synchronously, before the screen changes. + await act(async () => sock.emitControl(attached({ ref: 3, id: 'fresh1', reqId: 1 }))) expect(sock.ofType('update')).toEqual([{ type: 'update', id: 'fresh1', tags: ['api'] }]) }) diff --git a/web/src/testing/render.tsx b/web/src/testing/render.tsx index 94e84fb..8c5eaec 100644 --- a/web/src/testing/render.tsx +++ b/web/src/testing/render.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { render } from '@testing-library/react' +import { act, render } from '@testing-library/react' import { createMemoryHistory, createRootRoute, @@ -52,12 +52,19 @@ export async function renderWithRouter(ui: ReactNode, initialPath = '/sessions') // empty tree and every query in the test misses. Loading first is what // makes the render synchronous, which is why this helper is async. await router.load() - return { - router, - ...render( + // The async act matters even though the router is already loaded: mounting + // RouterProvider runs Transitioner's mount effect, which calls router.load() + // again, and that call's continuations update the router stores a microtask + // after RTL's own synchronous act has exited. Flushing them inside act here + // is what keeps every consumer of this helper from tripping "not wrapped in + // act(...)" warnings — one per mounted Match — on the first update. + let view!: ReturnType + await act(async () => { + view = render( , - ), - } + ) + }) + return { router, ...view } } diff --git a/web/src/testing/test-setup.ts b/web/src/testing/test-setup.ts index 231b84d..718a632 100644 --- a/web/src/testing/test-setup.ts +++ b/web/src/testing/test-setup.ts @@ -46,6 +46,26 @@ if (globalThis.Element && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = function scrollIntoView() {} } +// window.scrollTo exists in jsdom, but as a stub that logs "Not implemented: +// Window's scrollTo() method" to stderr every time focus mode or a Radix +// primitive calls it — sixty-odd lines per full run, none of them news. The +// no-op is the same honest stub as scrollIntoView above: nothing in jsdom +// scrolls. Overwritten unconditionally, because unlike the globals above the +// jsdom version is present — presence is the problem. +if (globalThis.window) { + window.scrollTo = () => {} +} + +// getContext is the same story: jsdom ships the method, and without the +// `canvas` package it logs "Not implemented" and returns null. Returning the +// null directly keeps the behaviour every caller already handles — the QR +// code on Devices falls back to a link when the context is missing, and a +// test pins exactly that — while dropping the log line. +if (globalThis.HTMLCanvasElement) { + HTMLCanvasElement.prototype.getContext = (() => + null) as typeof HTMLCanvasElement.prototype.getContext +} + /* * Fail a test that logged one of flue's own swallowed errors. *