Files
windmill/frontend/src/lib/utils/devPollingDormancy.dom.test.ts
T
Ruben Fiszel 435fbaece0 stop websockets resurrecting a reclaimed dev server (#10676)
* fix: stop websockets resurrecting a reclaimed dev server

* docs: condense the websocket invariant comment

* test: stub fetch suite-wide so waking cannot hit a real dev server

* fix: let websockets join an in-flight start
2026-08-13 07:49:14 +02:00

126 lines
4.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Freshly imported per case: install is idempotent by design, so a shared module instance
// would make the second case a no-op against a restored `window.setInterval`.
async function install() {
vi.resetModules()
const module = await import('./devPollingDormancy')
module.installDevPollingDormancy()
}
// The patch is invisible in production and has no UI, so the freeze/re-arm pair is the
// only thing standing between a forgotten tab and a dev server that never gets reclaimed.
describe('devPollingDormancy', () => {
const original = { setInterval: window.setInterval, clearInterval: window.clearInterval }
let fetchSpy: ReturnType<typeof vi.fn>
// Stubbed for every case, not just the one that asserts on it: waking issues a real
// request otherwise, and jsdom's default origin is localhost:3000 — the frontend port
// of the first worktree, whose dev server the suite would then resurrect.
beforeEach(() => {
fetchSpy = vi.fn(() => Promise.resolve(new Response()))
vi.stubGlobal('fetch', fetchSpy)
})
afterEach(() => {
window.setInterval = original.setInterval
window.clearInterval = original.clearInterval
// The install guard lives on `window`, so it is part of the global state to restore.
delete (window as unknown as Record<string, boolean>).__wmDevPollingDormancyInstalled
vi.useRealTimers()
vi.unstubAllEnvs()
// restoreAllMocks does not undo stubGlobal, and the config sets no unstubGlobals.
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function setHidden(hidden: boolean) {
Object.defineProperty(document, 'hidden', { value: hidden, configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
}
it('freezes intervals once the tab goes inactive and re-arms them on return', async () => {
vi.useFakeTimers()
vi.stubEnv('VITE_DEV_DORMANT_MS', '1000')
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
setHidden(false)
await install()
const tick = vi.fn()
const handle = window.setInterval(tick, 100)
vi.advanceTimersByTime(300)
expect(tick).toHaveBeenCalledTimes(3)
setHidden(true)
vi.advanceTimersByTime(1000)
tick.mockClear()
vi.advanceTimersByTime(500)
expect(tick).not.toHaveBeenCalled()
setHidden(false)
vi.advanceTimersByTime(300)
expect(tick).toHaveBeenCalledTimes(3)
// Clearing through the patched handle must still stop the underlying timer.
window.clearInterval(handle)
tick.mockClear()
vi.advanceTimersByTime(300)
expect(tick).not.toHaveBeenCalled()
})
it('warms the dev server on wake, since websockets cannot restart it', async () => {
vi.useFakeTimers()
vi.stubEnv('VITE_DEV_DORMANT_MS', '1000')
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
setHidden(false)
await install()
setHidden(true)
vi.advanceTimersByTime(1000)
expect(fetchSpy).not.toHaveBeenCalled()
setHidden(false)
expect(fetchSpy).toHaveBeenCalledTimes(1)
// The origin is the load-bearing half: it has to reach the supervised port.
expect(fetchSpy.mock.calls[0][0]).toBe(`${location.origin}/`)
expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: 'HEAD' })
})
it('does not re-patch when the module itself is hot-replaced', async () => {
vi.useFakeTimers()
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
setHidden(false)
await install()
const patched = window.setInterval
// A fresh module instance is what HMR hands the layout on the next edit.
await install()
expect(window.setInterval).toBe(patched)
})
it('arms intervals registered while dormant only once the tab is back', async () => {
vi.useFakeTimers()
vi.stubEnv('VITE_DEV_DORMANT_MS', '1000')
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
setHidden(false)
await install()
setHidden(true)
vi.advanceTimersByTime(1000)
const tick = vi.fn()
window.setInterval(tick, 100)
vi.advanceTimersByTime(500)
expect(tick).not.toHaveBeenCalled()
setHidden(false)
vi.advanceTimersByTime(200)
expect(tick).toHaveBeenCalledTimes(2)
})
})