diff --git a/README.md b/README.md index 028412d..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` | 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`; 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 @@ -55,6 +55,48 @@ if (tasks.length === 0) { } ``` +### Testing helpers (subpath) + +Reuse the shared vitest scaffolding instead of hand-rolling it per CLI: + +```ts +import { attachStatusCommand } from '@doist/cli-core/auth' +import { + alanGrant, + buildSingleEntryStore, + captureConsole, + createTestProgram, +} from '@doist/cli-core/testing' + +it('greets the active account', async () => { + const logSpy = captureConsole() // silences console.log, auto-restores + 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') +}) +``` + +`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 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) 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..01b3a6d 100644 --- a/src/auth/account.test.ts +++ b/src/auth/account.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson, formatNdjson } from '../json.js' +import { buildProgram, installCapturedConsole } 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 { type AttachAccountCurrentCommandOptions, type AttachAccountListCommandOptions, @@ -76,7 +76,7 @@ function buildRemove( } describe('attachAccountListCommand', () => { - const logSpy = installConsoleLogSpy() + const logSpy = installCapturedConsole() it('renders default human lines with a (default) marker only on the default entry', async () => { const { program } = buildList() @@ -309,7 +309,7 @@ describe('attachAccountListCommand', () => { }) describe('attachAccountUseCommand', () => { - const logSpy = installConsoleLogSpy() + const logSpy = installCapturedConsole() it('calls setDefault and echoes the raw ref in the human success line', async () => { const built = buildTokenStore() @@ -425,7 +425,7 @@ describe('attachAccountUseCommand', () => { }) describe('attachAccountCurrentCommand', () => { - const logSpy = installConsoleLogSpy() + const logSpy = installCapturedConsole() it('renders the default human line with a (default) marker for the active account', async () => { const { program } = buildCurrent() @@ -566,7 +566,7 @@ describe('attachAccountCurrentCommand', () => { }) describe('attachAccountRemoveCommand', () => { - const logSpy = installConsoleLogSpy() + const logSpy = installCapturedConsole() it('removes the matched account by ref and marks it as the former default', async () => { const built = buildTokenStore() 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..3a5a131 100644 --- a/src/auth/logout.test.ts +++ b/src/auth/logout.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson } from '../json.js' +import { buildProgram, installCapturedConsole } 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 { attachLogoutCommand } from './logout.js' import type { TokenStore } from './types.js' @@ -44,7 +44,7 @@ function build( } describe('attachLogoutCommand', () => { - const logSpy = installConsoleLogSpy() + const logSpy = installCapturedConsole() it('clears the store and emits the human success line in plain mode', async () => { const built = buildStore() 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..5424eda 100644 --- a/src/auth/status.test.ts +++ b/src/auth/status.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' import { formatJson, formatNdjson } from '../json.js' +import { buildProgram, installCapturedConsole } 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 { attachStatusCommand } from './status.js' import type { TokenStore } from './types.js' @@ -43,7 +43,7 @@ function build( } describe('attachStatusCommand', () => { - const logSpy = installConsoleLogSpy() + const logSpy = installCapturedConsole() it('emits renderText output in plain mode', async () => { const { program, renderText } = build() diff --git a/src/auth/token-view.test.ts b/src/auth/token-view.test.ts index 99e7efb..0f2954b 100644 --- a/src/auth/token-view.test.ts +++ b/src/auth/token-view.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../errors.js' +import { buildProgram, installCapturedStream } 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 { attachTokenViewCommand } from './token-view.js' const account = alanGrant @@ -19,7 +19,7 @@ function buildStore( } describe('attachTokenViewCommand', () => { - const stdoutSpy = installStdoutSpy() + const stdoutSpy = installCapturedStream() afterEach(() => { vi.unstubAllEnvs() 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..db368ce 100644 --- a/src/test-support/cli-harness.ts +++ b/src/test-support/cli-harness.ts @@ -1,43 +1,32 @@ -import { Command } from 'commander' -import { afterEach, beforeEach, vi } from 'vitest' +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). +// (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 +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 { +function installCaptured(make: () => Spy): () => Spy { let spy: Spy beforeEach(() => { - spy = register() - }) - afterEach(() => { - spy.mockRestore() + spy = make() }) return () => spy } -/** - * 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(() => {})) +export function installCapturedConsole(method?: Parameters[0]): () => Spy { + return installCaptured(() => captureConsole(method)) } -/** Same as {@link installConsoleLogSpy} for `process.stdout.write` (pipe-safe output). */ -export function installStdoutSpy(): () => Spy { - return installSpy(() => vi.spyOn(process.stdout, 'write').mockImplementation(() => true)) +export function installCapturedStream(stream?: Parameters[0]): () => Spy { + return installCaptured(() => captureStream(stream)) } /** @@ -45,8 +34,9 @@ export function installStdoutSpy(): () => Spy { * 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..7db8808 --- /dev/null +++ b/src/testing/accounts.test.ts @@ -0,0 +1,43 @@ +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() + }) + + 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/test-support/accounts.ts b/src/testing/accounts.ts similarity index 78% rename from src/test-support/accounts.ts rename to src/testing/accounts.ts index 07d306c..5e194d6 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,11 +47,29 @@ 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 } +/** + * 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 @@ -67,23 +85,32 @@ 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 /** * 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?: StoreOverrides + matchAccount?: MatchAccount + } = {}, ): TokenStoreHarness { + const matches: MatchAccount = opts.matchAccount ?? defaultMatchAccount const entries: StoreEntry[] = ( opts.entries ?? (ingenEntries() as unknown as StoreEntry[]) ).map((entry) => ({ ...entry })) @@ -106,7 +133,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 +166,7 @@ export function buildTokenStore( })) }) const setDefaultSpy = vi.fn(async (ref: AccountRef) => { - const target = entries.find((e) => matchesRef(e.account, ref)) + const target = find(ref) if (!target) throw accountNotFoundError(ref) pinnedDefaultId = target.account.id }) @@ -195,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 new file mode 100644 index 0000000..807d50e --- /dev/null +++ b/src/testing/console.test.ts @@ -0,0 +1,42 @@ +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('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)', 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)', 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 new file mode 100644 index 0000000..e3fccdf --- /dev/null +++ b/src/testing/console.ts @@ -0,0 +1,48 @@ +import type { MockInstance } from 'vitest' +import { onTestFinished, vi } from 'vitest' + +type ConsoleMethod = 'log' | 'error' | 'warn' | 'info' +type StdStream = 'stdout' | 'stderr' +// 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. + * Call inside a test or `beforeEach` — `onTestFinished` throws at `describe` + * top-level. Returns the spy so `.mock.calls` assertions keep working. + */ +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)`) 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') { + queueMicrotask(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'): Spy { + return captureSpy(() => + vi + .spyOn(process[stream], 'write') + .mockImplementation(silentWrite as typeof process.stdout.write), + ) +} 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.test.ts b/src/testing/program.test.ts new file mode 100644 index 0000000..b58ed5a --- /dev/null +++ b/src/testing/program.test.ts @@ -0,0 +1,37 @@ +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.toMatchObject({ + code: 'commander.unknownCommand', + }) + }) +}) 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 71% rename from src/testing.test.ts rename to src/testing/subpath.test.ts index 79d6584..dacee6a 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 } @@ -27,8 +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 - expect(typeof mod.describeEmptyMachineOutput).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()) }, ) })