diff --git a/src/commands/account/account.test.ts b/src/commands/account/account.test.ts index 40eb348..4763af9 100644 --- a/src/commands/account/account.test.ts +++ b/src/commands/account/account.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const storeMocks = vi.hoisted(() => ({ set: vi.fn(), @@ -31,12 +32,7 @@ import { type TwistAccount } from '../../lib/auth-provider.js' import { TOKEN_ENV_VAR } from '../../lib/auth.js' import { registerAccountCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerAccountCommand(program) - return program -} +const createProgram = () => createTestProgram(registerAccountCommand) /** Seed `store.list()` and `store.setDefault/clear` resolvers in one call. */ function seedStore(...records: Array): void { @@ -63,13 +59,11 @@ describe('account command', () => { beforeEach(() => { vi.clearAllMocks() legacyMocks.isLegacyAuthActive.mockResolvedValue(false) - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + consoleSpy = captureConsole() + errorSpy = captureConsole('error') }) afterEach(() => { - consoleSpy.mockRestore() - errorSpy.mockRestore() vi.unstubAllEnvs() }) diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 35498a6..b42c9f5 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -1,5 +1,7 @@ import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' // Mock the auth module (only the read-side shims are stubbed; the // write-side path now goes through `createTwistTokenStore` from @@ -89,12 +91,7 @@ const mockGetAuthMetadata = vi.mocked(getAuthMetadata) const mockCreateWrappedTwistClient = vi.mocked(createWrappedTwistClient) const mockAttachLoginCommand = vi.mocked(attachLoginCommand) -function createProgram() { - const program = new Command() - program.exitOverride() - registerAuthCommand(program) - return program -} +const createProgram = () => createTestProgram(registerAuthCommand) const TEST_USER: User = { id: 1, @@ -114,14 +111,8 @@ describe('auth command', () => { beforeEach(() => { vi.clearAllMocks() - // Mock console.log to capture output - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - }) - - afterEach(() => { - consoleSpy.mockRestore() - errorSpy.mockRestore() + consoleSpy = captureConsole() + errorSpy = captureConsole('error') }) const STORED_ACCOUNT: TwistAccount = { diff --git a/src/commands/away/away.test.ts b/src/commands/away/away.test.ts index cb3df7e..3deffaf 100644 --- a/src/commands/away/away.test.ts +++ b/src/commands/away/away.test.ts @@ -1,6 +1,7 @@ import { TwistRequestError } from '@doist/twist-sdk' -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getSessionUser: vi.fn(), @@ -17,12 +18,7 @@ vi.mock('chalk') import { registerAwayCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerAwayCommand(program) - return program -} +const createProgram = () => createTestProgram(registerAwayCommand) describe('away', () => { beforeEach(() => { @@ -45,13 +41,12 @@ describe('away', () => { name: 'Test User', awayMode: null, }) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'away']) expect(logSpy).toHaveBeenCalledWith('Not away.') - logSpy.mockRestore() }) it('shows away status when set', async () => { @@ -60,19 +55,18 @@ describe('away', () => { name: 'Test User', awayMode: { type: 'vacation', dateFrom: '2026-03-10', dateTo: '2026-03-20' }, }) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'away']) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Vacation')) - logSpy.mockRestore() }) }) describe('set', () => { it('calls users.update with awayMode', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'away', 'set', 'vacation', '2026-03-20']) @@ -85,11 +79,10 @@ describe('away', () => { }), }), ) - logSpy.mockRestore() }) it('supports --from flag', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() const program = createProgram() await program.parseAsync([ @@ -106,11 +99,10 @@ describe('away', () => { expect(apiMocks.updateUser).toHaveBeenCalledWith({ awayMode: { type: 'vacation', dateFrom: '2026-03-15', dateTo: '2026-03-20' }, }) - logSpy.mockRestore() }) it('shows dry-run message', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -125,7 +117,6 @@ describe('away', () => { expect(apiMocks.updateUser).not.toHaveBeenCalled() expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Would set away status')) - logSpy.mockRestore() }) it('propagates insufficient scope errors (handled globally by API proxy)', async () => { @@ -152,25 +143,23 @@ describe('away', () => { describe('clear', () => { it('calls users.update with empty string awayMode to clear', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'away', 'clear']) expect(apiMocks.updateUser).toHaveBeenCalledWith({ awayMode: '' }) expect(logSpy).toHaveBeenCalledWith('Away status cleared.') - logSpy.mockRestore() }) it('shows dry-run message', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'away', 'clear', '--dry-run']) expect(apiMocks.updateUser).not.toHaveBeenCalled() expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Would clear away status')) - logSpy.mockRestore() }) }) }) diff --git a/src/commands/changelog.test.ts b/src/commands/changelog.test.ts index 329d1e8..30f3196 100644 --- a/src/commands/changelog.test.ts +++ b/src/commands/changelog.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' +import { createTestProgram } from '../test-helpers/program.js' vi.mock('node:fs/promises') @@ -32,18 +33,13 @@ const SAMPLE_CHANGELOG = `# Changelog * prior release with a level-2 heading ` -function createProgram() { - const program = new Command() - program.exitOverride() - registerChangelogCommand(program) - return program -} +const createProgram = () => createTestProgram(registerChangelogCommand) describe('changelog wrapper', () => { let logSpy: ReturnType beforeEach(() => { - logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + logSpy = captureConsole() }) afterEach(() => { diff --git a/src/commands/channel/add.test.ts b/src/commands/channel/add.test.ts index 915109e..e25103d 100644 --- a/src/commands/channel/add.test.ts +++ b/src/commands/channel/add.test.ts @@ -1,5 +1,7 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createChannelFixture } from '../../lib/__fixtures__/channels.js' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const mockGetChannel = vi.fn() @@ -26,25 +28,9 @@ vi.mock('chalk') import { registerChannelCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerChannelCommand(program) - return program -} - -const sampleChannel = { - id: 500, - name: 'general', - workspaceId: 1, - userIds: [1, 2, 3], - creator: 1, - public: true, - archived: false, - created: new Date(), - version: 1, - url: 'https://twist.com/a/1/ch/500', -} +const createProgram = () => createTestProgram(registerChannelCommand) + +const sampleChannel = createChannelFixture() beforeEach(() => { vi.clearAllMocks() @@ -63,7 +49,7 @@ describe('tw channel members add', () => { expandedFrom: [], }) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -89,7 +75,7 @@ describe('tw channel members add', () => { expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4, 5] }], }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -114,7 +100,7 @@ describe('tw channel members add', () => { expandedFrom: [], }) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -136,7 +122,7 @@ describe('tw channel members add', () => { expandedFrom: [], }) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -158,7 +144,7 @@ describe('tw channel members add', () => { expandedFrom: [], }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -182,7 +168,7 @@ describe('tw channel members add', () => { expandedFrom: [], }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index 5c7bcc0..d64e40d 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -1,6 +1,7 @@ import { describeEmptyMachineOutput } from '@doist/cli-core/testing' -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -36,12 +37,7 @@ vi.mock('chalk') import { registerChannelCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerChannelCommand(program) - return program -} +const createProgram = () => createTestProgram(registerChannelCommand) function createChannel(id: number, name: string, overrides: Partial> = {}) { return { @@ -96,7 +92,7 @@ describe('channels list', () => { ], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels']) @@ -109,8 +105,6 @@ describe('channels list', () => { expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy.mock.calls[0][0]).toContain('General') expect(consoleSpy.mock.calls[0][0]).not.toContain('Leadership') - - consoleSpy.mockRestore() }) it('also works via the singular channel command name', async () => { @@ -118,7 +112,7 @@ describe('channels list', () => { joinedChannels: [createChannel(10, 'General')], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel']) @@ -128,8 +122,6 @@ describe('channels list', () => { archived: false, }) expect(consoleSpy.mock.calls[0][0]).toContain('General') - - consoleSpy.mockRestore() }) it('supports explicit channel list subcommand', async () => { @@ -137,7 +129,7 @@ describe('channels list', () => { joinedChannels: [createChannel(10, 'General')], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'list']) @@ -146,8 +138,6 @@ describe('channels list', () => { workspaceId: 1, archived: false, }) - - consoleSpy.mockRestore() }) it('includes joined private channels when --include-private-channels is enabled', async () => { @@ -159,7 +149,7 @@ describe('channels list', () => { ], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels']) @@ -171,8 +161,6 @@ describe('channels list', () => { }) expect(consoleSpy.mock.calls[1][0]).toContain('Leadership') expect(consoleSpy.mock.calls[1][0]).toContain('[private]') - - consoleSpy.mockRestore() }) it('lists active public channels and marks whether they are joined', async () => { @@ -188,7 +176,7 @@ describe('channels list', () => { ], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels', '--scope', 'public']) @@ -201,8 +189,6 @@ describe('channels list', () => { expect(consoleSpy.mock.calls[1][0]).toContain('Marketing') expect(consoleSpy.mock.calls[1][0]).toContain('[not joined]') expect(consoleSpy.mock.calls[0][0]).not.toContain('Archive') - - consoleSpy.mockRestore() }) it('lists only discoverable channels in JSON mode', async () => { @@ -211,7 +197,7 @@ describe('channels list', () => { publicChannels: [createChannel(10, 'General'), createChannel(30, 'Marketing')], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels', '--scope', 'discoverable', '--json']) @@ -220,8 +206,6 @@ describe('channels list', () => { expect(jsonOutput).toEqual([ { id: 30, name: 'Marketing', workspaceId: 1, archived: false, joined: false }, ]) - - consoleSpy.mockRestore() }) it('lists archived joined channels with --state archived', async () => { @@ -229,7 +213,7 @@ describe('channels list', () => { joinedChannels: [createChannel(90, 'Old General', { archived: true })], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels', '--state', 'archived']) @@ -238,8 +222,6 @@ describe('channels list', () => { expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy.mock.calls[0][0]).toContain('Old General') expect(consoleSpy.mock.calls[0][0]).toContain('(archived)') - - consoleSpy.mockRestore() }) it('lists all visible public channels with --scope public --state all', async () => { @@ -251,7 +233,7 @@ describe('channels list', () => { ], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -270,8 +252,6 @@ describe('channels list', () => { { id: 10, name: 'General', workspaceId: 1, archived: false, joined: true }, { id: 40, name: 'Archive', workspaceId: 1, archived: true, joined: false }, ]) - - consoleSpy.mockRestore() }) it('includes archived state in joined JSON output without --full', async () => { @@ -282,7 +262,7 @@ describe('channels list', () => { ], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels', '--state', 'all', '--json']) @@ -292,8 +272,6 @@ describe('channels list', () => { { id: 10, name: 'General', workspaceId: 1, archived: false }, { id: 40, name: 'Archive', workspaceId: 1, archived: true }, ]) - - consoleSpy.mockRestore() }) it('includes archived state in joined NDJSON output without --full', async () => { @@ -304,7 +282,7 @@ describe('channels list', () => { ], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels', '--state', 'all', '--ndjson']) @@ -316,8 +294,6 @@ describe('channels list', () => { { id: 10, name: 'General', workspaceId: 1, archived: false }, { id: 40, name: 'Archive', workspaceId: 1, archived: true }, ]) - - consoleSpy.mockRestore() }) it('includes joined metadata in full JSON for public scope', async () => { @@ -326,7 +302,7 @@ describe('channels list', () => { publicChannels: [createChannel(10, 'General', { description: 'Everyone' })], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -346,8 +322,6 @@ describe('channels list', () => { description: 'Everyone', joined: true, }) - - consoleSpy.mockRestore() }) describeEmptyMachineOutput('empty machine output contract', { @@ -371,14 +345,12 @@ describe('channels list', () => { ], }) apiMocks.getTwistClient.mockResolvedValue(client) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channels', '--scope', 'discoverable']) expect(consoleSpy).toHaveBeenCalledWith('No active discoverable channels found.') - - consoleSpy.mockRestore() }) it('rejects invalid scope values', async () => { diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index d9dbae0..588a992 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -1,5 +1,7 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createChannelFixture } from '../../lib/__fixtures__/channels.js' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const mockBatch = vi.fn() const mockGetUserById = vi.fn() @@ -28,25 +30,9 @@ vi.mock('chalk') import { registerChannelCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerChannelCommand(program) - return program -} - -const sampleChannel = { - id: 500, - name: 'general', - workspaceId: 1, - userIds: [1, 2, 3], - creator: 1, - public: true, - archived: false, - created: new Date(), - version: 1, - url: 'https://twist.com/a/1/ch/500', -} +const createProgram = () => createTestProgram(registerChannelCommand) + +const sampleChannel = createChannelFixture() const frontendGroup = { id: 100, name: 'Frontend', workspaceId: 1, userIds: [1, 2], version: 1 } const backendGroup = { id: 200, name: 'Backend', workspaceId: 1, userIds: [4, 5], version: 1 } @@ -86,7 +72,7 @@ describe('tw channel members (list)', () => { it('lists users and groups fully in channel', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'channel', 'members', 'general']) @@ -103,7 +89,7 @@ describe('tw channel members (list)', () => { it('outputs JSON with default shape', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--json']) @@ -118,7 +104,7 @@ describe('tw channel members (list)', () => { it('batches one getUserById request per channel member', async () => { const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--json']) @@ -139,7 +125,7 @@ describe('tw channel members (list)', () => { it('ndjson default shape matches json default shape', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--ndjson']) @@ -154,7 +140,7 @@ describe('tw channel members (list)', () => { it('--full ndjson includes raw channel fields', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', diff --git a/src/commands/channel/remove.test.ts b/src/commands/channel/remove.test.ts index 2a011b2..6b289c2 100644 --- a/src/commands/channel/remove.test.ts +++ b/src/commands/channel/remove.test.ts @@ -1,5 +1,7 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createChannelFixture } from '../../lib/__fixtures__/channels.js' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const mockGetChannel = vi.fn() @@ -26,25 +28,9 @@ vi.mock('chalk') import { registerChannelCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerChannelCommand(program) - return program -} - -const sampleChannel = { - id: 500, - name: 'general', - workspaceId: 1, - userIds: [1, 2, 3], - creator: 1, - public: true, - archived: false, - created: new Date(), - version: 1, - url: 'https://twist.com/a/1/ch/500', -} +const createProgram = () => createTestProgram(registerChannelCommand) + +const sampleChannel = createChannelFixture() beforeEach(() => { vi.clearAllMocks() @@ -63,7 +49,7 @@ describe('tw channel members remove', () => { expandedFrom: [], }) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -86,7 +72,7 @@ describe('tw channel members remove', () => { expandedFrom: [], }) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -108,7 +94,7 @@ describe('tw channel members remove', () => { expandedFrom: [], }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -132,7 +118,7 @@ describe('tw channel members remove', () => { expandedFrom: [], }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -157,7 +143,7 @@ describe('tw channel members remove', () => { }) mockGetChannel.mockResolvedValue({ ...sampleChannel, userIds: [1, 3] }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', diff --git a/src/commands/channel/set.test.ts b/src/commands/channel/set.test.ts index 2483846..c20bab6 100644 --- a/src/commands/channel/set.test.ts +++ b/src/commands/channel/set.test.ts @@ -1,5 +1,7 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createChannelFixture } from '../../lib/__fixtures__/channels.js' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), @@ -24,25 +26,9 @@ vi.mock('chalk') import { registerChannelCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerChannelCommand(program) - return program -} - -const sampleChannel = { - id: 500, - name: 'general', - workspaceId: 1, - userIds: [1, 2, 3], - creator: 1, - public: true, - archived: false, - created: new Date(), - version: 1, - url: 'https://twist.com/a/1/ch/500', -} +const createProgram = () => createTestProgram(registerChannelCommand) + +const sampleChannel = createChannelFixture() beforeEach(() => { vi.clearAllMocks() @@ -59,7 +45,7 @@ describe('tw channel members set', () => { expandedFrom: [], }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -86,7 +72,7 @@ describe('tw channel members set', () => { expandedFrom: [], }) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -135,7 +121,7 @@ describe('tw channel members set', () => { expandedFrom: [], }) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -161,7 +147,7 @@ describe('tw channel members set', () => { expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', diff --git a/src/commands/channel/threads.test.ts b/src/commands/channel/threads.test.ts index 45c83c5..b06ad3c 100644 --- a/src/commands/channel/threads.test.ts +++ b/src/commands/channel/threads.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -38,12 +39,7 @@ import { assertChannelIsPublic } from '../../lib/public-channels.js' import { decodeCursor, encodeCursor } from './helpers.js' import { registerChannelCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerChannelCommand(program) - return program -} +const createProgram = () => createTestProgram(registerChannelCommand) type Thread = { id: number @@ -214,7 +210,7 @@ describe('channel threads', () => { threads: [createThread(1), createThread(2), createThread(3)], unread: [{ threadId: 2 }], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) @@ -223,8 +219,6 @@ describe('channel threads', () => { expect(output.results.find((t: { id: number }) => t.id === 1).isUnread).toBe(false) expect(output.results.find((t: { id: number }) => t.id === 2).isUnread).toBe(true) expect(output.results.find((t: { id: number }) => t.id === 3).isUnread).toBe(false) - - consoleSpy.mockRestore() }) it('--unread filters to unread threads only', async () => { @@ -232,7 +226,7 @@ describe('channel threads', () => { threads: [createThread(1), createThread(2), createThread(3)], unread: [{ threadId: 2 }], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -248,8 +242,6 @@ describe('channel threads', () => { const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results).toHaveLength(1) expect(output.results[0].id).toBe(2) - - consoleSpy.mockRestore() }) it('--since filters by lastUpdated', async () => { @@ -260,7 +252,7 @@ describe('channel threads', () => { createThread(3, { lastUpdated: new Date('2026-03-01T00:00:00Z') }), ], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -276,8 +268,6 @@ describe('channel threads', () => { const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.map((t: { id: number }) => t.id).sort()).toEqual([2, 3]) - - consoleSpy.mockRestore() }) it('--until filters by lastUpdated (exclusive)', async () => { @@ -288,7 +278,7 @@ describe('channel threads', () => { createThread(3, { lastUpdated: new Date('2026-03-01T00:00:00Z') }), ], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -304,8 +294,6 @@ describe('channel threads', () => { const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.map((t: { id: number }) => t.id)).toEqual([1]) - - consoleSpy.mockRestore() }) it('sorts newest-first by lastUpdated', async () => { @@ -316,15 +304,13 @@ describe('channel threads', () => { createThread(3, { lastUpdated: new Date('2026-02-01T00:00:00Z') }), ], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.map((t: { id: number }) => t.id)).toEqual([2, 3, 1]) - - consoleSpy.mockRestore() }) it('--limit truncates results and emits nextCursor', async () => { @@ -337,7 +323,7 @@ describe('channel threads', () => { createThread(5, { lastUpdated: new Date('2026-01-01T00:00:00Z') }), ], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -354,8 +340,6 @@ describe('channel threads', () => { const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.map((t: { id: number }) => t.id)).toEqual([1, 2]) expect(output.nextCursor).toEqual(encodeCursor(2)) - - consoleSpy.mockRestore() }) it('--cursor advances to the next page', async () => { @@ -368,7 +352,7 @@ describe('channel threads', () => { createThread(5, { lastUpdated: new Date('2026-01-01T00:00:00Z') }), ], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -387,35 +371,29 @@ describe('channel threads', () => { const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.map((t: { id: number }) => t.id)).toEqual([3, 4]) expect(output.nextCursor).toEqual(encodeCursor(4)) - - consoleSpy.mockRestore() }) it('nextCursor is null on the last page', async () => { setupClient({ threads: [createThread(1), createThread(2)], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.nextCursor).toBeNull() - - consoleSpy.mockRestore() }) it('calls assertChannelIsPublic after resolving the channel', async () => { setupClient({ threads: [createThread(1)] }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) expect(vi.mocked(assertChannelIsPublic)).toHaveBeenCalledWith(100, 1) - - consoleSpy.mockRestore() }) it('propagates the error when the channel is private and the guard rejects', async () => { @@ -487,14 +465,12 @@ describe('channel threads', () => { it('prints empty-state message when no threads', async () => { refsMocks.resolveChannelRef.mockResolvedValue(channel(100, 'general')) setupClient({ threads: [] }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'threads', 'general']) expect(consoleSpy).toHaveBeenCalledWith('No threads in #general.') - - consoleSpy.mockRestore() }) it('--json emits isUnread and url without --full', async () => { @@ -502,7 +478,7 @@ describe('channel threads', () => { threads: [createThread(1)], unread: [{ threadId: 1 }], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) @@ -513,8 +489,6 @@ describe('channel threads', () => { isUnread: true, url: 'https://twist.com/a/1/ch/100/t/1', }) - - consoleSpy.mockRestore() }) it('--ndjson emits one thread per line plus _meta terminator when paginated', async () => { @@ -525,7 +499,7 @@ describe('channel threads', () => { createThread(3, { lastUpdated: new Date('2026-01-03T00:00:00Z') }), ], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync([ @@ -547,13 +521,11 @@ describe('channel threads', () => { expect(first.id).toBe(1) expect(second.id).toBe(2) expect(meta).toEqual({ _meta: true, nextCursor: encodeCursor(2) }) - - consoleSpy.mockRestore() }) it('does not crash when unreadResp.data is null (regression: batch getUnread returning null)', async () => { setupClient({ threads: [createThread(1)], unread: null }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await expect( @@ -563,8 +535,6 @@ describe('channel threads', () => { const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results).toHaveLength(1) expect(output.results[0].isUnread).toBe(false) - - consoleSpy.mockRestore() }) it('surfaces API error when threads batch sub-request fails', async () => { @@ -590,15 +560,13 @@ describe('channel threads', () => { setupClient({ threads: [createThread(1, { pinned: true })], }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json', '--full']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results[0]).toHaveProperty('pinned', true) - - consoleSpy.mockRestore() }) }) diff --git a/src/commands/comment/comment.test.ts b/src/commands/comment/comment.test.ts index 5039605..78a6a39 100644 --- a/src/commands/comment/comment.test.ts +++ b/src/commands/comment/comment.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -78,12 +79,7 @@ function createClient({ commentCreator = 1, sessionUserId = 1 } = {}) { } } -function createProgram() { - const program = new Command() - program.exitOverride() - registerCommentCommand(program) - return program -} +const createProgram = () => createTestProgram(registerCommentCommand) describe('comment implicit view', () => { beforeEach(() => { @@ -93,13 +89,11 @@ describe('comment implicit view', () => { it('tw comment routes to view (not unknown command)', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await expect(program.parseAsync(['node', 'tw', 'comment', '300'])).rejects.toThrow( 'MOCK_API_REACHED', ) - - consoleSpy.mockRestore() }) }) @@ -112,34 +106,32 @@ describe('comment view', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'view', '300']) expect(client.comments.getComment).toHaveBeenCalledWith(300) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Comment 300')) - consoleSpy.mockRestore() }) it('outputs JSON with --json', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'view', '300', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) expect(jsonOutput.content).toBe('Comment 300') - consoleSpy.mockRestore() }) it('outputs NDJSON with --ndjson', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'view', '300', '--ndjson']) @@ -148,21 +140,19 @@ describe('comment view', () => { const parsed = JSON.parse(line) expect(parsed.id).toBe(300) expect(parsed.content).toBe('Comment 300') - consoleSpy.mockRestore() }) it('includes creatorName in --json --full output', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'view', '300', '--json', '--full']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) expect(jsonOutput.creatorName).toBe('Bob') - consoleSpy.mockRestore() }) }) @@ -175,7 +165,7 @@ describe('comment update', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'update', '300', 'Updated content']) @@ -184,12 +174,11 @@ describe('comment update', () => { content: 'Updated content', }) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Comment updated:')) - consoleSpy.mockRestore() }) it('shows dry run output', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -204,21 +193,19 @@ describe('comment update', () => { expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would update comment')) expect(consoleSpy).toHaveBeenCalledWith(' Comment: 300') expect(consoleSpy).toHaveBeenCalledWith(' Content: New content') - consoleSpy.mockRestore() }) it('outputs JSON with --json', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'update', '300', 'Updated', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) expect(jsonOutput.content).toBe('Updated') - consoleSpy.mockRestore() }) it('reads content from stdin', async () => { @@ -226,7 +213,7 @@ describe('comment update', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'update', '300']) @@ -234,18 +221,15 @@ describe('comment update', () => { id: 300, content: 'Content from stdin', }) - consoleSpy.mockRestore() }) it('errors when no content is provided', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await expect( program.parseAsync(['node', 'tw', 'comment', 'update', '300']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') - - consoleSpy.mockRestore() }) }) @@ -258,20 +242,19 @@ describe('comment delete', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'delete', '300']) expect(client.comments.deleteComment).toHaveBeenCalledWith(300) expect(consoleSpy).toHaveBeenCalledWith('Comment 300 deleted.') - consoleSpy.mockRestore() }) it('shows dry run output', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--dry-run']) @@ -279,7 +262,6 @@ describe('comment delete', () => { expect(consoleSpy).toHaveBeenCalledWith(' Comment: 300') expect(consoleSpy).toHaveBeenCalledWith(' Thread: 500') expect(client.comments.deleteComment).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('rejects non-creator with NOT_CREATOR in dry-run', async () => { @@ -311,12 +293,11 @@ describe('comment delete', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 300, deleted: true }) - consoleSpy.mockRestore() }) }) diff --git a/src/commands/config/config.test.ts b/src/commands/config/config.test.ts index 287d8ac..0cc78a6 100644 --- a/src/commands/config/config.test.ts +++ b/src/commands/config/config.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' vi.mock('chalk') @@ -32,12 +33,7 @@ const mockReadConfigStrict = vi.mocked(readConfigStrict) const mockProbeApiToken = vi.mocked(probeApiToken) const mockSetConfig = vi.mocked(setConfig) -function createProgram() { - const program = new Command() - program.exitOverride() - registerConfigCommand(program) - return program -} +const createProgram = () => createTestProgram(registerConfigCommand) const fullConfig: Config = { token: 'tw_abcdefghij1234567890', @@ -81,7 +77,7 @@ describe('config view', () => { it('prints a pretty layout with the token masked by default', async () => { presentConfig() mockToken('config-file', { authScope: 'user:read' }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -96,14 +92,12 @@ describe('config view', () => { expect(output).toContain('read-write') expect(output).toContain('12345') expect(output).toContain('stable') - - consoleSpy.mockRestore() }) it('labels tokens stored in the system credential manager', async () => { presentConfig({ authMode: 'read-write' }) mockToken('secure-store', { token: 'tw_keychainXXXXXXXX1234' }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -111,8 +105,6 @@ describe('config view', () => { expect(output).toContain('****…1234') expect(output).toContain('system credential manager') expect(output).not.toContain('plaintext') - - consoleSpy.mockRestore() }) it('labels env-sourced tokens and shows active mode, not stale config values', async () => { @@ -123,7 +115,7 @@ describe('config view', () => { authScope: 'user:read', }) mockToken('env', { token: 'tw_envXXXXXXXX5678', authMode: 'unknown' }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -137,8 +129,6 @@ describe('config view', () => { expect(output).toContain('Scope: unknown') expect(output).not.toMatch(/Scope:\s+user:read/) expect(output).not.toMatch(/Scope:\s+not set/) - - consoleSpy.mockRestore() }) it('annotates a missing config file even when a token is present', async () => { @@ -146,7 +136,7 @@ describe('config view', () => { // the header must clearly report that the file does not exist. missingConfig() mockToken('env', { token: 'tw_envXXXXXXXX9999', authMode: 'unknown' }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -155,28 +145,24 @@ describe('config view', () => { expect(output).toContain('not created yet') expect(output).toContain('****…9999') expect(output).toContain('TWIST_API_TOKEN') - - consoleSpy.mockRestore() }) it('runs view by default when no subcommand is given', async () => { presentConfig() mockToken('config-file') - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Authentication') expect(output).toContain('****…7890') - - consoleSpy.mockRestore() }) it('degrades gracefully when the credential manager is unavailable', async () => { presentConfig({ authMode: 'read-write', updateChannel: 'stable' }) mockProbeApiToken.mockRejectedValue(new SecureStoreUnavailableError('macOS Keychain error')) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -184,13 +170,11 @@ describe('config view', () => { expect(output).toContain('unknown') expect(output).toContain('system credential manager unavailable') expect(output).toContain('stable') - - consoleSpy.mockRestore() }) it('--json emits the raw config with token masked', async () => { presentConfig() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) @@ -198,14 +182,12 @@ describe('config view', () => { expect(parsed.token).toBe('****…7890') expect(parsed.authMode).toBe('read-write') expect(parsed.currentWorkspace).toBe(12345) - - consoleSpy.mockRestore() }) it('--show-token reveals the full token in both views', async () => { presentConfig() mockToken('config-file') - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--show-token']) const pretty = consoleSpy.mock.calls.map((c) => c[0]).join('\n') @@ -215,14 +197,12 @@ describe('config view', () => { await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json', '--show-token']) const json = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(json.token).toBe('tw_abcdefghij1234567890') - - consoleSpy.mockRestore() }) it('handles a missing config file gracefully', async () => { missingConfig() mockProbeApiToken.mockRejectedValue(new NoTokenError()) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) expect(consoleSpy.mock.calls[0][0]).toContain('not created yet') @@ -230,8 +210,6 @@ describe('config view', () => { consoleSpy.mockClear() await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) expect(consoleSpy.mock.calls[0][0]).toBe('{}') - - consoleSpy.mockRestore() }) it('surfaces malformed-config errors instead of silently pretending it is empty', async () => { @@ -252,32 +230,28 @@ describe('config view', () => { it('shows "not set" when no token can be found anywhere', async () => { presentConfig({ updateChannel: 'stable' }) mockProbeApiToken.mockRejectedValue(new NoTokenError()) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('not set') expect(output).toContain('stable') - - consoleSpy.mockRestore() }) it('masks very short tokens without exposing characters', async () => { presentConfig({ token: 'abcd' }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.token).toBe('****') expect(parsed.token).not.toContain('abcd') - - consoleSpy.mockRestore() }) it('renders an "Authenticated accounts" block from config.users with default marker', async () => { presentConfig({ users: [STORED_ALAN, STORED_ELLIE], defaultUserId: '2' }) mockToken('secure-store') - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -291,7 +265,6 @@ describe('config view', () => { const adaLine = output.split('\n').find((l) => l.includes('Alan Grant')) ?? '' expect(bobLine).toContain('*') expect(adaLine).not.toContain('*') - consoleSpy.mockRestore() }) it('marks the first stored account as default when defaultUserId is missing', async () => { @@ -299,7 +272,7 @@ describe('config view', () => { // the view would claim no account is active when one will be used. presentConfig({ users: [STORED_ALAN, STORED_ELLIE] }) mockToken('secure-store') - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -308,7 +281,6 @@ describe('config view', () => { const bobLine = output.split('\n').find((l) => l.includes('Ellie Sattler')) ?? '' expect(adaLine).toContain('*') expect(bobLine).not.toContain('*') - consoleSpy.mockRestore() }) it('falls back to the first stored account when defaultUserId is stale', async () => { @@ -317,7 +289,7 @@ describe('config view', () => { // must follow. presentConfig({ users: [STORED_ALAN, STORED_ELLIE], defaultUserId: '999' }) mockToken('secure-store') - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -326,19 +298,17 @@ describe('config view', () => { const ellieLine = output.split('\n').find((l) => l.includes('Ellie Sattler')) ?? '' expect(alanLine).toContain('*') expect(ellieLine).not.toContain('*') - consoleSpy.mockRestore() }) it('omits the accounts block when config.users is empty or absent', async () => { presentConfig({ authMode: 'read-write' }) mockToken('secure-store') - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).not.toContain('Authenticated accounts') - consoleSpy.mockRestore() }) it('masks per-user fallback tokens in --json output', async () => { @@ -346,7 +316,7 @@ describe('config view', () => { users: [{ ...STORED_ALAN, token: 'tw_userA_plaintext_fallback_123' }, STORED_ELLIE], defaultUserId: '1', }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) @@ -354,24 +324,22 @@ describe('config view', () => { expect(parsed.users[0].token).toBe('****…_123') expect(parsed.users[0].token).not.toContain('plaintext') expect(parsed.users[1]).not.toHaveProperty('token') - consoleSpy.mockRestore() }) it('--show-token reveals per-user fallback tokens', async () => { presentConfig({ users: [{ ...STORED_ALAN, token: 'tw_userA_plaintext_fallback_123' }] }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json', '--show-token']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.users[0].token).toBe('tw_userA_plaintext_fallback_123') - consoleSpy.mockRestore() }) it('shows the user settings section', async () => { presentConfig({ userSettings: { unarchiveNewThreads: true } }) mockProbeApiToken.mockRejectedValue(new NoTokenError()) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync(['node', 'tw', 'config', 'view']) @@ -379,7 +347,6 @@ describe('config view', () => { expect(output).toContain('User settings') expect(output).toContain('Unarchive new threads') expect(output).toContain('true') - consoleSpy.mockRestore() }) }) @@ -391,7 +358,7 @@ describe('config set', () => { it('writes userSettings.unarchiveNewThreads = true', async () => { mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await createProgram().parseAsync([ 'node', @@ -407,7 +374,6 @@ describe('config set', () => { }) const output = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n') expect(output).toContain('userSettings.unarchiveNewThreads = true') - consoleSpy.mockRestore() }) it('writes false for off/0/no', async () => { @@ -415,7 +381,7 @@ describe('config set', () => { state: 'present', config: { userSettings: { unarchiveNewThreads: true } }, }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await createProgram().parseAsync([ 'node', @@ -429,7 +395,6 @@ describe('config set', () => { expect(mockSetConfig).toHaveBeenCalledWith({ userSettings: { unarchiveNewThreads: false }, }) - consoleSpy.mockRestore() }) it('preserves other userSettings keys when updating', async () => { @@ -440,7 +405,7 @@ describe('config set', () => { currentWorkspace: 7, } as Config, }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await createProgram().parseAsync([ 'node', @@ -455,7 +420,6 @@ describe('config set', () => { userSettings: { unarchiveNewThreads: true }, currentWorkspace: 7, }) - consoleSpy.mockRestore() }) it('rejects unknown keys', async () => { @@ -485,7 +449,7 @@ describe('config set', () => { it('writes a fresh config when the file is missing', async () => { mockReadConfigStrict.mockResolvedValue({ state: 'missing' }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await createProgram().parseAsync([ 'node', @@ -499,7 +463,6 @@ describe('config set', () => { expect(mockSetConfig).toHaveBeenCalledWith({ userSettings: { unarchiveNewThreads: true }, }) - consoleSpy.mockRestore() }) it('refuses to overwrite a malformed config file', async () => { diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 9d8ff04..d2da6e4 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -1,8 +1,9 @@ import { describeEmptyMachineOutput } from '@doist/cli-core/testing' import type { BatchResponse as TwistBatchResponse } from '@doist/twist-sdk' -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../../lib/errors.js' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -191,12 +192,7 @@ function createClient({ } } -function createProgram() { - const program = new Command() - program.exitOverride() - registerConversationCommand(program) - return program -} +const createProgram = () => createTestProgram(registerConversationCommand) describe('conversation implicit view', () => { beforeEach(() => { @@ -206,13 +202,11 @@ describe('conversation implicit view', () => { it('tw conversation routes to view (not unknown command)', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await expect(program.parseAsync(['node', 'tw', 'conversation', '100'])).rejects.toThrow( 'MOCK_API_REACHED', ) - - consoleSpy.mockRestore() }) }) @@ -273,7 +267,7 @@ describe('conversation with', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice']) @@ -290,8 +284,6 @@ describe('conversation with', () => { expect(client.conversations.getConversations).not.toHaveBeenCalledWith( expect.objectContaining({ archived: true }), ) - - consoleSpy.mockRestore() }) it('pages through older conversations to find a 1:1 DM', async () => { @@ -311,7 +303,7 @@ describe('conversation with', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice']) @@ -325,8 +317,6 @@ describe('conversation with', () => { beforeId: 1901, }) expect(refsMocks.resolveConversationId).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('checks archived conversations only after active pages miss', async () => { @@ -344,7 +334,7 @@ describe('conversation with', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice']) @@ -366,8 +356,6 @@ describe('conversation with', () => { limit: 100, beforeId: undefined, }) - - consoleSpy.mockRestore() }) it('lists matching group conversations when --include-groups is set', async () => { @@ -385,7 +373,7 @@ describe('conversation with', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -403,8 +391,6 @@ describe('conversation with', () => { (conversation: { id: number }) => conversation.id, ), ).toEqual([43, 42]) - - consoleSpy.mockRestore() }) it('finds the self-conversation when looking up yourself', async () => { @@ -418,13 +404,11 @@ describe('conversation with', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Me']) expect(consoleSpy).toHaveBeenCalledWith('Conversation with Me') - - consoleSpy.mockRestore() }) it('emits empty JSON array when no 1:1 conversation is found with --json', async () => { @@ -439,14 +423,12 @@ describe('conversation with', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice', '--json']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual([]) - - consoleSpy.mockRestore() }) it('prints a clean error and exits non-zero for ambiguous user refs', async () => { @@ -496,7 +478,7 @@ describe('conversation view machine output', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'view', '42', '--json']) @@ -555,8 +537,6 @@ describe('conversation view machine output', () => { expect(fullJsonOutput.conversation.participantNames).toEqual(['Me', 'Alice Example']) expect(fullJsonOutput.messages[0].creatorName).toBe('Alice Example') expect(fullJsonOutput.messages[0].extra).toBe('message-extra') - - consoleSpy.mockRestore() }) }) @@ -616,14 +596,12 @@ describe('conversation mute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'mute', '42']) expect(client.conversations.muteConversation).toHaveBeenCalledWith({ id: 42, minutes: 60 }) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 muted for 60 minutes.') - - consoleSpy.mockRestore() }) it('mutes a conversation with custom minutes', async () => { @@ -632,7 +610,7 @@ describe('conversation mute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'mute', '42', '--minutes', '480']) @@ -641,8 +619,6 @@ describe('conversation mute', () => { minutes: 480, }) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 muted for 480 minutes.') - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -651,7 +627,7 @@ describe('conversation mute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'mute', '42', '--dry-run']) @@ -659,8 +635,6 @@ describe('conversation mute', () => { expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42') expect(consoleSpy).toHaveBeenCalledWith(' Duration: 60 minutes') expect(client.conversations.muteConversation).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { @@ -698,14 +672,12 @@ describe('conversation unmute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'unmute', '42']) expect(client.conversations.unmuteConversation).toHaveBeenCalledWith(42) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 unmuted.') - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -714,7 +686,7 @@ describe('conversation unmute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'unmute', '42', '--dry-run']) @@ -723,8 +695,6 @@ describe('conversation unmute', () => { ) expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42') expect(client.conversations.unmuteConversation).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { @@ -754,14 +724,12 @@ describe('conversation done', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'done', '42']) expect(client.conversations.archiveConversation).toHaveBeenCalledWith(42) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 archived.') - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -770,7 +738,7 @@ describe('conversation done', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'conversation', 'done', '42', '--dry-run']) @@ -779,8 +747,6 @@ describe('conversation done', () => { ) expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42') expect(client.conversations.archiveConversation).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 756016b..8022477 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' -import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' +import { createTestProgram } from '../test-helpers/program.js' vi.mock('chalk') @@ -48,12 +49,7 @@ const mockCreateWrappedTwistClient = vi.mocked(createWrappedTwistClient) const mockProbeApiToken = vi.mocked(probeApiToken) const mockGetConfig = vi.mocked(getConfig) -function createProgram() { - const program = new Command() - program.exitOverride() - registerDoctorCommand(program) - return program -} +const createProgram = () => createTestProgram(registerDoctorCommand) function mockFetch(version: string) { vi.stubGlobal( @@ -70,7 +66,7 @@ describe('doctor command', () => { let originalProcessVersion: PropertyDescriptor | undefined beforeEach(() => { - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + consoleSpy = captureConsole() vi.clearAllMocks() vi.unstubAllGlobals() process.exitCode = undefined @@ -99,7 +95,6 @@ describe('doctor command', () => { }) afterEach(() => { - consoleSpy.mockRestore() process.exitCode = undefined if (originalProcessVersion) { Object.defineProperty(process, 'version', originalProcessVersion) diff --git a/src/commands/groups/groups.test.ts b/src/commands/groups/groups.test.ts index 5251c11..67add28 100644 --- a/src/commands/groups/groups.test.ts +++ b/src/commands/groups/groups.test.ts @@ -1,6 +1,7 @@ import { describeEmptyMachineOutput } from '@doist/cli-core/testing' -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const mockBatch = vi.fn() const mockGetUserById = vi.fn() @@ -32,12 +33,7 @@ vi.mock('chalk') import { registerGroupsCommand } from './index.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerGroupsCommand(program) - return program -} +const createProgram = () => createTestProgram(registerGroupsCommand) const sampleGroups = [ { @@ -88,7 +84,7 @@ describeEmptyMachineOutput('tw groups list empty output', { describe('tw groups list (default)', () => { it('lists all groups', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups']) @@ -98,7 +94,7 @@ describe('tw groups list (default)', () => { it('outputs JSON', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', '--json']) @@ -109,7 +105,7 @@ describe('tw groups list (default)', () => { it('still works with explicit list subcommand', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'list']) @@ -118,7 +114,7 @@ describe('tw groups list (default)', () => { it('filters groups with --search', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', '--search', 'front']) @@ -129,7 +125,7 @@ describe('tw groups list (default)', () => { it('shows empty message when no groups match', async () => { apiMocks.getWorkspaceGroups.mockResolvedValue([]) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups']) @@ -139,7 +135,7 @@ describe('tw groups list (default)', () => { it('outputs NDJSON', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', '--ndjson']) @@ -152,7 +148,7 @@ describe('tw groups list (default)', () => { it('includes all fields with --json --full', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', '--json', '--full']) @@ -164,7 +160,7 @@ describe('tw groups list (default)', () => { it('accepts [workspace-ref] positional argument', async () => { refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 1, name: 'Test' }) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'list', '1']) @@ -187,7 +183,7 @@ describe('tw groups view', () => { it('resolves group ref and batch-fetches only group members', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'view', 'Frontend']) @@ -202,7 +198,7 @@ describe('tw groups view', () => { it('outputs JSON with enriched members (default shape)', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'view', 'id:100', '--json']) @@ -218,7 +214,7 @@ describe('tw groups view', () => { it('outputs JSON with all fields when --full', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'view', 'id:100', '--json', '--full']) @@ -237,7 +233,7 @@ describe('tw groups create', () => { it('creates a group without users', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'create', 'Design']) @@ -252,7 +248,7 @@ describe('tw groups create', () => { it('resolves --users and passes ids to createGroup', async () => { refsMocks.resolveUserRefs.mockResolvedValue([10, 20]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -288,7 +284,7 @@ describe('tw groups rename', () => { it('renames an existing group', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'rename', 'Frontend', 'FE Team']) @@ -304,7 +300,7 @@ describe('tw groups delete', () => { it('refuses to delete without --yes', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'delete', 'Frontend']) @@ -314,7 +310,7 @@ describe('tw groups delete', () => { it('deletes when --yes is passed', async () => { const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'delete', 'Frontend', '--yes']) @@ -337,7 +333,7 @@ describe('tw groups add-user', () => { it('joins variadic refs and resolves them', async () => { refsMocks.resolveUserRefs.mockResolvedValue([3, 4]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -356,7 +352,7 @@ describe('tw groups add-user', () => { it('mixes comma- and space-separated refs', async () => { refsMocks.resolveUserRefs.mockResolvedValue([3, 4, 5]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -374,7 +370,7 @@ describe('tw groups add-user', () => { it('skips users already in the group', async () => { refsMocks.resolveUserRefs.mockResolvedValue([1, 3]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'add-user', 'Frontend', 'id:1,id:3']) @@ -384,7 +380,7 @@ describe('tw groups add-user', () => { it('makes no API call when all users are already members', async () => { refsMocks.resolveUserRefs.mockResolvedValue([1, 2]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'groups', 'add-user', 'Frontend', 'id:1,id:2']) @@ -401,7 +397,7 @@ describe('tw groups add-user', () => { it('deduplicates resolved user IDs', async () => { refsMocks.resolveUserRefs.mockResolvedValue([3, 3, 4]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -426,7 +422,7 @@ describe('tw groups remove-user', () => { it('only removes users that are members', async () => { refsMocks.resolveUserRefs.mockResolvedValue([2, 3, 99]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -443,7 +439,7 @@ describe('tw groups remove-user', () => { it('makes no API call when none of the users are members', async () => { refsMocks.resolveUserRefs.mockResolvedValue([99, 100]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -467,7 +463,7 @@ describe('tw groups remove-user', () => { it('deduplicates resolved user IDs', async () => { refsMocks.resolveUserRefs.mockResolvedValue([2, 2, 3]) const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index 7ca6a2a..c80a091 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -1,6 +1,7 @@ import { describeEmptyMachineOutput } from '@doist/cli-core/testing' -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' +import { createTestProgram } from '../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -29,12 +30,7 @@ vi.mock('chalk') import { registerInboxCommand } from './inbox.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerInboxCommand(program) - return program -} +const createProgram = () => createTestProgram(registerInboxCommand) describe('inbox --workspace conflict', () => { beforeEach(() => { @@ -169,7 +165,7 @@ describe('inbox empty output (channel filter)', () => { channels: { getChannel: vi.fn() }, batch: mockBatch, }) - logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + logSpy = captureConsole() }) it('outputs [] for --json when --channel filter matches nothing', async () => { @@ -242,7 +238,7 @@ describe('inbox batch errors', () => { channels: { getChannel: vi.fn() }, batch: mockBatch, }) - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() const program = createProgram() await program.parseAsync(['node', 'tw', 'inbox', '--json']) @@ -250,7 +246,5 @@ describe('inbox batch errors', () => { const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output).toHaveLength(1) expect(output[0]).toMatchObject({ id: 1, isUnread: false }) - - consoleSpy.mockRestore() }) }) diff --git a/src/commands/mentions.test.ts b/src/commands/mentions.test.ts index 329f7e7..22dd742 100644 --- a/src/commands/mentions.test.ts +++ b/src/commands/mentions.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' +import { createTestProgram } from '../test-helpers/program.js' const refsMocks = vi.hoisted(() => ({ resolveWorkspaceRef: vi.fn(), @@ -33,12 +34,7 @@ vi.mock('chalk') import { registerMentionsCommand } from './mentions.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerMentionsCommand(program) - return program -} +const createProgram = () => createTestProgram(registerMentionsCommand) describe('mentions', () => { beforeEach(() => { @@ -61,7 +57,7 @@ describe('mentions', () => { it('searches using mentionSelf without a query', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'mentions']) @@ -73,8 +69,6 @@ describe('mentions', () => { title: undefined, }), ) - - logSpy.mockRestore() }) it('fetches every page when --all is set', async () => { @@ -92,7 +86,7 @@ describe('mentions', () => { }) const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'mentions', '--all']) @@ -110,13 +104,11 @@ describe('mentions', () => { cursor: 'cursor-1', }), ) - - logSpy.mockRestore() }) it('emits an empty JSON payload when no mentions match', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() await program.parseAsync(['node', 'tw', 'mentions', '--json']) @@ -125,13 +117,11 @@ describe('mentions', () => { results: [], nextCursor: null, }) - - logSpy.mockRestore() }) it('emits NDJSON metadata when no mentions match', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() await program.parseAsync(['node', 'tw', 'mentions', '--ndjson']) @@ -140,7 +130,5 @@ describe('mentions', () => { _meta: true, nextCursor: null, }) - - logSpy.mockRestore() }) }) diff --git a/src/commands/msg/msg.test.ts b/src/commands/msg/msg.test.ts index 3bc8fec..eca5d2b 100644 --- a/src/commands/msg/msg.test.ts +++ b/src/commands/msg/msg.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -72,12 +73,7 @@ function createClient({ messageCreator = 1, sessionUserId = 1 } = {}) { } } -function createProgram() { - const program = new Command() - program.exitOverride() - registerMsgCommand(program) - return program -} +const createProgram = () => createTestProgram(registerMsgCommand) describe('msg implicit view', () => { beforeEach(() => { @@ -87,13 +83,11 @@ describe('msg implicit view', () => { it('tw msg routes to view (not unknown command)', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await expect(program.parseAsync(['node', 'tw', 'msg', '200'])).rejects.toThrow( 'MOCK_API_REACHED', ) - - consoleSpy.mockRestore() }) }) @@ -106,20 +100,19 @@ describe('msg delete', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'msg', 'delete', '200']) expect(client.conversationMessages.deleteMessage).toHaveBeenCalledWith(200) expect(consoleSpy).toHaveBeenCalledWith('Message 200 deleted.') - consoleSpy.mockRestore() }) it('shows dry run output', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'msg', 'delete', '200', '--dry-run']) @@ -127,7 +120,6 @@ describe('msg delete', () => { expect(consoleSpy).toHaveBeenCalledWith(' Message: 200') expect(consoleSpy).toHaveBeenCalledWith(' Conversation: 42') expect(client.conversationMessages.deleteMessage).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('rejects non-creator with NOT_CREATOR in dry-run', async () => { @@ -145,12 +137,11 @@ describe('msg delete', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'msg', 'delete', '200', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 200, deleted: true }) - consoleSpy.mockRestore() }) }) diff --git a/src/commands/react.test.ts b/src/commands/react.test.ts index b655ae1..d95edd1 100644 --- a/src/commands/react.test.ts +++ b/src/commands/react.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' +import { createTestProgram } from '../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -13,12 +14,7 @@ vi.mock('../lib/api.js', () => ({ import { registerReactCommand } from './react.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerReactCommand(program) - return program -} +const createProgram = () => createTestProgram(registerReactCommand) describe('react refs', () => { beforeEach(() => { @@ -35,7 +31,7 @@ describe('react refs', () => { it('accepts thread URLs for react', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -47,12 +43,11 @@ describe('react refs', () => { ]) expect(apiMocks.addReaction).toHaveBeenCalledWith({ threadId: 99, reaction: '👍' }) - logSpy.mockRestore() }) it('accepts message URLs for unreact', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -64,12 +59,11 @@ describe('react refs', () => { ]) expect(apiMocks.removeReaction).toHaveBeenCalledWith({ messageId: 44, reaction: '❤️' }) - logSpy.mockRestore() }) it('outputs JSON for react --json', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() await program.parseAsync(['node', 'tw', 'react', 'thread', '99', '+1', '--json']) @@ -81,12 +75,11 @@ describe('react refs', () => { emoji: '👍', action: 'added', }) - logSpy.mockRestore() }) it('outputs JSON for unreact --json', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() await program.parseAsync(['node', 'tw', 'unreact', 'comment', '42', 'heart', '--json']) @@ -98,12 +91,11 @@ describe('react refs', () => { emoji: '❤️', action: 'removed', }) - logSpy.mockRestore() }) it('outputs JSON for react --json --dry-run without calling API', async () => { const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() await program.parseAsync([ 'node', @@ -125,6 +117,5 @@ describe('react refs', () => { action: 'added', dryRun: true, }) - logSpy.mockRestore() }) }) diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 3c6f472..7b0ae56 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -1,5 +1,6 @@ -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' +import { createTestProgram } from '../test-helpers/program.js' const refsMocks = vi.hoisted(() => ({ resolveWorkspaceRef: vi.fn(), @@ -33,12 +34,7 @@ vi.mock('chalk') import { registerSearchCommand } from './search.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerSearchCommand(program) - return program -} +const createProgram = () => createTestProgram(registerSearchCommand) describe('search --workspace conflict', () => { beforeEach(() => { @@ -72,7 +68,7 @@ describe('search --workspace conflict', () => { }) const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', 'tw', @@ -90,8 +86,6 @@ describe('search --workspace conflict', () => { conversationIds: [30, 40], }), ) - - logSpy.mockRestore() }) it('fetches every page when --all is set', async () => { @@ -109,7 +103,7 @@ describe('search --workspace conflict', () => { }) const program = createProgram() - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'search', 'query', '--all']) @@ -127,7 +121,5 @@ describe('search --workspace conflict', () => { cursor: 'cursor-1', }), ) - - logSpy.mockRestore() }) }) diff --git a/src/commands/skill/skill.test.ts b/src/commands/skill/skill.test.ts index e482d2a..c0730da 100644 --- a/src/commands/skill/skill.test.ts +++ b/src/commands/skill/skill.test.ts @@ -1,8 +1,9 @@ import { mkdir, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' vi.mock('chalk') @@ -20,12 +21,7 @@ const packageJson = JSON.parse( await readFile(new URL('../../../package.json', import.meta.url), 'utf-8'), ) as { version: string } -function createProgram() { - const program = new Command() - program.exitOverride() - registerSkillCommand(program) - return program -} +const createProgram = () => createTestProgram(registerSkillCommand) describe('skill registry', () => { it('returns claude-code installer', () => { @@ -230,14 +226,13 @@ describe('listAgents', () => { describe('skill command', () => { let consoleSpy: ReturnType - let consoleErrorSpy: ReturnType let testDir: string const originalCwd = process.cwd() beforeEach(async () => { vi.clearAllMocks() - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + consoleSpy = captureConsole() + captureConsole('error') testDir = join(tmpdir(), `twist-cli-test-${Date.now()}`) await mkdir(testDir, { recursive: true }) @@ -245,8 +240,6 @@ describe('skill command', () => { }) afterEach(async () => { - consoleSpy.mockRestore() - consoleErrorSpy.mockRestore() process.chdir(originalCwd) await rm(testDir, { recursive: true, force: true }) }) diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 1c43980..d36c2ef 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -1,6 +1,7 @@ import type { BatchResponse as TwistBatchResponse } from '@doist/twist-sdk' -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../../test-helpers/console.js' +import { createTestProgram } from '../../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), @@ -195,12 +196,7 @@ function createClient({ } } -function createProgram() { - const program = new Command() - program.exitOverride() - registerThreadCommand(program) - return program -} +const createProgram = () => createTestProgram(registerThreadCommand) describe('thread implicit view', () => { beforeEach(() => { @@ -210,15 +206,13 @@ describe('thread implicit view', () => { it('tw thread routes to view (not unknown command)', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() // If Commander routes to view, it will call getTwistClient which throws MOCK_API_REACHED. // If it doesn't route, Commander throws "unknown command '100'". await expect(program.parseAsync(['node', 'tw', 'thread', '100'])).rejects.toThrow( 'MOCK_API_REACHED', ) - - consoleSpy.mockRestore() }) it('accepts id: prefixes in --notify for thread reply', async () => { @@ -227,7 +221,7 @@ describe('thread implicit view', () => { groupsMock.getWorkspaceGroups.mockResolvedValue([]) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -244,7 +238,6 @@ describe('thread implicit view', () => { expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Would post comment to thread'), ) - consoleSpy.mockRestore() }) it('--close dry-run indicates thread will be closed', async () => { @@ -253,7 +246,7 @@ describe('thread implicit view', () => { groupsMock.getWorkspaceGroups.mockResolvedValue([]) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -269,7 +262,6 @@ describe('thread implicit view', () => { expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Would post comment to thread and close it'), ) - consoleSpy.mockRestore() }) it('--reopen dry-run indicates thread will be reopened', async () => { @@ -278,7 +270,7 @@ describe('thread implicit view', () => { groupsMock.getWorkspaceGroups.mockResolvedValue([]) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -294,7 +286,6 @@ describe('thread implicit view', () => { expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Would post comment to thread and reopen it'), ) - consoleSpy.mockRestore() }) it('--close calls closeThread instead of createComment', async () => { @@ -302,7 +293,7 @@ describe('thread implicit view', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() vi.mocked(readStdin).mockResolvedValueOnce('closing comment') await program.parseAsync(['node', 'tw', 'thread', 'reply', '500', '--close']) @@ -311,7 +302,6 @@ describe('thread implicit view', () => { expect.objectContaining({ id: 500, content: 'closing comment' }), ) expect(client.comments.createComment).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('--reopen calls reopenThread instead of createComment', async () => { @@ -319,7 +309,7 @@ describe('thread implicit view', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() vi.mocked(readStdin).mockResolvedValueOnce('reopening comment') await program.parseAsync(['node', 'tw', 'thread', 'reply', '500', '--reopen']) @@ -328,7 +318,6 @@ describe('thread implicit view', () => { expect.objectContaining({ id: 500, content: 'reopening comment' }), ) expect(client.comments.createComment).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('--close and --reopen together produces an error', async () => { @@ -363,7 +352,7 @@ describe('thread view --unread', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread']) @@ -371,8 +360,6 @@ describe('thread view --unread', () => { expect(output).toContain('Test Thread') expect(output).toContain('Thread body') expect(output).toContain('No unread comments.') - - consoleSpy.mockRestore() }) it('filters to only unread comments in human-readable output', async () => { @@ -384,7 +371,7 @@ describe('thread view --unread', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread']) @@ -398,8 +385,6 @@ describe('thread view --unread', () => { expect(output).not.toContain('Comment 1') // Should show unread separator expect(output).toContain('UNREAD (2 new)') - - consoleSpy.mockRestore() }) it('filters comments in --json output when --unread is set', async () => { @@ -411,7 +396,7 @@ describe('thread view --unread', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--json']) @@ -420,8 +405,6 @@ describe('thread view --unread', () => { // Only comment 3 is unread (objIndex 3 > lastReadObjIndex 2) expect(jsonOutput.comments).toHaveLength(1) expect(jsonOutput.comments[0].id).toBe(3) - - consoleSpy.mockRestore() }) it('returns empty comments in --json output when no unread data exists', async () => { @@ -433,15 +416,13 @@ describe('thread view --unread', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.thread.id).toBe(500) expect(jsonOutput.comments).toHaveLength(0) - - consoleSpy.mockRestore() }) it('filters comments in --ndjson output when --unread is set', async () => { @@ -453,7 +434,7 @@ describe('thread view --unread', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--ndjson']) @@ -465,8 +446,6 @@ describe('thread view --unread', () => { expect(commentLines).toHaveLength(2) expect(commentLines[0].id).toBe(2) expect(commentLines[1].id).toBe(3) - - consoleSpy.mockRestore() }) it('returns all comments in --json without --unread', async () => { @@ -478,7 +457,7 @@ describe('thread view --unread', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--json']) @@ -487,8 +466,6 @@ describe('thread view --unread', () => { expect(jsonOutput.comments).toHaveLength(2) // getUnread should not be called expect(client.threads.getUnread).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) }) @@ -505,7 +482,7 @@ describe('thread view --since', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -527,8 +504,6 @@ describe('thread view --since', () => { ) const [args] = client.comments.getComments.mock.calls[0] as [Record] expect(args).not.toHaveProperty('from') - - consoleSpy.mockRestore() }) }) @@ -549,13 +524,11 @@ describe('thread view with failed batch response', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await expect( program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--comment', '99999']), ).rejects.toThrow('Failed to fetch comment 99999.') - - consoleSpy.mockRestore() }) it('throws a clear error when thread batch response fails', async () => { @@ -568,13 +541,11 @@ describe('thread view with failed batch response', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await expect(program.parseAsync(['node', 'tw', 'thread', 'view', '500'])).rejects.toThrow( 'Failed to fetch thread.', ) - - consoleSpy.mockRestore() }) }) @@ -630,15 +601,13 @@ describe('thread view with failed user batch response', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'view', '500']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Alice') expect(output).toContain('user:2') - - consoleSpy.mockRestore() }) }) @@ -653,7 +622,7 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -673,8 +642,6 @@ describe('thread create', () => { }), ) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Thread created:')) - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -682,7 +649,7 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -698,8 +665,6 @@ describe('thread create', () => { expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would create thread')) expect(consoleSpy).toHaveBeenCalledWith(' Title: Test Title') expect(consoleSpy).toHaveBeenCalledWith(' Content: Dry run content') - - consoleSpy.mockRestore() }) it('outputs JSON with --json', async () => { @@ -707,7 +672,7 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -723,8 +688,6 @@ describe('thread create', () => { const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(999) expect(jsonOutput.channelId).toBe(100) - - consoleSpy.mockRestore() }) it('reads content from stdin', async () => { @@ -733,7 +696,7 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'My Title']) @@ -744,8 +707,6 @@ describe('thread create', () => { content: 'Content from stdin', }), ) - - consoleSpy.mockRestore() }) it('passes notify recipients (users only)', async () => { @@ -758,7 +719,7 @@ describe('thread create', () => { ]) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -779,8 +740,6 @@ describe('thread create', () => { recipients: [123, 456], }), ) - - consoleSpy.mockRestore() }) it('partitions notify IDs into users and groups', async () => { @@ -792,7 +751,7 @@ describe('thread create', () => { groupsMock.getWorkspaceUsers.mockResolvedValue([{ id: 123, name: 'Alice' }]) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -814,8 +773,6 @@ describe('thread create', () => { groups: [456], }), ) - - consoleSpy.mockRestore() }) it('errors when no content is provided', async () => { @@ -831,12 +788,11 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'T', 'body']) expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('unarchives the new thread when --unarchive is passed', async () => { @@ -844,7 +800,7 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -858,7 +814,6 @@ describe('thread create', () => { ]) expect(client.inbox.unarchiveThread).toHaveBeenCalledWith(999) - consoleSpy.mockRestore() }) it('unarchives when userSettings.unarchiveNewThreads is true', async () => { @@ -869,12 +824,11 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'T', 'body']) expect(client.inbox.unarchiveThread).toHaveBeenCalledWith(999) - consoleSpy.mockRestore() }) it('--no-unarchive overrides config default of true', async () => { @@ -885,7 +839,7 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync([ 'node', @@ -899,7 +853,6 @@ describe('thread create', () => { ]) expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('unarchive failure does not fail the command', async () => { @@ -908,8 +861,8 @@ describe('thread create', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleSpy = captureConsole() + const errorSpy = captureConsole('error') await program.parseAsync([ 'node', @@ -925,8 +878,6 @@ describe('thread create', () => { expect(client.threads.createThread).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('failed to unarchive')) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Thread created:')) - consoleSpy.mockRestore() - errorSpy.mockRestore() }) }) @@ -940,14 +891,12 @@ describe('thread mute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'mute', '500']) expect(client.threads.muteThread).toHaveBeenCalledWith({ id: 500, minutes: 60 }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 muted for 60 minutes.') - - consoleSpy.mockRestore() }) it('mutes a thread with custom minutes', async () => { @@ -955,14 +904,12 @@ describe('thread mute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--minutes', '480']) expect(client.threads.muteThread).toHaveBeenCalledWith({ id: 500, minutes: 480 }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 muted for 480 minutes.') - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -970,7 +917,7 @@ describe('thread mute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--dry-run']) @@ -978,8 +925,6 @@ describe('thread mute', () => { expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') expect(consoleSpy).toHaveBeenCalledWith(' Duration: 60 minutes') expect(client.threads.muteThread).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { @@ -1000,7 +945,7 @@ describe('thread mute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--json']) @@ -1008,8 +953,6 @@ describe('thread mute', () => { expect(jsonOutput.id).toBe(500) expect(jsonOutput.mutedUntil).toBeDefined() expect(Object.keys(jsonOutput)).toEqual(['id', 'mutedUntil']) - - consoleSpy.mockRestore() }) it('rejects non-integer --minutes value', async () => { @@ -1031,14 +974,12 @@ describe('thread unmute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'unmute', '500']) expect(client.threads.unmuteThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 unmuted.') - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -1046,15 +987,13 @@ describe('thread unmute', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'unmute', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would unmute thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') expect(client.threads.unmuteThread).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { @@ -1080,54 +1019,50 @@ describe('thread delete', () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--yes']) expect(client.threads.deleteThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (500) deleted.') - consoleSpy.mockRestore() }) it('prompts for confirmation without --yes', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'delete', '500']) expect(consoleSpy).toHaveBeenCalledWith('Would delete: Test Thread') expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') expect(client.threads.deleteThread).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('shows dry run output', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would delete thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') expect(client.threads.deleteThread).not.toHaveBeenCalled() - consoleSpy.mockRestore() }) it('outputs JSON with --json --yes', async () => { const client = createClient() apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--json', '--yes']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 500, deleted: true }) - consoleSpy.mockRestore() }) it('errors when --json is used without --yes', async () => { @@ -1165,14 +1100,12 @@ describe('thread rename', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'rename', '500', 'New Title']) expect(client.threads.updateThread).toHaveBeenCalledWith({ id: 500, title: 'New Title' }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 renamed to "New Title".') - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -1180,7 +1113,7 @@ describe('thread rename', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -1196,8 +1129,6 @@ describe('thread rename', () => { expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') expect(consoleSpy).toHaveBeenCalledWith(' New title: New Title') expect(client.threads.updateThread).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { @@ -1218,7 +1149,7 @@ describe('thread rename', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'rename', '500', 'New Title', '--json']) @@ -1226,8 +1157,6 @@ describe('thread rename', () => { expect(jsonOutput.id).toBe(500) expect(jsonOutput.title).toBe('New Title') expect(Object.keys(jsonOutput)).toEqual(['id', 'title']) - - consoleSpy.mockRestore() }) it('outputs full JSON with --json --full', async () => { @@ -1235,7 +1164,7 @@ describe('thread rename', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync([ 'node', @@ -1253,8 +1182,6 @@ describe('thread rename', () => { expect(jsonOutput.title).toBe('New Title') // Full output includes more fields expect(Object.keys(jsonOutput).length).toBeGreaterThan(2) - - consoleSpy.mockRestore() }) }) @@ -1268,7 +1195,7 @@ describe('thread update', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'update', '500', 'New body']) @@ -1277,8 +1204,6 @@ describe('thread update', () => { content: 'New body', }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 updated.') - - consoleSpy.mockRestore() }) it('shows dry run output without calling updateThread', async () => { @@ -1286,7 +1211,7 @@ describe('thread update', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'update', '500', 'New body', '--dry-run']) @@ -1294,8 +1219,6 @@ describe('thread update', () => { expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') expect(consoleSpy).toHaveBeenCalledWith(' Content: New body') expect(client.threads.updateThread).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('reads content from stdin', async () => { @@ -1304,7 +1227,7 @@ describe('thread update', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'update', '500']) @@ -1312,19 +1235,15 @@ describe('thread update', () => { id: 500, content: 'Body from stdin', }) - - consoleSpy.mockRestore() }) it('errors when no content is provided', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + captureConsole() await expect( program.parseAsync(['node', 'tw', 'thread', 'update', '500']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') - - consoleSpy.mockRestore() }) it('outputs JSON with --json including id and content', async () => { @@ -1332,7 +1251,7 @@ describe('thread update', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'update', '500', 'New body', '--json']) @@ -1340,8 +1259,6 @@ describe('thread update', () => { expect(jsonOutput.id).toBe(500) expect(jsonOutput.content).toBe('New body') expect(Object.keys(jsonOutput)).toEqual(['id', 'content']) - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { @@ -1368,14 +1285,12 @@ describe('thread done', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'done', '500']) expect(client.inbox.archiveThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 archived.') - - consoleSpy.mockRestore() }) it('shows dry run output', async () => { @@ -1383,15 +1298,13 @@ describe('thread done', () => { apiMocks.getTwistClient.mockResolvedValue(client) const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'thread', 'done', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would archive thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') expect(client.inbox.archiveThread).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) it('runs validation in dry-run mode', async () => { diff --git a/src/commands/user.test.ts b/src/commands/user.test.ts index be0b2c2..f84f6ee 100644 --- a/src/commands/user.test.ts +++ b/src/commands/user.test.ts @@ -1,6 +1,7 @@ import { describeEmptyMachineOutput } from '@doist/cli-core/testing' -import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' +import { createTestProgram } from '../test-helpers/program.js' const apiMocks = vi.hoisted(() => ({ getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), @@ -22,12 +23,7 @@ vi.mock('chalk') import { registerUserCommand } from './user.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerUserCommand(program) - return program -} +const createProgram = () => createTestProgram(registerUserCommand) describe('users --workspace conflict', () => { beforeEach(() => { @@ -76,7 +72,7 @@ describe('user --json', () => { it('outputs essential user fields as JSON', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'user', '--json']) @@ -88,13 +84,11 @@ describe('user --json', () => { expect(jsonOutput.timezone).toBe('America/New_York') expect(jsonOutput).not.toHaveProperty('lang') expect(jsonOutput).not.toHaveProperty('shortName') - - consoleSpy.mockRestore() }) it('outputs full user fields with --full', async () => { const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = captureConsole() await program.parseAsync(['node', 'tw', 'user', '--json', '--full']) @@ -103,7 +97,5 @@ describe('user --json', () => { expect(jsonOutput).toHaveProperty('lang', 'en') expect(jsonOutput).toHaveProperty('shortName', 'Jane') expect(jsonOutput).toHaveProperty('defaultWorkspace', 1) - - consoleSpy.mockRestore() }) }) diff --git a/src/commands/view.test.ts b/src/commands/view.test.ts index 59cc06e..c7b97fe 100644 --- a/src/commands/view.test.ts +++ b/src/commands/view.test.ts @@ -1,5 +1,6 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestProgram } from '../test-helpers/program.js' vi.mock('./thread/index.js', () => ({ registerThreadCommand: (program: Command) => { @@ -30,12 +31,7 @@ vi.mock('./msg/index.js', () => ({ import { registerViewCommand } from './view.js' -function createProgram() { - const program = new Command() - program.exitOverride() - registerViewCommand(program) - return program -} +const createProgram = () => createTestProgram(registerViewCommand) describe('tw view routing', () => { beforeEach(() => { diff --git a/src/lib/__fixtures__/channels.ts b/src/lib/__fixtures__/channels.ts new file mode 100644 index 0000000..013367c --- /dev/null +++ b/src/lib/__fixtures__/channels.ts @@ -0,0 +1,19 @@ +import type { Channel } from '@doist/twist-sdk' + +// A fully-populated channel object as returned by the API, for tests that +// resolve/fetch a single channel. Pass overrides to vary individual fields. +export function createChannelFixture(overrides: Partial = {}): Channel { + return { + id: 500, + name: 'general', + workspaceId: 1, + userIds: [1, 2, 3], + creator: 1, + public: true, + archived: false, + created: new Date(), + version: 1, + url: 'https://twist.com/a/1/ch/500', + ...overrides, + } +} diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts index b5b7318..f9d1696 100644 --- a/src/lib/output.test.ts +++ b/src/lib/output.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' import { BaseCliError } from './errors.js' import { isAccessible, resetGlobalArgs } from './global-args.js' import { formatError, formatErrorJson, printDryRun, printEmpty } from './output.js' @@ -44,19 +45,17 @@ describe('isAccessible', () => { describe('printDryRun', () => { it('prints header, details, and footer', () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() printDryRun('delete thread', { Thread: 'My thread (500)' }) expect(logSpy).toHaveBeenNthCalledWith(1, '[dry-run] Would delete thread:') expect(logSpy).toHaveBeenNthCalledWith(2, ' Thread: My thread (500)') expect(logSpy).toHaveBeenNthCalledWith(3, 'Run without --dry-run to execute.') - - logSpy.mockRestore() }) it('skips undefined values', () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() printDryRun('mute thread', { Thread: 'My thread (500)', @@ -68,12 +67,10 @@ describe('printDryRun', () => { expect(calls).toContain(' Thread: My thread (500)') expect(calls).toContain(' Duration: 60 minutes') expect(calls.some((line) => String(line).includes('Notes'))).toBe(false) - - logSpy.mockRestore() }) it('indents continuation lines for multiline values', () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() printDryRun('update thread', { Content: 'First line\nSecond line\nThird line', @@ -84,19 +81,15 @@ describe('printDryRun', () => { expect(logSpy).toHaveBeenNthCalledWith(3, ' Second line') expect(logSpy).toHaveBeenNthCalledWith(4, ' Third line') expect(logSpy).toHaveBeenNthCalledWith(5, 'Run without --dry-run to execute.') - - logSpy.mockRestore() }) it('works without details', () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logSpy = captureConsole() printDryRun('clear away status') expect(logSpy).toHaveBeenCalledWith('[dry-run] Would clear away status:') expect(logSpy).toHaveBeenCalledWith('Run without --dry-run to execute.') - - logSpy.mockRestore() }) }) @@ -104,11 +97,7 @@ describe('printEmpty', () => { let logSpy: ReturnType beforeEach(() => { - logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - logSpy.mockRestore() + logSpy = captureConsole() }) it('prints "[]" for --json', () => { diff --git a/src/lib/progress.test.ts b/src/lib/progress.test.ts index 019dc80..872a01c 100644 --- a/src/lib/progress.test.ts +++ b/src/lib/progress.test.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { captureConsole } from '../test-helpers/console.js' import { resetGlobalArgs } from './global-args.js' import type { ProgressEvent } from './progress.js' import { getProgressTracker, ProgressTracker, resetProgressTracker } from './progress.js' @@ -115,7 +116,7 @@ describe('ProgressTracker', () => { throw new Error('Permission denied') }) - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleSpy = captureConsole('error') const tracker = new ProgressTracker() tracker.emit({ type: 'start', command: 'threads' }) @@ -124,8 +125,6 @@ describe('ProgressTracker', () => { expect.stringContaining('Warning: Could not create progress file'), ) expect(mockStderr.write).toHaveBeenCalled() - - consoleSpy.mockRestore() }) }) diff --git a/src/test-helpers/console.ts b/src/test-helpers/console.ts new file mode 100644 index 0000000..4271e5d --- /dev/null +++ b/src/test-helpers/console.ts @@ -0,0 +1,13 @@ +import { onTestFinished, vi } from 'vitest' + +type ConsoleMethod = 'log' | 'error' | 'warn' | 'info' + +// Spy on a console method, silence it, and auto-restore when the current test +// finishes. Returns the spy so `.mock.calls` assertions keep working. +export function captureConsole(method: ConsoleMethod = 'log') { + const spy = vi.spyOn(console, method).mockImplementation(() => {}) + onTestFinished(() => { + spy.mockRestore() + }) + return spy +} diff --git a/src/test-helpers/program.ts b/src/test-helpers/program.ts new file mode 100644 index 0000000..61618e3 --- /dev/null +++ b/src/test-helpers/program.ts @@ -0,0 +1,10 @@ +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/tsconfig.build.json b/tsconfig.build.json index 57291ad..bc4fb28 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,7 @@ "src/**/*.test.ts", "src/**/*.spec.ts", "src/__mocks__", - "src/**/__fixtures__" + "src/**/__fixtures__", + "src/test-helpers" ] }