also looking at this
test: stop leaking mocked module state across test files
#2274
8 files
+77
-67
| @@ -1,14 +1,17 @@ | ||
| 1 | 1 | import type { CleanupOptions } from '../src/types' |
| 2 | import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' | |
| 2 | import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test' | |
| 3 | 3 | import realProcess from 'node:process' |
| 4 | import * as Hosts from '../src/hosts' | |
| 5 | import * as Https from '../src/https' | |
| 4 | 6 | import { cleanup } from '../src/start' |
| 5 | 7 | |
| 6 | 8 | // Mock dependencies |
| 7 | 9 | const mockProcessExit = mock(() => {}) |
| 8 | 10 | const mockConsoleLog = mock(() => {}) |
| 9 | 11 | const mockConsoleSuccess = mock(() => {}) |
| 10 | const mockRemoveHosts = mock(async () => {}) | |
| 11 | const mockCleanupCertificates = mock(async () => {}) | |
| 12 | // Spies, not `mock.module`: see the beforeEach below. | |
| 13 | let mockRemoveHosts: any | |
| 14 | let mockCleanupCertificates: any | |
| 12 | 15 | |
| 13 | 16 | // `mock.module` is global and persists for the rest of the test run. A partial |
| 14 | 17 | // process replacement ({ exit, on, once, env }) therefore poisons every file |
| @@ -33,21 +36,23 @@ const processMock = new Proxy(realProcess, { | ||
| 33 | 36 | processOverrides.default = processMock |
| 34 | 37 | mock.module('node:process', () => processMock) |
| 35 | 38 | |
| 36 | mock.module('../src/hosts', () => ({ | |
| 37 | removeHosts: mockRemoveHosts, | |
| 38 | })) | |
| 39 | ||
| 40 | mock.module('../src/https', () => ({ | |
| 41 | cleanupCertificates: mockCleanupCertificates, | |
| 42 | })) | |
| 43 | ||
| 44 | 39 | describe('Cleanup Process', () => { |
| 45 | 40 | beforeEach(() => { |
| 46 | 41 | mockProcessExit.mockClear() |
| 47 | 42 | mockConsoleLog.mockClear() |
| 48 | 43 | 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() | |
| 51 | 56 | }) |
| 52 | 57 | |
| 53 | 58 | it('should handle multiple cleanup calls gracefully', async () => { |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { beforeEach, describe, expect, it, spyOn } from 'bun:test' | |
| 1 | import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test' | |
| 2 | 2 | import * as hostsModule from '../src/hosts' |
| 3 | 3 | import { dropStaleRpxHostsLines, filterRpxHostsEntries, hostsLineMapsHost, isLoopbackDevelopmentHost, parseHostsLine } from '../src/hosts' |
| 4 | 4 | |
| @@ -34,6 +34,14 @@ describe('hosts', () => { | ||
| 34 | 34 | }) |
| 35 | 35 | }) |
| 36 | 36 | |
| 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 | ||
| 37 | 45 | describe('hosts file operations', () => { |
| 38 | 46 | it('checks hosts entries correctly', async () => { |
| 39 | 47 | const result = await hostsModule.checkHosts(['localhost', 'example.com'], true) |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { describe, expect, it, spyOn } from 'bun:test' | |
| 1 | import { afterEach, describe, expect, it, mock } from 'bun:test' | |
| 2 | 2 | import { config } from '../src/config' |
| 3 | 3 | import * as Hosts from '../src/hosts' |
| 4 | 4 | import * as Https from '../src/https' |
| @@ -6,6 +6,11 @@ import * as Start from '../src/start' | ||
| 6 | 6 | |
| 7 | 7 | // Simplified test that just verifies exports and basic functionality |
| 8 | 8 | describe('Integration', () => { |
| 9 | // Guard: nothing here should outlive the file (see #2270). | |
| 10 | afterEach(() => { | |
| 11 | mock.restore() | |
| 12 | }) | |
| 13 | ||
| 9 | 14 | // Just verify that the required functions exist and are exported |
| 10 | 15 | it('exports required functionality', () => { |
| 11 | 16 | expect(Start.startProxies).toBeDefined() |
| @@ -25,14 +30,12 @@ describe('Integration', () => { | ||
| 25 | 30 | |
| 26 | 31 | // Check that util functions are working |
| 27 | 32 | 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() | |
| 36 | 39 | }) |
| 37 | 40 | |
| 38 | 41 | // Check HTTPS functionality |
| @@ -274,6 +274,10 @@ describe('SiteSupervisor', () => { | ||
| 274 | 274 | probePort: async () => false, |
| 275 | 275 | pollIntervalMs: 5, |
| 276 | 276 | }) |
| 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) | |
| 277 | 281 | await sup.onRequest('myapp.localhost') |
| 278 | 282 | await sup.stopAll() |
| 279 | 283 | expect(proc.signals).toContain('SIGTERM') |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { MultiProxyConfig } from '../src/types' |
| 2 | import { describe, expect, it, spyOn } from 'bun:test' | |
| 2 | import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' | |
| 3 | 3 | import * as Start from '../src/start' |
| 4 | 4 | |
| 5 | 5 | /** |
| @@ -21,6 +21,14 @@ import * as Start from '../src/start' | ||
| 21 | 21 | * single-proxy-in-multi-array tests once this passthrough is corrected). |
| 22 | 22 | */ |
| 23 | 23 | describe('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 | ||
| 24 | 32 | it('a single-entry `proxies` array (no shared listener) forwards loadBalancer to startServer', async () => { |
| 25 | 33 | // Matches this file's established convention (see start.test.ts): re-spy |
| 26 | 34 | // immediately before the call and inspect the most recent call, since |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | * redirect server (challenge store + reactive issuance kick), and serves the |
| 10 | 10 | * manager's live SNI set on the shared :443 listener. |
| 11 | 11 | */ |
| 12 | import { afterEach, describe, expect, it, spyOn } from 'bun:test' | |
| 12 | import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' | |
| 13 | 13 | import * as fsp from 'node:fs/promises' |
| 14 | 14 | import * as os from 'node:os' |
| 15 | 15 | import * as path from 'node:path' |
| @@ -21,6 +21,14 @@ const HTTPS_PORT = 49443 | ||
| 21 | 21 | const HTTP_PORT = 49080 |
| 22 | 22 | const REDIRECT_PORT = 49081 |
| 23 | 23 | |
| 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. | |
| 28 | afterEach(() => { | |
| 29 | mock.restore() | |
| 30 | }) | |
| 31 | ||
| 24 | 32 | describe('startProxies on-demand TLS wiring', () => { |
| 25 | 33 | let dir: string | undefined |
| 26 | 34 | |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * a single entry fell through to `startServer`, which never receives |
| 11 | 11 | * `productionTlsConfig` at all and unconditionally mints/uses a dev cert. |
| 12 | 12 | */ |
| 13 | import { afterEach, describe, expect, it, spyOn } from 'bun:test' | |
| 13 | import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' | |
| 14 | 14 | import * as fsp from 'node:fs/promises' |
| 15 | 15 | import * as os from 'node:os' |
| 16 | 16 | import * as path from 'node:path' |
| @@ -20,6 +20,11 @@ describe('startProxies single-proxy production certs', () => { | ||
| 20 | 20 | let dir: string | undefined |
| 21 | 21 | |
| 22 | 22 | 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() | |
| 23 | 28 | if (dir) |
| 24 | 29 | await fsp.rm(dir, { recursive: true, force: true }).catch(() => {}) |
| 25 | 30 | dir = undefined |
| @@ -61,8 +66,5 @@ describe('startProxies single-proxy production certs', () => { | ||
| 61 | 66 | // The individual (dev-cert-only) path must NOT have been used — that's |
| 62 | 67 | // exactly the path that was silently dropping the real production cert. |
| 63 | 68 | expect(startServerSpy).not.toHaveBeenCalled() |
| 64 | ||
| 65 | createSharedSpy.mockRestore() | |
| 66 | startServerSpy.mockRestore() | |
| 67 | 69 | }) |
| 68 | 70 | }) |
| @@ -33,9 +33,6 @@ function mockVitePluginRpx() { | ||
| 33 | 33 | } |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | // Expose a named export for the mock to satisfy the import | |
| 37 | const VitePluginRpx = mock(mockVitePluginRpx) | |
| 38 | ||
| 39 | 36 | // Mock dependencies |
| 40 | 37 | const mockStartProxies = mock(async (_opts?: any) => {}) |
| 41 | 38 | const mockCleanup = mock(async (_opts?: any) => {}) |
| @@ -61,41 +58,16 @@ const mockHttpServer = { | ||
| 61 | 58 | }), |
| 62 | 59 | } |
| 63 | 60 | |
| 64 | // Set up mocks | |
| 65 | mock.module('@stacksjs/rpx', () => ({ | |
| 66 | startProxies: mockStartProxies, | |
| 67 | cleanup: mockCleanup, | |
| 68 | checkExistingCertificates: mockCheckExistingCertificates, | |
| 69 | checkHosts: mockCheckHosts, | |
| 70 | })) | |
| 71 | ||
| 72 | const 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 | } | |
| 90 | mock.module('node:process', () => ({ | |
| 91 | ...processMock, | |
| 92 | default: processMock, | |
| 93 | })) | |
| 94 | ||
| 95 | // Mock the actual plugin import | |
| 96 | mock.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). | |
| 99 | 71 | |
| 100 | 72 | describe('Vite Plugin RPX', () => { |
| 101 | 73 | let plugin: ReturnType<typeof mockVitePluginRpx> |