Skip to content

Commit c0c4c94

Browse files
authored
refactor: deduplicate logs-stats/logs-summary pipeline and test scaffolding (#2433)
* Initial plan * refactor: deduplicate logs-stats/logs-summary pipeline and tests Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/db2594ab-4341-4cda-9b73-2391401b8c8c * refactor: tighten LoggingOptions types and document harness spies Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/db2594ab-4341-4cda-9b73-2391401b8c8c * fix: exclude test-utils from build, add shouldLog behavior tests Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/ecadd9eb-35b3-4054-9be8-afffc1aa5c90 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 26c1d58 commit c0c4c94

7 files changed

Lines changed: 227 additions & 156 deletions

File tree

src/commands/logs-command-helpers.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import * as fs from 'fs';
66
import * as path from 'path';
77
import { logger } from '../logger';
8-
import type { LogSource, PolicyManifest } from '../types';
8+
import type { LogSource, LogStatsFormat, PolicyManifest } from '../types';
99
import {
1010
discoverLogSources,
1111
selectMostRecent,
@@ -14,15 +14,16 @@ import {
1414
import { loadAndAggregate, loadAllLogs } from '../logs/log-aggregator';
1515
import type { AggregatedStats } from '../logs/log-aggregator';
1616
import { enrichWithPolicyRules, computeRuleStats } from '../logs/audit-enricher';
17+
import { formatStats } from '../logs/stats-formatter';
1718

1819
/**
1920
* Options for determining which logs to show (based on log level)
2021
*/
2122
export interface LoggingOptions {
2223
/** The output format being used */
23-
format: string;
24+
format: LogStatsFormat;
2425
/** Callback to determine if info logs should be shown */
25-
shouldLog: (format: string) => boolean;
26+
shouldLog: (format: LogStatsFormat) => boolean;
2627
}
2728

2829
/**
@@ -144,3 +145,29 @@ export async function loadLogsWithErrorHandling(
144145
process.exit(1);
145146
}
146147
}
148+
149+
/**
150+
* Shared output pipeline for `logs stats` and `logs summary`.
151+
*
152+
* Discovers the log source, loads and aggregates the logs, formats them, and
153+
* prints the result. Each command passes only the `shouldLog` predicate that
154+
* controls whether informational source-selection messages are emitted.
155+
*
156+
* @param options - Command options containing `format` and optional `source`
157+
* @param shouldLog - Returns true when info-level log messages should be shown
158+
*/
159+
export async function runLogsCommand(
160+
options: { format: LogStatsFormat; source?: string },
161+
shouldLog: (format: LogStatsFormat) => boolean
162+
): Promise<void> {
163+
const source = await discoverAndSelectSource(options.source, {
164+
format: options.format,
165+
shouldLog,
166+
});
167+
168+
const stats = await loadLogsWithErrorHandling(source);
169+
170+
const colorize = !!(process.stdout.isTTY && options.format === 'pretty');
171+
const output = formatStats(stats, options.format, colorize);
172+
console.log(output);
173+
}

src/commands/logs-stats.test.ts

Lines changed: 71 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
*/
44

55
import { statsCommand, StatsCommandOptions } from './logs-stats';
6-
import * as logDiscovery from '../logs/log-discovery';
7-
import * as logAggregator from '../logs/log-aggregator';
8-
import * as statsFormatter from '../logs/stats-formatter';
6+
import { logger } from '../logger';
97
import { LogSource } from '../types';
8+
import { createLogCommandTestHarness } from './test-helpers.test-utils';
109

1110
// Mock dependencies
1211
jest.mock('../logs/log-discovery');
@@ -21,26 +20,8 @@ jest.mock('../logger', () => ({
2120
},
2221
}));
2322

24-
const mockedDiscovery = logDiscovery as jest.Mocked<typeof logDiscovery>;
25-
const mockedAggregator = logAggregator as jest.Mocked<typeof logAggregator>;
26-
const mockedFormatter = statsFormatter as jest.Mocked<typeof statsFormatter>;
27-
2823
describe('logs-stats command', () => {
29-
let mockExit: jest.SpyInstance;
30-
let mockConsoleLog: jest.SpyInstance;
31-
32-
beforeEach(() => {
33-
jest.clearAllMocks();
34-
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
35-
throw new Error('process.exit called');
36-
});
37-
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
38-
});
39-
40-
afterEach(() => {
41-
mockExit.mockRestore();
42-
mockConsoleLog.mockRestore();
43-
});
24+
const harness = createLogCommandTestHarness();
4425

4526
it('should discover and use most recent log source', async () => {
4627
const mockSource: LogSource = {
@@ -50,29 +31,29 @@ describe('logs-stats command', () => {
5031
dateStr: new Date().toLocaleString(),
5132
};
5233

53-
mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]);
54-
mockedDiscovery.selectMostRecent.mockReturnValue(mockSource);
55-
mockedAggregator.loadAndAggregate.mockResolvedValue({
34+
harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]);
35+
harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource);
36+
harness.mockedAggregator.loadAndAggregate.mockResolvedValue({
5637
totalRequests: 10,
5738
allowedRequests: 8,
5839
deniedRequests: 2,
5940
uniqueDomains: 3,
6041
byDomain: new Map(),
6142
timeRange: { start: 1000, end: 2000 },
6243
});
63-
mockedFormatter.formatStats.mockReturnValue('formatted output');
44+
harness.mockedFormatter.formatStats.mockReturnValue('formatted output');
6445

6546
const options: StatsCommandOptions = {
6647
format: 'pretty',
6748
};
6849

6950
await statsCommand(options);
7051

71-
expect(mockedDiscovery.discoverLogSources).toHaveBeenCalled();
72-
expect(mockedDiscovery.selectMostRecent).toHaveBeenCalled();
73-
expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource);
74-
expect(mockedFormatter.formatStats).toHaveBeenCalled();
75-
expect(mockConsoleLog).toHaveBeenCalledWith('formatted output');
52+
expect(harness.mockedDiscovery.discoverLogSources).toHaveBeenCalled();
53+
expect(harness.mockedDiscovery.selectMostRecent).toHaveBeenCalled();
54+
expect(harness.mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource);
55+
expect(harness.mockedFormatter.formatStats).toHaveBeenCalled();
56+
expect(harness.mockConsoleLog).toHaveBeenCalledWith('formatted output');
7657
});
7758

7859
it('should use specified source when provided', async () => {
@@ -81,17 +62,17 @@ describe('logs-stats command', () => {
8162
path: '/custom/path',
8263
};
8364

84-
mockedDiscovery.discoverLogSources.mockResolvedValue([]);
85-
mockedDiscovery.validateSource.mockResolvedValue(mockSource);
86-
mockedAggregator.loadAndAggregate.mockResolvedValue({
65+
harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]);
66+
harness.mockedDiscovery.validateSource.mockResolvedValue(mockSource);
67+
harness.mockedAggregator.loadAndAggregate.mockResolvedValue({
8768
totalRequests: 5,
8869
allowedRequests: 5,
8970
deniedRequests: 0,
9071
uniqueDomains: 2,
9172
byDomain: new Map(),
9273
timeRange: null,
9374
});
94-
mockedFormatter.formatStats.mockReturnValue('formatted');
75+
harness.mockedFormatter.formatStats.mockReturnValue('formatted');
9576

9677
const options: StatsCommandOptions = {
9778
format: 'json',
@@ -100,67 +81,67 @@ describe('logs-stats command', () => {
10081

10182
await statsCommand(options);
10283

103-
expect(mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path');
104-
expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource);
84+
expect(harness.mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path');
85+
expect(harness.mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource);
10586
});
10687

10788
it('should exit with error if no sources found', async () => {
108-
mockedDiscovery.discoverLogSources.mockResolvedValue([]);
89+
harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]);
10990

11091
const options: StatsCommandOptions = {
11192
format: 'pretty',
11293
};
11394

11495
await expect(statsCommand(options)).rejects.toThrow('process.exit called');
115-
expect(mockExit).toHaveBeenCalledWith(1);
96+
expect(harness.mockExit).toHaveBeenCalledWith(1);
11697
});
11798

11899
it('should exit with error if specified source is invalid', async () => {
119-
mockedDiscovery.discoverLogSources.mockResolvedValue([]);
120-
mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found'));
100+
harness.mockedDiscovery.discoverLogSources.mockResolvedValue([]);
101+
harness.mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found'));
121102

122103
const options: StatsCommandOptions = {
123104
format: 'pretty',
124105
source: '/invalid/path',
125106
};
126107

127108
await expect(statsCommand(options)).rejects.toThrow('process.exit called');
128-
expect(mockExit).toHaveBeenCalledWith(1);
109+
expect(harness.mockExit).toHaveBeenCalledWith(1);
129110
});
130111

131112
it('should pass correct format to formatter', async () => {
132113
const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' };
133114

134-
mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]);
135-
mockedDiscovery.selectMostRecent.mockReturnValue(mockSource);
136-
mockedAggregator.loadAndAggregate.mockResolvedValue({
115+
harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]);
116+
harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource);
117+
harness.mockedAggregator.loadAndAggregate.mockResolvedValue({
137118
totalRequests: 0,
138119
allowedRequests: 0,
139120
deniedRequests: 0,
140121
uniqueDomains: 0,
141122
byDomain: new Map(),
142123
timeRange: null,
143124
});
144-
mockedFormatter.formatStats.mockReturnValue('{}');
125+
harness.mockedFormatter.formatStats.mockReturnValue('{}');
145126

146127
await statsCommand({ format: 'json' });
147-
expect(mockedFormatter.formatStats).toHaveBeenCalledWith(
128+
expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith(
148129
expect.anything(),
149130
'json',
150131
expect.any(Boolean)
151132
);
152133

153-
mockedFormatter.formatStats.mockClear();
134+
harness.mockedFormatter.formatStats.mockClear();
154135
await statsCommand({ format: 'markdown' });
155-
expect(mockedFormatter.formatStats).toHaveBeenCalledWith(
136+
expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith(
156137
expect.anything(),
157138
'markdown',
158139
expect.any(Boolean)
159140
);
160141

161-
mockedFormatter.formatStats.mockClear();
142+
harness.mockedFormatter.formatStats.mockClear();
162143
await statsCommand({ format: 'pretty' });
163-
expect(mockedFormatter.formatStats).toHaveBeenCalledWith(
144+
expect(harness.mockedFormatter.formatStats).toHaveBeenCalledWith(
164145
expect.anything(),
165146
'pretty',
166147
expect.any(Boolean)
@@ -170,15 +151,50 @@ describe('logs-stats command', () => {
170151
it('should handle aggregation errors gracefully', async () => {
171152
const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' };
172153

173-
mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]);
174-
mockedDiscovery.selectMostRecent.mockReturnValue(mockSource);
175-
mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load'));
154+
harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]);
155+
harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource);
156+
harness.mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load'));
176157

177158
const options: StatsCommandOptions = {
178159
format: 'pretty',
179160
};
180161

181162
await expect(statsCommand(options)).rejects.toThrow('process.exit called');
182-
expect(mockExit).toHaveBeenCalledWith(1);
163+
expect(harness.mockExit).toHaveBeenCalledWith(1);
164+
});
165+
166+
it('should emit source-selection info logs for non-JSON formats but suppress them for JSON', async () => {
167+
const mockSource: LogSource = {
168+
type: 'preserved',
169+
path: '/tmp/squid-logs-123',
170+
dateStr: 'Mon Jan 01 2024',
171+
};
172+
const emptyStats = {
173+
totalRequests: 0,
174+
allowedRequests: 0,
175+
deniedRequests: 0,
176+
uniqueDomains: 0,
177+
byDomain: new Map(),
178+
timeRange: null,
179+
};
180+
181+
harness.mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]);
182+
harness.mockedDiscovery.selectMostRecent.mockReturnValue(mockSource);
183+
harness.mockedAggregator.loadAndAggregate.mockResolvedValue(emptyStats);
184+
harness.mockedFormatter.formatStats.mockReturnValue('');
185+
186+
// pretty: shouldLog returns true → logger.info should be called
187+
await statsCommand({ format: 'pretty' });
188+
expect((logger.info as jest.Mock)).toHaveBeenCalled();
189+
(logger.info as jest.Mock).mockClear();
190+
191+
// markdown: shouldLog returns true → logger.info should be called
192+
await statsCommand({ format: 'markdown' });
193+
expect((logger.info as jest.Mock)).toHaveBeenCalled();
194+
(logger.info as jest.Mock).mockClear();
195+
196+
// json: shouldLog returns false → logger.info should NOT be called
197+
await statsCommand({ format: 'json' });
198+
expect((logger.info as jest.Mock)).not.toHaveBeenCalled();
183199
});
184200
});

src/commands/logs-stats.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@
33
*/
44

55
import type { LogStatsFormat } from '../types';
6-
import { formatStats } from '../logs/stats-formatter';
7-
import {
8-
discoverAndSelectSource,
9-
loadLogsWithErrorHandling,
10-
} from './logs-command-helpers';
6+
import { runLogsCommand } from './logs-command-helpers';
117

128
/**
139
* Output format type for stats command (alias for shared type)
@@ -33,18 +29,6 @@ export interface StatsCommandOptions {
3329
* @param options - Command options
3430
*/
3531
export async function statsCommand(options: StatsCommandOptions): Promise<void> {
36-
// Discover and select log source
3732
// For stats command: show info logs for all non-JSON formats
38-
const source = await discoverAndSelectSource(options.source, {
39-
format: options.format,
40-
shouldLog: (format) => format !== 'json',
41-
});
42-
43-
// Load and aggregate logs
44-
const stats = await loadLogsWithErrorHandling(source);
45-
46-
// Format and output
47-
const colorize = !!(process.stdout.isTTY && options.format === 'pretty');
48-
const output = formatStats(stats, options.format, colorize);
49-
console.log(output);
33+
await runLogsCommand(options, (format) => format !== 'json');
5034
}

0 commit comments

Comments
 (0)