Skip to content

Commit 2151a0f

Browse files
test: cover least-tested modules (timemachine, purgeable, docker, homebrew, dns, config)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 9bc7c61 commit 2151a0f

9 files changed

Lines changed: 986 additions & 189 deletions

File tree

src/maintenance/dns-cache.test.ts

Lines changed: 115 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,139 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { flushDnsCache } from './dns-cache.js';
33

4-
// Mock child_process spawn
5-
const mockOn = vi.fn();
6-
const mockStdout = { on: vi.fn() };
7-
const mockStderr = { on: vi.fn() };
4+
interface FakeProcess {
5+
stdout?: string;
6+
stderr?: string;
7+
code?: number;
8+
error?: Error;
9+
}
10+
11+
const spawnCalls: Array<{ command: string; args: string[] }> = [];
12+
let respond: (command: string, args: string[]) => FakeProcess;
813

914
vi.mock('child_process', () => ({
10-
spawn: vi.fn(() => {
15+
spawn: (command: string, args: string[]) => {
16+
spawnCalls.push({ command, args });
17+
const result = respond(command, args);
18+
1119
return {
12-
stdout: mockStdout,
13-
stderr: mockStderr,
14-
on: (event: string, callback: (code: number) => void) => {
15-
if (event === 'close') {
16-
// Simulate successful execution by default
17-
setTimeout(() => callback(0), 0);
20+
stdout: {
21+
on: (event: string, callback: (data: Buffer) => void) => {
22+
if (event === 'data' && result.stdout) {
23+
setTimeout(() => callback(Buffer.from(result.stdout as string)), 0);
24+
}
25+
},
26+
},
27+
stderr: {
28+
on: (event: string, callback: (data: Buffer) => void) => {
29+
if (event === 'data' && result.stderr) {
30+
setTimeout(() => callback(Buffer.from(result.stderr as string)), 0);
31+
}
32+
},
33+
},
34+
on: (event: string, callback: (arg?: number | Error) => void) => {
35+
if (event === 'error' && result.error) {
36+
setTimeout(() => callback(result.error), 0);
37+
}
38+
if (event === 'close' && !result.error) {
39+
setTimeout(() => callback(result.code ?? 0), 0);
1840
}
19-
return mockOn(event, callback);
2041
},
2142
};
22-
}),
43+
},
2344
}));
2445

25-
describe('dns-cache', () => {
46+
describe('flushDnsCache', () => {
2647
beforeEach(() => {
27-
vi.clearAllMocks();
28-
mockStdout.on.mockReset();
29-
mockStderr.on.mockReset();
30-
mockOn.mockReset();
31-
32-
// Setup default mock behavior
33-
mockStdout.on.mockImplementation(() => {
34-
// No output by default
35-
});
36-
mockStderr.on.mockImplementation(() => {
37-
// No error output by default
38-
});
48+
spawnCalls.length = 0;
49+
respond = () => ({ code: 0 });
50+
vi.spyOn(process, 'getuid').mockReturnValue(501);
3951
});
4052

4153
afterEach(() => {
4254
vi.restoreAllMocks();
4355
});
4456

45-
describe('flushDnsCache', () => {
46-
it('should return a MaintenanceResult', async () => {
47-
const result = await flushDnsCache();
57+
it('should require sudo when not root and sudo needs a password', async () => {
58+
respond = () => ({ code: 1, stderr: 'a password is required' });
4859

49-
expect(result).toHaveProperty('success');
50-
expect(result).toHaveProperty('message');
51-
expect(typeof result.success).toBe('boolean');
52-
expect(typeof result.message).toBe('string');
53-
});
60+
const result = await flushDnsCache();
5461

55-
it('should have error property when fails', async () => {
56-
// The function checks for sudo permissions first
57-
// If not running as root and can't sudo, it should fail with requiresSudo
58-
const result = await flushDnsCache();
59-
60-
// Either it succeeds (if running with sudo) or it fails with proper error
61-
expect(result).toHaveProperty('success');
62-
expect(result).toHaveProperty('message');
63-
64-
if (!result.success) {
65-
expect(result.error || result.requiresSudo).toBeDefined();
66-
}
62+
expect(result).toEqual({
63+
success: false,
64+
message: 'DNS cache flush requires administrator privileges',
65+
error: 'Run with sudo: sudo mac-cleaner-cli maintenance --dns',
66+
requiresSudo: true,
6767
});
68+
expect(spawnCalls).toEqual([{ command: 'sudo', args: ['-n', 'true'] }]);
69+
});
6870

69-
it('should have requiresSudo property when sudo is needed', async () => {
70-
const result = await flushDnsCache();
71-
72-
// The result should have proper structure
73-
expect(result).toHaveProperty('success');
74-
expect(result).toHaveProperty('message');
75-
76-
// If it needs sudo, requiresSudo should be set
77-
if (!result.success && result.message.includes('privileges')) {
78-
expect(result.requiresSudo).toBe(true);
79-
}
80-
});
71+
it('should flush the cache directly when running as root', async () => {
72+
vi.spyOn(process, 'getuid').mockReturnValue(0);
73+
74+
const result = await flushDnsCache();
75+
76+
expect(result).toEqual({ success: true, message: 'DNS cache flushed successfully' });
77+
expect(spawnCalls).toEqual([
78+
{ command: '/usr/bin/dscacheutil', args: ['-flushcache'] },
79+
{ command: '/usr/bin/killall', args: ['-HUP', 'mDNSResponder'] },
80+
]);
81+
});
82+
83+
it('should flush the cache through sudo when passwordless sudo works', async () => {
84+
const result = await flushDnsCache();
85+
86+
expect(result.success).toBe(true);
87+
expect(spawnCalls).toEqual([
88+
{ command: 'sudo', args: ['-n', 'true'] },
89+
{ command: 'sudo', args: ['-n', '/usr/bin/dscacheutil', '-flushcache'] },
90+
{ command: 'sudo', args: ['-n', '/usr/bin/killall', '-HUP', 'mDNSResponder'] },
91+
]);
92+
});
93+
94+
it('should report that sudo is required when the flush is not permitted', async () => {
95+
vi.spyOn(process, 'getuid').mockReturnValue(0);
96+
respond = () => ({ code: 1, stderr: 'dscacheutil: Operation not permitted' });
97+
98+
const result = await flushDnsCache();
99+
100+
expect(result.success).toBe(false);
101+
expect(result.message).toBe('Failed to flush DNS cache');
102+
expect(result.error).toBe('Run with sudo: sudo mac-cleaner-cli maintenance --dns');
103+
expect(result.requiresSudo).toBe(true);
104+
});
105+
106+
it('should surface other failures verbatim', async () => {
107+
vi.spyOn(process, 'getuid').mockReturnValue(0);
108+
respond = () => ({ code: 1, stderr: 'mDNSResponder: no matching processes' });
109+
110+
const result = await flushDnsCache();
111+
112+
expect(result.success).toBe(false);
113+
expect(result.error).toBe('mDNSResponder: no matching processes');
114+
expect(result.requiresSudo).toBe(false);
115+
});
116+
117+
it('should surface spawn errors', async () => {
118+
vi.spyOn(process, 'getuid').mockReturnValue(0);
119+
respond = () => ({ error: new Error('spawn ENOENT') });
120+
121+
const result = await flushDnsCache();
122+
123+
expect(result.success).toBe(false);
124+
expect(result.error).toBe('spawn ENOENT');
125+
});
126+
127+
it('should fail when killall fails after a successful flush', async () => {
128+
vi.spyOn(process, 'getuid').mockReturnValue(0);
129+
respond = (command) => {
130+
if (command === '/usr/bin/killall') return { code: 1, stderr: 'killall failed' };
131+
return { code: 0 };
132+
};
133+
134+
const result = await flushDnsCache();
135+
136+
expect(result.success).toBe(false);
137+
expect(result.error).toBe('killall failed');
81138
});
82139
});

src/maintenance/purgeable.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { freePurgeableSpace } from './purgeable.js';
3+
4+
interface FakeProcess {
5+
stderr?: string;
6+
code?: number;
7+
error?: Error;
8+
}
9+
10+
const spawnCalls: Array<{ command: string; args: string[] }> = [];
11+
let respond: (command: string, args: string[]) => FakeProcess;
12+
13+
vi.mock('child_process', () => ({
14+
spawn: (command: string, args: string[]) => {
15+
spawnCalls.push({ command, args });
16+
const result = respond(command, args);
17+
18+
return {
19+
stdout: { on: () => undefined },
20+
stderr: {
21+
on: (event: string, callback: (data: Buffer) => void) => {
22+
if (event === 'data' && result.stderr) {
23+
setTimeout(() => callback(Buffer.from(result.stderr as string)), 0);
24+
}
25+
},
26+
},
27+
on: (event: string, callback: (arg?: number | Error) => void) => {
28+
if (event === 'error' && result.error) {
29+
setTimeout(() => callback(result.error), 0);
30+
}
31+
if (event === 'close' && !result.error) {
32+
setTimeout(() => callback(result.code ?? 0), 0);
33+
}
34+
},
35+
};
36+
},
37+
}));
38+
39+
const PURGE = '/usr/sbin/purge';
40+
41+
describe('freePurgeableSpace', () => {
42+
beforeEach(() => {
43+
spawnCalls.length = 0;
44+
respond = () => ({ code: 0 });
45+
vi.spyOn(process, 'getuid').mockReturnValue(501);
46+
});
47+
48+
afterEach(() => {
49+
vi.restoreAllMocks();
50+
});
51+
52+
it('should run purge directly when running as root', async () => {
53+
vi.spyOn(process, 'getuid').mockReturnValue(0);
54+
55+
const result = await freePurgeableSpace();
56+
57+
expect(result).toEqual({
58+
success: true,
59+
message: 'Purgeable space freed successfully',
60+
});
61+
expect(spawnCalls).toEqual([{ command: PURGE, args: [] }]);
62+
});
63+
64+
it('should run purge through non-interactive sudo when not root', async () => {
65+
const result = await freePurgeableSpace();
66+
67+
expect(result.success).toBe(true);
68+
expect(spawnCalls).toEqual([{ command: 'sudo', args: ['-n', PURGE] }]);
69+
});
70+
71+
it('should fall back to running purge without sudo', async () => {
72+
respond = (command) => {
73+
if (command === 'sudo') return { code: 1, stderr: 'a password is required' };
74+
return { code: 0 };
75+
};
76+
77+
const result = await freePurgeableSpace();
78+
79+
expect(result.success).toBe(true);
80+
expect(spawnCalls.map((call) => call.command)).toEqual(['sudo', PURGE]);
81+
});
82+
83+
it('should report that sudo is required when purge is not permitted', async () => {
84+
respond = () => ({ code: 1, stderr: 'purge: Operation not permitted' });
85+
86+
const result = await freePurgeableSpace();
87+
88+
expect(result.success).toBe(false);
89+
expect(result.message).toBe('Failed to free purgeable space');
90+
expect(result.requiresSudo).toBe(true);
91+
expect(result.error).toContain('sudo mac-cleaner-cli maintenance --purgeable');
92+
});
93+
94+
it('should report that sudo is required on permission denied', async () => {
95+
respond = () => ({ code: 1, stderr: 'Permission denied' });
96+
97+
const result = await freePurgeableSpace();
98+
99+
expect(result.requiresSudo).toBe(true);
100+
});
101+
102+
it('should surface other failures verbatim', async () => {
103+
respond = () => ({ code: 127, stderr: 'purge: command not found' });
104+
105+
const result = await freePurgeableSpace();
106+
107+
expect(result.success).toBe(false);
108+
expect(result.requiresSudo).toBe(false);
109+
expect(result.error).toBe('purge: command not found');
110+
});
111+
112+
it('should surface spawn errors', async () => {
113+
respond = () => ({ error: new Error('spawn ENOENT') });
114+
115+
const result = await freePurgeableSpace();
116+
117+
expect(result.success).toBe(false);
118+
expect(result.error).toBe('spawn ENOENT');
119+
});
120+
});

0 commit comments

Comments
 (0)