Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions relay/test/setup.ts
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 6 additions & 1 deletion relay/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
})
14 changes: 10 additions & 4 deletions web/src/components/app-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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()
})

Expand Down
10 changes: 7 additions & 3 deletions web/src/components/update-notice.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -63,8 +63,12 @@ function mount() {
<UpdateNotice />
</FlueClientContext.Provider>,
)
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
}

Expand Down
58 changes: 41 additions & 17 deletions web/src/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,18 @@ async function renderAt(path: string) {
const router = createFlueRouter()
await router.load()
const { client, sockets } = fakeClient()
const view = render(
<FlueClientProvider client={client}>
<RouterProvider router={router} />
</FlueClientProvider>,
)
// 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<typeof render>
await act(async () => {
view = render(
<FlueClientProvider client={client}>
<RouterProvider router={router} />
</FlueClientProvider>,
)
})
return { ...view, client, sockets }
}

Expand All @@ -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(
<FleetProvider fleet={fleet}>
<RouterProvider router={router} />
</FleetProvider>,
)
// The same async act as renderAt, for the same post-mount load.
let view!: ReturnType<typeof render>
await act(async () => {
view = render(
<FleetProvider fleet={fleet}>
<RouterProvider router={router} />
</FleetProvider>,
)
})
return { ...view, local, attic }
}

Expand All @@ -74,7 +85,12 @@ async function renderPicker(path: string) {
window.history.replaceState(null, '', path)
const router = createFlueRouter({ picker: true })
await router.load()
return render(<RouterProvider router={router} />)
// The same async act as renderAt, for the same post-mount load.
let view!: ReturnType<typeof render>
await act(async () => {
view = render(<RouterProvider router={router} />)
})
return view
}

/**
Expand Down Expand Up @@ -104,7 +120,10 @@ async function renderBare(path: string, picker = false): Promise<string[]> {
window.history.replaceState(null, '', path)
const router = createFlueRouter({ picker })
await router.load()
render(<RouterProvider router={router} />)
// The same async act as renderAt, for the same post-mount load.
await act(async () => {
render(<RouterProvider router={router} />)
})
return urls
}

Expand Down Expand Up @@ -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(
<FleetProvider fleet={fleet}>
<RouterProvider router={router} />
</FleetProvider>,
)
// 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(
<FleetProvider fleet={fleet}>
<RouterProvider router={router} />
</FleetProvider>,
).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')
Expand Down
19 changes: 16 additions & 3 deletions web/src/routes/pair.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -79,7 +79,15 @@ async function renderPair(search = '') {
window.history.replaceState(null, '', `/pair${search}`)
const router = createFlueRouter()
await router.load()
return render(<RouterProvider router={router} />)
// 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<typeof render>
await act(async () => {
view = render(<RouterProvider router={router} />)
})
return view
}

const pairButton = () => screen.getByRole('button', { name: 'Pair' })
Expand Down Expand Up @@ -468,7 +476,12 @@ async function renderRelayPair(search = '') {
})
const router = createFlueRouter()
await router.load()
return render(<RouterProvider router={router} />)
// The same async act as renderPair, for the same post-mount load.
let view!: ReturnType<typeof render>
await act(async () => {
view = render(<RouterProvider router={router} />)
})
return view
}

describe('PairRoute on a relay origin', () => {
Expand Down
28 changes: 20 additions & 8 deletions web/src/routes/sessions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,21 @@ async function mountSessions({ open = true, strict = false, solo = false } = {})
// tree and every query misses.
await router.load()

const view = render(
<SidebarProvider>
<FleetProvider fleet={fleet}>
<RouterProvider router={router as never} />
</FleetProvider>
</SidebarProvider>,
)
// 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<typeof render>
await act(async () => {
view = render(
<SidebarProvider>
<FleetProvider fleet={fleet}>
<RouterProvider router={router as never} />
</FleetProvider>
</SidebarProvider>,
)
})
const sock = local.sockets[0]!
if (open) act(() => sock.open())

Expand Down Expand Up @@ -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'] }])
})
Expand Down
19 changes: 13 additions & 6 deletions web/src/testing/render.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<typeof render>
await act(async () => {
view = render(
<SidebarProvider>
<RouterProvider router={router as never} />
</SidebarProvider>,
),
}
)
})
return { router, ...view }
}
20 changes: 20 additions & 0 deletions web/src/testing/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down