Skip to content

Commit beac47f

Browse files
CopilotMossaka
andauthored
test: add unit tests for logs feature to meet coverage thresholds (#79)
* Initial plan * test: add unit tests for logs feature to meet coverage thresholds Co-authored-by: Mossaka <[email protected]> * refactor: use proper imports instead of require in tests Co-authored-by: Mossaka <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Mossaka <[email protected]>
1 parent 8d172fe commit beac47f

File tree

5 files changed

+1321
-0
lines changed

5 files changed

+1321
-0
lines changed

src/commands/logs.test.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* Unit tests for logs command handler
3+
*/
4+
5+
import { logsCommand } from './logs';
6+
import * as logDiscovery from '../logs/log-discovery';
7+
import * as logStreamer from '../logs/log-streamer';
8+
import { LogSource } from '../types';
9+
import { logger } from '../logger';
10+
11+
// Mock the log modules
12+
jest.mock('../logs/log-discovery');
13+
jest.mock('../logs/log-streamer');
14+
jest.mock('../logger', () => ({
15+
logger: {
16+
debug: jest.fn(),
17+
info: jest.fn(),
18+
warn: jest.fn(),
19+
error: jest.fn(),
20+
},
21+
}));
22+
23+
const mockedDiscovery = logDiscovery as jest.Mocked<typeof logDiscovery>;
24+
const mockedStreamer = logStreamer as jest.Mocked<typeof logStreamer>;
25+
const mockedLogger = logger as jest.Mocked<typeof logger>;
26+
27+
describe('logsCommand', () => {
28+
let consoleLogSpy: jest.SpyInstance;
29+
let processExitSpy: jest.SpyInstance;
30+
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
34+
processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
35+
throw new Error('process.exit called');
36+
});
37+
38+
// Default mocks
39+
mockedDiscovery.discoverLogSources.mockResolvedValue([]);
40+
mockedStreamer.streamLogs.mockResolvedValue(undefined);
41+
});
42+
43+
afterEach(() => {
44+
consoleLogSpy.mockRestore();
45+
processExitSpy.mockRestore();
46+
});
47+
48+
describe('--list flag', () => {
49+
it('should call listLogSources and output result', async () => {
50+
const listing = 'Available log sources:\n [running] awf-squid';
51+
mockedDiscovery.listLogSources.mockResolvedValue(listing);
52+
53+
await logsCommand({ list: true, format: 'pretty' });
54+
55+
expect(mockedDiscovery.listLogSources).toHaveBeenCalled();
56+
expect(consoleLogSpy).toHaveBeenCalledWith(listing);
57+
});
58+
59+
it('should not stream logs when --list is specified', async () => {
60+
mockedDiscovery.listLogSources.mockResolvedValue('test');
61+
62+
await logsCommand({ list: true, format: 'pretty' });
63+
64+
expect(mockedStreamer.streamLogs).not.toHaveBeenCalled();
65+
});
66+
});
67+
68+
describe('source selection', () => {
69+
it('should exit with error when no sources found', async () => {
70+
mockedDiscovery.discoverLogSources.mockResolvedValue([]);
71+
72+
await expect(logsCommand({ format: 'pretty' })).rejects.toThrow('process.exit called');
73+
expect(processExitSpy).toHaveBeenCalledWith(1);
74+
});
75+
76+
it('should use specified source when --source is provided', async () => {
77+
const source: LogSource = { type: 'preserved', path: '/tmp/logs' };
78+
mockedDiscovery.validateSource.mockResolvedValue(source);
79+
80+
await logsCommand({ format: 'pretty', source: '/tmp/logs' });
81+
82+
expect(mockedDiscovery.validateSource).toHaveBeenCalledWith('/tmp/logs');
83+
expect(mockedStreamer.streamLogs).toHaveBeenCalledWith(
84+
expect.objectContaining({ source })
85+
);
86+
});
87+
88+
it('should exit with error when specified source is invalid', async () => {
89+
mockedDiscovery.validateSource.mockRejectedValue(new Error('Invalid source'));
90+
91+
await expect(
92+
logsCommand({ format: 'pretty', source: '/invalid' })
93+
).rejects.toThrow('process.exit called');
94+
expect(processExitSpy).toHaveBeenCalledWith(1);
95+
});
96+
97+
it('should select most recent source when no --source specified', async () => {
98+
const sources: LogSource[] = [
99+
{ type: 'running', containerName: 'awf-squid' },
100+
{ type: 'preserved', path: '/tmp/logs', timestamp: 1000 },
101+
];
102+
const selectedSource = sources[0];
103+
104+
mockedDiscovery.discoverLogSources.mockResolvedValue(sources);
105+
mockedDiscovery.selectMostRecent.mockReturnValue(selectedSource);
106+
107+
await logsCommand({ format: 'pretty' });
108+
109+
expect(mockedDiscovery.selectMostRecent).toHaveBeenCalledWith(sources);
110+
expect(mockedStreamer.streamLogs).toHaveBeenCalledWith(
111+
expect.objectContaining({ source: selectedSource })
112+
);
113+
});
114+
115+
it('should exit when selectMostRecent returns null', async () => {
116+
mockedDiscovery.discoverLogSources.mockResolvedValue([
117+
{ type: 'preserved', path: '/tmp/logs' },
118+
]);
119+
mockedDiscovery.selectMostRecent.mockReturnValue(null);
120+
121+
await expect(logsCommand({ format: 'pretty' })).rejects.toThrow('process.exit called');
122+
expect(processExitSpy).toHaveBeenCalledWith(1);
123+
});
124+
});
125+
126+
describe('streaming options', () => {
127+
beforeEach(() => {
128+
const source: LogSource = { type: 'running', containerName: 'awf-squid' };
129+
mockedDiscovery.discoverLogSources.mockResolvedValue([source]);
130+
mockedDiscovery.selectMostRecent.mockReturnValue(source);
131+
});
132+
133+
it('should pass follow option to streamLogs', async () => {
134+
await logsCommand({ format: 'pretty', follow: true });
135+
136+
expect(mockedStreamer.streamLogs).toHaveBeenCalledWith(
137+
expect.objectContaining({ follow: true })
138+
);
139+
});
140+
141+
it('should default follow to false', async () => {
142+
await logsCommand({ format: 'pretty' });
143+
144+
expect(mockedStreamer.streamLogs).toHaveBeenCalledWith(
145+
expect.objectContaining({ follow: false })
146+
);
147+
});
148+
149+
it('should set parse to false for raw format', async () => {
150+
await logsCommand({ format: 'raw' });
151+
152+
expect(mockedStreamer.streamLogs).toHaveBeenCalledWith(
153+
expect.objectContaining({ parse: false })
154+
);
155+
});
156+
157+
it('should set parse to true for pretty format', async () => {
158+
await logsCommand({ format: 'pretty' });
159+
160+
expect(mockedStreamer.streamLogs).toHaveBeenCalledWith(
161+
expect.objectContaining({ parse: true })
162+
);
163+
});
164+
165+
it('should set parse to true for json format', async () => {
166+
await logsCommand({ format: 'json' });
167+
168+
expect(mockedStreamer.streamLogs).toHaveBeenCalledWith(
169+
expect.objectContaining({ parse: true })
170+
);
171+
});
172+
});
173+
174+
describe('error handling', () => {
175+
beforeEach(() => {
176+
const source: LogSource = { type: 'running', containerName: 'awf-squid' };
177+
mockedDiscovery.discoverLogSources.mockResolvedValue([source]);
178+
mockedDiscovery.selectMostRecent.mockReturnValue(source);
179+
});
180+
181+
it('should exit with error when streamLogs fails', async () => {
182+
mockedStreamer.streamLogs.mockRejectedValue(new Error('Stream error'));
183+
184+
await expect(logsCommand({ format: 'pretty' })).rejects.toThrow('process.exit called');
185+
expect(processExitSpy).toHaveBeenCalledWith(1);
186+
});
187+
188+
it('should handle non-Error objects in catch', async () => {
189+
mockedStreamer.streamLogs.mockRejectedValue('string error');
190+
191+
await expect(logsCommand({ format: 'pretty' })).rejects.toThrow('process.exit called');
192+
});
193+
194+
it('should handle non-Error in validateSource', async () => {
195+
mockedDiscovery.validateSource.mockRejectedValue('string error');
196+
197+
await expect(
198+
logsCommand({ format: 'pretty', source: '/path' })
199+
).rejects.toThrow('process.exit called');
200+
});
201+
});
202+
203+
describe('logging source info', () => {
204+
it('should log info about running container source', async () => {
205+
const source: LogSource = { type: 'running', containerName: 'awf-squid' };
206+
mockedDiscovery.discoverLogSources.mockResolvedValue([source]);
207+
mockedDiscovery.selectMostRecent.mockReturnValue(source);
208+
209+
await logsCommand({ format: 'pretty' });
210+
211+
expect(mockedLogger.info).toHaveBeenCalledWith(
212+
expect.stringContaining('awf-squid')
213+
);
214+
});
215+
216+
it('should log info about preserved source with date', async () => {
217+
const source: LogSource = {
218+
type: 'preserved',
219+
path: '/tmp/logs',
220+
dateStr: '2023-01-01',
221+
};
222+
mockedDiscovery.discoverLogSources.mockResolvedValue([source]);
223+
mockedDiscovery.selectMostRecent.mockReturnValue(source);
224+
225+
await logsCommand({ format: 'pretty' });
226+
227+
expect(mockedLogger.info).toHaveBeenCalledWith(expect.stringContaining('/tmp/logs'));
228+
expect(mockedLogger.info).toHaveBeenCalledWith(expect.stringContaining('2023-01-01'));
229+
});
230+
231+
it('should skip date log when dateStr is not present', async () => {
232+
const source: LogSource = {
233+
type: 'preserved',
234+
path: '/tmp/logs',
235+
};
236+
mockedDiscovery.discoverLogSources.mockResolvedValue([source]);
237+
mockedDiscovery.selectMostRecent.mockReturnValue(source);
238+
239+
await logsCommand({ format: 'pretty' });
240+
241+
// Should log path but not timestamp
242+
expect(mockedLogger.info).toHaveBeenCalledWith(expect.stringContaining('/tmp/logs'));
243+
// Check that we don't call with "Log timestamp" when dateStr is undefined
244+
const timestampCalls = (mockedLogger.info as jest.Mock).mock.calls.filter((call: unknown[]) =>
245+
(call[0] as string).includes('Log timestamp')
246+
);
247+
expect(timestampCalls).toHaveLength(0);
248+
});
249+
});
250+
});

0 commit comments

Comments
 (0)