ReviewOS

also looking at this

stacks/rpx

test: stop leaking mocked module state across test files

#2274
Merged glennmichael123 wants to merge fix/2270-2271-test-state-leaks into main
8 files +77 -67
packages/rpx/test/cleanup.test.tsmodified+18-13
Changes to packages/rpx/test/cleanup.test.ts
@@ -1,14 +1,17 @@
11import type { CleanupOptions } from '../src/types'
2import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
33import realProcess from 'node:process'
4import * as Hosts from '../src/hosts'
5import * as Https from '../src/https'
46import { cleanup } from '../src/start'
57
68// Mock dependencies
79const mockProcessExit = mock(() => {})
810const mockConsoleLog = mock(() => {})
911const mockConsoleSuccess = mock(() => {})
10const mockRemoveHosts = mock(async () => {})
11const mockCleanupCertificates = mock(async () => {})
12// Spies, not `mock.module`: see the beforeEach below.
13let mockRemoveHosts: any
14let mockCleanupCertificates: any
1215
1316// `mock.module` is global and persists for the rest of the test run. A partial
1417// process replacement ({ exit, on, once, env }) therefore poisons every file
@@ -33,21 +36,23 @@ const processMock = new Proxy(realProcess, {
3336processOverrides.default = processMock
3437mock.module('node:process', () => processMock)
3538
36mock.module('../src/hosts', () => ({
37 removeHosts: mockRemoveHosts,
38}))
39
40mock.module('../src/https', () => ({
41 cleanupCertificates: mockCleanupCertificates,
42}))
43
4439describe('Cleanup Process', () => {
4540 beforeEach(() => {
4641 mockProcessExit.mockClear()
4742 mockConsoleLog.mockClear()
4843 mockConsoleSuccess.mockClear()
49 mockRemoveHosts.mockClear()
50 mockCleanupCertificates.mockClear()
44 // These two were `mock.module('../src/hosts')` / `('../src/https')`, which
45 // replace the whole namespace permanently — `mock.restore()` does not undo a
46 // module mock, so no later file could recover, and each namespace was left
47 // holding a single export (#2270). Spies on the namespace members are
48 // restorable and still observe `cleanup`'s internal calls, the same way
49 // start.test.ts does it.
50 mockRemoveHosts = spyOn(Hosts, 'removeHosts').mockImplementation(async () => {})
51 mockCleanupCertificates = spyOn(Https, 'cleanupCertificates').mockImplementation(async () => {})
52 })
53
54 afterEach(() => {
55 mock.restore()
5156 })
5257
5358 it('should handle multiple cleanup calls gracefully', async () => {
packages/rpx/test/hosts.test.tsmodified+9-1
Changes to packages/rpx/test/hosts.test.ts
@@ -1,4 +1,4 @@
1import { beforeEach, describe, expect, it, spyOn } from 'bun:test'
1import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
22import * as hostsModule from '../src/hosts'
33import { dropStaleRpxHostsLines, filterRpxHostsEntries, hostsLineMapsHost, isLoopbackDevelopmentHost, parseHostsLine } from '../src/hosts'
44
@@ -34,6 +34,14 @@ describe('hosts', () => {
3434 })
3535 })
3636
37 // `bun test` shares one module registry across every file in the process and
38 // `spyOn` does not auto-restore, so without this these three stubs stay
39 // installed on `../src/hosts` for every file that runs after this one —
40 // silently turning real hosts-file writes into no-ops there (#2270).
41 afterEach(() => {
42 mock.restore()
43 })
44
3745 describe('hosts file operations', () => {
3846 it('checks hosts entries correctly', async () => {
3947 const result = await hostsModule.checkHosts(['localhost', 'example.com'], true)
packages/rpx/test/integration.test.tsmodified+12-9
Changes to packages/rpx/test/integration.test.ts
@@ -1,4 +1,4 @@
1import { describe, expect, it, spyOn } from 'bun:test'
1import { afterEach, describe, expect, it, mock } from 'bun:test'
22import { config } from '../src/config'
33import * as Hosts from '../src/hosts'
44import * as Https from '../src/https'
@@ -6,6 +6,11 @@ import * as Start from '../src/start'
66
77// Simplified test that just verifies exports and basic functionality
88describe('Integration', () => {
9 // Guard: nothing here should outlive the file (see #2270).
10 afterEach(() => {
11 mock.restore()
12 })
13
914 // Just verify that the required functions exist and are exported
1015 it('exports required functionality', () => {
1116 expect(Start.startProxies).toBeDefined()
@@ -25,14 +30,12 @@ describe('Integration', () => {
2530
2631 // Check that util functions are working
2732 it('has working host utilities', () => {
28 // Mock the functions to avoid system calls
29 const mockAddHosts = spyOn(Hosts, 'addHosts').mockImplementation(async () => {})
30 const mockCheckHosts = spyOn(Hosts, 'checkHosts').mockImplementation(async (hosts) => {
31 return hosts.map(host => host === 'localhost')
32 })
33
34 expect(mockAddHosts).toBeDefined()
35 expect(mockCheckHosts).toBeDefined()
33 // These were spied "to avoid system calls", but the spies were only ever
34 // asserted on — never invoked — so they bought nothing and leaked no-op
35 // stubs onto `../src/hosts` for the rest of the run (#2270). Assert on the
36 // real exports instead.
37 expect(Hosts.addHosts).toBeDefined()
38 expect(Hosts.checkHosts).toBeDefined()
3639 })
3740
3841 // Check HTTPS functionality
packages/rpx/test/site-supervisor.test.tsmodified+4-0
Changes to packages/rpx/test/site-supervisor.test.ts
@@ -274,6 +274,10 @@ describe('SiteSupervisor', () => {
274274 probePort: async () => false,
275275 pollIntervalMs: 5,
276276 })
277 // Registered before the first await that can reject: the only one of these
278 // eight supervisors the afterEach could not reach, so its 30s reaper and
279 // readiness loop outlived a failing run (#2271).
280 supervisors.push(sup)
277281 await sup.onRequest('myapp.localhost')
278282 await sup.stopAll()
279283 expect(proc.signals).toContain('SIGTERM')
packages/rpx/test/start-lb-passthrough.test.tsmodified+9-1
Changes to packages/rpx/test/start-lb-passthrough.test.ts
@@ -1,5 +1,5 @@
11import type { MultiProxyConfig } from '../src/types'
2import { describe, expect, it, spyOn } from 'bun:test'
2import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
33import * as Start from '../src/start'
44
55/**
@@ -21,6 +21,14 @@ import * as Start from '../src/start'
2121 * single-proxy-in-multi-array tests once this passthrough is corrected).
2222 */
2323describe('startProxies non-shared-listener fallback passes loadBalancer through', () => {
24 // This file already defends against spies leaking IN (see the comment on the
25 // spy below); without this it leaked right back OUT. `Start.startServer` is a
26 // shared export and `spyOn` does not auto-restore, so the no-op stub — and its
27 // call history — reached every later file (#2270).
28 afterEach(() => {
29 mock.restore()
30 })
31
2432 it('a single-entry `proxies` array (no shared listener) forwards loadBalancer to startServer', async () => {
2533 // Matches this file's established convention (see start.test.ts): re-spy
2634 // immediately before the call and inspect the most recent call, since
packages/rpx/test/start-on-demand.test.tsmodified+9-1
Changes to packages/rpx/test/start-on-demand.test.ts
@@ -9,7 +9,7 @@
99 * redirect server (challenge store + reactive issuance kick), and serves the
1010 * manager's live SNI set on the shared :443 listener.
1111 */
12import { afterEach, describe, expect, it, spyOn } from 'bun:test'
12import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
1313import * as fsp from 'node:fs/promises'
1414import * as os from 'node:os'
1515import * as path from 'node:path'
@@ -21,6 +21,14 @@ const HTTPS_PORT = 49443
2121const HTTP_PORT = 49080
2222const REDIRECT_PORT = 49081
2323
24// File-level, outside both describes: the spies below are restored only after
25// their assertions, so a throw left them installed (#2270). The second describe
26// needs the REAL startHttpRedirectServer, and other files spy this same
27// namespace, so restore globally between every test in this file.
28afterEach(() => {
29 mock.restore()
30})
31
2432describe('startProxies on-demand TLS wiring', () => {
2533 let dir: string | undefined
2634
packages/rpx/test/start-single-proxy-production-certs.test.tsmodified+6-4
Changes to packages/rpx/test/start-single-proxy-production-certs.test.ts
@@ -10,7 +10,7 @@
1010 * a single entry fell through to `startServer`, which never receives
1111 * `productionTlsConfig` at all and unconditionally mints/uses a dev cert.
1212 */
13import { afterEach, describe, expect, it, spyOn } from 'bun:test'
13import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
1414import * as fsp from 'node:fs/promises'
1515import * as os from 'node:os'
1616import * as path from 'node:path'
@@ -20,6 +20,11 @@ describe('startProxies single-proxy production certs', () => {
2020 let dir: string | undefined
2121
2222 afterEach(async () => {
23 // Restore on EVERY path: the mockRestore()s used to sit at the very end of
24 // the `it`, so any assertion above them left `startServer` a permanent no-op
25 // and `createSharedProxyServer` permanently returning null for the rest of
26 // the run (#2270).
27 mock.restore()
2328 if (dir)
2429 await fsp.rm(dir, { recursive: true, force: true }).catch(() => {})
2530 dir = undefined
@@ -61,8 +66,5 @@ describe('startProxies single-proxy production certs', () => {
6166 // The individual (dev-cert-only) path must NOT have been used — that's
6267 // exactly the path that was silently dropping the real production cert.
6368 expect(startServerSpy).not.toHaveBeenCalled()
64
65 createSharedSpy.mockRestore()
66 startServerSpy.mockRestore()
6769 })
6870})
packages/vite-plugin/test/vite-plugin.test.tsmodified+10-38
Changes to packages/vite-plugin/test/vite-plugin.test.ts
@@ -33,9 +33,6 @@ function mockVitePluginRpx() {
3333 }
3434}
3535
36// Expose a named export for the mock to satisfy the import
37const VitePluginRpx = mock(mockVitePluginRpx)
38
3936// Mock dependencies
4037const mockStartProxies = mock(async (_opts?: any) => {})
4138const mockCleanup = mock(async (_opts?: any) => {})
@@ -61,41 +58,16 @@ const mockHttpServer = {
6158 }),
6259}
6360
64// Set up mocks
65mock.module('@stacksjs/rpx', () => ({
66 startProxies: mockStartProxies,
67 cleanup: mockCleanup,
68 checkExistingCertificates: mockCheckExistingCertificates,
69 checkHosts: mockCheckHosts,
70}))
71
72const processMock = {
73 ...process,
74 on: (event: string, cb: SignalHandler) => {
75 processListeners[event] = processListeners[event] || []
76 processListeners[event].push(cb)
77 },
78 once: (event: string, cb: SignalHandler) => {
79 processListeners[event] = processListeners[event] || []
80 processListeners[event].push(cb)
81 },
82 emit: (event: string) => {
83 if (processListeners[event]) {
84 processListeners[event].forEach(cb => cb())
85 }
86 },
87 listeners: (event: string) => processListeners[event] || [],
88 exit: mock(() => {}),
89}
90mock.module('node:process', () => ({
91 ...processMock,
92 default: processMock,
93}))
94
95// Mock the actual plugin import
96mock.module('../src', () => ({
97 VitePluginRpx,
98}))
61// No `mock.module` here on purpose. Nothing in this file imports `@stacksjs/rpx`,
62// `node:process` or `../src` — the tests drive the locally defined
63// `mockVitePluginRpx()`, which closes over the mock fns directly — so the three
64// module mocks that used to live here were dead weight. They were not harmless:
65// `mock.module` is global and permanent (`mock.restore()` does not undo it), and
66// the `node:process` one spread `{ ...process }`, dropping every
67// EventEmitter.prototype method. That only stayed invisible because `bun run
68// test` runs this directory as its own process; a plain `bun test` at the repo
69// root puts it in with the rpx suite, where ten src modules import
70// `node:process` (#2270).
9971
10072describe('Vite Plugin RPX', () => {
10173 let plugin: ReturnType<typeof mockVitePluginRpx>