diff --git a/src/commands/logs-command-helpers.ts b/src/commands/logs-command-helpers.ts index 73b2624c6..2296d66eb 100644 --- a/src/commands/logs-command-helpers.ts +++ b/src/commands/logs-command-helpers.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { logger } from '../logger'; -import type { LogSource, PolicyManifest } from '../types'; +import type { LogSource, LogStatsFormat, PolicyManifest } from '../types'; import { discoverLogSources, selectMostRecent, @@ -14,15 +14,16 @@ import { import { loadAndAggregate, loadAllLogs } from '../logs/log-aggregator'; import type { AggregatedStats } from '../logs/log-aggregator'; import { enrichWithPolicyRules, computeRuleStats } from '../logs/audit-enricher'; +import { formatStats } from '../logs/stats-formatter'; /** * Options for determining which logs to show (based on log level) */ export interface LoggingOptions { /** The output format being used */ - format: string; + format: LogStatsFormat; /** Callback to determine if info logs should be shown */ - shouldLog: (format: string) => boolean; + shouldLog: (format: LogStatsFormat) => boolean; } /** @@ -144,3 +145,29 @@ export async function loadLogsWithErrorHandling( process.exit(1); } } + +/** + * Shared output pipeline for `logs stats` and `logs summary`. + * + * Discovers the log source, loads and aggregates the logs, formats them, and + * prints the result. Each command passes only the `shouldLog` predicate that + * controls whether informational source-selection messages are emitted. + * + * @param options - Command options containing `format` and optional `source` + * @param shouldLog - Returns true when info-level log messages should be shown + */ +export async function runLogsCommand( + options: { format: LogStatsFormat; source?: string }, + shouldLog: (format: LogStatsFormat) => boolean +): Promise { + const source = await discoverAndSelectSource(options.source, { + format: options.format, + shouldLog, + }); + + const stats = await loadLogsWithErrorHandling(source); + + const colorize = !!(process.stdout.isTTY && options.format === 'pretty'); + const output = formatStats(stats, options.format, colorize); + console.log(output); +} diff --git a/src/commands/logs-stats.test.ts b/src/commands/logs-stats.test.ts index 52fa7779e..16cd154c7 100644 --- a/src/commands/logs-stats.test.ts +++ b/src/commands/logs-stats.test.ts @@ -3,10 +3,9 @@ */ import { statsCommand, StatsCommandOptions } from './logs-stats'; -import * as logDiscovery from '../logs/log-discovery'; -import * as logAggregator from '../logs/log-aggregator'; -import * as statsFormatter from '../logs/stats-formatter'; +import { logger } from '../logger'; import { LogSource } from '../types'; +import { createLogCommandTestHarness } from './test-helpers.test-utils'; // Mock dependencies jest.mock('../logs/log-discovery'); @@ -21,26 +20,8 @@ jest.mock('../logger', () => ({ }, })); -const mockedDiscovery = logDiscovery as jest.Mocked; -const mockedAggregator = logAggregator as jest.Mocked; -const mockedFormatter = statsFormatter as jest.Mocked; - describe('logs-stats command', () => { - let mockExit: jest.SpyInstance; - let mockConsoleLog: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); - }); - - afterEach(() => { - mockExit.mockRestore(); - mockConsoleLog.mockRestore(); - }); + const harness = createLogCommandTestHarness(); it('should discover and use most recent log source', async () => { const mockSource: LogSource = { @@ -50,9 +31,9 @@ describe('logs-stats command', () => { dateStr: new Date().toLocaleString(), }; - mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); - mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); - mockedAggregator.loadAndAggregate.mockResolvedValue({ + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue({ totalRequests: 10, allowedRequests: 8, deniedRequests: 2, @@ -60,7 +41,7 @@ describe('logs-stats command', () => { byDomain: new Map(), timeRange: { start: 1000, end: 2000 }, }); - mockedFormatter.formatStats.mockReturnValue('formatted output'); + harness.mockedFormatter.formatStats.mockReturnValue('formatted output'); const options: StatsCommandOptions = { format: 'pretty', @@ -68,11 +49,11 @@ describe('logs-stats command', () => { await statsCommand(options); - expect(mockedDiscovery.discoverLogSources).toHaveBeenCalled(); - expect(mockedDiscovery.selectMostRecent).toHaveBeenCalled(); - expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); - expect(mockedFormatter.formatStats).toHaveBeenCalled(); - expect(mockConsoleLog).toHaveBeenCalledWith('formatted output'); + expect(harness.mockedDiscovery.discoverLogSources).toHaveBeenCalled(); + expect(harness.mockedDiscovery.selectMostRecent).toHaveBeenCalled(); + expect(harness.mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + expect(harness.mockedFormatter.formatStats).toHaveBeenCalled(); + expect(harness.mockConsoleLog).toHaveBeenCalledWith('formatted output'); }); it('should use specified source when provided', async () => { @@ -81,9 +62,9 @@ describe('logs-stats command', () => { path: '/custom/path', }; - mockedDiscovery.discoverLogSources.mockResolvedValue([]); - mockedDiscovery.validateSource.mockResolvedValue(mockSource); - mockedAggregator.loadAndAggregate.mockResolvedValue({ + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]); + harness.mockedDiscovery.validateSource.mockResolvedValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue({ totalRequests: 5, allowedRequests: 5, deniedRequests: 0, @@ -91,7 +72,7 @@ describe('logs-stats command', () => { byDomain: new Map(), timeRange: null, }); - mockedFormatter.formatStats.mockReturnValue('formatted'); + harness.mockedFormatter.formatStats.mockReturnValue('formatted'); const options: StatsCommandOptions = { format: 'json', @@ -100,24 +81,24 @@ describe('logs-stats command', () => { await statsCommand(options); - expect(mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path'); - expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + expect(harness.mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path'); + expect(harness.mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); }); it('should exit with error if no sources found', async () => { - mockedDiscovery.discoverLogSources.mockResolvedValue([]); + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]); const options: StatsCommandOptions = { format: 'pretty', }; await expect(statsCommand(options)).rejects.toThrow('process.exit called'); - expect(mockExit).toHaveBeenCalledWith(1); + expect(harness.mockExit).toHaveBeenCalledWith(1); }); it('should exit with error if specified source is invalid', async () => { - mockedDiscovery.discoverLogSources.mockResolvedValue([]); - mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found')); + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]); + harness.mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found')); const options: StatsCommandOptions = { format: 'pretty', @@ -125,15 +106,15 @@ describe('logs-stats command', () => { }; await expect(statsCommand(options)).rejects.toThrow('process.exit called'); - expect(mockExit).toHaveBeenCalledWith(1); + expect(harness.mockExit).toHaveBeenCalledWith(1); }); it('should pass correct format to formatter', async () => { const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; - mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); - mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); - mockedAggregator.loadAndAggregate.mockResolvedValue({ + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue({ totalRequests: 0, allowedRequests: 0, deniedRequests: 0, @@ -141,26 +122,26 @@ describe('logs-stats command', () => { byDomain: new Map(), timeRange: null, }); - mockedFormatter.formatStats.mockReturnValue('{}'); + harness.mockedFormatter.formatStats.mockReturnValue('{}'); await statsCommand({ format: 'json' }); - expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith( expect.anything(), 'json', expect.any(Boolean) ); - mockedFormatter.formatStats.mockClear(); + harness.mockedFormatter.formatStats.mockClear(); await statsCommand({ format: 'markdown' }); - expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith( expect.anything(), 'markdown', expect.any(Boolean) ); - mockedFormatter.formatStats.mockClear(); + harness.mockedFormatter.formatStats.mockClear(); await statsCommand({ format: 'pretty' }); - expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith( expect.anything(), 'pretty', expect.any(Boolean) @@ -170,15 +151,50 @@ describe('logs-stats command', () => { it('should handle aggregation errors gracefully', async () => { const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; - mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); - mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); - mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load')); + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load')); const options: StatsCommandOptions = { format: 'pretty', }; await expect(statsCommand(options)).rejects.toThrow('process.exit called'); - expect(mockExit).toHaveBeenCalledWith(1); + expect(harness.mockExit).toHaveBeenCalledWith(1); + }); + + it('should emit source-selection info logs for non-JSON formats but suppress them for JSON', async () => { + const mockSource: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-123', + dateStr: 'Mon Jan 01 2024', + }; + const emptyStats = { + totalRequests: 0, + allowedRequests: 0, + deniedRequests: 0, + uniqueDomains: 0, + byDomain: new Map(), + timeRange: null, + }; + + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue(emptyStats); + harness.mockedFormatter.formatStats.mockReturnValue(''); + + // pretty: shouldLog returns true → logger.info should be called + await statsCommand({ format: 'pretty' }); + expect((logger.info as jest.Mock)).toHaveBeenCalled(); + (logger.info as jest.Mock).mockClear(); + + // markdown: shouldLog returns true → logger.info should be called + await statsCommand({ format: 'markdown' }); + expect((logger.info as jest.Mock)).toHaveBeenCalled(); + (logger.info as jest.Mock).mockClear(); + + // json: shouldLog returns false → logger.info should NOT be called + await statsCommand({ format: 'json' }); + expect((logger.info as jest.Mock)).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/logs-stats.ts b/src/commands/logs-stats.ts index 71f745e8f..d02004305 100644 --- a/src/commands/logs-stats.ts +++ b/src/commands/logs-stats.ts @@ -3,11 +3,7 @@ */ import type { LogStatsFormat } from '../types'; -import { formatStats } from '../logs/stats-formatter'; -import { - discoverAndSelectSource, - loadLogsWithErrorHandling, -} from './logs-command-helpers'; +import { runLogsCommand } from './logs-command-helpers'; /** * Output format type for stats command (alias for shared type) @@ -33,18 +29,6 @@ export interface StatsCommandOptions { * @param options - Command options */ export async function statsCommand(options: StatsCommandOptions): Promise { - // Discover and select log source // For stats command: show info logs for all non-JSON formats - const source = await discoverAndSelectSource(options.source, { - format: options.format, - shouldLog: (format) => format !== 'json', - }); - - // Load and aggregate logs - const stats = await loadLogsWithErrorHandling(source); - - // Format and output - const colorize = !!(process.stdout.isTTY && options.format === 'pretty'); - const output = formatStats(stats, options.format, colorize); - console.log(output); + await runLogsCommand(options, (format) => format !== 'json'); } diff --git a/src/commands/logs-summary.test.ts b/src/commands/logs-summary.test.ts index 850272e99..df40a5411 100644 --- a/src/commands/logs-summary.test.ts +++ b/src/commands/logs-summary.test.ts @@ -3,10 +3,9 @@ */ import { summaryCommand, SummaryCommandOptions } from './logs-summary'; -import * as logDiscovery from '../logs/log-discovery'; -import * as logAggregator from '../logs/log-aggregator'; -import * as statsFormatter from '../logs/stats-formatter'; +import { logger } from '../logger'; import { LogSource } from '../types'; +import { createLogCommandTestHarness } from './test-helpers.test-utils'; // Mock dependencies jest.mock('../logs/log-discovery'); @@ -21,26 +20,8 @@ jest.mock('../logger', () => ({ }, })); -const mockedDiscovery = logDiscovery as jest.Mocked; -const mockedAggregator = logAggregator as jest.Mocked; -const mockedFormatter = statsFormatter as jest.Mocked; - describe('logs-summary command', () => { - let mockExit: jest.SpyInstance; - let mockConsoleLog: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); - }); - - afterEach(() => { - mockExit.mockRestore(); - mockConsoleLog.mockRestore(); - }); + const harness = createLogCommandTestHarness(); it('should discover and use most recent log source', async () => { const mockSource: LogSource = { @@ -50,9 +31,9 @@ describe('logs-summary command', () => { dateStr: new Date().toLocaleString(), }; - mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); - mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); - mockedAggregator.loadAndAggregate.mockResolvedValue({ + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue({ totalRequests: 10, allowedRequests: 8, deniedRequests: 2, @@ -60,7 +41,7 @@ describe('logs-summary command', () => { byDomain: new Map(), timeRange: { start: 1000, end: 2000 }, }); - mockedFormatter.formatStats.mockReturnValue('markdown summary'); + harness.mockedFormatter.formatStats.mockReturnValue('markdown summary'); const options: SummaryCommandOptions = { format: 'markdown', @@ -68,19 +49,19 @@ describe('logs-summary command', () => { await summaryCommand(options); - expect(mockedDiscovery.discoverLogSources).toHaveBeenCalled(); - expect(mockedDiscovery.selectMostRecent).toHaveBeenCalled(); - expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); - expect(mockedFormatter.formatStats).toHaveBeenCalled(); - expect(mockConsoleLog).toHaveBeenCalledWith('markdown summary'); + expect(harness.mockedDiscovery.discoverLogSources).toHaveBeenCalled(); + expect(harness.mockedDiscovery.selectMostRecent).toHaveBeenCalled(); + expect(harness.mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + expect(harness.mockedFormatter.formatStats).toHaveBeenCalled(); + expect(harness.mockConsoleLog).toHaveBeenCalledWith('markdown summary'); }); it('should default to markdown format', async () => { const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; - mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); - mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); - mockedAggregator.loadAndAggregate.mockResolvedValue({ + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue({ totalRequests: 0, allowedRequests: 0, deniedRequests: 0, @@ -88,12 +69,12 @@ describe('logs-summary command', () => { byDomain: new Map(), timeRange: null, }); - mockedFormatter.formatStats.mockReturnValue('### Summary'); + harness.mockedFormatter.formatStats.mockReturnValue('### Summary'); // Note: default format is 'markdown' for summary command await summaryCommand({ format: 'markdown' }); - expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith( expect.anything(), 'markdown', expect.any(Boolean) @@ -106,9 +87,9 @@ describe('logs-summary command', () => { path: '/custom/path', }; - mockedDiscovery.discoverLogSources.mockResolvedValue([]); - mockedDiscovery.validateSource.mockResolvedValue(mockSource); - mockedAggregator.loadAndAggregate.mockResolvedValue({ + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]); + harness.mockedDiscovery.validateSource.mockResolvedValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue({ totalRequests: 5, allowedRequests: 5, deniedRequests: 0, @@ -116,7 +97,7 @@ describe('logs-summary command', () => { byDomain: new Map(), timeRange: null, }); - mockedFormatter.formatStats.mockReturnValue('formatted'); + harness.mockedFormatter.formatStats.mockReturnValue('formatted'); const options: SummaryCommandOptions = { format: 'markdown', @@ -125,24 +106,24 @@ describe('logs-summary command', () => { await summaryCommand(options); - expect(mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path'); - expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + expect(harness.mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path'); + expect(harness.mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); }); it('should exit with error if no sources found', async () => { - mockedDiscovery.discoverLogSources.mockResolvedValue([]); + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]); const options: SummaryCommandOptions = { format: 'markdown', }; await expect(summaryCommand(options)).rejects.toThrow('process.exit called'); - expect(mockExit).toHaveBeenCalledWith(1); + expect(harness.mockExit).toHaveBeenCalledWith(1); }); it('should exit with error if specified source is invalid', async () => { - mockedDiscovery.discoverLogSources.mockResolvedValue([]); - mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found')); + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]); + harness.mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found')); const options: SummaryCommandOptions = { format: 'markdown', @@ -150,15 +131,15 @@ describe('logs-summary command', () => { }; await expect(summaryCommand(options)).rejects.toThrow('process.exit called'); - expect(mockExit).toHaveBeenCalledWith(1); + expect(harness.mockExit).toHaveBeenCalledWith(1); }); it('should support all output formats', async () => { const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; - mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); - mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); - mockedAggregator.loadAndAggregate.mockResolvedValue({ + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue({ totalRequests: 0, allowedRequests: 0, deniedRequests: 0, @@ -166,29 +147,29 @@ describe('logs-summary command', () => { byDomain: new Map(), timeRange: null, }); - mockedFormatter.formatStats.mockReturnValue('output'); + harness.mockedFormatter.formatStats.mockReturnValue('output'); // Test JSON format await summaryCommand({ format: 'json' }); - expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith( expect.anything(), 'json', expect.any(Boolean) ); // Test markdown format - mockedFormatter.formatStats.mockClear(); + harness.mockedFormatter.formatStats.mockClear(); await summaryCommand({ format: 'markdown' }); - expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith( expect.anything(), 'markdown', expect.any(Boolean) ); // Test pretty format - mockedFormatter.formatStats.mockClear(); + harness.mockedFormatter.formatStats.mockClear(); await summaryCommand({ format: 'pretty' }); - expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith( expect.anything(), 'pretty', expect.any(Boolean) @@ -198,15 +179,50 @@ describe('logs-summary command', () => { it('should handle aggregation errors gracefully', async () => { const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; - mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); - mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); - mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load')); + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load')); const options: SummaryCommandOptions = { format: 'markdown', }; await expect(summaryCommand(options)).rejects.toThrow('process.exit called'); - expect(mockExit).toHaveBeenCalledWith(1); + expect(harness.mockExit).toHaveBeenCalledWith(1); + }); + + it('should emit source-selection info logs only for pretty format, suppress them for markdown and json', async () => { + const mockSource: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-123', + dateStr: 'Mon Jan 01 2024', + }; + const emptyStats = { + totalRequests: 0, + allowedRequests: 0, + deniedRequests: 0, + uniqueDomains: 0, + byDomain: new Map(), + timeRange: null, + }; + + harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + harness.mockedAggregator.loadAndAggregate.mockResolvedValue(emptyStats); + harness.mockedFormatter.formatStats.mockReturnValue(''); + + // pretty: shouldLog returns true → logger.info should be called + await summaryCommand({ format: 'pretty' }); + expect((logger.info as jest.Mock)).toHaveBeenCalled(); + (logger.info as jest.Mock).mockClear(); + + // markdown: shouldLog returns false → logger.info should NOT be called + await summaryCommand({ format: 'markdown' }); + expect((logger.info as jest.Mock)).not.toHaveBeenCalled(); + (logger.info as jest.Mock).mockClear(); + + // json: shouldLog returns false → logger.info should NOT be called + await summaryCommand({ format: 'json' }); + expect((logger.info as jest.Mock)).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/logs-summary.ts b/src/commands/logs-summary.ts index 36116a61a..b5bbe3892 100644 --- a/src/commands/logs-summary.ts +++ b/src/commands/logs-summary.ts @@ -6,11 +6,7 @@ */ import type { LogStatsFormat } from '../types'; -import { formatStats } from '../logs/stats-formatter'; -import { - discoverAndSelectSource, - loadLogsWithErrorHandling, -} from './logs-command-helpers'; +import { runLogsCommand } from './logs-command-helpers'; /** * Output format type for summary command (alias for shared type) @@ -41,21 +37,9 @@ export interface SummaryCommandOptions { * @param options - Command options */ export async function summaryCommand(options: SummaryCommandOptions): Promise { - // Discover and select log source - // For summary command: only show info logs in pretty format + // For summary command: only show info logs in pretty format. // This differs intentionally from `logs-stats` which logs for all non-JSON formats. // The stricter approach here keeps markdown output (the default, intended for // GitHub Actions step summaries) free of extra lines that would pollute $GITHUB_STEP_SUMMARY. - const source = await discoverAndSelectSource(options.source, { - format: options.format, - shouldLog: (format) => format === 'pretty', - }); - - // Load and aggregate logs - const stats = await loadLogsWithErrorHandling(source); - - // Format and output - const colorize = !!(process.stdout.isTTY && options.format === 'pretty'); - const output = formatStats(stats, options.format, colorize); - console.log(output); + await runLogsCommand(options, (format) => format === 'pretty'); } diff --git a/src/commands/test-helpers.test-utils.ts b/src/commands/test-helpers.test-utils.ts new file mode 100644 index 000000000..9a1acb000 --- /dev/null +++ b/src/commands/test-helpers.test-utils.ts @@ -0,0 +1,44 @@ +/** + * Shared test helpers for log command tests (stats, summary) + */ + +import * as logDiscovery from '../logs/log-discovery'; +import * as logAggregator from '../logs/log-aggregator'; +import * as statsFormatter from '../logs/stats-formatter'; + +/** + * Creates typed mock references and registers shared beforeEach/afterEach + * hooks for log command tests. Call once at the top of a describe block. + * + * Note: jest.mock() calls for log-discovery, log-aggregator, stats-formatter, + * and logger must remain in each test file — Jest hoists them file-locally. + * + * @returns Harness with typed mock references and spy instances (mockExit and + * mockConsoleLog are populated before each test runs). + */ +export function createLogCommandTestHarness() { + const harness = { + mockedDiscovery: logDiscovery as jest.Mocked, + mockedAggregator: logAggregator as jest.Mocked, + mockedFormatter: statsFormatter as jest.Mocked, + // Populated in beforeEach before each test runs; typed as non-null for + // convenient use in test assertions without optional chaining. + mockExit: undefined as unknown as jest.SpyInstance, + mockConsoleLog: undefined as unknown as jest.SpyInstance, + }; + + beforeEach(() => { + jest.clearAllMocks(); + harness.mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + harness.mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + harness.mockExit.mockRestore(); + harness.mockConsoleLog.mockRestore(); + }); + + return harness; +} diff --git a/tsconfig.json b/tsconfig.json index be5fb2ac5..3bba78776 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "types": ["node", "jest"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test-utils.ts"] }