From 35b1f0116529d1ed30fc641b496a874a5b0e721b Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 12:37:21 +0100 Subject: [PATCH 1/2] test: adopt @doist/cli-core/testing shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bespoke inline test scaffolding with the published @doist/cli-core/testing helpers, mirroring todoist-cli#361 (cli-core#49): - createTestProgram(register) replaces inline `new Command(); exitOverride(); registerX(program)` harness blocks. - captureConsole(method?) replaces inline `vi.spyOn(console, …)` recorders and the local captureLogs/captureStreams helpers; assertions read the spy's .mock.calls via a small lines() accessor. Bump @doist/cli-core 0.23.0 -> 0.24.0, which ships these helpers. No source/runtime files changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 4 +- package-lock.json | 8 +- package.json | 2 +- src/commands/auth-command.test.ts | 100 +++++++++---------- src/commands/changelog-integration.test.ts | 14 +-- src/commands/commands.test.ts | 107 ++++++--------------- src/commands/empty-output.test.ts | 7 +- src/lib/output.test.ts | 23 ++--- 8 files changed, 97 insertions(+), 168 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5d4e98..6f45ee9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,8 +44,8 @@ Vitest with module mocking. Tests are colocated next to the source they cover (` - Mock `apiRequest` with `vi.mock()` - Stub `fetch` globally for API tests -- Capture `console.log` calls in an array for output assertions -- Use `program.exitOverride()` + `program.parseAsync()` to test command parsing +- Capture output with `captureConsole(method?)` / `captureStream(stream?)` from `@doist/cli-core/testing` (auto-restoring spies); read `spy.mock.calls` for assertions +- Build the command harness with `createTestProgram(register)` from `@doist/cli-core/testing` (applies `exitOverride()`), then `program.parseAsync()` - Auth tests use tmpdir with `process.pid` for filesystem isolation ## Skill Content (Agent Command Reference) diff --git a/package-lock.json b/package-lock.json index 3def946..f0d0cc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@doist/cli-core": "0.23.0", + "@doist/cli-core": "0.24.0", "chalk": "5.6.2", "commander": "14.0.2", "marked": "18.0.3", @@ -133,9 +133,9 @@ } }, "node_modules/@doist/cli-core": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.23.0.tgz", - "integrity": "sha512-SzqbFi7m5AGQYsgX2U+1zIcKAGsiORk5IJvtfalBuKVlRXT6xdVoQtL8HgsOHli+GqRtAm6xqQlxMHBJqfwAEg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.24.0.tgz", + "integrity": "sha512-Jg8KFd7jtoHJp8mGLiT4sL3pk6iMronIwm7cXPLAVJYuMU4f1u0180Djc+cIJlVoi9t0D6/npJcKP649VdJwXQ==", "license": "MIT", "dependencies": { "chalk": "5.6.2", diff --git a/package.json b/package.json index c767f70..67e8a31 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "node": ">=20.18.1" }, "dependencies": { - "@doist/cli-core": "0.23.0", + "@doist/cli-core": "0.24.0", "chalk": "5.6.2", "commander": "14.0.2", "marked": "18.0.3", diff --git a/src/commands/auth-command.test.ts b/src/commands/auth-command.test.ts index d50cc65..bc87916 100644 --- a/src/commands/auth-command.test.ts +++ b/src/commands/auth-command.test.ts @@ -1,5 +1,6 @@ +import { captureConsole, createTestProgram } from '@doist/cli-core/testing' import { Command } from 'commander' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_INFO, TWO_USER_CONFIG } from '../_fixtures/auth.js' vi.mock('../lib/auth.js', () => ({ @@ -32,16 +33,11 @@ vi.mock('@doist/cli-core/auth', async () => ({ })) /** - * Replace `console.log` with a recorder. Tests read `logs` to assert on - * stdout-bound output. Lines are joined with spaces, matching how chalk's - * styled fragments arrive at the spy. + * Read a `captureConsole` spy's recorded calls as joined lines, matching how + * chalk's styled fragments arrive (one console call → one space-joined line). */ -function captureLogs(): { logs: string[] } { - const logs: string[] = [] - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.join(' ')) - }) - return { logs } +function lines(spy: MockInstance): string[] { + return spy.mock.calls.map((args) => args.join(' ')) } async function captureAttachOptions() { @@ -49,9 +45,7 @@ async function captureAttachOptions() { const login = new Command('login') vi.mocked(attachLoginCommand).mockReturnValue(login) const { registerAuthCommand } = await import('./auth.js') - const program = new Command() - program.exitOverride() - registerAuthCommand(program) + const program = createTestProgram(registerAuthCommand) return { options: vi.mocked(attachLoginCommand).mock.calls[0][1], login, program } } @@ -79,7 +73,7 @@ afterEach(() => { describe('registerAuthCommand', () => { it('wires --base-url / --client-id, env-driven port, and prints success only in human output mode', async () => { process.env.OUTLINE_OAUTH_CALLBACK_PORT = '7000' - const { logs } = captureLogs() + const log = captureConsole() const { options, login } = await captureAttachOptions() @@ -98,8 +92,8 @@ describe('registerAuthCommand', () => { await options.onSuccess({ view: { json: false, ndjson: false }, flags: {}, account }) await options.onSuccess({ view: { json: true, ndjson: false }, flags: {}, account }) - expect(logs.length).toBe(1) - expect(logs[0]).toContain('Authenticated to Analytics as Ada') + expect(lines(log).length).toBe(1) + expect(lines(log)[0]).toContain('Authenticated to Analytics as Ada') }) it('falls back to the default callback port when the env var is unparseable', async () => { @@ -117,7 +111,7 @@ describe('auth status subcommand', () => { it('renders the human status from the env-token snapshot path', async () => { process.env.OUTLINE_API_TOKEN = 'env-token' - const { logs } = captureLogs() + const log = captureConsole() const apiRequest = await importApiMock() apiRequest.mockResolvedValue({ data: AUTH_INFO }) @@ -131,25 +125,25 @@ describe('auth status subcommand', () => { {}, { token: 'env-token', baseUrl: 'https://test.outline.com' }, ) - expect(logs.some((l) => l.includes('Authenticated'))).toBe(true) - expect(logs.some((l) => l.includes('Team:') && l.includes('Analytics'))).toBe(true) - expect(logs.some((l) => l.includes('Ada Lovelace') && l.includes('ada@example.com'))).toBe( - true, - ) - expect(logs.some((l) => l.includes('Token source: env'))).toBe(true) + expect(lines(log).some((l) => l.includes('Authenticated'))).toBe(true) + expect(lines(log).some((l) => l.includes('Team:') && l.includes('Analytics'))).toBe(true) + expect( + lines(log).some((l) => l.includes('Ada Lovelace') && l.includes('ada@example.com')), + ).toBe(true) + expect(lines(log).some((l) => l.includes('Token source: env'))).toBe(true) }) it('emits a PII-free JSON envelope under --json', async () => { process.env.OUTLINE_API_TOKEN = 'env-token' - const { logs } = captureLogs() + const log = captureConsole() const apiRequest = await importApiMock() apiRequest.mockResolvedValue({ data: AUTH_INFO }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'auth', 'status', '--json']) - expect(logs).toHaveLength(1) - const payload = JSON.parse(logs[0]) + expect(lines(log)).toHaveLength(1) + const payload = JSON.parse(lines(log)[0]) expect(payload).toEqual({ id: 'user-uuid', team: 'Analytics', @@ -162,16 +156,16 @@ describe('auth status subcommand', () => { it('emits a single newline-free NDJSON line under --ndjson', async () => { process.env.OUTLINE_API_TOKEN = 'env-token' - const { logs } = captureLogs() + const log = captureConsole() const apiRequest = await importApiMock() apiRequest.mockResolvedValue({ data: AUTH_INFO }) const program = await buildProgram() await program.parseAsync(['node', 'ol', 'auth', 'status', '--ndjson']) - expect(logs).toHaveLength(1) - expect(logs[0]).not.toContain('\n') - expect(JSON.parse(logs[0])).toEqual({ + expect(lines(log)).toHaveLength(1) + expect(lines(log)[0]).not.toContain('\n') + expect(JSON.parse(lines(log)[0])).toEqual({ id: 'user-uuid', team: 'Analytics', baseUrl: 'https://test.outline.com', @@ -230,22 +224,22 @@ describe('auth status subcommand', () => { describe('auth logout subcommand', () => { it('prints the registrar success line in human mode', async () => { - const { logs } = captureLogs() + const log = captureConsole() const program = await buildProgram() await program.parseAsync(['node', 'ol', 'auth', 'logout']) - expect(logs).toContain('✓ Logged out') + expect(lines(log)).toContain('✓ Logged out') }) it('emits {"ok": true} under --json and skips the human success line', async () => { - const { logs } = captureLogs() + const log = captureConsole() const program = await buildProgram() await program.parseAsync(['node', 'ol', 'auth', 'logout', '--json']) - expect(logs).toHaveLength(1) - expect(JSON.parse(logs[0])).toEqual({ ok: true }) + expect(lines(log)).toHaveLength(1) + expect(JSON.parse(lines(log)[0])).toEqual({ ok: true }) }) it('stays silent on stdout under --ndjson (no human storage-result line leaks)', async () => { @@ -254,49 +248,39 @@ describe('auth logout subcommand', () => { // clean stdout — any human-readable line here would corrupt the // stream. Guards the `isMachineOutput` branch in // `logTokenStorageResult`. - const { logs } = captureLogs() + const log = captureConsole() const program = await buildProgram() await program.parseAsync(['node', 'ol', 'auth', 'logout', '--ndjson']) - expect(logs).toEqual([]) + expect(lines(log)).toEqual([]) }) }) describe('logTokenStorageResult', () => { - function captureStreams() { - const logs: string[] = [] - const errs: string[] = [] - vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { - logs.push(a.join(' ')) - }) - vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { - errs.push(a.join(' ')) - }) - return { logs, errs } - } - it('prints the secure-store confirmation to stdout in human mode', async () => { - const { logs, errs } = captureStreams() + const log = captureConsole() + const errorSpy = captureConsole('error') const { logTokenStorageResult } = await import('./auth.js') logTokenStorageResult({ storage: 'secure-store' }, 'Token stored securely', false) - expect(logs.some((l) => l.includes('Token stored securely'))).toBe(true) - expect(errs).toEqual([]) + expect(lines(log).some((l) => l.includes('Token stored securely'))).toBe(true) + expect(lines(errorSpy)).toEqual([]) }) it('suppresses the stdout confirmation in machine-output mode', async () => { - const { logs } = captureStreams() + const log = captureConsole() const { logTokenStorageResult } = await import('./auth.js') logTokenStorageResult({ storage: 'secure-store' }, 'Token stored securely', true) - expect(logs).toEqual([]) + expect(lines(log)).toEqual([]) }) it('routes the keyring-fallback warning to stderr (in both human and machine modes)', async () => { - const { logs, errs } = captureStreams() + const log = captureConsole() + const errorSpy = captureConsole('error') const { logTokenStorageResult } = await import('./auth.js') logTokenStorageResult( @@ -306,7 +290,9 @@ describe('logTokenStorageResult', () => { ) // No stdout in machine mode, but warning still reaches operator on stderr. - expect(logs).toEqual([]) - expect(errs.some((e) => e.includes('system credential manager unavailable'))).toBe(true) + expect(lines(log)).toEqual([]) + expect( + lines(errorSpy).some((e) => e.includes('system credential manager unavailable')), + ).toBe(true) }) }) diff --git a/src/commands/changelog-integration.test.ts b/src/commands/changelog-integration.test.ts index 3df8d19..ed32322 100644 --- a/src/commands/changelog-integration.test.ts +++ b/src/commands/changelog-integration.test.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander' +import { createTestProgram } from '@doist/cli-core/testing' import { describe, expect, it } from 'vitest' import { BaseCliError } from '../lib/errors.js' import { formatError, formatErrorJson } from '../lib/output.js' @@ -6,9 +6,7 @@ import { registerChangelogCommand } from './changelog.js' describe('changelog command end-to-end', () => { it('rejects with BaseCliError(INVALID_TYPE) when --count is not a number', async () => { - const program = new Command() - program.exitOverride() - registerChangelogCommand(program) + const program = createTestProgram(registerChangelogCommand) await expect( program.parseAsync(['node', 'ol', 'changelog', '-n', 'abc']), @@ -22,9 +20,7 @@ describe('changelog command end-to-end', () => { }) it('formats the rejected BaseCliError through formatError (human)', async () => { - const program = new Command() - program.exitOverride() - registerChangelogCommand(program) + const program = createTestProgram(registerChangelogCommand) const err = await program .parseAsync(['node', 'ol', 'changelog', '-n', 'abc']) @@ -36,9 +32,7 @@ describe('changelog command end-to-end', () => { }) it('formats the rejected BaseCliError through formatErrorJson', async () => { - const program = new Command() - program.exitOverride() - registerChangelogCommand(program) + const program = createTestProgram(registerChangelogCommand) const err = await program .parseAsync(['node', 'ol', 'changelog', '-n', 'abc']) diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index fe872e4..5adb365 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -1,5 +1,5 @@ -import { Command } from 'commander' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole, createTestProgram } from '@doist/cli-core/testing' +import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../lib/auth.js', () => ({ getApiToken: async () => 'test-token', @@ -14,13 +14,11 @@ vi.mock('../lib/api.js', () => ({ })) describe('search command', () => { - let logs: string[] + let log: MockInstance + const lines = () => log.mock.calls.map((args) => args.join(' ')) beforeEach(() => { - logs = [] - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.join(' ')) - }) + log = captureConsole() }) afterEach(() => { @@ -46,9 +44,7 @@ describe('search command', () => { }) const { registerSearchCommand } = await import('./search.js') - const program = new Command() - program.exitOverride() - registerSearchCommand(program) + const program = createTestProgram(registerSearchCommand) await program.parseAsync(['node', 'ol', 'search', 'test query', '--limit', '10']) @@ -76,25 +72,21 @@ describe('search command', () => { }) const { registerSearchCommand } = await import('./search.js') - const program = new Command() - program.exitOverride() - registerSearchCommand(program) + const program = createTestProgram(registerSearchCommand) await program.parseAsync(['node', 'ol', 'search', 'test', '--json']) - const parsed = JSON.parse(logs[0]) + const parsed = JSON.parse(lines()[0]) expect(parsed[0].document.title).toBe('Test') }) }) describe('document commands', () => { - let logs: string[] + let log: MockInstance + const lines = () => log.mock.calls.map((args) => args.join(' ')) beforeEach(() => { - logs = [] - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.join(' ')) - }) + log = captureConsole() }) afterEach(() => { @@ -115,15 +107,13 @@ describe('document commands', () => { }) const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await program.parseAsync(['node', 'ol', 'document', 'get', 'my-doc-abc123']) expect(apiRequest).toHaveBeenCalledWith('documents.info', { id: 'abc123' }) - expect(logs[0]).toContain('My Doc') - expect(logs[0]).toContain('Hello world') + expect(lines()[0]).toContain('My Doc') + expect(lines()[0]).toContain('Hello world') }) it('document list passes pagination options', async () => { @@ -134,9 +124,7 @@ describe('document commands', () => { }) const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await program.parseAsync([ 'node', @@ -187,9 +175,7 @@ describe('document commands', () => { }) const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await program.parseAsync([ 'node', @@ -213,15 +199,10 @@ describe('document commands', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`) }) - const errors: string[] = [] - vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - errors.push(args.join(' ')) - }) + const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await expect( program.parseAsync([ @@ -238,7 +219,7 @@ describe('document commands', () => { ]), ).rejects.toThrow('process.exit(1)') - expect(errors.join(' ')).toContain('mutually exclusive') + expect(errorSpy.mock.calls.flat().join(' ')).toContain('mutually exclusive') mockExit.mockRestore() }) @@ -264,9 +245,7 @@ describe('document commands', () => { }) const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await program.parseAsync([ 'node', @@ -289,15 +268,10 @@ describe('document commands', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`) }) - const errors: string[] = [] - vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - errors.push(args.join(' ')) - }) + const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await expect( program.parseAsync([ @@ -313,7 +287,7 @@ describe('document commands', () => { ]), ).rejects.toThrow('process.exit(1)') - expect(errors.join(' ')).toContain('mutually exclusive') + expect(errorSpy.mock.calls.flat().join(' ')).toContain('mutually exclusive') mockExit.mockRestore() }) @@ -321,22 +295,17 @@ describe('document commands', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`) }) - const errors: string[] = [] - vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - errors.push(args.join(' ')) - }) + const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await expect( program.parseAsync(['node', 'ol', 'document', 'move', 'docABC1']), ).rejects.toThrow('process.exit(1)') - expect(errors.join(' ')).toContain('--collection') - expect(errors.join(' ')).toContain('--parent') + expect(errorSpy.mock.calls.flat().join(' ')).toContain('--collection') + expect(errorSpy.mock.calls.flat().join(' ')).toContain('--parent') mockExit.mockRestore() }) @@ -361,15 +330,10 @@ describe('document commands', () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`) }) - const errors: string[] = [] - vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - errors.push(args.join(' ')) - }) + const errorSpy = captureConsole('error') const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await expect( program.parseAsync([ @@ -383,19 +347,14 @@ describe('document commands', () => { ]), ).rejects.toThrow('process.exit(1)') - expect(errors.join(' ')).toContain('cannot be its own parent') + expect(errorSpy.mock.calls.flat().join(' ')).toContain('cannot be its own parent') mockExit.mockRestore() }) }) describe('collection commands', () => { - let logs: string[] - beforeEach(() => { - logs = [] - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.join(' ')) - }) + captureConsole() }) afterEach(() => { @@ -409,9 +368,7 @@ describe('collection commands', () => { }) const { registerCollectionCommand } = await import('./collection.js') - const program = new Command() - program.exitOverride() - registerCollectionCommand(program) + const program = createTestProgram(registerCollectionCommand) await program.parseAsync(['node', 'ol', 'collection', 'list']) diff --git a/src/commands/empty-output.test.ts b/src/commands/empty-output.test.ts index 78b8cd6..97c01d9 100644 --- a/src/commands/empty-output.test.ts +++ b/src/commands/empty-output.test.ts @@ -1,5 +1,4 @@ -import { describeEmptyMachineOutput } from '@doist/cli-core/testing' -import { Command } from 'commander' +import { createTestProgram, describeEmptyMachineOutput } from '@doist/cli-core/testing' import { vi } from 'vitest' vi.mock('../lib/auth.js', () => ({ @@ -18,9 +17,7 @@ describeEmptyMachineOutput('ol document list', { setup: () => {}, run: async (extraArgs) => { const { registerDocumentCommand } = await import('./document.js') - const program = new Command() - program.exitOverride() - registerDocumentCommand(program) + const program = createTestProgram(registerDocumentCommand) await program.parseAsync(['node', 'ol', 'document', 'list', ...extraArgs]) }, humanMessage: 'No documents found.', diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts index d3bde7b..13634bf 100644 --- a/src/lib/output.test.ts +++ b/src/lib/output.test.ts @@ -1,19 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '@doist/cli-core/testing' +import { type MockInstance, beforeEach, describe, expect, it } from 'vitest' import { BaseCliError } from './errors.js' import { formatError, formatErrorJson, getOutputOptions, outputItem, outputList } from './output.js' describe('output', () => { - let logs: string[] + let log: MockInstance + const lines = () => log.mock.calls.map((args) => args.join(' ')) beforeEach(() => { - logs = [] - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.join(' ')) - }) - }) - - afterEach(() => { - vi.restoreAllMocks() + log = captureConsole() }) const item = { id: '1', name: 'Test', extra: 'hidden' } @@ -22,24 +17,24 @@ describe('output', () => { it('outputItem human mode', () => { outputItem(item, formatter, keys) - expect(logs[0]).toBe('Test (1)') + expect(lines()[0]).toBe('Test (1)') }) it('outputItem json mode shows essential keys only', () => { outputItem(item, formatter, keys, { json: true }) - const parsed = JSON.parse(logs[0]) + const parsed = JSON.parse(lines()[0]) expect(parsed).toEqual({ id: '1', name: 'Test' }) }) it('outputItem json full mode shows all keys', () => { outputItem(item, formatter, keys, { json: true, full: true }) - const parsed = JSON.parse(logs[0]) + const parsed = JSON.parse(lines()[0]) expect(parsed).toEqual({ id: '1', name: 'Test', extra: 'hidden' }) }) it('outputList ndjson mode', () => { outputList([item, { ...item, id: '2' }], formatter, keys, { ndjson: true }) - const records = logs + const records = lines() .flatMap((line) => line.split('\n')) .filter(Boolean) .map((line) => JSON.parse(line)) From c85639b33c5be0783ba5150dd0ba07b72105d649 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sun, 24 May 2026 12:46:39 +0100 Subject: [PATCH 2/2] test: lift lines() helper to top level in commands.test.ts Address review: dedupe the per-describe lines() helper into a single top-level function (matching auth-command.test.ts) and reuse it for the errorSpy assertions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/commands.test.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index 5adb365..539f6ec 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -13,9 +13,13 @@ vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn(), })) +/** Read a `captureConsole` spy's recorded calls as joined lines. */ +function lines(spy: MockInstance): string[] { + return spy.mock.calls.map((args) => args.join(' ')) +} + describe('search command', () => { let log: MockInstance - const lines = () => log.mock.calls.map((args) => args.join(' ')) beforeEach(() => { log = captureConsole() @@ -76,14 +80,13 @@ describe('search command', () => { await program.parseAsync(['node', 'ol', 'search', 'test', '--json']) - const parsed = JSON.parse(lines()[0]) + const parsed = JSON.parse(lines(log)[0]) expect(parsed[0].document.title).toBe('Test') }) }) describe('document commands', () => { let log: MockInstance - const lines = () => log.mock.calls.map((args) => args.join(' ')) beforeEach(() => { log = captureConsole() @@ -112,8 +115,8 @@ describe('document commands', () => { await program.parseAsync(['node', 'ol', 'document', 'get', 'my-doc-abc123']) expect(apiRequest).toHaveBeenCalledWith('documents.info', { id: 'abc123' }) - expect(lines()[0]).toContain('My Doc') - expect(lines()[0]).toContain('Hello world') + expect(lines(log)[0]).toContain('My Doc') + expect(lines(log)[0]).toContain('Hello world') }) it('document list passes pagination options', async () => { @@ -219,7 +222,7 @@ describe('document commands', () => { ]), ).rejects.toThrow('process.exit(1)') - expect(errorSpy.mock.calls.flat().join(' ')).toContain('mutually exclusive') + expect(lines(errorSpy).join(' ')).toContain('mutually exclusive') mockExit.mockRestore() }) @@ -287,7 +290,7 @@ describe('document commands', () => { ]), ).rejects.toThrow('process.exit(1)') - expect(errorSpy.mock.calls.flat().join(' ')).toContain('mutually exclusive') + expect(lines(errorSpy).join(' ')).toContain('mutually exclusive') mockExit.mockRestore() }) @@ -304,8 +307,8 @@ describe('document commands', () => { program.parseAsync(['node', 'ol', 'document', 'move', 'docABC1']), ).rejects.toThrow('process.exit(1)') - expect(errorSpy.mock.calls.flat().join(' ')).toContain('--collection') - expect(errorSpy.mock.calls.flat().join(' ')).toContain('--parent') + expect(lines(errorSpy).join(' ')).toContain('--collection') + expect(lines(errorSpy).join(' ')).toContain('--parent') mockExit.mockRestore() }) @@ -347,7 +350,7 @@ describe('document commands', () => { ]), ).rejects.toThrow('process.exit(1)') - expect(errorSpy.mock.calls.flat().join(' ')).toContain('cannot be its own parent') + expect(lines(errorSpy).join(' ')).toContain('cannot be its own parent') mockExit.mockRestore() }) })