@ai-devkit/agent-manager 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/AgentAdapter.d.ts +2 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +49 -38
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +286 -293
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +32 -30
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +148 -284
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -10
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +6 -3
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +12 -11
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/matching.d.ts +39 -0
- package/dist/utils/matching.d.ts.map +1 -0
- package/dist/utils/matching.js +103 -0
- package/dist/utils/matching.js.map +1 -0
- package/dist/utils/process.d.ts +25 -40
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +151 -105
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.d.ts +30 -0
- package/dist/utils/session.d.ts.map +1 -0
- package/dist/utils/session.js +101 -0
- package/dist/utils/session.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/AgentManager.test.ts +0 -25
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +921 -205
- package/src/__tests__/adapters/CodexAdapter.test.ts +468 -269
- package/src/__tests__/utils/matching.test.ts +191 -0
- package/src/__tests__/utils/process.test.ts +202 -0
- package/src/__tests__/utils/session.test.ts +117 -0
- package/src/adapters/AgentAdapter.ts +3 -0
- package/src/adapters/ClaudeCodeAdapter.ts +341 -418
- package/src/adapters/CodexAdapter.ts +155 -420
- package/src/index.ts +1 -3
- package/src/utils/index.ts +6 -3
- package/src/utils/matching.ts +92 -0
- package/src/utils/process.ts +133 -119
- package/src/utils/session.ts +92 -0
- package/dist/utils/file.d.ts +0 -52
- package/dist/utils/file.d.ts.map +0 -1
- package/dist/utils/file.js +0 -135
- package/dist/utils/file.js.map +0 -1
- package/src/utils/file.ts +0 -100
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for utils/matching.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from '@jest/globals';
|
|
6
|
+
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
|
|
7
|
+
import type { ProcessInfo } from '../../adapters/AgentAdapter';
|
|
8
|
+
import type { SessionFile } from '../../utils/session';
|
|
9
|
+
|
|
10
|
+
function makeProcess(overrides: Partial<ProcessInfo> & { pid: number }): ProcessInfo {
|
|
11
|
+
return {
|
|
12
|
+
command: 'claude',
|
|
13
|
+
cwd: '/projects/my-app',
|
|
14
|
+
tty: 'ttys001',
|
|
15
|
+
startTime: new Date('2026-03-18T23:18:01.000Z'),
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeSession(overrides: Partial<SessionFile> & { sessionId: string }): SessionFile {
|
|
21
|
+
return {
|
|
22
|
+
filePath: `/home/.claude/projects/my-app/${overrides.sessionId}.jsonl`,
|
|
23
|
+
projectDir: '/home/.claude/projects/my-app',
|
|
24
|
+
birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime(),
|
|
25
|
+
resolvedCwd: '/projects/my-app',
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('matchProcessesToSessions', () => {
|
|
31
|
+
it('should return empty array when no processes', () => {
|
|
32
|
+
const sessions = [makeSession({ sessionId: 's1' })];
|
|
33
|
+
expect(matchProcessesToSessions([], sessions)).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should return empty array when no sessions', () => {
|
|
37
|
+
const processes = [makeProcess({ pid: 100 })];
|
|
38
|
+
expect(matchProcessesToSessions(processes, [])).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should match a single process to closest session', () => {
|
|
42
|
+
const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
|
|
43
|
+
const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
|
|
44
|
+
const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:20:00.000Z').getTime() });
|
|
45
|
+
|
|
46
|
+
const results = matchProcessesToSessions([proc], [s1, s2]);
|
|
47
|
+
expect(results).toHaveLength(1);
|
|
48
|
+
expect(results[0].session.sessionId).toBe('s1');
|
|
49
|
+
expect(results[0].deltaMs).toBe(43000);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should enforce 1:1 constraint — each process matches only one session', () => {
|
|
53
|
+
const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
|
|
54
|
+
const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:18:30.000Z') });
|
|
55
|
+
const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:10.000Z').getTime() });
|
|
56
|
+
const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:18:35.000Z').getTime() });
|
|
57
|
+
|
|
58
|
+
const results = matchProcessesToSessions([p1, p2], [s1, s2]);
|
|
59
|
+
expect(results).toHaveLength(2);
|
|
60
|
+
|
|
61
|
+
const pids = results.map(r => r.process.pid).sort();
|
|
62
|
+
const sids = results.map(r => r.session.sessionId).sort();
|
|
63
|
+
expect(pids).toEqual([100, 200]);
|
|
64
|
+
expect(sids).toEqual(['s1', 's2']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should disambiguate multiple processes with same CWD by birthtime', () => {
|
|
68
|
+
const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
|
|
69
|
+
const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-19T08:53:11.000Z') });
|
|
70
|
+
const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
|
|
71
|
+
const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-19T08:55:35.000Z').getTime() });
|
|
72
|
+
|
|
73
|
+
const results = matchProcessesToSessions([p1, p2], [s1, s2]);
|
|
74
|
+
expect(results).toHaveLength(2);
|
|
75
|
+
|
|
76
|
+
const match1 = results.find(r => r.process.pid === 100);
|
|
77
|
+
const match2 = results.find(r => r.process.pid === 200);
|
|
78
|
+
expect(match1?.session.sessionId).toBe('s1');
|
|
79
|
+
expect(match2?.session.sessionId).toBe('s2');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should exclude processes without startTime', () => {
|
|
83
|
+
const proc = makeProcess({ pid: 100, startTime: undefined });
|
|
84
|
+
const session = makeSession({ sessionId: 's1' });
|
|
85
|
+
|
|
86
|
+
const results = matchProcessesToSessions([proc], [session]);
|
|
87
|
+
expect(results).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should exclude processes without cwd', () => {
|
|
91
|
+
const proc = makeProcess({ pid: 100, cwd: '' });
|
|
92
|
+
const session = makeSession({ sessionId: 's1' });
|
|
93
|
+
|
|
94
|
+
const results = matchProcessesToSessions([proc], [session]);
|
|
95
|
+
expect(results).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should not match when CWD does not match resolvedCwd', () => {
|
|
99
|
+
const proc = makeProcess({ pid: 100, cwd: '/projects/app-a' });
|
|
100
|
+
const session = makeSession({ sessionId: 's1', resolvedCwd: '/projects/app-b' });
|
|
101
|
+
|
|
102
|
+
const results = matchProcessesToSessions([proc], [session]);
|
|
103
|
+
expect(results).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should not match when delta exceeds 3-minute tolerance', () => {
|
|
107
|
+
const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
|
|
108
|
+
const session = makeSession({
|
|
109
|
+
sessionId: 's1',
|
|
110
|
+
birthtimeMs: new Date('2026-03-18T23:22:00.000Z').getTime(), // ~4 min later
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const results = matchProcessesToSessions([proc], [session]);
|
|
114
|
+
expect(results).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should match at exactly 3-minute boundary', () => {
|
|
118
|
+
const startTime = new Date('2026-03-18T23:18:00.000Z');
|
|
119
|
+
const proc = makeProcess({ pid: 100, startTime });
|
|
120
|
+
const session = makeSession({
|
|
121
|
+
sessionId: 's1',
|
|
122
|
+
birthtimeMs: startTime.getTime() + 180_000, // exactly 3 min
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const results = matchProcessesToSessions([proc], [session]);
|
|
126
|
+
expect(results).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle more sessions than processes', () => {
|
|
130
|
+
const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
|
|
131
|
+
const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
|
|
132
|
+
const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:19:00.000Z').getTime() });
|
|
133
|
+
const s3 = makeSession({ sessionId: 's3', birthtimeMs: new Date('2026-03-18T23:19:30.000Z').getTime() });
|
|
134
|
+
|
|
135
|
+
const results = matchProcessesToSessions([proc], [s1, s2, s3]);
|
|
136
|
+
expect(results).toHaveLength(1);
|
|
137
|
+
expect(results[0].session.sessionId).toBe('s1'); // closest
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should handle more processes than sessions', () => {
|
|
141
|
+
const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
|
|
142
|
+
const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:20:01.000Z') });
|
|
143
|
+
const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
|
|
144
|
+
|
|
145
|
+
const results = matchProcessesToSessions([p1, p2], [s1]);
|
|
146
|
+
expect(results).toHaveLength(1);
|
|
147
|
+
expect(results[0].process.pid).toBe(100); // closest
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should skip sessions with empty resolvedCwd', () => {
|
|
151
|
+
const proc = makeProcess({ pid: 100 });
|
|
152
|
+
const session = makeSession({ sessionId: 's1', resolvedCwd: '' });
|
|
153
|
+
|
|
154
|
+
const results = matchProcessesToSessions([proc], [session]);
|
|
155
|
+
expect(results).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should prefer best match when greedy ordering matters', () => {
|
|
159
|
+
// p1 is 10s from s2, p2 is 5s from s2 — p2 should win s2, p1 gets s1
|
|
160
|
+
const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:00.000Z') });
|
|
161
|
+
const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:18:25.000Z') });
|
|
162
|
+
const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:08.000Z').getTime() });
|
|
163
|
+
const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:18:30.000Z').getTime() });
|
|
164
|
+
|
|
165
|
+
const results = matchProcessesToSessions([p1, p2], [s1, s2]);
|
|
166
|
+
expect(results).toHaveLength(2);
|
|
167
|
+
|
|
168
|
+
const match1 = results.find(r => r.process.pid === 200);
|
|
169
|
+
const match2 = results.find(r => r.process.pid === 100);
|
|
170
|
+
expect(match1?.session.sessionId).toBe('s2'); // 5s delta
|
|
171
|
+
expect(match2?.session.sessionId).toBe('s1'); // 8s delta
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('generateAgentName', () => {
|
|
176
|
+
it('should return folderName (pid)', () => {
|
|
177
|
+
expect(generateAgentName('/projects/my-app', 12345)).toBe('my-app (12345)');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should handle root path', () => {
|
|
181
|
+
expect(generateAgentName('/', 100)).toBe('unknown (100)');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should handle empty cwd', () => {
|
|
185
|
+
expect(generateAgentName('', 100)).toBe('unknown (100)');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should handle nested paths', () => {
|
|
189
|
+
expect(generateAgentName('/home/user/projects/ai-devkit', 78070)).toBe('ai-devkit (78070)');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for new functions in utils/process.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import {
|
|
8
|
+
listAgentProcesses,
|
|
9
|
+
batchGetProcessCwds,
|
|
10
|
+
batchGetProcessStartTimes,
|
|
11
|
+
enrichProcesses,
|
|
12
|
+
} from '../../utils/process';
|
|
13
|
+
|
|
14
|
+
jest.mock('child_process', () => ({
|
|
15
|
+
execSync: jest.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockedExecSync = execSync as jest.MockedFunction<typeof execSync>;
|
|
19
|
+
|
|
20
|
+
describe('listAgentProcesses', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockedExecSync.mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should parse ps aux | grep output and post-filter by executable name', () => {
|
|
26
|
+
mockedExecSync.mockReturnValue(
|
|
27
|
+
'user 78070 1.0 0.5 485636016 245952 s018 S+ 11:18PM 1:55.14 claude\n' +
|
|
28
|
+
'user 55106 0.1 0.4 485620368 72496 s015 S+ 9Mar26 8:06.36 claude\n',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const processes = listAgentProcesses('claude');
|
|
32
|
+
expect(processes).toHaveLength(2);
|
|
33
|
+
expect(processes[0].pid).toBe(78070);
|
|
34
|
+
expect(processes[0].command).toBe('claude');
|
|
35
|
+
expect(processes[0].tty).toBe('s018');
|
|
36
|
+
expect(processes[0].cwd).toBe(''); // not populated yet
|
|
37
|
+
expect(processes[1].pid).toBe(55106);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should filter out non-matching executables', () => {
|
|
41
|
+
mockedExecSync.mockReturnValue(
|
|
42
|
+
'user 100 0.0 0.0 0 0 s001 S 1:00PM 0:00 claude\n' +
|
|
43
|
+
'user 200 0.0 0.0 0 0 s002 S 1:00PM 0:00 claude-helper --pid 100\n' +
|
|
44
|
+
'user 300 0.0 0.0 0 0 s003 S 1:00PM 0:00 /usr/bin/claude\n',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const processes = listAgentProcesses('claude');
|
|
48
|
+
expect(processes).toHaveLength(2);
|
|
49
|
+
expect(processes.map(p => p.pid)).toEqual([100, 300]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should return empty array on command failure', () => {
|
|
53
|
+
mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
|
|
54
|
+
expect(listAgentProcesses('claude')).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should handle empty output', () => {
|
|
58
|
+
mockedExecSync.mockReturnValue('');
|
|
59
|
+
expect(listAgentProcesses('claude')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should reject empty pattern', () => {
|
|
63
|
+
expect(listAgentProcesses('')).toEqual([]);
|
|
64
|
+
expect(mockedExecSync).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should reject patterns with shell injection characters', () => {
|
|
68
|
+
expect(listAgentProcesses('claude; rm -rf /')).toEqual([]);
|
|
69
|
+
expect(listAgentProcesses("claude' || true")).toEqual([]);
|
|
70
|
+
expect(listAgentProcesses('$(whoami)')).toEqual([]);
|
|
71
|
+
expect(mockedExecSync).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should accept valid patterns with dashes and underscores', () => {
|
|
75
|
+
mockedExecSync.mockReturnValue('');
|
|
76
|
+
listAgentProcesses('claude-code');
|
|
77
|
+
expect(mockedExecSync).toHaveBeenCalled();
|
|
78
|
+
|
|
79
|
+
mockedExecSync.mockReset();
|
|
80
|
+
mockedExecSync.mockReturnValue('');
|
|
81
|
+
listAgentProcesses('my_agent');
|
|
82
|
+
expect(mockedExecSync).toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('batchGetProcessCwds', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
mockedExecSync.mockReset();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should parse batched lsof output', () => {
|
|
92
|
+
mockedExecSync.mockReturnValue(
|
|
93
|
+
'p78070\nn/Users/user/ai-devkit\np55106\nn/Users/user/other-project\n',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const cwds = batchGetProcessCwds([78070, 55106]);
|
|
97
|
+
expect(cwds.get(78070)).toBe('/Users/user/ai-devkit');
|
|
98
|
+
expect(cwds.get(55106)).toBe('/Users/user/other-project');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return empty map for empty pids', () => {
|
|
102
|
+
expect(batchGetProcessCwds([])).toEqual(new Map());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should return partial results when lsof succeeds for some PIDs', () => {
|
|
106
|
+
// lsof might not return entries for dead processes
|
|
107
|
+
mockedExecSync.mockReturnValue(
|
|
108
|
+
'p78070\nn/Users/user/ai-devkit\n',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const cwds = batchGetProcessCwds([78070, 99999]);
|
|
112
|
+
expect(cwds.size).toBe(1);
|
|
113
|
+
expect(cwds.get(78070)).toBe('/Users/user/ai-devkit');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should return empty map on total failure', () => {
|
|
117
|
+
mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
|
|
118
|
+
const cwds = batchGetProcessCwds([78070]);
|
|
119
|
+
// Falls through to pwdx fallback which also fails
|
|
120
|
+
expect(cwds.size).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('batchGetProcessStartTimes', () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
mockedExecSync.mockReset();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should parse ps lstart output', () => {
|
|
130
|
+
mockedExecSync.mockReturnValue(
|
|
131
|
+
' 78070 Wed Mar 18 23:18:01 2026\n' +
|
|
132
|
+
' 55106 Mon Mar 9 21:41:42 2026\n',
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const times = batchGetProcessStartTimes([78070, 55106]);
|
|
136
|
+
expect(times.size).toBe(2);
|
|
137
|
+
expect(times.get(78070)?.getFullYear()).toBe(2026);
|
|
138
|
+
expect(times.get(55106)?.getMonth()).toBe(2); // March = 2
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should return empty map for empty pids', () => {
|
|
142
|
+
expect(batchGetProcessStartTimes([])).toEqual(new Map());
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should skip lines with unparseable dates', () => {
|
|
146
|
+
mockedExecSync.mockReturnValue(
|
|
147
|
+
' 78070 Wed Mar 18 23:18:01 2026\n' +
|
|
148
|
+
' 99999 INVALID_DATE\n',
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const times = batchGetProcessStartTimes([78070, 99999]);
|
|
152
|
+
expect(times.size).toBe(1);
|
|
153
|
+
expect(times.has(78070)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should return empty map on failure', () => {
|
|
157
|
+
mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
|
|
158
|
+
expect(batchGetProcessStartTimes([78070])).toEqual(new Map());
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('enrichProcesses', () => {
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
mockedExecSync.mockReset();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should populate cwd and startTime on processes', () => {
|
|
168
|
+
// First call: batchGetProcessCwds (lsof)
|
|
169
|
+
// Second call: batchGetProcessStartTimes (ps lstart)
|
|
170
|
+
mockedExecSync
|
|
171
|
+
.mockReturnValueOnce('p100\nn/projects/app\n')
|
|
172
|
+
.mockReturnValueOnce(' 100 Wed Mar 18 23:18:01 2026\n');
|
|
173
|
+
|
|
174
|
+
const processes = [
|
|
175
|
+
{ pid: 100, command: 'claude', cwd: '', tty: 's001' },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const enriched = enrichProcesses(processes);
|
|
179
|
+
expect(enriched[0].cwd).toBe('/projects/app');
|
|
180
|
+
expect(enriched[0].startTime).toBeDefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return empty array for empty input', () => {
|
|
184
|
+
expect(enrichProcesses([])).toEqual([]);
|
|
185
|
+
expect(mockedExecSync).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should handle partial failures', () => {
|
|
189
|
+
// lsof succeeds, ps lstart fails
|
|
190
|
+
mockedExecSync
|
|
191
|
+
.mockReturnValueOnce('p100\nn/projects/app\n')
|
|
192
|
+
.mockImplementationOnce(() => { throw new Error('fail'); });
|
|
193
|
+
|
|
194
|
+
const processes = [
|
|
195
|
+
{ pid: 100, command: 'claude', cwd: '', tty: 's001' },
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const enriched = enrichProcesses(processes);
|
|
199
|
+
expect(enriched[0].cwd).toBe('/projects/app');
|
|
200
|
+
expect(enriched[0].startTime).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for utils/session.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { batchGetSessionFileBirthtimes } from '../../utils/session';
|
|
8
|
+
|
|
9
|
+
jest.mock('child_process', () => ({
|
|
10
|
+
execSync: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const mockedExecSync = execSync as jest.MockedFunction<typeof execSync>;
|
|
14
|
+
|
|
15
|
+
describe('batchGetSessionFileBirthtimes', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockedExecSync.mockReset();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should parse stat output correctly', () => {
|
|
21
|
+
mockedExecSync.mockReturnValue(
|
|
22
|
+
'1710800324 /home/.claude/projects/my-app/abc123.jsonl\n' +
|
|
23
|
+
'1710800500 /home/.claude/projects/my-app/def456.jsonl\n',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const results = batchGetSessionFileBirthtimes(['/home/.claude/projects/my-app']);
|
|
27
|
+
|
|
28
|
+
expect(results).toHaveLength(2);
|
|
29
|
+
expect(results[0]).toEqual({
|
|
30
|
+
sessionId: 'abc123',
|
|
31
|
+
filePath: '/home/.claude/projects/my-app/abc123.jsonl',
|
|
32
|
+
projectDir: '/home/.claude/projects/my-app',
|
|
33
|
+
birthtimeMs: 1710800324000,
|
|
34
|
+
resolvedCwd: '',
|
|
35
|
+
});
|
|
36
|
+
expect(results[1].sessionId).toBe('def456');
|
|
37
|
+
expect(results[1].birthtimeMs).toBe(1710800500000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return empty array for empty dirs list', () => {
|
|
41
|
+
expect(batchGetSessionFileBirthtimes([])).toEqual([]);
|
|
42
|
+
expect(mockedExecSync).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return empty array on command failure', () => {
|
|
46
|
+
mockedExecSync.mockImplementation(() => {
|
|
47
|
+
throw new Error('Command failed');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(batchGetSessionFileBirthtimes(['/some/dir'])).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should skip lines with invalid epoch (0 or negative)', () => {
|
|
54
|
+
mockedExecSync.mockReturnValue(
|
|
55
|
+
'0 /dir/bad.jsonl\n' +
|
|
56
|
+
'-1 /dir/negative.jsonl\n' +
|
|
57
|
+
'1710800324 /dir/good.jsonl\n',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const results = batchGetSessionFileBirthtimes(['/dir']);
|
|
61
|
+
expect(results).toHaveLength(1);
|
|
62
|
+
expect(results[0].sessionId).toBe('good');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should skip non-jsonl files in output', () => {
|
|
66
|
+
mockedExecSync.mockReturnValue(
|
|
67
|
+
'1710800324 /dir/sessions-index.json\n' +
|
|
68
|
+
'1710800500 /dir/abc123.jsonl\n',
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const results = batchGetSessionFileBirthtimes(['/dir']);
|
|
72
|
+
expect(results).toHaveLength(1);
|
|
73
|
+
expect(results[0].sessionId).toBe('abc123');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should handle empty output', () => {
|
|
77
|
+
mockedExecSync.mockReturnValue('');
|
|
78
|
+
expect(batchGetSessionFileBirthtimes(['/dir'])).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle UUID session IDs', () => {
|
|
82
|
+
mockedExecSync.mockReturnValue(
|
|
83
|
+
'1710800324 /dir/068e7b1f-cff5-4c94-bf69-b9acd32d765c.jsonl\n',
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const results = batchGetSessionFileBirthtimes(['/dir']);
|
|
87
|
+
expect(results).toHaveLength(1);
|
|
88
|
+
expect(results[0].sessionId).toBe('068e7b1f-cff5-4c94-bf69-b9acd32d765c');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should leave resolvedCwd empty', () => {
|
|
92
|
+
mockedExecSync.mockReturnValue('1710800324 /dir/abc.jsonl\n');
|
|
93
|
+
|
|
94
|
+
const results = batchGetSessionFileBirthtimes(['/dir']);
|
|
95
|
+
expect(results[0].resolvedCwd).toBe('');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should combine multiple directories into a single stat call', () => {
|
|
99
|
+
mockedExecSync.mockReturnValue(
|
|
100
|
+
'1710800324 /projects/app-a/sess1.jsonl\n' +
|
|
101
|
+
'1710800400 /projects/app-b/sess2.jsonl\n' +
|
|
102
|
+
'1710800500 /projects/app-a/sess3.jsonl\n',
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const results = batchGetSessionFileBirthtimes(['/projects/app-a', '/projects/app-b']);
|
|
106
|
+
|
|
107
|
+
expect(mockedExecSync).toHaveBeenCalledTimes(1);
|
|
108
|
+
const cmd = mockedExecSync.mock.calls[0][0] as string;
|
|
109
|
+
expect(cmd).toContain('"/projects/app-a"/*.jsonl');
|
|
110
|
+
expect(cmd).toContain('"/projects/app-b"/*.jsonl');
|
|
111
|
+
|
|
112
|
+
expect(results).toHaveLength(3);
|
|
113
|
+
expect(results[0].projectDir).toBe('/projects/app-a');
|
|
114
|
+
expect(results[1].projectDir).toBe('/projects/app-b');
|
|
115
|
+
expect(results[2].projectDir).toBe('/projects/app-a');
|
|
116
|
+
});
|
|
117
|
+
});
|