From f4029e0a67fb1187dc0e2689595562ca0dd49c6e Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 10:58:58 +0100 Subject: [PATCH 1/4] feat(testing): publish shared test helpers + fixtures via ./testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the genuinely-shared testing scaffolding into the published `@doist/cli-core/testing` subpath so consuming CLIs stop reinventing it: - `createTestProgram(register)` — Commander harness (was byte-identical in todoist-cli/twist-cli and inline in outline-cli) - `captureConsole(method?)` / `captureStream(stream?)` — silencing spies with onTestFinished auto-restore - `buildTokenStore` / `buildSingleEntryStore` + Ingen account fixtures + TestAccount/StoreEntry/TokenStoreHarness/MatchAccount types, moved out of the build-excluded test-support/ buildTokenStore gains an optional `matchAccount` so consumers whose stores match refs differently (twist's numeric-id/case-insensitive matcher, outline's) can mirror production semantics; it defaults to the existing id/email/label rule. Convert src/testing.ts into a src/testing/ directory and point the ./testing export at dist/testing/index. Converge cli-core's own auth suites onto the canonical helpers (drop installConsoleLogSpy/installStdoutSpy; buildProgram now wraps createTestProgram). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 39 +++++- package.json | 4 +- src/auth/account.test.ts | 115 ++++++++++-------- src/auth/flow.test.ts | 2 +- src/auth/login.test.ts | 2 +- src/auth/logout.test.ts | 25 ++-- src/auth/persist.test.ts | 6 +- src/auth/providers/pkce.test.ts | 2 +- src/auth/refresh.test.ts | 2 +- src/auth/status.test.ts | 29 +++-- src/auth/token-view.test.ts | 37 +++--- src/auth/user-flag.test.ts | 4 +- src/empty.test.ts | 2 +- src/test-support/cli-harness.ts | 52 ++------ src/testing/accounts.test.ts | 24 ++++ src/{test-support => testing}/accounts.ts | 41 +++++-- src/testing/console.ts | 28 +++++ src/{testing.ts => testing/empty-output.ts} | 0 src/testing/index.ts | 4 + src/testing/program.ts | 12 ++ .../subpath.test.ts} | 2 +- 21 files changed, 269 insertions(+), 163 deletions(-) create mode 100644 src/testing/accounts.test.ts rename src/{test-support => testing}/accounts.ts (83%) create mode 100644 src/testing/console.ts rename src/{testing.ts => testing/empty-output.ts} (100%) create mode 100644 src/testing/index.ts create mode 100644 src/testing/program.ts rename src/{testing.test.ts => testing/subpath.test.ts} (99%) diff --git a/README.md b/README.md index 028412d..e27a2d7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm install @doist/cli-core | `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | | `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | | `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | -| `testing` (subpath) | `describeEmptyMachineOutput` | Vitest helpers reusable by consuming CLIs (e.g. parametrised empty-state suite covering `--json` / `--ndjson` / human modes). | +| `testing` (subpath) | `describeEmptyMachineOutput`, `createTestProgram`, `captureConsole`, `captureStream`, `buildTokenStore`, `buildSingleEntryStore`, `ingenEntries`, `alanGrant` / `ellieSattler` / `ianMalcolm`, `TestAccount` / `StoreEntry` / `TokenStoreHarness` / `MatchAccount` types | Vitest helpers + fixtures reusable by consuming CLIs: a parametrised empty-state suite (`--json` / `--ndjson` / human modes); a Commander test-program builder (`createTestProgram`, **requires** `commander`); console / stdout-stderr spies that silence + auto-restore (`captureConsole` / `captureStream`, call inside a test or `beforeEach`); and a canonical stateful in-memory `TokenStore` mock plus shared account fixtures (`buildTokenStore` / `buildSingleEntryStore`) modelling `createKeyringTokenStore`'s default-selection contract — pass `matchAccount` to mirror a consumer's own ref-matching (numeric-id / case-insensitive label). | ## Usage @@ -55,6 +55,43 @@ if (tasks.length === 0) { } ``` +### Testing helpers (subpath) + +Reuse the shared vitest scaffolding instead of hand-rolling it per CLI: + +```ts +import { + createTestProgram, + captureConsole, + buildTokenStore, + type TestAccount, +} from '@doist/cli-core/testing' + +it('greets the active account', async () => { + const logSpy = captureConsole() // silences console.log, auto-restores + const program = createTestProgram((p) => attachStatusCommand(p)) + await program.parseAsync(['node', 'cli', 'status']) + expect(logSpy).toHaveBeenCalledWith('Signed in as alan@ingen.com') +}) +``` + +`buildTokenStore` is a stateful in-memory `TokenStore` mock. For a CLI whose store matches refs +differently from the default id/email/label rule, pass `matchAccount` so the mock resolves refs exactly +like production: + +```ts +import { buildTokenStore } from '@doist/cli-core/testing' +import { matchTwistAccount } from './lib/auth-provider.js' + +const { store } = buildTokenStore({ + entries: [{ account: ACCOUNT_ALAN, isDefault: true }], + matchAccount: matchTwistAccount, +}) +``` + +The `createTestProgram` helper requires `commander` (already an optional peer-dep). These helpers import +`vitest`, so only import this subpath from test files. + ### Markdown rendering (optional subpath) Install the peer-deps in the consuming CLI: diff --git a/package.json b/package.json index 5025d1f..ac1af44 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "import": "./dist/markdown.js" }, "./testing": { - "types": "./dist/testing.d.ts", - "import": "./dist/testing.js" + "types": "./dist/testing/index.d.ts", + "import": "./dist/testing/index.js" } }, "scripts": { diff --git a/src/auth/account.test.ts b/src/auth/account.test.ts index e1ca089..8614cfe 100644 --- a/src/auth/account.test.ts +++ b/src/auth/account.test.ts @@ -1,16 +1,17 @@ import type { Command } from 'commander' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson, formatNdjson } from '../json.js' +import { buildProgram } from '../test-support/cli-harness.js' import { type TestAccount as Account, alanGrant, buildTokenStore, ellieSattler, ingenEntries, -} from '../test-support/accounts.js' -import { buildProgram, installConsoleLogSpy } from '../test-support/cli-harness.js' +} from '../testing/accounts.js' +import { captureConsole } from '../testing/console.js' import { type AttachAccountCurrentCommandOptions, type AttachAccountListCommandOptions, @@ -76,14 +77,18 @@ function buildRemove( } describe('attachAccountListCommand', () => { - const logSpy = installConsoleLogSpy() + let logSpy: ReturnType + + beforeEach(() => { + logSpy = captureConsole() + }) it('renders default human lines with a (default) marker only on the default entry', async () => { const { program } = buildList() await program.parseAsync(['node', 'cli', 'account', 'list']) - const emitted = logSpy().mock.calls.map((call: unknown[]) => call[0]) + const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]) expect(emitted).toEqual(['Alan Grant (id:1) (default)', 'Ellie Sattler (id:2)']) }) @@ -99,7 +104,7 @@ describe('attachAccountListCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy()).toHaveBeenCalledWith('one line') + expect(logSpy).toHaveBeenCalledWith('one line') }) it('emits each line when renderText returns an array', async () => { @@ -108,9 +113,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list']) - const emitted = logSpy() - .mock.calls.map((call: unknown[]) => call[0]) - .join('\n') + const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]).join('\n') expect(emitted).toBe('line 1\nline 2\nline 3') }) @@ -119,7 +122,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) - expect(logSpy()).toHaveBeenCalledWith( + expect(logSpy).toHaveBeenCalledWith( formatJson({ accounts: [ { account: alanGrant, isDefault: true }, @@ -152,7 +155,7 @@ describe('attachAccountListCommand', () => { isDefault: false, flags: {}, }) - expect(logSpy()).toHaveBeenCalledWith( + expect(logSpy).toHaveBeenCalledWith( formatJson({ accounts: [ { name: 'Alan Grant', isDefault: true }, @@ -168,7 +171,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--ndjson']) - const emitted = logSpy().mock.calls.map((call: unknown[]) => call[0]) + const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]) expect(emitted).toEqual([ formatNdjson([{ account: alanGrant, isDefault: true }]), formatNdjson([{ account: ellieSattler, isDefault: false }]), @@ -196,7 +199,7 @@ describe('attachAccountListCommand', () => { isDefault: false, flags: {}, }) - const emitted = logSpy().mock.calls.map((call: unknown[]) => call[0]) + const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]) expect(emitted).toEqual([ formatNdjson([{ name: 'Alan Grant', isDefault: true }]), formatNdjson([{ name: 'Ellie Sattler', isDefault: false }]), @@ -208,8 +211,8 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json', '--ndjson']) - expect(logSpy()).toHaveBeenCalledTimes(1) - expect(logSpy()).toHaveBeenCalledWith( + expect(logSpy).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith( formatJson({ accounts: [ { account: alanGrant, isDefault: true }, @@ -245,7 +248,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ accounts: [], default: null })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ accounts: [], default: null })) }) it('emits nothing under --ndjson when no accounts are stored', async () => { @@ -253,7 +256,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--ndjson']) - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() }) it('emits the default empty-state message in human mode when no accounts are stored', async () => { @@ -261,7 +264,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list']) - expect(logSpy()).toHaveBeenCalledWith('No accounts stored.') + expect(logSpy).toHaveBeenCalledWith('No accounts stored.') }) it('reports default null when no entry is marked default', async () => { @@ -275,7 +278,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) - expect(logSpy()).toHaveBeenCalledWith( + expect(logSpy).toHaveBeenCalledWith( formatJson({ accounts: [ { account: alanGrant, isDefault: false }, @@ -309,7 +312,11 @@ describe('attachAccountListCommand', () => { }) describe('attachAccountUseCommand', () => { - const logSpy = installConsoleLogSpy() + let logSpy: ReturnType + + beforeEach(() => { + logSpy = captureConsole() + }) it('calls setDefault and echoes the raw ref in the human success line', async () => { const built = buildTokenStore() @@ -318,7 +325,7 @@ describe('attachAccountUseCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'use', 'ellie@ingen.com']) expect(built.setDefaultSpy).toHaveBeenCalledWith('ellie@ingen.com') - expect(logSpy()).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') + expect(logSpy).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') }) it('emits the canonical resolved id under --json, not the requested ref', async () => { @@ -326,7 +333,7 @@ describe('attachAccountUseCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'use', 'ellie@ingen.com', '--json']) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) }) it('prefers --json over --ndjson when both flags are passed', async () => { @@ -342,7 +349,7 @@ describe('attachAccountUseCommand', () => { '--ndjson', ]) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) }) it('does not re-read the store outside --json', async () => { @@ -362,7 +369,7 @@ describe('attachAccountUseCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'use', 'ellie@ingen.com', '--ndjson']) expect(built.setDefaultSpy).toHaveBeenCalledWith('ellie@ingen.com') - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() }) it('propagates ACCOUNT_NOT_FOUND from setDefault and prints nothing', async () => { @@ -374,7 +381,7 @@ describe('attachAccountUseCommand', () => { program.parseAsync(['node', 'cli', 'account', 'use', 'ghost']), ).rejects.toMatchObject({ constructor: CliError, code: 'ACCOUNT_NOT_FOUND' }) expect(built.listSpy).not.toHaveBeenCalled() - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() }) it('emits the success line before awaiting onDefaultSet', async () => { @@ -391,7 +398,7 @@ describe('attachAccountUseCommand', () => { await vi.waitFor(() => expect(onDefaultSet).toHaveBeenCalled()) // Success line is already out, but the command is still parked on the hook. - expect(logSpy()).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') + expect(logSpy).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') expect(onDefaultSet).toHaveBeenCalledWith({ ref: 'ellie@ingen.com', view: { json: false, ndjson: false }, @@ -425,14 +432,18 @@ describe('attachAccountUseCommand', () => { }) describe('attachAccountCurrentCommand', () => { - const logSpy = installConsoleLogSpy() + let logSpy: ReturnType + + beforeEach(() => { + logSpy = captureConsole() + }) it('renders the default human line with a (default) marker for the active account', async () => { const { program } = buildCurrent() await program.parseAsync(['node', 'cli', 'account', 'current']) - expect(logSpy()).toHaveBeenCalledWith('Alan Grant (id:1) (default)') + expect(logSpy).toHaveBeenCalledWith('Alan Grant (id:1) (default)') }) it('omits the marker when the active account is not the default', async () => { @@ -450,7 +461,7 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current']) - expect(logSpy()).toHaveBeenCalledWith('Alan Grant (id:1)') + expect(logSpy).toHaveBeenCalledWith('Alan Grant (id:1)') }) it('passes account + isDefault to a custom renderText', async () => { @@ -465,7 +476,7 @@ describe('attachAccountCurrentCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy()).toHaveBeenCalledWith('custom line') + expect(logSpy).toHaveBeenCalledWith('custom line') }) it('emits the default { account, isDefault } payload under --json', async () => { @@ -473,7 +484,7 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--json']) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) }) it('shapes the --json payload via renderJson', async () => { @@ -483,7 +494,7 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--json']) expect(renderJson).toHaveBeenCalledWith({ account: alanGrant, isDefault: true, flags: {} }) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ email: 'alan@ingen.com' })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ email: 'alan@ingen.com' })) }) it('emits a single payload object under --ndjson', async () => { @@ -491,9 +502,7 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--ndjson']) - expect(logSpy()).toHaveBeenCalledWith( - formatNdjson([{ account: alanGrant, isDefault: true }]), - ) + expect(logSpy).toHaveBeenCalledWith(formatNdjson([{ account: alanGrant, isDefault: true }])) }) it('prefers --json over --ndjson when both flags are passed', async () => { @@ -501,8 +510,8 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--json', '--ndjson']) - expect(logSpy()).toHaveBeenCalledOnce() - expect(logSpy()).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) + expect(logSpy).toHaveBeenCalledOnce() + expect(logSpy).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) }) // Covers both non-serializable shapes: a top-level `undefined` @@ -534,7 +543,7 @@ describe('attachAccountCurrentCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() }) it('throws NOT_AUTHENTICATED when nothing is active and no hook is supplied', async () => { @@ -555,7 +564,7 @@ describe('attachAccountCurrentCommand', () => { expect(built.store.activeAccount).toHaveBeenCalledOnce() expect(built.activeSpy).not.toHaveBeenCalled() expect(built.listSpy).not.toHaveBeenCalled() - expect(logSpy()).toHaveBeenCalledWith('Ellie Sattler (id:2)') + expect(logSpy).toHaveBeenCalledWith('Ellie Sattler (id:2)') }) it('returns the new Command so the consumer can chain', () => { @@ -566,7 +575,11 @@ describe('attachAccountCurrentCommand', () => { }) describe('attachAccountRemoveCommand', () => { - const logSpy = installConsoleLogSpy() + let logSpy: ReturnType + + beforeEach(() => { + logSpy = captureConsole() + }) it('removes the matched account by ref and marks it as the former default', async () => { const built = buildTokenStore() @@ -577,8 +590,8 @@ describe('attachAccountRemoveCommand', () => { expect(built.clearSpy).toHaveBeenCalledWith('alan@ingen.com') expect(await built.store.list()).toEqual([{ account: ellieSattler, isDefault: true }]) - expect(logSpy()).toHaveBeenCalledOnce() - expect(logSpy()).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') + expect(logSpy).toHaveBeenCalledOnce() + expect(logSpy).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') }) it('omits the (default) marker when the removed account was not the default', async () => { @@ -587,8 +600,8 @@ describe('attachAccountRemoveCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'remove', 'ellie@ingen.com']) - expect(logSpy()).toHaveBeenCalledOnce() - expect(logSpy()).toHaveBeenCalledWith('✓ Removed Ellie Sattler') + expect(logSpy).toHaveBeenCalledOnce() + expect(logSpy).toHaveBeenCalledWith('✓ Removed Ellie Sattler') }) it('throws ACCOUNT_NOT_FOUND and removes nothing when the ref misses', async () => { @@ -615,7 +628,7 @@ describe('attachAccountRemoveCommand', () => { expect(built.store.active).not.toHaveBeenCalled() expect(await built.store.list()).toEqual([{ account: ellieSattler, isDefault: true }]) - expect(logSpy()).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') + expect(logSpy).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') }) it('emits { ok, removed } with the canonical id under --json', async () => { @@ -623,7 +636,7 @@ describe('attachAccountRemoveCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'remove', 'ellie@ingen.com', '--json']) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) }) it('prefers --json over --ndjson when both flags are passed', async () => { @@ -639,8 +652,8 @@ describe('attachAccountRemoveCommand', () => { '--ndjson', ]) - expect(logSpy()).toHaveBeenCalledOnce() - expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) + expect(logSpy).toHaveBeenCalledOnce() + expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) }) it('is silent under --ndjson but still clears and runs onRemoved', async () => { @@ -658,7 +671,7 @@ describe('attachAccountRemoveCommand', () => { ]) expect(await built.store.list()).toEqual([{ account: alanGrant, isDefault: true }]) - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() expect(onRemoved).toHaveBeenCalledOnce() }) @@ -678,7 +691,7 @@ describe('attachAccountRemoveCommand', () => { } expect(renderText).toHaveBeenCalledWith(expectedCtx) expect(onRemoved).toHaveBeenCalledWith(expectedCtx) - expect(logSpy()).toHaveBeenCalledWith('gone') + expect(logSpy).toHaveBeenCalledWith('gone') }) it('emits the success line before awaiting onRemoved', async () => { @@ -695,7 +708,7 @@ describe('attachAccountRemoveCommand', () => { await vi.waitFor(() => expect(onRemoved).toHaveBeenCalled()) // Success line is already out, but the command is still parked on the hook. - expect(logSpy()).toHaveBeenCalledWith('✓ Removed Ellie Sattler') + expect(logSpy).toHaveBeenCalledWith('✓ Removed Ellie Sattler') expect(await Promise.race([parsed, Promise.resolve('pending')])).toBe('pending') releaseHook() diff --git a/src/auth/flow.test.ts b/src/auth/flow.test.ts index 8ac5d76..2354148 100644 --- a/src/auth/flow.test.ts +++ b/src/auth/flow.test.ts @@ -26,7 +26,7 @@ import { alanGrant, buildTokenStore, ellieSattler, -} from '../test-support/accounts.js' +} from '../testing/accounts.js' import { type RunOAuthFlowOptions, runOAuthFlow } from './flow.js' import type { AuthProvider, TokenBundle, TokenStore } from './types.js' diff --git a/src/auth/login.test.ts b/src/auth/login.test.ts index 3250450..2cb0dbe 100644 --- a/src/auth/login.test.ts +++ b/src/auth/login.test.ts @@ -2,8 +2,8 @@ import type { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' -import { type TestAccount as Account, alanGrant } from '../test-support/accounts.js' import { buildProgram } from '../test-support/cli-harness.js' +import { type TestAccount as Account, alanGrant } from '../testing/accounts.js' import { attachLoginCommand } from './login.js' import type { AuthProvider, TokenStore } from './types.js' diff --git a/src/auth/logout.test.ts b/src/auth/logout.test.ts index 0a33532..adb0d98 100644 --- a/src/auth/logout.test.ts +++ b/src/auth/logout.test.ts @@ -1,15 +1,16 @@ import type { Command } from 'commander' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson } from '../json.js' +import { buildProgram } from '../test-support/cli-harness.js' import { type TestAccount as Account, type TokenStoreHarness, alanGrant, buildSingleEntryStore, -} from '../test-support/accounts.js' -import { buildProgram, installConsoleLogSpy } from '../test-support/cli-harness.js' +} from '../testing/accounts.js' +import { captureConsole } from '../testing/console.js' import { attachLogoutCommand } from './logout.js' import type { TokenStore } from './types.js' @@ -44,7 +45,11 @@ function build( } describe('attachLogoutCommand', () => { - const logSpy = installConsoleLogSpy() + let logSpy: ReturnType + + beforeEach(() => { + logSpy = captureConsole() + }) it('clears the store and emits the human success line in plain mode', async () => { const built = buildStore() @@ -54,7 +59,7 @@ describe('attachLogoutCommand', () => { expect(built.activeSpy).toHaveBeenCalledWith(undefined) expect(built.clearSpy).toHaveBeenCalledWith(undefined) - expect(logSpy()).toHaveBeenCalledWith('✓ Logged out') + expect(logSpy).toHaveBeenCalledWith('✓ Logged out') expect(onCleared).toHaveBeenCalledWith({ account, ref: undefined, @@ -68,7 +73,7 @@ describe('attachLogoutCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'logout', '--json']) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true })) expect(onCleared).toHaveBeenCalledWith({ account, ref: undefined, @@ -82,7 +87,7 @@ describe('attachLogoutCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'logout', '--ndjson']) - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() expect(onCleared).toHaveBeenCalledWith({ account, ref: undefined, @@ -355,7 +360,7 @@ describe('attachLogoutCommand', () => { code: 'ACCOUNT_NOT_FOUND', }) expect(built.clearSpy).not.toHaveBeenCalled() - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() }) it('proceeds with clear(ref) when active(ref) throws AUTH_STORE_READ_FAILED', async () => { @@ -375,7 +380,7 @@ describe('attachLogoutCommand', () => { expect(built.clearSpy).toHaveBeenCalledWith('me') expect(revokeSpy).not.toHaveBeenCalled() - expect(logSpy()).toHaveBeenCalledWith('✓ Logged out') + expect(logSpy).toHaveBeenCalledWith('✓ Logged out') // `account` is null (no readable snapshot) but `ref` is populated, so // consumers can distinguish "nothing was stored" from "cleared an // unreadable record". @@ -401,7 +406,7 @@ describe('attachLogoutCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'logout', '--user', 'me']) expect(built.clearSpy).toHaveBeenCalledWith('me') - expect(logSpy()).toHaveBeenCalledWith('✓ Logged out') + expect(logSpy).toHaveBeenCalledWith('✓ Logged out') }) it('still propagates non-read errors from the snapshot pre-flight', async () => { diff --git a/src/auth/persist.test.ts b/src/auth/persist.test.ts index fbe9113..d90896b 100644 --- a/src/auth/persist.test.ts +++ b/src/auth/persist.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' -import { - type TestAccount as Account, - buildTokenStore, - ianMalcolm, -} from '../test-support/accounts.js' +import { type TestAccount as Account, buildTokenStore, ianMalcolm } from '../testing/accounts.js' import { persistBundle } from './persist.js' import type { TokenBundle, TokenStore } from './types.js' diff --git a/src/auth/providers/pkce.test.ts b/src/auth/providers/pkce.test.ts index f49d3f3..6afc3fa 100644 --- a/src/auth/providers/pkce.test.ts +++ b/src/auth/providers/pkce.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { type TestAccount as Account, alanGrant } from '../../test-support/accounts.js' +import { type TestAccount as Account, alanGrant } from '../../testing/accounts.js' import { createPkceProvider } from './pkce.js' const respond = (body: unknown, status = 200): Response => diff --git a/src/auth/refresh.test.ts b/src/auth/refresh.test.ts index b589eb7..a78efbb 100644 --- a/src/auth/refresh.test.ts +++ b/src/auth/refresh.test.ts @@ -10,7 +10,7 @@ import { type TokenStoreHarness, buildTokenStore, ianMalcolm, -} from '../test-support/accounts.js' +} from '../testing/accounts.js' import { refreshAccessToken } from './refresh.js' import type { ActiveBundleSnapshot, diff --git a/src/auth/status.test.ts b/src/auth/status.test.ts index c3e36d4..a7d4a4b 100644 --- a/src/auth/status.test.ts +++ b/src/auth/status.test.ts @@ -1,15 +1,16 @@ import type { Command } from 'commander' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson, formatNdjson } from '../json.js' +import { buildProgram } from '../test-support/cli-harness.js' import { type TestAccount as Account, type TokenStoreHarness, alanGrant, buildSingleEntryStore, -} from '../test-support/accounts.js' -import { buildProgram, installConsoleLogSpy } from '../test-support/cli-harness.js' +} from '../testing/accounts.js' +import { captureConsole } from '../testing/console.js' import { attachStatusCommand } from './status.js' import type { TokenStore } from './types.js' @@ -43,7 +44,11 @@ function build( } describe('attachStatusCommand', () => { - const logSpy = installConsoleLogSpy() + let logSpy: ReturnType + + beforeEach(() => { + logSpy = captureConsole() + }) it('emits renderText output in plain mode', async () => { const { program, renderText } = build() @@ -55,7 +60,7 @@ describe('attachStatusCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy()).toHaveBeenCalledWith('Signed in as alan@ingen.com') + expect(logSpy).toHaveBeenCalledWith('Signed in as alan@ingen.com') }) it('emits each line when renderText returns an array', async () => { @@ -64,9 +69,7 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status']) - const emitted = logSpy() - .mock.calls.map((call: unknown[]) => call[0]) - .join('\n') + const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]).join('\n') expect(emitted).toBe('line 1\nline 2\nline 3') }) @@ -76,7 +79,7 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status', '--json']) expect(renderText).not.toHaveBeenCalled() - expect(logSpy()).toHaveBeenCalledWith(formatJson(account)) + expect(logSpy).toHaveBeenCalledWith(formatJson(account)) }) it('emits renderJson payload when supplied under --json', async () => { @@ -89,7 +92,7 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status', '--json']) expect(renderJson).toHaveBeenCalledWith({ account, flags: {} }) - expect(logSpy()).toHaveBeenCalledWith(formatJson({ id: '1', email: 'alan@ingen.com' })) + expect(logSpy).toHaveBeenCalledWith(formatJson({ id: '1', email: 'alan@ingen.com' })) }) it('emits a single NDJSON line under --ndjson', async () => { @@ -97,7 +100,7 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status', '--ndjson']) - expect(logSpy()).toHaveBeenCalledWith(formatNdjson([account])) + expect(logSpy).toHaveBeenCalledWith(formatNdjson([account])) }) it('does not invoke renderJson in human mode', async () => { @@ -127,7 +130,7 @@ describe('attachStatusCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy()).toHaveBeenCalledWith('Signed in as live@ingen.com') + expect(logSpy).toHaveBeenCalledWith('Signed in as live@ingen.com') }) it('propagates fetchLive throws', async () => { @@ -149,7 +152,7 @@ describe('attachStatusCommand', () => { constructor: CliError, code: 'NOT_AUTHENTICATED', }) - expect(logSpy()).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() }) it('awaits an async onNotAuthenticated when supplied instead of throwing', async () => { diff --git a/src/auth/token-view.test.ts b/src/auth/token-view.test.ts index 99e7efb..22929cf 100644 --- a/src/auth/token-view.test.ts +++ b/src/auth/token-view.test.ts @@ -1,13 +1,14 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' +import { buildProgram } from '../test-support/cli-harness.js' import { type TestAccount as Account, type TokenStoreHarness, alanGrant, buildSingleEntryStore, -} from '../test-support/accounts.js' -import { buildProgram, installStdoutSpy } from '../test-support/cli-harness.js' +} from '../testing/accounts.js' +import { captureStream } from '../testing/console.js' import { attachTokenViewCommand } from './token-view.js' const account = alanGrant @@ -19,7 +20,11 @@ function buildStore( } describe('attachTokenViewCommand', () => { - const stdoutSpy = installStdoutSpy() + let stdoutSpy: ReturnType + + beforeEach(() => { + stdoutSpy = captureStream() + }) afterEach(() => { vi.unstubAllEnvs() @@ -41,11 +46,9 @@ describe('attachTokenViewCommand', () => { }) } - const emitted = stdoutSpy() - .mock.calls.map((call: unknown[]) => call[0]) - .join('') + const emitted = stdoutSpy.mock.calls.map((call: unknown[]) => call[0]).join('') expect(emitted).toBe('tok-xyz') - expect(stdoutSpy()).toHaveBeenCalledTimes(1) + expect(stdoutSpy).toHaveBeenCalledTimes(1) }) it('appends a newline only when stdout is a TTY', async () => { @@ -64,9 +67,7 @@ describe('attachTokenViewCommand', () => { }) } - const emitted = stdoutSpy() - .mock.calls.map((call: unknown[]) => call[0]) - .join('') + const emitted = stdoutSpy.mock.calls.map((call: unknown[]) => call[0]).join('') expect(emitted).toBe('tok-xyz\n') }) @@ -81,7 +82,7 @@ describe('attachTokenViewCommand', () => { code: 'TOKEN_FROM_ENV', }) expect(activeSpy).not.toHaveBeenCalled() - expect(stdoutSpy()).not.toHaveBeenCalled() + expect(stdoutSpy).not.toHaveBeenCalled() }) it('prints normally when envVarName is set but env is empty', async () => { @@ -92,7 +93,7 @@ describe('attachTokenViewCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'token']) - expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') }) it('throws CliError(NOT_AUTHENTICATED) when the store is empty', async () => { @@ -104,7 +105,7 @@ describe('attachTokenViewCommand', () => { constructor: CliError, code: 'NOT_AUTHENTICATED', }) - expect(stdoutSpy()).not.toHaveBeenCalled() + expect(stdoutSpy).not.toHaveBeenCalled() }) it('registers under a custom name when supplied', async () => { @@ -115,7 +116,7 @@ describe('attachTokenViewCommand', () => { expect(cmd.name()).toBe('view') await program.parseAsync(['node', 'cli', 'auth', 'view']) - expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') }) it('returns the new Command so the consumer can chain', () => { @@ -134,7 +135,7 @@ describe('attachTokenViewCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'token', '--user', 'alan@ingen.com']) expect(activeSpy).toHaveBeenCalledWith('alan@ingen.com') - expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') }) it('calls store.active(undefined) when --user is absent', async () => { @@ -145,7 +146,7 @@ describe('attachTokenViewCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'token']) expect(activeSpy).toHaveBeenCalledWith(undefined) - expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') }) it('throws ACCOUNT_NOT_FOUND when --user does not match a stored account', async () => { @@ -159,6 +160,6 @@ describe('attachTokenViewCommand', () => { constructor: CliError, code: 'ACCOUNT_NOT_FOUND', }) - expect(stdoutSpy()).not.toHaveBeenCalled() + expect(stdoutSpy).not.toHaveBeenCalled() }) }) diff --git a/src/auth/user-flag.test.ts b/src/auth/user-flag.test.ts index 394bf52..f2777db 100644 --- a/src/auth/user-flag.test.ts +++ b/src/auth/user-flag.test.ts @@ -2,12 +2,12 @@ import { Command } from 'commander' import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' +import { buildProgram } from '../test-support/cli-harness.js' import { type TestAccount as Account, alanGrant, buildSingleEntryStore, -} from '../test-support/accounts.js' -import { buildProgram } from '../test-support/cli-harness.js' +} from '../testing/accounts.js' import type { TokenStore } from './types.js' import { attachUserFlag, extractUserRef, requireSnapshotForRef } from './user-flag.js' diff --git a/src/empty.test.ts b/src/empty.test.ts index 814cad7..b2c7dc8 100644 --- a/src/empty.test.ts +++ b/src/empty.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { printEmpty } from './empty.js' -import { describeEmptyMachineOutput } from './testing.js' +import { describeEmptyMachineOutput } from './testing/index.js' const HUMAN_MESSAGE = 'No threads in inbox.' diff --git a/src/test-support/cli-harness.ts b/src/test-support/cli-harness.ts index 195362d..497e1a4 100644 --- a/src/test-support/cli-harness.ts +++ b/src/test-support/cli-harness.ts @@ -1,52 +1,20 @@ -import { Command } from 'commander' -import { afterEach, beforeEach, vi } from 'vitest' +import type { Command } from 'commander' -// Shared test scaffolding for the Commander attacher suites. Internal-only -// (under `src/test-support/`, excluded from the build). - -type Spy = ReturnType - -/** - * Own the `beforeEach`/`afterEach` spy lifecycle: `register` creates + configures - * a fresh spy before each test (where the target's types are concrete, so no - * casts), and the spy is restored afterwards. Returns a getter for the live spy - * — call it inside the test body, since a new spy is installed per test. - */ -function installSpy(register: () => Spy): () => Spy { - let spy: Spy - beforeEach(() => { - spy = register() - }) - afterEach(() => { - spy.mockRestore() - }) - return () => spy -} +import { createTestProgram } from '../testing/program.js' -/** - * Silence + spy on `console.log`. Call once at the top of a `describe`: - * - * ```ts - * const logSpy = installConsoleLogSpy() - * it('...', () => { expect(logSpy()).toHaveBeenCalledWith('…') }) - * ``` - */ -export function installConsoleLogSpy(): () => Spy { - return installSpy(() => vi.spyOn(console, 'log').mockImplementation(() => {})) -} - -/** Same as {@link installConsoleLogSpy} for `process.stdout.write` (pipe-safe output). */ -export function installStdoutSpy(): () => Spy { - return installSpy(() => vi.spyOn(process.stdout, 'write').mockImplementation(() => true)) -} +// Shared test scaffolding for the Commander attacher suites. Internal-only +// (under `src/test-support/`, excluded from the build). Console/stdout spies +// live in the published `@doist/cli-core/testing` surface — import +// `captureConsole`/`captureStream` from `../testing/console.js` directly. /** * Build a Commander program with `exitOverride()` and a single named parent * subcommand to attach to — the boilerplate every attacher suite repeats. */ export function buildProgram(parentName: string): { program: Command; parent: Command } { - const program = new Command() - program.exitOverride() - const parent = program.command(parentName) + let parent!: Command + const program = createTestProgram((p) => { + parent = p.command(parentName) + }) return { program, parent } } diff --git a/src/testing/accounts.test.ts b/src/testing/accounts.test.ts new file mode 100644 index 0000000..1cb6c98 --- /dev/null +++ b/src/testing/accounts.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' + +import type { AuthAccount, TokenStore } from '../auth/types.js' +import { buildTokenStore } from './accounts.js' + +// A consumer-style account with no `email` (mirrors TwistAccount) — proves the +// generic store mock type-checks and works for account shapes beyond the +// default `TestAccount`, standing in for a real consumer without importing one. +type NoEmailAccount = AuthAccount & { authMode: string; authScope: string } + +describe('buildTokenStore generic reuse', () => { + it('serves a no-email account and honours a custom matchAccount', async () => { + const alan: NoEmailAccount = { id: '1', label: 'Alan', authMode: 'rw', authScope: 's' } + const { store } = buildTokenStore({ + entries: [{ account: alan, isDefault: true }], + matchAccount: (account, ref) => account.id === ref, + }) + + expectTypeOf(store).toEqualTypeOf>() + await expect(store.active('1')).resolves.toEqual({ token: 'token-1', account: alan }) + // The default id/email/label matcher is overridden, so a label ref no longer resolves. + await expect(store.active('Alan')).resolves.toBeNull() + }) +}) diff --git a/src/test-support/accounts.ts b/src/testing/accounts.ts similarity index 83% rename from src/test-support/accounts.ts rename to src/testing/accounts.ts index 07d306c..9f47d90 100644 --- a/src/test-support/accounts.ts +++ b/src/testing/accounts.ts @@ -10,9 +10,9 @@ import type { } from '../auth/types.js' import { accountNotFoundError } from '../auth/user-flag.js' -// Shared account fixtures + a canonical in-memory `TokenStore` mock for the -// auth test suites. Lives under `src/test-support/` so it's excluded from the -// build (per `tsconfig.build.json`) and never reaches consumers via `dist/`. +// Shared account fixtures + a canonical in-memory `TokenStore` mock for auth +// test suites, shipped via the `@doist/cli-core/testing` subpath so consuming +// CLIs can reuse them instead of hand-rolling store mocks. // // The mock mirrors `createKeyringTokenStore`'s default-selection rules so tests // can't assert states production never produces: the *effective default* is the @@ -47,8 +47,14 @@ export function ingenEntries(): StoreEntry[] { ] } -/** Stores own the matching rule; the mock matches by id, email, or label. */ -function matchesRef(account: AuthAccount, ref: string): boolean { +/** Account matcher used to resolve a ref. The default matches by id, email, or label. */ +export type MatchAccount = ( + account: TAccount, + ref: AccountRef, +) => boolean + +/** Default ref matcher: id, email, or label. Pass a consumer's own matcher via `matchAccount`. */ +function defaultMatchAccount(account: AuthAccount, ref: AccountRef): boolean { return account.id === ref || account.email === ref || account.label === ref } @@ -68,22 +74,31 @@ export type TokenStoreHarness = { export function buildTokenStore(opts?: { entries?: StoreEntry[] overrides?: Partial> + matchAccount?: MatchAccount }): TokenStoreHarness export function buildTokenStore(opts: { entries: StoreEntry[] overrides?: Partial> + matchAccount?: MatchAccount }): TokenStoreHarness /** * Canonical stateful, multi-account `TokenStore` mock. Models the full contract - * over a mutable entry list — id/email/label matching, effective-default - * resolution, promote-if-unpinned, token-free removal returning `ClearedAccount`, - * and bundle read/write with slot replacement. The default Ingen seed only - * applies to `TestAccount`; other `TAccount`s must pass explicit `entries`. - * Pass `overrides` to replace (or delete, via `{ method: undefined }`) any method. + * over a mutable entry list — ref matching, effective-default resolution, + * promote-if-unpinned, token-free removal returning `ClearedAccount`, and bundle + * read/write with slot replacement. The default Ingen seed only applies to + * `TestAccount`; other `TAccount`s must pass explicit `entries`. Ref matching + * defaults to id/email/label; pass `matchAccount` to mirror a consumer's own + * store matcher (e.g. numeric-id or case-insensitive label rules). Pass + * `overrides` to replace (or delete, via `{ method: undefined }`) any method. */ export function buildTokenStore( - opts: { entries?: StoreEntry[]; overrides?: Partial> } = {}, + opts: { + entries?: StoreEntry[] + overrides?: Partial> + matchAccount?: MatchAccount + } = {}, ): TokenStoreHarness { + const matches: MatchAccount = opts.matchAccount ?? defaultMatchAccount const entries: StoreEntry[] = ( opts.entries ?? (ingenEntries() as unknown as StoreEntry[]) ).map((entry) => ({ ...entry })) @@ -106,7 +121,7 @@ export function buildTokenStore( const tokenFor = (entry: StoreEntry): string => entry.token ?? entry.bundle?.accessToken ?? `token-${entry.account.id}` const find = (ref?: AccountRef): StoreEntry | undefined => - ref === undefined ? effectiveDefault() : entries.find((e) => matchesRef(e.account, ref)) + ref === undefined ? effectiveDefault() : entries.find((e) => matches(e.account, ref)) const activeSpy = vi.fn(async (ref?: AccountRef) => { const entry = find(ref) @@ -139,7 +154,7 @@ export function buildTokenStore( })) }) const setDefaultSpy = vi.fn(async (ref: AccountRef) => { - const target = entries.find((e) => matchesRef(e.account, ref)) + const target = entries.find((e) => matches(e.account, ref)) if (!target) throw accountNotFoundError(ref) pinnedDefaultId = target.account.id }) diff --git a/src/testing/console.ts b/src/testing/console.ts new file mode 100644 index 0000000..3bc0d3f --- /dev/null +++ b/src/testing/console.ts @@ -0,0 +1,28 @@ +import { onTestFinished, vi } from 'vitest' + +type ConsoleMethod = 'log' | 'error' | 'warn' | 'info' +type StdStream = 'stdout' | 'stderr' + +/** + * Spy on a console method, silence it, and auto-restore when the current test + * finishes. Returns the spy so `.mock.calls` assertions keep working. Call it + * inside a test or `beforeEach` — `onTestFinished` throws at `describe` top-level. + */ +export function captureConsole(method: ConsoleMethod = 'log'): ReturnType { + const spy = vi.spyOn(console, method).mockImplementation(() => {}) + onTestFinished(() => { + spy.mockRestore() + }) + return spy +} + +/** Same as {@link captureConsole} for `process.stdout`/`process.stderr.write` (pipe-safe paths). */ +export function captureStream(stream: StdStream = 'stdout'): ReturnType { + const spy = vi + .spyOn(process[stream], 'write') + .mockImplementation((() => true) as typeof process.stdout.write) + onTestFinished(() => { + spy.mockRestore() + }) + return spy +} diff --git a/src/testing.ts b/src/testing/empty-output.ts similarity index 100% rename from src/testing.ts rename to src/testing/empty-output.ts diff --git a/src/testing/index.ts b/src/testing/index.ts new file mode 100644 index 0000000..2ba64ce --- /dev/null +++ b/src/testing/index.ts @@ -0,0 +1,4 @@ +export * from './accounts.js' +export * from './console.js' +export * from './empty-output.js' +export * from './program.js' diff --git a/src/testing/program.ts b/src/testing/program.ts new file mode 100644 index 0000000..878e779 --- /dev/null +++ b/src/testing/program.ts @@ -0,0 +1,12 @@ +import { Command } from 'commander' + +/** + * Build a Commander program with `exitOverride()` (so parse errors throw instead + * of calling `process.exit`) and the given command(s) registered onto it. + */ +export function createTestProgram(register: (program: Command) => void): Command { + const program = new Command() + program.exitOverride() + register(program) + return program +} diff --git a/src/testing.test.ts b/src/testing/subpath.test.ts similarity index 99% rename from src/testing.test.ts rename to src/testing/subpath.test.ts index 79d6584..58bd4d9 100644 --- a/src/testing.test.ts +++ b/src/testing/subpath.test.ts @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { exports?: Record } From 2f8337e93d5bce0a3325e668eb3b849fb87a00ea Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 11:16:53 +0100 Subject: [PATCH 2/4] fix(testing): address PR review on the ./testing subpath - captureStream now detects a trailing write callback and invokes it, matching the WriteStream.write contract so write(chunk, cb) / write(chunk, enc, cb) paths don't hang - dedupe the spy lifecycle behind a private captureSpy helper - reintroduce internal installCapturedConsole/installCapturedStream wrappers in cli-harness (mirroring buildProgram over createTestProgram) so the attacher suites declare a spy once per describe instead of repeating let+beforeEach - setDefault reuses the find(ref) helper instead of duplicating the lookup - add colocated console.test.ts + program.test.ts (AGENTS.md module layout) - pin the custom matchAccount on a mutating path (setDefault) in accounts.test - extend the subpath smoke test to assert every runtime export the barrel adds - README: type-correct attachStatusCommand example; document that the whole ./testing entrypoint links commander (the barrel re-exports createTestProgram) Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 19 ++++-- src/auth/account.test.ts | 113 ++++++++++++++------------------ src/auth/logout.test.ts | 23 +++---- src/auth/status.test.ts | 27 ++++---- src/auth/token-view.test.ts | 35 +++++----- src/test-support/cli-harness.ts | 30 ++++++++- src/testing/accounts.test.ts | 19 ++++++ src/testing/accounts.ts | 2 +- src/testing/console.test.ts | 44 +++++++++++++ src/testing/console.ts | 41 ++++++++---- src/testing/program.test.ts | 35 ++++++++++ src/testing/subpath.test.ts | 14 +++- 12 files changed, 267 insertions(+), 135 deletions(-) create mode 100644 src/testing/console.test.ts create mode 100644 src/testing/program.test.ts diff --git a/README.md b/README.md index e27a2d7..9370a76 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm install @doist/cli-core | `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | | `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | | `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | -| `testing` (subpath) | `describeEmptyMachineOutput`, `createTestProgram`, `captureConsole`, `captureStream`, `buildTokenStore`, `buildSingleEntryStore`, `ingenEntries`, `alanGrant` / `ellieSattler` / `ianMalcolm`, `TestAccount` / `StoreEntry` / `TokenStoreHarness` / `MatchAccount` types | Vitest helpers + fixtures reusable by consuming CLIs: a parametrised empty-state suite (`--json` / `--ndjson` / human modes); a Commander test-program builder (`createTestProgram`, **requires** `commander`); console / stdout-stderr spies that silence + auto-restore (`captureConsole` / `captureStream`, call inside a test or `beforeEach`); and a canonical stateful in-memory `TokenStore` mock plus shared account fixtures (`buildTokenStore` / `buildSingleEntryStore`) modelling `createKeyringTokenStore`'s default-selection contract — pass `matchAccount` to mirror a consumer's own ref-matching (numeric-id / case-insensitive label). | +| `testing` (subpath) | `describeEmptyMachineOutput`, `createTestProgram`, `captureConsole`, `captureStream`, `buildTokenStore`, `buildSingleEntryStore`, `ingenEntries`, `alanGrant` / `ellieSattler` / `ianMalcolm`, `TestAccount` / `StoreEntry` / `TokenStoreHarness` / `MatchAccount` types | Vitest helpers + fixtures reusable by consuming CLIs: a parametrised empty-state suite (`--json` / `--ndjson` / human modes); a Commander test-program builder (`createTestProgram`; the whole subpath **requires** `commander` since the barrel re-exports it); console / stdout-stderr spies that silence + auto-restore (`captureConsole` / `captureStream`, call inside a test or `beforeEach`); and a canonical stateful in-memory `TokenStore` mock plus shared account fixtures (`buildTokenStore` / `buildSingleEntryStore`) modelling `createKeyringTokenStore`'s default-selection contract — pass `matchAccount` to mirror a consumer's own ref-matching (numeric-id / case-insensitive label). | ## Usage @@ -60,16 +60,20 @@ if (tasks.length === 0) { Reuse the shared vitest scaffolding instead of hand-rolling it per CLI: ```ts +import { attachStatusCommand } from '@doist/cli-core/auth' import { - createTestProgram, + alanGrant, + buildSingleEntryStore, captureConsole, - buildTokenStore, - type TestAccount, + createTestProgram, } from '@doist/cli-core/testing' it('greets the active account', async () => { const logSpy = captureConsole() // silences console.log, auto-restores - const program = createTestProgram((p) => attachStatusCommand(p)) + const { store } = buildSingleEntryStore({ token: 'tok', account: alanGrant }) + const program = createTestProgram((p) => + attachStatusCommand(p, { store, renderText: (ctx) => `Signed in as ${ctx.account.email}` }), + ) await program.parseAsync(['node', 'cli', 'status']) expect(logSpy).toHaveBeenCalledWith('Signed in as alan@ingen.com') }) @@ -89,8 +93,9 @@ const { store } = buildTokenStore({ }) ``` -The `createTestProgram` helper requires `commander` (already an optional peer-dep). These helpers import -`vitest`, so only import this subpath from test files. +The whole `@doist/cli-core/testing` entrypoint links `commander` (the barrel re-exports +`createTestProgram`) and `vitest` at import time, so install both — consuming CLIs already have them — +and only import this subpath from test files. ### Markdown rendering (optional subpath) diff --git a/src/auth/account.test.ts b/src/auth/account.test.ts index 8614cfe..01b3a6d 100644 --- a/src/auth/account.test.ts +++ b/src/auth/account.test.ts @@ -1,9 +1,9 @@ import type { Command } from 'commander' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson, formatNdjson } from '../json.js' -import { buildProgram } from '../test-support/cli-harness.js' +import { buildProgram, installCapturedConsole } from '../test-support/cli-harness.js' import { type TestAccount as Account, alanGrant, @@ -11,7 +11,6 @@ import { ellieSattler, ingenEntries, } from '../testing/accounts.js' -import { captureConsole } from '../testing/console.js' import { type AttachAccountCurrentCommandOptions, type AttachAccountListCommandOptions, @@ -77,18 +76,14 @@ function buildRemove( } describe('attachAccountListCommand', () => { - let logSpy: ReturnType - - beforeEach(() => { - logSpy = captureConsole() - }) + const logSpy = installCapturedConsole() it('renders default human lines with a (default) marker only on the default entry', async () => { const { program } = buildList() await program.parseAsync(['node', 'cli', 'account', 'list']) - const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]) + const emitted = logSpy().mock.calls.map((call: unknown[]) => call[0]) expect(emitted).toEqual(['Alan Grant (id:1) (default)', 'Ellie Sattler (id:2)']) }) @@ -104,7 +99,7 @@ describe('attachAccountListCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy).toHaveBeenCalledWith('one line') + expect(logSpy()).toHaveBeenCalledWith('one line') }) it('emits each line when renderText returns an array', async () => { @@ -113,7 +108,9 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list']) - const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]).join('\n') + const emitted = logSpy() + .mock.calls.map((call: unknown[]) => call[0]) + .join('\n') expect(emitted).toBe('line 1\nline 2\nline 3') }) @@ -122,7 +119,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) - expect(logSpy).toHaveBeenCalledWith( + expect(logSpy()).toHaveBeenCalledWith( formatJson({ accounts: [ { account: alanGrant, isDefault: true }, @@ -155,7 +152,7 @@ describe('attachAccountListCommand', () => { isDefault: false, flags: {}, }) - expect(logSpy).toHaveBeenCalledWith( + expect(logSpy()).toHaveBeenCalledWith( formatJson({ accounts: [ { name: 'Alan Grant', isDefault: true }, @@ -171,7 +168,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--ndjson']) - const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]) + const emitted = logSpy().mock.calls.map((call: unknown[]) => call[0]) expect(emitted).toEqual([ formatNdjson([{ account: alanGrant, isDefault: true }]), formatNdjson([{ account: ellieSattler, isDefault: false }]), @@ -199,7 +196,7 @@ describe('attachAccountListCommand', () => { isDefault: false, flags: {}, }) - const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]) + const emitted = logSpy().mock.calls.map((call: unknown[]) => call[0]) expect(emitted).toEqual([ formatNdjson([{ name: 'Alan Grant', isDefault: true }]), formatNdjson([{ name: 'Ellie Sattler', isDefault: false }]), @@ -211,8 +208,8 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json', '--ndjson']) - expect(logSpy).toHaveBeenCalledTimes(1) - expect(logSpy).toHaveBeenCalledWith( + expect(logSpy()).toHaveBeenCalledTimes(1) + expect(logSpy()).toHaveBeenCalledWith( formatJson({ accounts: [ { account: alanGrant, isDefault: true }, @@ -248,7 +245,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) - expect(logSpy).toHaveBeenCalledWith(formatJson({ accounts: [], default: null })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ accounts: [], default: null })) }) it('emits nothing under --ndjson when no accounts are stored', async () => { @@ -256,7 +253,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--ndjson']) - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() }) it('emits the default empty-state message in human mode when no accounts are stored', async () => { @@ -264,7 +261,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list']) - expect(logSpy).toHaveBeenCalledWith('No accounts stored.') + expect(logSpy()).toHaveBeenCalledWith('No accounts stored.') }) it('reports default null when no entry is marked default', async () => { @@ -278,7 +275,7 @@ describe('attachAccountListCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'list', '--json']) - expect(logSpy).toHaveBeenCalledWith( + expect(logSpy()).toHaveBeenCalledWith( formatJson({ accounts: [ { account: alanGrant, isDefault: false }, @@ -312,11 +309,7 @@ describe('attachAccountListCommand', () => { }) describe('attachAccountUseCommand', () => { - let logSpy: ReturnType - - beforeEach(() => { - logSpy = captureConsole() - }) + const logSpy = installCapturedConsole() it('calls setDefault and echoes the raw ref in the human success line', async () => { const built = buildTokenStore() @@ -325,7 +318,7 @@ describe('attachAccountUseCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'use', 'ellie@ingen.com']) expect(built.setDefaultSpy).toHaveBeenCalledWith('ellie@ingen.com') - expect(logSpy).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') + expect(logSpy()).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') }) it('emits the canonical resolved id under --json, not the requested ref', async () => { @@ -333,7 +326,7 @@ describe('attachAccountUseCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'use', 'ellie@ingen.com', '--json']) - expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) }) it('prefers --json over --ndjson when both flags are passed', async () => { @@ -349,7 +342,7 @@ describe('attachAccountUseCommand', () => { '--ndjson', ]) - expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, default: '2' })) }) it('does not re-read the store outside --json', async () => { @@ -369,7 +362,7 @@ describe('attachAccountUseCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'use', 'ellie@ingen.com', '--ndjson']) expect(built.setDefaultSpy).toHaveBeenCalledWith('ellie@ingen.com') - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() }) it('propagates ACCOUNT_NOT_FOUND from setDefault and prints nothing', async () => { @@ -381,7 +374,7 @@ describe('attachAccountUseCommand', () => { program.parseAsync(['node', 'cli', 'account', 'use', 'ghost']), ).rejects.toMatchObject({ constructor: CliError, code: 'ACCOUNT_NOT_FOUND' }) expect(built.listSpy).not.toHaveBeenCalled() - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() }) it('emits the success line before awaiting onDefaultSet', async () => { @@ -398,7 +391,7 @@ describe('attachAccountUseCommand', () => { await vi.waitFor(() => expect(onDefaultSet).toHaveBeenCalled()) // Success line is already out, but the command is still parked on the hook. - expect(logSpy).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') + expect(logSpy()).toHaveBeenCalledWith('✓ Default account set to ellie@ingen.com') expect(onDefaultSet).toHaveBeenCalledWith({ ref: 'ellie@ingen.com', view: { json: false, ndjson: false }, @@ -432,18 +425,14 @@ describe('attachAccountUseCommand', () => { }) describe('attachAccountCurrentCommand', () => { - let logSpy: ReturnType - - beforeEach(() => { - logSpy = captureConsole() - }) + const logSpy = installCapturedConsole() it('renders the default human line with a (default) marker for the active account', async () => { const { program } = buildCurrent() await program.parseAsync(['node', 'cli', 'account', 'current']) - expect(logSpy).toHaveBeenCalledWith('Alan Grant (id:1) (default)') + expect(logSpy()).toHaveBeenCalledWith('Alan Grant (id:1) (default)') }) it('omits the marker when the active account is not the default', async () => { @@ -461,7 +450,7 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current']) - expect(logSpy).toHaveBeenCalledWith('Alan Grant (id:1)') + expect(logSpy()).toHaveBeenCalledWith('Alan Grant (id:1)') }) it('passes account + isDefault to a custom renderText', async () => { @@ -476,7 +465,7 @@ describe('attachAccountCurrentCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy).toHaveBeenCalledWith('custom line') + expect(logSpy()).toHaveBeenCalledWith('custom line') }) it('emits the default { account, isDefault } payload under --json', async () => { @@ -484,7 +473,7 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--json']) - expect(logSpy).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) }) it('shapes the --json payload via renderJson', async () => { @@ -494,7 +483,7 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--json']) expect(renderJson).toHaveBeenCalledWith({ account: alanGrant, isDefault: true, flags: {} }) - expect(logSpy).toHaveBeenCalledWith(formatJson({ email: 'alan@ingen.com' })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ email: 'alan@ingen.com' })) }) it('emits a single payload object under --ndjson', async () => { @@ -502,7 +491,9 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--ndjson']) - expect(logSpy).toHaveBeenCalledWith(formatNdjson([{ account: alanGrant, isDefault: true }])) + expect(logSpy()).toHaveBeenCalledWith( + formatNdjson([{ account: alanGrant, isDefault: true }]), + ) }) it('prefers --json over --ndjson when both flags are passed', async () => { @@ -510,8 +501,8 @@ describe('attachAccountCurrentCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'current', '--json', '--ndjson']) - expect(logSpy).toHaveBeenCalledOnce() - expect(logSpy).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) + expect(logSpy()).toHaveBeenCalledOnce() + expect(logSpy()).toHaveBeenCalledWith(formatJson({ account: alanGrant, isDefault: true })) }) // Covers both non-serializable shapes: a top-level `undefined` @@ -543,7 +534,7 @@ describe('attachAccountCurrentCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() }) it('throws NOT_AUTHENTICATED when nothing is active and no hook is supplied', async () => { @@ -564,7 +555,7 @@ describe('attachAccountCurrentCommand', () => { expect(built.store.activeAccount).toHaveBeenCalledOnce() expect(built.activeSpy).not.toHaveBeenCalled() expect(built.listSpy).not.toHaveBeenCalled() - expect(logSpy).toHaveBeenCalledWith('Ellie Sattler (id:2)') + expect(logSpy()).toHaveBeenCalledWith('Ellie Sattler (id:2)') }) it('returns the new Command so the consumer can chain', () => { @@ -575,11 +566,7 @@ describe('attachAccountCurrentCommand', () => { }) describe('attachAccountRemoveCommand', () => { - let logSpy: ReturnType - - beforeEach(() => { - logSpy = captureConsole() - }) + const logSpy = installCapturedConsole() it('removes the matched account by ref and marks it as the former default', async () => { const built = buildTokenStore() @@ -590,8 +577,8 @@ describe('attachAccountRemoveCommand', () => { expect(built.clearSpy).toHaveBeenCalledWith('alan@ingen.com') expect(await built.store.list()).toEqual([{ account: ellieSattler, isDefault: true }]) - expect(logSpy).toHaveBeenCalledOnce() - expect(logSpy).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') + expect(logSpy()).toHaveBeenCalledOnce() + expect(logSpy()).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') }) it('omits the (default) marker when the removed account was not the default', async () => { @@ -600,8 +587,8 @@ describe('attachAccountRemoveCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'remove', 'ellie@ingen.com']) - expect(logSpy).toHaveBeenCalledOnce() - expect(logSpy).toHaveBeenCalledWith('✓ Removed Ellie Sattler') + expect(logSpy()).toHaveBeenCalledOnce() + expect(logSpy()).toHaveBeenCalledWith('✓ Removed Ellie Sattler') }) it('throws ACCOUNT_NOT_FOUND and removes nothing when the ref misses', async () => { @@ -628,7 +615,7 @@ describe('attachAccountRemoveCommand', () => { expect(built.store.active).not.toHaveBeenCalled() expect(await built.store.list()).toEqual([{ account: ellieSattler, isDefault: true }]) - expect(logSpy).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') + expect(logSpy()).toHaveBeenCalledWith('✓ Removed Alan Grant (default)') }) it('emits { ok, removed } with the canonical id under --json', async () => { @@ -636,7 +623,7 @@ describe('attachAccountRemoveCommand', () => { await program.parseAsync(['node', 'cli', 'account', 'remove', 'ellie@ingen.com', '--json']) - expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) }) it('prefers --json over --ndjson when both flags are passed', async () => { @@ -652,8 +639,8 @@ describe('attachAccountRemoveCommand', () => { '--ndjson', ]) - expect(logSpy).toHaveBeenCalledOnce() - expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) + expect(logSpy()).toHaveBeenCalledOnce() + expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' })) }) it('is silent under --ndjson but still clears and runs onRemoved', async () => { @@ -671,7 +658,7 @@ describe('attachAccountRemoveCommand', () => { ]) expect(await built.store.list()).toEqual([{ account: alanGrant, isDefault: true }]) - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() expect(onRemoved).toHaveBeenCalledOnce() }) @@ -691,7 +678,7 @@ describe('attachAccountRemoveCommand', () => { } expect(renderText).toHaveBeenCalledWith(expectedCtx) expect(onRemoved).toHaveBeenCalledWith(expectedCtx) - expect(logSpy).toHaveBeenCalledWith('gone') + expect(logSpy()).toHaveBeenCalledWith('gone') }) it('emits the success line before awaiting onRemoved', async () => { @@ -708,7 +695,7 @@ describe('attachAccountRemoveCommand', () => { await vi.waitFor(() => expect(onRemoved).toHaveBeenCalled()) // Success line is already out, but the command is still parked on the hook. - expect(logSpy).toHaveBeenCalledWith('✓ Removed Ellie Sattler') + expect(logSpy()).toHaveBeenCalledWith('✓ Removed Ellie Sattler') expect(await Promise.race([parsed, Promise.resolve('pending')])).toBe('pending') releaseHook() diff --git a/src/auth/logout.test.ts b/src/auth/logout.test.ts index adb0d98..3a5a131 100644 --- a/src/auth/logout.test.ts +++ b/src/auth/logout.test.ts @@ -1,16 +1,15 @@ import type { Command } from 'commander' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson } from '../json.js' -import { buildProgram } from '../test-support/cli-harness.js' +import { buildProgram, installCapturedConsole } from '../test-support/cli-harness.js' import { type TestAccount as Account, type TokenStoreHarness, alanGrant, buildSingleEntryStore, } from '../testing/accounts.js' -import { captureConsole } from '../testing/console.js' import { attachLogoutCommand } from './logout.js' import type { TokenStore } from './types.js' @@ -45,11 +44,7 @@ function build( } describe('attachLogoutCommand', () => { - let logSpy: ReturnType - - beforeEach(() => { - logSpy = captureConsole() - }) + const logSpy = installCapturedConsole() it('clears the store and emits the human success line in plain mode', async () => { const built = buildStore() @@ -59,7 +54,7 @@ describe('attachLogoutCommand', () => { expect(built.activeSpy).toHaveBeenCalledWith(undefined) expect(built.clearSpy).toHaveBeenCalledWith(undefined) - expect(logSpy).toHaveBeenCalledWith('✓ Logged out') + expect(logSpy()).toHaveBeenCalledWith('✓ Logged out') expect(onCleared).toHaveBeenCalledWith({ account, ref: undefined, @@ -73,7 +68,7 @@ describe('attachLogoutCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'logout', '--json']) - expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ ok: true })) expect(onCleared).toHaveBeenCalledWith({ account, ref: undefined, @@ -87,7 +82,7 @@ describe('attachLogoutCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'logout', '--ndjson']) - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() expect(onCleared).toHaveBeenCalledWith({ account, ref: undefined, @@ -360,7 +355,7 @@ describe('attachLogoutCommand', () => { code: 'ACCOUNT_NOT_FOUND', }) expect(built.clearSpy).not.toHaveBeenCalled() - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() }) it('proceeds with clear(ref) when active(ref) throws AUTH_STORE_READ_FAILED', async () => { @@ -380,7 +375,7 @@ describe('attachLogoutCommand', () => { expect(built.clearSpy).toHaveBeenCalledWith('me') expect(revokeSpy).not.toHaveBeenCalled() - expect(logSpy).toHaveBeenCalledWith('✓ Logged out') + expect(logSpy()).toHaveBeenCalledWith('✓ Logged out') // `account` is null (no readable snapshot) but `ref` is populated, so // consumers can distinguish "nothing was stored" from "cleared an // unreadable record". @@ -406,7 +401,7 @@ describe('attachLogoutCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'logout', '--user', 'me']) expect(built.clearSpy).toHaveBeenCalledWith('me') - expect(logSpy).toHaveBeenCalledWith('✓ Logged out') + expect(logSpy()).toHaveBeenCalledWith('✓ Logged out') }) it('still propagates non-read errors from the snapshot pre-flight', async () => { diff --git a/src/auth/status.test.ts b/src/auth/status.test.ts index a7d4a4b..5424eda 100644 --- a/src/auth/status.test.ts +++ b/src/auth/status.test.ts @@ -1,16 +1,15 @@ import type { Command } from 'commander' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson, formatNdjson } from '../json.js' -import { buildProgram } from '../test-support/cli-harness.js' +import { buildProgram, installCapturedConsole } from '../test-support/cli-harness.js' import { type TestAccount as Account, type TokenStoreHarness, alanGrant, buildSingleEntryStore, } from '../testing/accounts.js' -import { captureConsole } from '../testing/console.js' import { attachStatusCommand } from './status.js' import type { TokenStore } from './types.js' @@ -44,11 +43,7 @@ function build( } describe('attachStatusCommand', () => { - let logSpy: ReturnType - - beforeEach(() => { - logSpy = captureConsole() - }) + const logSpy = installCapturedConsole() it('emits renderText output in plain mode', async () => { const { program, renderText } = build() @@ -60,7 +55,7 @@ describe('attachStatusCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy).toHaveBeenCalledWith('Signed in as alan@ingen.com') + expect(logSpy()).toHaveBeenCalledWith('Signed in as alan@ingen.com') }) it('emits each line when renderText returns an array', async () => { @@ -69,7 +64,9 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status']) - const emitted = logSpy.mock.calls.map((call: unknown[]) => call[0]).join('\n') + const emitted = logSpy() + .mock.calls.map((call: unknown[]) => call[0]) + .join('\n') expect(emitted).toBe('line 1\nline 2\nline 3') }) @@ -79,7 +76,7 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status', '--json']) expect(renderText).not.toHaveBeenCalled() - expect(logSpy).toHaveBeenCalledWith(formatJson(account)) + expect(logSpy()).toHaveBeenCalledWith(formatJson(account)) }) it('emits renderJson payload when supplied under --json', async () => { @@ -92,7 +89,7 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status', '--json']) expect(renderJson).toHaveBeenCalledWith({ account, flags: {} }) - expect(logSpy).toHaveBeenCalledWith(formatJson({ id: '1', email: 'alan@ingen.com' })) + expect(logSpy()).toHaveBeenCalledWith(formatJson({ id: '1', email: 'alan@ingen.com' })) }) it('emits a single NDJSON line under --ndjson', async () => { @@ -100,7 +97,7 @@ describe('attachStatusCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'status', '--ndjson']) - expect(logSpy).toHaveBeenCalledWith(formatNdjson([account])) + expect(logSpy()).toHaveBeenCalledWith(formatNdjson([account])) }) it('does not invoke renderJson in human mode', async () => { @@ -130,7 +127,7 @@ describe('attachStatusCommand', () => { view: { json: false, ndjson: false }, flags: {}, }) - expect(logSpy).toHaveBeenCalledWith('Signed in as live@ingen.com') + expect(logSpy()).toHaveBeenCalledWith('Signed in as live@ingen.com') }) it('propagates fetchLive throws', async () => { @@ -152,7 +149,7 @@ describe('attachStatusCommand', () => { constructor: CliError, code: 'NOT_AUTHENTICATED', }) - expect(logSpy).not.toHaveBeenCalled() + expect(logSpy()).not.toHaveBeenCalled() }) it('awaits an async onNotAuthenticated when supplied instead of throwing', async () => { diff --git a/src/auth/token-view.test.ts b/src/auth/token-view.test.ts index 22929cf..0f2954b 100644 --- a/src/auth/token-view.test.ts +++ b/src/auth/token-view.test.ts @@ -1,14 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' -import { buildProgram } from '../test-support/cli-harness.js' +import { buildProgram, installCapturedStream } from '../test-support/cli-harness.js' import { type TestAccount as Account, type TokenStoreHarness, alanGrant, buildSingleEntryStore, } from '../testing/accounts.js' -import { captureStream } from '../testing/console.js' import { attachTokenViewCommand } from './token-view.js' const account = alanGrant @@ -20,11 +19,7 @@ function buildStore( } describe('attachTokenViewCommand', () => { - let stdoutSpy: ReturnType - - beforeEach(() => { - stdoutSpy = captureStream() - }) + const stdoutSpy = installCapturedStream() afterEach(() => { vi.unstubAllEnvs() @@ -46,9 +41,11 @@ describe('attachTokenViewCommand', () => { }) } - const emitted = stdoutSpy.mock.calls.map((call: unknown[]) => call[0]).join('') + const emitted = stdoutSpy() + .mock.calls.map((call: unknown[]) => call[0]) + .join('') expect(emitted).toBe('tok-xyz') - expect(stdoutSpy).toHaveBeenCalledTimes(1) + expect(stdoutSpy()).toHaveBeenCalledTimes(1) }) it('appends a newline only when stdout is a TTY', async () => { @@ -67,7 +64,9 @@ describe('attachTokenViewCommand', () => { }) } - const emitted = stdoutSpy.mock.calls.map((call: unknown[]) => call[0]).join('') + const emitted = stdoutSpy() + .mock.calls.map((call: unknown[]) => call[0]) + .join('') expect(emitted).toBe('tok-xyz\n') }) @@ -82,7 +81,7 @@ describe('attachTokenViewCommand', () => { code: 'TOKEN_FROM_ENV', }) expect(activeSpy).not.toHaveBeenCalled() - expect(stdoutSpy).not.toHaveBeenCalled() + expect(stdoutSpy()).not.toHaveBeenCalled() }) it('prints normally when envVarName is set but env is empty', async () => { @@ -93,7 +92,7 @@ describe('attachTokenViewCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'token']) - expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') }) it('throws CliError(NOT_AUTHENTICATED) when the store is empty', async () => { @@ -105,7 +104,7 @@ describe('attachTokenViewCommand', () => { constructor: CliError, code: 'NOT_AUTHENTICATED', }) - expect(stdoutSpy).not.toHaveBeenCalled() + expect(stdoutSpy()).not.toHaveBeenCalled() }) it('registers under a custom name when supplied', async () => { @@ -116,7 +115,7 @@ describe('attachTokenViewCommand', () => { expect(cmd.name()).toBe('view') await program.parseAsync(['node', 'cli', 'auth', 'view']) - expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') }) it('returns the new Command so the consumer can chain', () => { @@ -135,7 +134,7 @@ describe('attachTokenViewCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'token', '--user', 'alan@ingen.com']) expect(activeSpy).toHaveBeenCalledWith('alan@ingen.com') - expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') }) it('calls store.active(undefined) when --user is absent', async () => { @@ -146,7 +145,7 @@ describe('attachTokenViewCommand', () => { await program.parseAsync(['node', 'cli', 'auth', 'token']) expect(activeSpy).toHaveBeenCalledWith(undefined) - expect(stdoutSpy).toHaveBeenCalledWith('tok-xyz') + expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz') }) it('throws ACCOUNT_NOT_FOUND when --user does not match a stored account', async () => { @@ -160,6 +159,6 @@ describe('attachTokenViewCommand', () => { constructor: CliError, code: 'ACCOUNT_NOT_FOUND', }) - expect(stdoutSpy).not.toHaveBeenCalled() + expect(stdoutSpy()).not.toHaveBeenCalled() }) }) diff --git a/src/test-support/cli-harness.ts b/src/test-support/cli-harness.ts index 497e1a4..854859a 100644 --- a/src/test-support/cli-harness.ts +++ b/src/test-support/cli-harness.ts @@ -1,11 +1,35 @@ import type { Command } from 'commander' +import { beforeEach } from 'vitest' +import { captureConsole, captureStream } from '../testing/console.js' import { createTestProgram } from '../testing/program.js' // Shared test scaffolding for the Commander attacher suites. Internal-only -// (under `src/test-support/`, excluded from the build). Console/stdout spies -// live in the published `@doist/cli-core/testing` surface — import -// `captureConsole`/`captureStream` from `../testing/console.js` directly. +// (under `src/test-support/`, excluded from the build). These thin wrappers +// own the per-test `beforeEach` lifecycle over the published `captureConsole` / +// `captureStream` helpers (which self-restore via `onTestFinished`), so the +// attacher suites declare a spy once per `describe` instead of repeating the +// `let spy` + `beforeEach` dance. + +type Spy = ReturnType + +function installCaptured(make: () => Spy): () => Spy { + let spy: Spy + beforeEach(() => { + spy = make() + }) + return () => spy +} + +/** Re-install a silenced `console` spy before each test; returns a getter for the live spy. */ +export function installCapturedConsole(method?: Parameters[0]): () => Spy { + return installCaptured(() => captureConsole(method)) +} + +/** Re-install a silenced `process.std*` write spy before each test; returns a getter. */ +export function installCapturedStream(stream?: Parameters[0]): () => Spy { + return installCaptured(() => captureStream(stream)) +} /** * Build a Commander program with `exitOverride()` and a single named parent diff --git a/src/testing/accounts.test.ts b/src/testing/accounts.test.ts index 1cb6c98..7db8808 100644 --- a/src/testing/accounts.test.ts +++ b/src/testing/accounts.test.ts @@ -21,4 +21,23 @@ describe('buildTokenStore generic reuse', () => { // The default id/email/label matcher is overridden, so a label ref no longer resolves. await expect(store.active('Alan')).resolves.toBeNull() }) + + it('applies the custom matchAccount to mutating ref lookups (setDefault)', async () => { + const alan: NoEmailAccount = { id: '1', label: 'Alan', authMode: 'rw', authScope: 's' } + const ellie: NoEmailAccount = { id: '2', label: 'Ellie', authMode: 'ro', authScope: 's' } + const { store } = buildTokenStore({ + entries: [ + { account: alan, isDefault: true }, + { account: ellie, isDefault: false }, + ], + matchAccount: (account, ref) => account.id === ref, + }) + + await expect(store.setDefault('2')).resolves.toBeUndefined() + const listed = await store.list() + expect(listed.find((entry) => entry.isDefault)?.account.id).toBe('2') + + // A label ref misses under the id-only matcher, so setDefault rejects. + await expect(store.setDefault('Ellie')).rejects.toThrow() + }) }) diff --git a/src/testing/accounts.ts b/src/testing/accounts.ts index 9f47d90..81afd29 100644 --- a/src/testing/accounts.ts +++ b/src/testing/accounts.ts @@ -154,7 +154,7 @@ export function buildTokenStore( })) }) const setDefaultSpy = vi.fn(async (ref: AccountRef) => { - const target = entries.find((e) => matches(e.account, ref)) + const target = find(ref) if (!target) throw accountNotFoundError(ref) pinnedDefaultId = target.account.id }) diff --git a/src/testing/console.test.ts b/src/testing/console.test.ts new file mode 100644 index 0000000..2f2b049 --- /dev/null +++ b/src/testing/console.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +import { captureConsole, captureStream } from './console.js' + +describe('captureConsole', () => { + it('silences console.log and records calls', () => { + const spy = captureConsole() + console.log('hello', 'world') + expect(spy).toHaveBeenCalledWith('hello', 'world') + }) + + it('restores the original console.log once the previous test finished', () => { + expect(vi.isMockFunction(console.log)).toBe(false) + }) + + it('can spy on other console methods', () => { + const spy = captureConsole('error') + console.error('boom') + expect(spy).toHaveBeenCalledWith('boom') + }) +}) + +describe('captureStream', () => { + it('silences the stream, returns true, and records writes', () => { + const spy = captureStream('stdout') + const result = process.stdout.write('chunk') + expect(result).toBe(true) + expect(spy).toHaveBeenCalledWith('chunk') + }) + + it('invokes a trailing write callback — write(chunk, cb)', () => { + captureStream('stderr') + const cb = vi.fn() + process.stderr.write('chunk', cb) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('invokes a trailing write callback — write(chunk, encoding, cb)', () => { + captureStream('stdout') + const cb = vi.fn() + process.stdout.write('chunk', 'utf8', cb) + expect(cb).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/testing/console.ts b/src/testing/console.ts index 3bc0d3f..ea2a572 100644 --- a/src/testing/console.ts +++ b/src/testing/console.ts @@ -2,27 +2,42 @@ import { onTestFinished, vi } from 'vitest' type ConsoleMethod = 'log' | 'error' | 'warn' | 'info' type StdStream = 'stdout' | 'stderr' +type Spy = ReturnType /** - * Spy on a console method, silence it, and auto-restore when the current test - * finishes. Returns the spy so `.mock.calls` assertions keep working. Call it - * inside a test or `beforeEach` — `onTestFinished` throws at `describe` top-level. + * Install a silencing spy and auto-restore it when the current test finishes. + * Call inside a test or `beforeEach` — `onTestFinished` throws at `describe` + * top-level. Returns the spy so `.mock.calls` assertions keep working. */ -export function captureConsole(method: ConsoleMethod = 'log'): ReturnType { - const spy = vi.spyOn(console, method).mockImplementation(() => {}) +function captureSpy(install: () => Spy): Spy { + const spy = install() onTestFinished(() => { spy.mockRestore() }) return spy } +// `WriteStream.write` accepts an optional trailing callback (`write(chunk, cb)` +// or `write(chunk, encoding, cb)`); the real stream invokes it once flushed. +// The silencing stub mirrors that so code paths awaiting the callback resolve. +function silentWrite(...args: unknown[]): boolean { + const last = args.at(-1) + if (typeof last === 'function') { + ;(last as (error?: Error | null) => void)() + } + return true +} + +/** Spy on a console method, silence it, and auto-restore when the test finishes. */ +export function captureConsole(method: ConsoleMethod = 'log'): Spy { + return captureSpy(() => vi.spyOn(console, method).mockImplementation(() => {})) +} + /** Same as {@link captureConsole} for `process.stdout`/`process.stderr.write` (pipe-safe paths). */ -export function captureStream(stream: StdStream = 'stdout'): ReturnType { - const spy = vi - .spyOn(process[stream], 'write') - .mockImplementation((() => true) as typeof process.stdout.write) - onTestFinished(() => { - spy.mockRestore() - }) - return spy +export function captureStream(stream: StdStream = 'stdout'): Spy { + return captureSpy(() => + vi + .spyOn(process[stream], 'write') + .mockImplementation(silentWrite as typeof process.stdout.write), + ) } diff --git a/src/testing/program.test.ts b/src/testing/program.test.ts new file mode 100644 index 0000000..485e67d --- /dev/null +++ b/src/testing/program.test.ts @@ -0,0 +1,35 @@ +import { Command } from 'commander' +import { describe, expect, it, vi } from 'vitest' + +import { createTestProgram } from './program.js' + +describe('createTestProgram', () => { + it('returns a Command after running the register callback', () => { + const register = vi.fn((program: Command) => { + program.command('greet').action(() => {}) + }) + const program = createTestProgram(register) + + expect(program).toBeInstanceOf(Command) + expect(register).toHaveBeenCalledWith(program) + }) + + it('runs a registered command action', async () => { + const action = vi.fn() + const program = createTestProgram((p) => { + p.command('greet').action(action) + }) + + await program.parseAsync(['node', 'cli', 'greet']) + + expect(action).toHaveBeenCalled() + }) + + it('throws instead of calling process.exit on an unknown command (exitOverride)', async () => { + const program = createTestProgram((p) => { + p.command('greet').action(() => {}) + }) + + await expect(program.parseAsync(['node', 'cli', 'nope'])).rejects.toBeDefined() + }) +}) diff --git a/src/testing/subpath.test.ts b/src/testing/subpath.test.ts index 58bd4d9..92876cf 100644 --- a/src/testing/subpath.test.ts +++ b/src/testing/subpath.test.ts @@ -28,7 +28,19 @@ describe('@doist/cli-core/testing subpath wiring', () => { expect(existsSync(importPath)).toBe(true) expect(existsSync(typesPath)).toBe(true) const mod = (await import(importPath)) as Record - expect(typeof mod.describeEmptyMachineOutput).toBe('function') + // Assert each runtime export the barrel re-exports: nothing in-repo + // imports through the barrel (the suites use relative paths), so a + // dropped re-export wouldn't otherwise fail the type-check. + for (const name of [ + 'describeEmptyMachineOutput', + 'createTestProgram', + 'captureConsole', + 'captureStream', + 'buildTokenStore', + 'buildSingleEntryStore', + ]) { + expect(typeof mod[name], `${name} should be exported`).toBe('function') + } }, ) }) From a25c8235c73e421c720f38fbceba1515d1062141 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 11:49:10 +0100 Subject: [PATCH 3/4] fix(testing): address second review round on ./testing - captureStream queues the trailing write callback on the microtask queue instead of invoking it inline, matching the async WriteStream.write contract - narrow buildTokenStore overrides to a StoreOverrides type covering only the optional store members, so callers can't erase required methods and leave the returned store an invalid TokenStore - subpath smoke test compares the source barrel's runtime keys against the dist module's instead of a hand-maintained name list - drop the order-dependent console-restore test (passes trivially in isolation; onTestFinished is a vitest primitive) - tighten the exitOverride test to assert the commander.unknownCommand code - drop the restating JSDoc on the installCaptured* wrappers Co-Authored-By: Claude Opus 4.7 (1M context) --- src/test-support/cli-harness.ts | 2 -- src/testing/accounts.ts | 20 ++++++++++++++++---- src/testing/console.test.ts | 10 ++++------ src/testing/console.ts | 7 ++++--- src/testing/program.test.ts | 4 +++- src/testing/subpath.test.ts | 22 ++++++++-------------- 6 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/test-support/cli-harness.ts b/src/test-support/cli-harness.ts index 854859a..db368ce 100644 --- a/src/test-support/cli-harness.ts +++ b/src/test-support/cli-harness.ts @@ -21,12 +21,10 @@ function installCaptured(make: () => Spy): () => Spy { return () => spy } -/** Re-install a silenced `console` spy before each test; returns a getter for the live spy. */ export function installCapturedConsole(method?: Parameters[0]): () => Spy { return installCaptured(() => captureConsole(method)) } -/** Re-install a silenced `process.std*` write spy before each test; returns a getter. */ export function installCapturedStream(stream?: Parameters[0]): () => Spy { return installCaptured(() => captureStream(stream)) } diff --git a/src/testing/accounts.ts b/src/testing/accounts.ts index 81afd29..5e194d6 100644 --- a/src/testing/accounts.ts +++ b/src/testing/accounts.ts @@ -58,6 +58,18 @@ function defaultMatchAccount(account: AuthAccount, ref: AccountRef): boolean { return account.id === ref || account.email === ref || account.label === ref } +/** + * Overridable store members. Only the optional contract methods + * (`activeAccount` / `setBundle` / `activeBundle`) can be replaced or omitted + * (via `undefined`); the required methods stay intact so the built `store` is + * always a valid `TokenStore`. + */ +export type StoreOverrides = { + [K in keyof TokenStore as undefined extends TokenStore[K] + ? K + : never]?: TokenStore[K] +} + export type TokenStoreHarness = { store: TokenStore activeSpy: ReturnType @@ -73,12 +85,12 @@ export type TokenStoreHarness = { export function buildTokenStore(opts?: { entries?: StoreEntry[] - overrides?: Partial> + overrides?: StoreOverrides matchAccount?: MatchAccount }): TokenStoreHarness export function buildTokenStore(opts: { entries: StoreEntry[] - overrides?: Partial> + overrides?: StoreOverrides matchAccount?: MatchAccount }): TokenStoreHarness /** @@ -94,7 +106,7 @@ export function buildTokenStore(opts: { export function buildTokenStore( opts: { entries?: StoreEntry[] - overrides?: Partial> + overrides?: StoreOverrides matchAccount?: MatchAccount } = {}, ): TokenStoreHarness { @@ -210,7 +222,7 @@ export function buildTokenStore( */ export function buildSingleEntryStore( initial: { token: string; account: TestAccount } | null, - overrides?: Partial>, + overrides?: StoreOverrides, ): TokenStoreHarness { return buildTokenStore({ entries: initial diff --git a/src/testing/console.test.ts b/src/testing/console.test.ts index 2f2b049..807d50e 100644 --- a/src/testing/console.test.ts +++ b/src/testing/console.test.ts @@ -9,10 +9,6 @@ describe('captureConsole', () => { expect(spy).toHaveBeenCalledWith('hello', 'world') }) - it('restores the original console.log once the previous test finished', () => { - expect(vi.isMockFunction(console.log)).toBe(false) - }) - it('can spy on other console methods', () => { const spy = captureConsole('error') console.error('boom') @@ -28,17 +24,19 @@ describe('captureStream', () => { expect(spy).toHaveBeenCalledWith('chunk') }) - it('invokes a trailing write callback — write(chunk, cb)', () => { + it('invokes a trailing write callback — write(chunk, cb)', async () => { captureStream('stderr') const cb = vi.fn() process.stderr.write('chunk', cb) + await Promise.resolve() expect(cb).toHaveBeenCalledTimes(1) }) - it('invokes a trailing write callback — write(chunk, encoding, cb)', () => { + it('invokes a trailing write callback — write(chunk, encoding, cb)', async () => { captureStream('stdout') const cb = vi.fn() process.stdout.write('chunk', 'utf8', cb) + await Promise.resolve() expect(cb).toHaveBeenCalledTimes(1) }) }) diff --git a/src/testing/console.ts b/src/testing/console.ts index ea2a572..ba56d56 100644 --- a/src/testing/console.ts +++ b/src/testing/console.ts @@ -18,12 +18,13 @@ function captureSpy(install: () => Spy): Spy { } // `WriteStream.write` accepts an optional trailing callback (`write(chunk, cb)` -// or `write(chunk, encoding, cb)`); the real stream invokes it once flushed. -// The silencing stub mirrors that so code paths awaiting the callback resolve. +// or `write(chunk, encoding, cb)`) and invokes it asynchronously once the write +// is handled. Queue it on the microtask queue rather than calling inline so +// callback ordering matches the real stream. function silentWrite(...args: unknown[]): boolean { const last = args.at(-1) if (typeof last === 'function') { - ;(last as (error?: Error | null) => void)() + queueMicrotask(last as (error?: Error | null) => void) } return true } diff --git a/src/testing/program.test.ts b/src/testing/program.test.ts index 485e67d..b58ed5a 100644 --- a/src/testing/program.test.ts +++ b/src/testing/program.test.ts @@ -30,6 +30,8 @@ describe('createTestProgram', () => { p.command('greet').action(() => {}) }) - await expect(program.parseAsync(['node', 'cli', 'nope'])).rejects.toBeDefined() + await expect(program.parseAsync(['node', 'cli', 'nope'])).rejects.toMatchObject({ + code: 'commander.unknownCommand', + }) }) }) diff --git a/src/testing/subpath.test.ts b/src/testing/subpath.test.ts index 92876cf..dacee6a 100644 --- a/src/testing/subpath.test.ts +++ b/src/testing/subpath.test.ts @@ -27,20 +27,14 @@ describe('@doist/cli-core/testing subpath wiring', () => { const typesPath = resolve(repoRoot, entry?.types ?? '') expect(existsSync(importPath)).toBe(true) expect(existsSync(typesPath)).toBe(true) - const mod = (await import(importPath)) as Record - // Assert each runtime export the barrel re-exports: nothing in-repo - // imports through the barrel (the suites use relative paths), so a - // dropped re-export wouldn't otherwise fail the type-check. - for (const name of [ - 'describeEmptyMachineOutput', - 'createTestProgram', - 'captureConsole', - 'captureStream', - 'buildTokenStore', - 'buildSingleEntryStore', - ]) { - expect(typeof mod[name], `${name} should be exported`).toBe('function') - } + const dist = (await import(importPath)) as Record + // The source barrel is the source of truth for the subpath surface; + // assert the built dist module exposes exactly the same runtime + // exports. Nothing in-repo imports through the barrel (the suites use + // relative paths), so a dropped re-export wouldn't otherwise fail the + // type-check. + const source = await import('./index.js') + expect(Object.keys(dist).sort()).toEqual(Object.keys(source).sort()) }, ) }) From b39c681667a12a5244ef9b9d07d45fd251866cc4 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 12:03:40 +0100 Subject: [PATCH 4/4] fix(testing): type capture spies as MockInstance to keep .mock.calls usable `ReturnType` erases to a shape whose `.mock.calls` is `any`, forcing consumers to annotate `.mock.calls.map((c) => ...)` callbacks. Typing the spies as vitest's `MockInstance` restores `.mock.calls` typing so the helpers are a true drop-in for hand-rolled console/stream spies. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/testing/console.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/testing/console.ts b/src/testing/console.ts index ba56d56..e3fccdf 100644 --- a/src/testing/console.ts +++ b/src/testing/console.ts @@ -1,8 +1,12 @@ +import type { MockInstance } from 'vitest' import { onTestFinished, vi } from 'vitest' type ConsoleMethod = 'log' | 'error' | 'warn' | 'info' type StdStream = 'stdout' | 'stderr' -type Spy = ReturnType +// Use vitest's `MockInstance` (not `ReturnType`, which erases +// down to a shape whose `.mock.calls` is `any`) so consumers keep usable +// `.mock.calls` typing — making these a true drop-in for hand-rolled spies. +type Spy = MockInstance /** * Install a silencing spy and auto-restore it when the current test finishes.