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.
*