From 0764b3fc39041c93ab3565276be625c47e122755 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 18:42:29 +0100 Subject: [PATCH 1/7] test: extract shared lines/mockProcessExit test helpers Consolidate the per-file `lines(spy)` console-capture readers and the repeated `process.exit` throwing-spy into src/_fixtures/testing.ts, so command and output tests share one implementation instead of redefining them in five files. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_fixtures/testing.ts | 24 +++++++++++++++++++++ src/commands/account.test.ts | 35 ++++++++++++++----------------- src/commands/auth-command.test.ts | 11 ++-------- src/commands/auth-token.test.ts | 11 ++++------ src/commands/commands.test.ts | 22 +++++-------------- src/lib/output.test.ts | 10 ++++----- 6 files changed, 56 insertions(+), 57 deletions(-) create mode 100644 src/_fixtures/testing.ts diff --git a/src/_fixtures/testing.ts b/src/_fixtures/testing.ts new file mode 100644 index 0000000..bea2949 --- /dev/null +++ b/src/_fixtures/testing.ts @@ -0,0 +1,24 @@ +import { type MockInstance, vi } from 'vitest' + +/** + * A `captureConsole`/`captureStream` spy's recorded calls as one string per + * call (space-joined args, matching how chalk's styled fragments arrive). + */ +export function lines(spy: MockInstance): string[] { + return spy.mock.calls.map((args) => args.join(' ')) +} + +/** Same as {@link lines} but newline-joined into a single string. */ +export function linesText(spy: MockInstance): string { + return lines(spy).join('\n') +} + +/** + * Spy on `process.exit` so it throws `process.exit()` instead of killing + * the test runner. Pair with `.rejects.toThrow('process.exit(1)')`. + */ +export function mockProcessExit(): MockInstance { + return vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`) + }) +} diff --git a/src/commands/account.test.ts b/src/commands/account.test.ts index 98933b1..1afea95 100644 --- a/src/commands/account.test.ts +++ b/src/commands/account.test.ts @@ -2,6 +2,7 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import type { Command } from 'commander' import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js' +import { linesText } from '../_fixtures/testing.js' import { CliError } from '../lib/errors.js' // `account` consumes two auth-provider exports: the token store (list/use/remove @@ -25,10 +26,6 @@ vi.mock('../lib/auth-provider.js', async (importOriginal) => { } }) -function lines(spy: MockInstance): string { - return spy.mock.calls.map((args) => args.join(' ')).join('\n') -} - async function buildProgram(): Promise { const { registerAccountCommand } = await import('./account.js') return createTestProgram(registerAccountCommand) @@ -61,7 +58,7 @@ describe('account command', () => { ]) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list']) - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Ada') expect(out).toContain('Bob') expect(out).toContain(`id:${STORED_ACCOUNT.id}`) @@ -72,14 +69,14 @@ describe('account command', () => { storeMocks.list.mockResolvedValue([]) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list']) - expect(lines(logSpy)).toMatch(/No stored accounts/) + expect(linesText(logSpy)).toMatch(/No stored accounts/) }) it('runs by default when no subcommand is given (ol account)', async () => { storeMocks.list.mockResolvedValue([{ account: STORED_ACCOUNT, isDefault: true }]) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account']) - expect(lines(logSpy)).toContain('Ada') + expect(linesText(logSpy)).toContain('Ada') }) it('emits a {accounts, default} envelope under --json', async () => { @@ -89,7 +86,7 @@ describe('account command', () => { ]) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list', '--json']) - const payload = JSON.parse(lines(logSpy)) + const payload = JSON.parse(linesText(logSpy)) expect(payload.default).toBe(STORED_ACCOUNT.id) expect(payload.accounts).toHaveLength(2) expect(payload.accounts[0]).toMatchObject({ id: STORED_ACCOUNT.id, isDefault: true }) @@ -104,7 +101,7 @@ describe('account command', () => { const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'use', STORED_ACCOUNT_BOB.id]) expect(storeMocks.setDefault).toHaveBeenCalledWith(STORED_ACCOUNT_BOB.id) - expect(lines(logSpy)).toContain(`Default account set to ${STORED_ACCOUNT_BOB.id}`) + expect(linesText(logSpy)).toContain(`Default account set to ${STORED_ACCOUNT_BOB.id}`) }) it('propagates ACCOUNT_NOT_FOUND from setDefault for an unknown ref', async () => { @@ -125,14 +122,14 @@ describe('account command', () => { const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'remove', 'bob']) expect(storeMocks.clear).toHaveBeenCalledWith('bob') - expect(lines(logSpy)).toContain('Removed Bob') + expect(linesText(logSpy)).toContain('Removed Bob') }) it('notes a cleared default when removing the default account', async () => { storeMocks.clear.mockResolvedValue({ account: STORED_ACCOUNT, wasDefault: true }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'remove', STORED_ACCOUNT.id]) - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Removed Ada') expect(out).toMatch(/Cleared default account/) }) @@ -153,7 +150,7 @@ describe('account command', () => { }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'remove', STORED_ACCOUNT.id]) - expect(lines(errSpy)).toContain('OS keyring unavailable') + expect(linesText(errSpy)).toContain('OS keyring unavailable') }) }) @@ -166,7 +163,7 @@ describe('account command', () => { }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'current']) - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Ada') expect(out).toContain(STORED_ACCOUNT.baseUrl) }) @@ -179,7 +176,7 @@ describe('account command', () => { }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'current', '--json']) - const payload = JSON.parse(lines(logSpy)) + const payload = JSON.parse(linesText(logSpy)) expect(payload.source).toBe('stored') expect(payload.account).toMatchObject({ id: STORED_ACCOUNT.id, isDefault: true }) expect(payload.account).not.toHaveProperty('oauthClientId') @@ -198,7 +195,7 @@ describe('account command', () => { const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'current']) expect(resolveMock).toHaveBeenCalledWith('Bob') - const out = lines(logSpy) + const out = linesText(logSpy) expect(out).toContain('Bob') expect(out).not.toContain('OUTLINE_API_TOKEN') }) @@ -206,25 +203,25 @@ describe('account command', () => { it('reports the env-token source (human + --json)', async () => { resolveMock.mockResolvedValue({ source: 'env' }) await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current']) - expect(lines(logSpy)).toContain('OUTLINE_API_TOKEN') + expect(linesText(logSpy)).toContain('OUTLINE_API_TOKEN') logSpy.mockClear() resolveMock.mockResolvedValue({ source: 'env' }) await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current', '--json']) - expect(JSON.parse(lines(logSpy))).toEqual({ source: 'env' }) + expect(JSON.parse(linesText(logSpy))).toEqual({ source: 'env' }) }) it('reports the legacy source (human + --json)', async () => { resolveMock.mockResolvedValue({ source: 'legacy' }) await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current']) - expect(lines(logSpy)).toMatch(/legacy single-user credentials/) + expect(linesText(logSpy)).toMatch(/legacy single-user credentials/) logSpy.mockClear() resolveMock.mockResolvedValue({ source: 'legacy' }) await ( await buildProgram() ).parseAsync(['node', 'ol', 'account', 'current', '--ndjson']) - expect(JSON.parse(lines(logSpy))).toEqual({ source: 'legacy' }) + expect(JSON.parse(linesText(logSpy))).toEqual({ source: 'legacy' }) }) it('throws NOT_AUTHENTICATED when nothing is active', async () => { diff --git a/src/commands/auth-command.test.ts b/src/commands/auth-command.test.ts index 1eb11e8..0ac2959 100644 --- a/src/commands/auth-command.test.ts +++ b/src/commands/auth-command.test.ts @@ -1,7 +1,8 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import { Command } from 'commander' -import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_INFO, TWO_USER_CONFIG } from '../_fixtures/auth.js' +import { lines } from '../_fixtures/testing.js' vi.mock('../lib/auth.js', () => ({ getApiToken: async () => 'test-token', @@ -32,14 +33,6 @@ vi.mock('@doist/cli-core/auth', async () => ({ attachLoginCommand: vi.fn(), })) -/** - * Read a `captureConsole` spy's recorded calls as joined lines, matching how - * chalk's styled fragments arrive (one console call → one space-joined line). - */ -function lines(spy: MockInstance): string[] { - return spy.mock.calls.map((args) => args.join(' ')) -} - async function captureAttachOptions() { const { attachLoginCommand } = await import('@doist/cli-core/auth') const login = new Command('login') diff --git a/src/commands/auth-token.test.ts b/src/commands/auth-token.test.ts index 85be749..d8282cb 100644 --- a/src/commands/auth-token.test.ts +++ b/src/commands/auth-token.test.ts @@ -1,7 +1,8 @@ import { captureConsole, captureStream, createTestProgram } from '@doist/cli-core/testing' import type { Command } from 'commander' -import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_INFO, STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js' +import { linesText } from '../_fixtures/testing.js' import type { CliError } from '../lib/errors.js' // `auth token` save drives the raw store's `set` + `getLastStorageResult`; @@ -27,10 +28,6 @@ vi.mock('../lib/auth-provider.js', async (importOriginal) => { vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) -function lines(spy: MockInstance): string { - return spy.mock.calls.map((args) => args.join(' ')).join('\n') -} - async function buildProgram(): Promise { const { registerAuthCommand } = await import('./auth.js') return createTestProgram(registerAuthCommand) @@ -94,7 +91,7 @@ describe('auth token (save)', () => { }, 'tok-paste', ) - expect(lines(log)).toContain('Saved token for Ada Lovelace (Analytics)') + expect(linesText(log)).toContain('Saved token for Ada Lovelace (Analytics)') }) it('collapses any auth.info failure into a leak-free AUTH_VERIFICATION_FAILED', async () => { @@ -173,7 +170,7 @@ describe('auth token (save)', () => { ]) expect(storeMocks.set).toHaveBeenCalled() - expect(lines(log)).toEqual('') + expect(linesText(log)).toEqual('') }) }) diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index 539f6ec..4ee2887 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -1,5 +1,6 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { lines, mockProcessExit } from '../_fixtures/testing.js' vi.mock('../lib/auth.js', () => ({ getApiToken: async () => 'test-token', @@ -13,11 +14,6 @@ vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn(), })) -/** Read a `captureConsole` spy's recorded calls as joined lines. */ -function lines(spy: MockInstance): string[] { - return spy.mock.calls.map((args) => args.join(' ')) -} - describe('search command', () => { let log: MockInstance @@ -199,9 +195,7 @@ describe('document commands', () => { }) it('document create with both --collection and --parent errors', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') @@ -268,9 +262,7 @@ describe('document commands', () => { }) it('document move with both --collection and --parent errors', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') @@ -295,9 +287,7 @@ describe('document commands', () => { }) it('document move without --collection or --parent errors', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') @@ -330,9 +320,7 @@ describe('document commands', () => { return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`)) }) - const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`) - }) + const mockExit = mockProcessExit() const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts index 13634bf..71af51d 100644 --- a/src/lib/output.test.ts +++ b/src/lib/output.test.ts @@ -1,11 +1,11 @@ import { captureConsole } from '@doist/cli-core/testing' import { type MockInstance, beforeEach, describe, expect, it } from 'vitest' +import { lines } from '../_fixtures/testing.js' import { BaseCliError } from './errors.js' import { formatError, formatErrorJson, getOutputOptions, outputItem, outputList } from './output.js' describe('output', () => { let log: MockInstance - const lines = () => log.mock.calls.map((args) => args.join(' ')) beforeEach(() => { log = captureConsole() @@ -17,24 +17,24 @@ describe('output', () => { it('outputItem human mode', () => { outputItem(item, formatter, keys) - expect(lines()[0]).toBe('Test (1)') + expect(lines(log)[0]).toBe('Test (1)') }) it('outputItem json mode shows essential keys only', () => { outputItem(item, formatter, keys, { json: true }) - const parsed = JSON.parse(lines()[0]) + const parsed = JSON.parse(lines(log)[0]) expect(parsed).toEqual({ id: '1', name: 'Test' }) }) it('outputItem json full mode shows all keys', () => { outputItem(item, formatter, keys, { json: true, full: true }) - const parsed = JSON.parse(lines()[0]) + const parsed = JSON.parse(lines(log)[0]) expect(parsed).toEqual({ id: '1', name: 'Test', extra: 'hidden' }) }) it('outputList ndjson mode', () => { outputList([item, { ...item, id: '2' }], formatter, keys, { ndjson: true }) - const records = lines() + const records = lines(log) .flatMap((line) => line.split('\n')) .filter(Boolean) .map((line) => JSON.parse(line)) From 019cddee6ad51d73265247cba66b72cd3d732f06 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 18:43:10 +0100 Subject: [PATCH 2/7] test: extract shared proxy-env fixture The proxy env-var snapshot/clear/restore scaffolding was duplicated verbatim in the fetch-with-retry and http-dispatcher transport tests. Move it to src/_fixtures/proxy-env.ts so both suites share one implementation and can't drift on teardown. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_fixtures/proxy-env.ts | 31 ++++++++++++++++++++++++++ src/transport/fetch-with-retry.test.ts | 30 +------------------------ src/transport/http-dispatcher.test.ts | 30 +------------------------ 3 files changed, 33 insertions(+), 58 deletions(-) create mode 100644 src/_fixtures/proxy-env.ts diff --git a/src/_fixtures/proxy-env.ts b/src/_fixtures/proxy-env.ts new file mode 100644 index 0000000..1f5ccb1 --- /dev/null +++ b/src/_fixtures/proxy-env.ts @@ -0,0 +1,31 @@ +const PROXY_ENV_KEYS = [ + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', +] as const + +/** Snapshot of the ambient proxy env, captured once at module load. */ +const originalProxyEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) + +/** Unset every proxy env var so transport-selection tests start from a clean slate. */ +export function clearProxyEnv(): void { + for (const key of PROXY_ENV_KEYS) { + delete process.env[key] + } +} + +/** Restore the proxy env to the snapshot taken at module load. */ +export function restoreProxyEnv(): void { + for (const key of PROXY_ENV_KEYS) { + const value = originalProxyEnv.get(key) + if (value === undefined) { + delete process.env[key] + continue + } + + process.env[key] = value + } +} diff --git a/src/transport/fetch-with-retry.test.ts b/src/transport/fetch-with-retry.test.ts index 83f24f2..c9ac938 100644 --- a/src/transport/fetch-with-retry.test.ts +++ b/src/transport/fetch-with-retry.test.ts @@ -1,33 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const PROXY_ENV_KEYS = [ - 'HTTP_PROXY', - 'http_proxy', - 'HTTPS_PROXY', - 'https_proxy', - 'NO_PROXY', - 'no_proxy', -] as const - -const originalProxyEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) - -function clearProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - delete process.env[key] - } -} - -function restoreProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - const value = originalProxyEnv.get(key) - if (value === undefined) { - delete process.env[key] - continue - } - - process.env[key] = value - } -} +import { clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' describe('fetchWithRetry', () => { beforeEach(() => { diff --git a/src/transport/http-dispatcher.test.ts b/src/transport/http-dispatcher.test.ts index 8274884..7385663 100644 --- a/src/transport/http-dispatcher.test.ts +++ b/src/transport/http-dispatcher.test.ts @@ -3,35 +3,7 @@ import type { AddressInfo } from 'node:net' import { gzipSync } from 'node:zlib' import { Agent, EnvHttpProxyAgent } from 'undici' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const PROXY_ENV_KEYS = [ - 'HTTP_PROXY', - 'http_proxy', - 'HTTPS_PROXY', - 'https_proxy', - 'NO_PROXY', - 'no_proxy', -] as const - -const originalProxyEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) - -function clearProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - delete process.env[key] - } -} - -function restoreProxyEnv(): void { - for (const key of PROXY_ENV_KEYS) { - const value = originalProxyEnv.get(key) - if (value === undefined) { - delete process.env[key] - continue - } - - process.env[key] = value - } -} +import { clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' describe('http-dispatcher', () => { beforeEach(() => { From b534ba4268e759b11f650b129e0a7b3b014ed699 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 18:45:49 +0100 Subject: [PATCH 3/7] test: centralize the Ada persona + account-list fixture Add STORED_USER_ADA (the snake-case StoredUser twin of STORED_ACCOUNT) to the shared auth fixtures and consume it in user-records tests, whose list() assertions already require the two shapes to stay in lockstep. Collapse the twice-repeated stored-account list literal in the account tests into a local STORED_LIST const. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_fixtures/auth.ts | 11 ++++++++++- src/commands/account.test.ts | 15 +++++++-------- src/lib/user-records.test.ts | 10 +--------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/_fixtures/auth.ts b/src/_fixtures/auth.ts index c407f73..4e9c08b 100644 --- a/src/_fixtures/auth.ts +++ b/src/_fixtures/auth.ts @@ -1,5 +1,5 @@ import type { MigrateAuthResult } from '@doist/cli-core/auth' -import type { Config } from '../lib/config.js' +import type { Config, StoredUser } from '../lib/config.js' import type { OutlineAccount } from '../lib/outline-account.js' /** Canonical persisted `OutlineAccount` used across auth tests. */ @@ -11,6 +11,15 @@ export const STORED_ACCOUNT: OutlineAccount = { teamName: 'Analytics', } +/** Snake-case `StoredUser` twin of {@link STORED_ACCOUNT} — same persona on disk. */ +export const STORED_USER_ADA: StoredUser = { + id: 'user-uuid', + name: 'Ada', + base_url: 'https://wiki.example.com', + oauth_client_id: 'cid-xyz', + team_name: 'Analytics', +} + /** Secondary persisted `OutlineAccount` on a different instance — for multi-account tests. */ export const STORED_ACCOUNT_BOB: OutlineAccount = { id: 'bob-uuid', diff --git a/src/commands/account.test.ts b/src/commands/account.test.ts index 1afea95..5c04f08 100644 --- a/src/commands/account.test.ts +++ b/src/commands/account.test.ts @@ -31,6 +31,11 @@ async function buildProgram(): Promise { return createTestProgram(registerAccountCommand) } +const STORED_LIST = [ + { account: STORED_ACCOUNT, isDefault: true }, + { account: STORED_ACCOUNT_BOB, isDefault: false }, +] + let logSpy: MockInstance let errSpy: MockInstance @@ -52,10 +57,7 @@ afterEach(() => { describe('account command', () => { describe('list', () => { it('renders all stored accounts with the default marker', async () => { - storeMocks.list.mockResolvedValue([ - { account: STORED_ACCOUNT, isDefault: true }, - { account: STORED_ACCOUNT_BOB, isDefault: false }, - ]) + storeMocks.list.mockResolvedValue(STORED_LIST) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list']) const out = linesText(logSpy) @@ -80,10 +82,7 @@ describe('account command', () => { }) it('emits a {accounts, default} envelope under --json', async () => { - storeMocks.list.mockResolvedValue([ - { account: STORED_ACCOUNT, isDefault: true }, - { account: STORED_ACCOUNT_BOB, isDefault: false }, - ]) + storeMocks.list.mockResolvedValue(STORED_LIST) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'account', 'list', '--json']) const payload = JSON.parse(linesText(logSpy)) diff --git a/src/lib/user-records.test.ts b/src/lib/user-records.test.ts index d61ea37..6f89370 100644 --- a/src/lib/user-records.test.ts +++ b/src/lib/user-records.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { STORED_ACCOUNT } from '../_fixtures/auth.js' +import { STORED_ACCOUNT, STORED_USER_ADA as ADA } from '../_fixtures/auth.js' const configMock = vi.hoisted(() => ({ getConfig: vi.fn(), @@ -11,14 +11,6 @@ vi.mock('./config.js', async (importOriginal) => { return { ...actual, getConfig: configMock.getConfig, updateConfig: configMock.updateConfig } }) -const ADA = { - id: 'user-uuid', - name: 'Ada', - base_url: 'https://wiki.example.com', - oauth_client_id: 'cid-xyz', - team_name: 'Analytics', -} as const - const GRACE = { id: 'grace-uuid', name: 'Grace', From abee9f0af80e4c68b9692f36252379733ea0b053 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 18:48:24 +0100 Subject: [PATCH 4/7] test: reuse Response fixtures + dedupe abort fetch mock Route the apiRequest tests through the shared okResponse/errResponse helpers (matching how the migrate-auth and auth-provider suites already mock fetchWithRetry), keeping only the deliberate malformed-JSON case inline. In fetch-with-retry, replace the repeated `new Response(...)` literals with okResponse and extract the duplicated abort-on-signal fetch implementation into a single abortableFetch helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/api.test.ts | 43 ++++++--------- src/transport/fetch-with-retry.test.ts | 76 ++++++++------------------ 2 files changed, 41 insertions(+), 78 deletions(-) diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 84dd600..58ad268 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { errResponse, okResponse } from '../_fixtures/auth.js' const authMocks = vi.hoisted(() => ({ getApiToken: vi.fn(async () => 'test-token'), @@ -30,9 +31,10 @@ describe('apiRequest', () => { }) it('uses fetchWithRetry for API requests', async () => { - const mockResponse = { ok: true, json: async () => ({ data: { id: '123' } }) } const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) + ;(fetchWithRetry as ReturnType).mockResolvedValue( + okResponse({ data: { id: '123' } }), + ) const { apiRequest } = await import('./api.js') await apiRequest('documents.info', { id: 'abc' }) @@ -51,12 +53,10 @@ describe('apiRequest', () => { }) it('returns data and pagination', async () => { - const mockResponse = { - ok: true, - json: async () => ({ data: [{ id: '1' }], pagination: { offset: 0, limit: 25 } }), - } const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) + ;(fetchWithRetry as ReturnType).mockResolvedValue( + okResponse({ data: [{ id: '1' }], pagination: { offset: 0, limit: 25 } }), + ) const { apiRequest } = await import('./api.js') const result = await apiRequest('documents.list') @@ -66,14 +66,13 @@ describe('apiRequest', () => { }) it('throws on non-ok response with API message', async () => { - const mockResponse = { - ok: false, - status: 500, - statusText: 'Internal Server Error', - json: async () => ({ error: 'server_error', message: 'Server exploded' }), - } const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) + ;(fetchWithRetry as ReturnType).mockResolvedValue( + errResponse(500, 'Internal Server Error', { + error: 'server_error', + message: 'Server exploded', + }), + ) const { apiRequest } = await import('./api.js') await expect(apiRequest('documents.list')).rejects.toThrow('Server exploded') @@ -100,13 +99,8 @@ describe('apiRequest', () => { it('force-refreshes and retries once when a managed token gets a 401', async () => { const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') const f = fetchWithRetry as ReturnType - f.mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - json: async () => ({}), - }) - f.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { id: 'ok' } }) }) + f.mockResolvedValueOnce(errResponse(401, 'Unauthorized')) + f.mockResolvedValueOnce(okResponse({ data: { id: 'ok' } })) authMocks.reactiveRefresh.mockResolvedValueOnce(true) authMocks.getApiToken .mockResolvedValueOnce('stale-token') @@ -125,7 +119,7 @@ describe('apiRequest', () => { it('proactively refreshes a managed token and uses the rotated token (no extra store read)', async () => { const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') const f = fetchWithRetry as ReturnType - f.mockResolvedValue({ ok: true, json: async () => ({ data: {} }) }) + f.mockResolvedValue(okResponse({ data: {} })) authMocks.proactiveRefresh.mockResolvedValueOnce('rotated-proactive') const { apiRequest } = await import('./api.js') @@ -141,10 +135,7 @@ describe('apiRequest', () => { it('does not refresh when OUTLINE_API_TOKEN is set (unmanaged token)', async () => { vi.stubEnv('OUTLINE_API_TOKEN', 'env-tok') const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue({ - ok: true, - json: async () => ({ data: {} }), - }) + ;(fetchWithRetry as ReturnType).mockResolvedValue(okResponse({ data: {} })) const { apiRequest } = await import('./api.js') await apiRequest('documents.list') diff --git a/src/transport/fetch-with-retry.test.ts b/src/transport/fetch-with-retry.test.ts index c9ac938..87545d8 100644 --- a/src/transport/fetch-with-retry.test.ts +++ b/src/transport/fetch-with-retry.test.ts @@ -1,6 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { okResponse } from '../_fixtures/auth.js' import { clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' +/** A `fetch` impl that never resolves and rejects with the abort reason on signal. */ +function abortableFetch(_url: RequestInfo | URL, options?: RequestInit): Promise { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + const reason = options.signal?.reason + reject( + reason instanceof Error + ? reason + : new Error(String(reason ?? 'Request aborted')), + ) + }, + { once: true }, + ) + }) +} + describe('fetchWithRetry', () => { beforeEach(() => { clearProxyEnv() @@ -19,12 +38,7 @@ describe('fetchWithRetry', () => { it('uses the default dispatcher for requests', async () => { const fetchMock = vi.fn() - fetchMock.mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: 'OK', - }), - ) + fetchMock.mockResolvedValue(okResponse({ ok: true })) vi.stubGlobal('fetch', fetchMock) const { getDefaultDispatcher } = await import('./http-dispatcher.js') @@ -47,12 +61,7 @@ describe('fetchWithRetry', () => { fetchMock .mockRejectedValueOnce(new TypeError('Failed to fetch')) .mockRejectedValueOnce(new TypeError('Failed to fetch')) - .mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: 'OK', - }), - ) + .mockResolvedValue(okResponse({ ok: true })) vi.stubGlobal('fetch', fetchMock) const { fetchWithRetry } = await import('./fetch-with-retry.js') @@ -73,29 +82,8 @@ describe('fetchWithRetry', () => { const fetchMock = vi.fn() fetchMock - .mockImplementationOnce( - (_url: RequestInfo | URL, options?: RequestInit) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - const reason = options.signal?.reason - reject( - reason instanceof Error - ? reason - : new Error(String(reason ?? 'Request aborted')), - ) - }, - { once: true }, - ) - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: 'OK', - }), - ) + .mockImplementationOnce(abortableFetch) + .mockResolvedValueOnce(okResponse({ ok: true })) vi.stubGlobal('fetch', fetchMock) const { fetchWithRetry } = await import('./fetch-with-retry.js') @@ -122,23 +110,7 @@ describe('fetchWithRetry', () => { vi.useFakeTimers() const fetchMock = vi.fn() - fetchMock.mockImplementation( - (_url: RequestInfo | URL, options?: RequestInit) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - const reason = options.signal?.reason - reject( - reason instanceof Error - ? reason - : new Error(String(reason ?? 'Request aborted')), - ) - }, - { once: true }, - ) - }), - ) + fetchMock.mockImplementation(abortableFetch) vi.stubGlobal('fetch', fetchMock) const { getDefaultDispatcher } = await import('./http-dispatcher.js') From 29fbe996ce7e5b36cc0140f9e009a101477e021d Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 18:49:46 +0100 Subject: [PATCH 5/7] test: hoist repeated ref fixtures to named consts The Engineering/Product document and collection pairs were duplicated verbatim across several resolve-ref cases. Hoist them to local named consts so the shared example data lives in one place while each test keeps its query and assertion inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/refs.test.ts | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index b123ab5..2c2b110 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -10,6 +10,16 @@ vi.mock('./api.js', () => ({ apiRequest: (...args: unknown[]) => mockApiRequest(...args), })) +const ENG_PRODUCT_DOCS = [ + { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, + { id: '2', title: 'Product Docs', urlId: 'prod-2' }, +] + +const ENG_PRODUCT_COLLECTIONS = [ + { id: '1', name: 'Engineering' }, + { id: '2', name: 'Product' }, +] + describe('resolveDocumentRef', () => { beforeEach(() => { vi.resetModules() @@ -68,10 +78,7 @@ describe('resolveDocumentRef', () => { }) it('resolves document by exact name match (case-insensitive)', async () => { - const mockDocs = [ - { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, - { id: '2', title: 'Product Docs', urlId: 'prod-2' }, - ] + const mockDocs = ENG_PRODUCT_DOCS // "engineering guide" has spaces, doesn't look like an ID mockApiRequest.mockResolvedValueOnce({ data: mockDocs }) @@ -82,10 +89,7 @@ describe('resolveDocumentRef', () => { }) it('resolves document by partial name match when unique', async () => { - const mockDocs = [ - { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, - { id: '2', title: 'Product Docs', urlId: 'prod-2' }, - ] + const mockDocs = ENG_PRODUCT_DOCS // "product" is 7 chars, looks like an ID, so will try ID lookup first mockApiRequest .mockRejectedValueOnce(new Error('Not found')) // ID lookup fails @@ -163,10 +167,7 @@ describe('resolveDocumentRef', () => { }) it('falls back to name search when ID lookup fails', async () => { - const mockDocs = [ - { id: '1', title: 'Engineering Guide', urlId: 'eng-1' }, - { id: '2', title: 'Product Docs', urlId: 'prod-2' }, - ] + const mockDocs = ENG_PRODUCT_DOCS // "abc123" looks like an ID, but fails, then falls back to name search mockApiRequest .mockRejectedValueOnce(new Error('Not found')) @@ -244,10 +245,7 @@ describe('resolveCollectionRef', () => { }) it('resolves collection by exact name match', async () => { - const mockCols = [ - { id: '1', name: 'Engineering' }, - { id: '2', name: 'Product' }, - ] + const mockCols = ENG_PRODUCT_COLLECTIONS // "Engineering" is 11 chars, looks like an ID mockApiRequest .mockRejectedValueOnce(new Error('Not found')) @@ -260,10 +258,7 @@ describe('resolveCollectionRef', () => { }) it('resolves collection by partial name match when unique', async () => { - const mockCols = [ - { id: '1', name: 'Engineering' }, - { id: '2', name: 'Product' }, - ] + const mockCols = ENG_PRODUCT_COLLECTIONS // "prod" is 4 chars, doesn't look like an ID (needs 6+) mockApiRequest.mockResolvedValueOnce({ data: mockCols }) From 05f4aee42a682010c8557253886e5017738c8135 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 18:51:47 +0100 Subject: [PATCH 6/7] test: share the default auth-module mock The command-surface tests stubbed ../lib/auth.js with an identical logged-in config-file user. Extract that into mockOutlineAuthModule so the two suites (and future command tests) share one stub. Skip sharing the config.js/spinner vi.mock blocks: they hook vitest's hoisting, differ per file, and a wrapper would trade clarity for almost no line savings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_fixtures/testing.ts | 15 +++++++++++++++ src/commands/commands.test.ts | 12 +++--------- src/commands/empty-output.test.ts | 9 ++------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/_fixtures/testing.ts b/src/_fixtures/testing.ts index bea2949..48e4dbf 100644 --- a/src/_fixtures/testing.ts +++ b/src/_fixtures/testing.ts @@ -22,3 +22,18 @@ export function mockProcessExit(): MockInstance { throw new Error(`process.exit(${code})`) }) } + +/** + * Default `../lib/auth.js` mock for command-surface tests: a logged-in + * config-file user on the test instance. Use as + * `vi.mock('../lib/auth.js', () => mockOutlineAuthModule())`. + */ +export function mockOutlineAuthModule() { + return { + getApiToken: async () => 'test-token', + getBaseUrl: async () => 'https://test.outline.com', + getOAuthClientId: async () => undefined, + getTokenSource: async () => 'config' as const, + clearConfig: vi.fn(), + } +} diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index 4ee2887..87b643f 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -1,14 +1,8 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { lines, mockProcessExit } from '../_fixtures/testing.js' - -vi.mock('../lib/auth.js', () => ({ - getApiToken: async () => 'test-token', - getBaseUrl: async () => 'https://test.outline.com', - getOAuthClientId: async () => undefined, - getTokenSource: async () => 'config' as const, - clearConfig: vi.fn(), -})) +import { lines, mockOutlineAuthModule, mockProcessExit } from '../_fixtures/testing.js' + +vi.mock('../lib/auth.js', () => mockOutlineAuthModule()) vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn(), diff --git a/src/commands/empty-output.test.ts b/src/commands/empty-output.test.ts index 97c01d9..9e3beba 100644 --- a/src/commands/empty-output.test.ts +++ b/src/commands/empty-output.test.ts @@ -1,13 +1,8 @@ import { createTestProgram, describeEmptyMachineOutput } from '@doist/cli-core/testing' import { vi } from 'vitest' +import { mockOutlineAuthModule } from '../_fixtures/testing.js' -vi.mock('../lib/auth.js', () => ({ - getApiToken: async () => 'test-token', - getBaseUrl: async () => 'https://test.outline.com', - getOAuthClientId: async () => undefined, - getTokenSource: async () => 'config' as const, - clearConfig: vi.fn(), -})) +vi.mock('../lib/auth.js', () => mockOutlineAuthModule()) vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn().mockResolvedValue({ data: [], pagination: undefined }), From d8693987bb7a7b977c96253bda04d42ff45b250a Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 19:38:35 +0100 Subject: [PATCH 7/7] test: address review feedback on shared fixtures - Align mockOutlineAuthModule with the real auth.ts exports (getActiveTokenSource, drop the non-existent getTokenSource/clearConfig) and accept overrides; reuse it in auth-command tests. - Derive STORED_USER_ADA from STORED_ACCOUNT so the two can't drift. - Make the proxy-env snapshot per-suite (captureProxyEnv + restoreProxyEnv(snapshot)) to remove hidden cross-suite state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_fixtures/auth.ts | 12 ++++++------ src/_fixtures/proxy-env.ts | 14 +++++++++----- src/_fixtures/testing.ts | 11 ++++++----- src/commands/auth-command.test.ts | 23 +++++++++++------------ src/transport/fetch-with-retry.test.ts | 6 ++++-- src/transport/http-dispatcher.test.ts | 6 ++++-- 6 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/_fixtures/auth.ts b/src/_fixtures/auth.ts index 4e9c08b..8cca024 100644 --- a/src/_fixtures/auth.ts +++ b/src/_fixtures/auth.ts @@ -11,13 +11,13 @@ export const STORED_ACCOUNT: OutlineAccount = { teamName: 'Analytics', } -/** Snake-case `StoredUser` twin of {@link STORED_ACCOUNT} — same persona on disk. */ +/** Snake-case `StoredUser` twin of {@link STORED_ACCOUNT}, derived so the two can't drift. */ export const STORED_USER_ADA: StoredUser = { - id: 'user-uuid', - name: 'Ada', - base_url: 'https://wiki.example.com', - oauth_client_id: 'cid-xyz', - team_name: 'Analytics', + id: STORED_ACCOUNT.id, + name: STORED_ACCOUNT.label, + base_url: STORED_ACCOUNT.baseUrl, + oauth_client_id: STORED_ACCOUNT.oauthClientId, + team_name: STORED_ACCOUNT.teamName, } /** Secondary persisted `OutlineAccount` on a different instance — for multi-account tests. */ diff --git a/src/_fixtures/proxy-env.ts b/src/_fixtures/proxy-env.ts index 1f5ccb1..c7adb3a 100644 --- a/src/_fixtures/proxy-env.ts +++ b/src/_fixtures/proxy-env.ts @@ -7,8 +7,12 @@ const PROXY_ENV_KEYS = [ 'no_proxy', ] as const -/** Snapshot of the ambient proxy env, captured once at module load. */ -const originalProxyEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) +type ProxyEnvSnapshot = Map + +/** Snapshot the current proxy env. Take this per suite so restore is order-independent. */ +export function captureProxyEnv(): ProxyEnvSnapshot { + return new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) +} /** Unset every proxy env var so transport-selection tests start from a clean slate. */ export function clearProxyEnv(): void { @@ -17,10 +21,10 @@ export function clearProxyEnv(): void { } } -/** Restore the proxy env to the snapshot taken at module load. */ -export function restoreProxyEnv(): void { +/** Restore the proxy env to a snapshot taken by {@link captureProxyEnv}. */ +export function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void { for (const key of PROXY_ENV_KEYS) { - const value = originalProxyEnv.get(key) + const value = snapshot.get(key) if (value === undefined) { delete process.env[key] continue diff --git a/src/_fixtures/testing.ts b/src/_fixtures/testing.ts index 48e4dbf..141e991 100644 --- a/src/_fixtures/testing.ts +++ b/src/_fixtures/testing.ts @@ -25,15 +25,16 @@ export function mockProcessExit(): MockInstance { /** * Default `../lib/auth.js` mock for command-surface tests: a logged-in - * config-file user on the test instance. Use as - * `vi.mock('../lib/auth.js', () => mockOutlineAuthModule())`. + * config-file user on the test instance. Keys mirror the real `auth.ts` + * exports; pass `overrides` for the few tests that need dynamic behaviour. + * Use as `vi.mock('../lib/auth.js', () => mockOutlineAuthModule())`. */ -export function mockOutlineAuthModule() { +export function mockOutlineAuthModule(overrides: Record = {}) { return { getApiToken: async () => 'test-token', getBaseUrl: async () => 'https://test.outline.com', getOAuthClientId: async () => undefined, - getTokenSource: async () => 'config' as const, - clearConfig: vi.fn(), + getActiveTokenSource: async () => 'config-file' as const, + ...overrides, } } diff --git a/src/commands/auth-command.test.ts b/src/commands/auth-command.test.ts index 0ac2959..2685079 100644 --- a/src/commands/auth-command.test.ts +++ b/src/commands/auth-command.test.ts @@ -2,18 +2,17 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_INFO, TWO_USER_CONFIG } from '../_fixtures/auth.js' -import { lines } from '../_fixtures/testing.js' - -vi.mock('../lib/auth.js', () => ({ - getApiToken: async () => 'test-token', - getBaseUrl: async () => 'https://test.outline.com', - getOAuthClientId: async () => undefined, - getActiveTokenSource: async () => - process.env.OUTLINE_API_TOKEN ? ('env' as const) : ('secure-store' as const), - // status resolves the live token for the selected account via this; echo - // the snapshot token back (no refresh in these command-surface tests). - refreshedTokenForStatus: async (_account: unknown, fallback: string) => fallback, -})) +import { lines, mockOutlineAuthModule } from '../_fixtures/testing.js' + +vi.mock('../lib/auth.js', () => + mockOutlineAuthModule({ + getActiveTokenSource: async () => + process.env.OUTLINE_API_TOKEN ? ('env' as const) : ('secure-store' as const), + // status resolves the live token for the selected account via this; echo + // the snapshot token back (no refresh in these command-surface tests). + refreshedTokenForStatus: async (_account: unknown, fallback: string) => fallback, + }), +) vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) diff --git a/src/transport/fetch-with-retry.test.ts b/src/transport/fetch-with-retry.test.ts index 87545d8..9c21bc5 100644 --- a/src/transport/fetch-with-retry.test.ts +++ b/src/transport/fetch-with-retry.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { okResponse } from '../_fixtures/auth.js' -import { clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' +import { captureProxyEnv, clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' + +const originalProxyEnv = captureProxyEnv() /** A `fetch` impl that never resolves and rejects with the abort reason on signal. */ function abortableFetch(_url: RequestInfo | URL, options?: RequestInit): Promise { @@ -29,7 +31,7 @@ describe('fetchWithRetry', () => { afterEach(async () => { const { resetDefaultDispatcherForTests } = await import('./http-dispatcher.js') await resetDefaultDispatcherForTests() - restoreProxyEnv() + restoreProxyEnv(originalProxyEnv) vi.useRealTimers() vi.unstubAllGlobals() vi.restoreAllMocks() diff --git a/src/transport/http-dispatcher.test.ts b/src/transport/http-dispatcher.test.ts index 7385663..c8fd277 100644 --- a/src/transport/http-dispatcher.test.ts +++ b/src/transport/http-dispatcher.test.ts @@ -3,7 +3,9 @@ import type { AddressInfo } from 'node:net' import { gzipSync } from 'node:zlib' import { Agent, EnvHttpProxyAgent } from 'undici' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' +import { captureProxyEnv, clearProxyEnv, restoreProxyEnv } from '../_fixtures/proxy-env.js' + +const originalProxyEnv = captureProxyEnv() describe('http-dispatcher', () => { beforeEach(() => { @@ -14,7 +16,7 @@ describe('http-dispatcher', () => { afterEach(async () => { const { resetDefaultDispatcherForTests } = await import('./http-dispatcher.js') await resetDefaultDispatcherForTests() - restoreProxyEnv() + restoreProxyEnv(originalProxyEnv) vi.restoreAllMocks() vi.resetModules() })