Skip to content

Commit fb2617a

Browse files
committed
test: add comprehensive coverage for dynamic orchestration features (#68)
1 parent 06642db commit fb2617a

4 files changed

Lines changed: 1101 additions & 2 deletions

File tree

tests/lib/job-comms.test.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2+
import type { JobSpec } from '../../src/lib/plan-types';
3+
import { JobComms, type RelayContext } from '../../src/lib/job-comms';
4+
import * as sdkClientMod from '../../src/lib/sdk-client';
5+
6+
function makeJob(name: string, overrides: Partial<JobSpec> = {}): JobSpec {
7+
return {
8+
id: `${name}-id`,
9+
name,
10+
prompt: `do ${name}`,
11+
status: 'running',
12+
...overrides,
13+
};
14+
}
15+
16+
describe('JobComms', () => {
17+
let comms: JobComms;
18+
19+
beforeEach(() => {
20+
comms = new JobComms();
21+
});
22+
23+
afterEach(() => {
24+
mock.restore();
25+
});
26+
27+
describe('registerJob / unregisterJob', () => {
28+
it('registers a job and makes it visible in getAllRegisteredJobs', () => {
29+
comms.registerJob(makeJob('alpha'));
30+
31+
expect(comms.getAllRegisteredJobs()).toContain('alpha');
32+
});
33+
34+
it('unregisters a job and removes it from all lookups', () => {
35+
comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**'] }));
36+
comms.unregisterJob('alpha');
37+
38+
expect(comms.getAllRegisteredJobs()).not.toContain('alpha');
39+
expect(comms.getRelayPatternsForJob('alpha')).toBeUndefined();
40+
});
41+
42+
it('registers relay patterns from job spec', () => {
43+
comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**', 'tests/'] }));
44+
45+
expect(comms.getRelayPatternsForJob('alpha')).toEqual(['src/**', 'tests/']);
46+
});
47+
48+
it('does not register patterns when relayPatterns is empty', () => {
49+
comms.registerJob(makeJob('alpha', { relayPatterns: [] }));
50+
51+
expect(comms.getRelayPatternsForJob('alpha')).toBeUndefined();
52+
});
53+
});
54+
55+
describe('relayFinding', () => {
56+
it('stores a message in the target job message bus', () => {
57+
comms.registerJob(makeJob('sender'));
58+
comms.registerJob(makeJob('receiver'));
59+
60+
const context: RelayContext = {
61+
finding: 'API signature changed',
62+
filePath: 'src/api.ts',
63+
lineNumber: 42,
64+
severity: 'warning',
65+
};
66+
comms.relayFinding('sender', 'receiver', context);
67+
68+
const messages = comms.getMessagesForJob('receiver');
69+
expect(messages).toHaveLength(1);
70+
expect(messages[0].from).toBe('sender');
71+
expect(messages[0].to).toBe('receiver');
72+
expect(messages[0].context.finding).toBe('API signature changed');
73+
expect(messages[0].context.filePath).toBe('src/api.ts');
74+
expect(messages[0].context.lineNumber).toBe(42);
75+
expect(messages[0].context.severity).toBe('warning');
76+
expect(messages[0].timestamp).toBeTruthy();
77+
});
78+
79+
it('accumulates multiple messages for the same target', () => {
80+
comms.registerJob(makeJob('a'));
81+
comms.registerJob(makeJob('b'));
82+
83+
comms.relayFinding('a', 'b', { finding: 'first' });
84+
comms.relayFinding('a', 'b', { finding: 'second' });
85+
86+
expect(comms.getMessagesForJob('b')).toHaveLength(2);
87+
});
88+
89+
it('stores messages even for unregistered targets', () => {
90+
comms.relayFinding('unknown-sender', 'unknown-receiver', { finding: 'orphan' });
91+
92+
const messages = comms.getMessagesForJob('unknown-receiver');
93+
expect(messages).toHaveLength(1);
94+
expect(messages[0].context.finding).toBe('orphan');
95+
});
96+
});
97+
98+
describe('getMessagesForJob / clearMessagesForJob', () => {
99+
it('returns empty array when no messages exist', () => {
100+
expect(comms.getMessagesForJob('nonexistent')).toEqual([]);
101+
});
102+
103+
it('clears messages for a job', () => {
104+
comms.registerJob(makeJob('target'));
105+
comms.relayFinding('source', 'target', { finding: 'msg' });
106+
107+
comms.clearMessagesForJob('target');
108+
109+
expect(comms.getMessagesForJob('target')).toHaveLength(0);
110+
});
111+
});
112+
113+
describe('shouldRelayForFile', () => {
114+
it('returns true when file matches a relay pattern', () => {
115+
comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**'] }));
116+
117+
expect(comms.shouldRelayForFile('alpha', 'src/lib/foo.ts')).toBe(true);
118+
});
119+
120+
it('returns false when file does not match any pattern', () => {
121+
comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**'] }));
122+
123+
expect(comms.shouldRelayForFile('alpha', 'tests/foo.test.ts')).toBe(false);
124+
});
125+
126+
it('returns false for unregistered job', () => {
127+
expect(comms.shouldRelayForFile('nonexistent', 'src/foo.ts')).toBe(false);
128+
});
129+
130+
it('handles trailing slash pattern (directory prefix)', () => {
131+
comms.registerJob(makeJob('alpha', { relayPatterns: ['docs/'] }));
132+
133+
expect(comms.shouldRelayForFile('alpha', 'docs/guide.md')).toBe(true);
134+
expect(comms.shouldRelayForFile('alpha', 'src/app.ts')).toBe(false);
135+
});
136+
137+
it('returns false when job has no relay patterns', () => {
138+
comms.registerJob(makeJob('alpha'));
139+
140+
expect(comms.shouldRelayForFile('alpha', 'src/foo.ts')).toBe(false);
141+
});
142+
});
143+
144+
describe('deliverMessages', () => {
145+
it('delivers messages to job via SDK and clears queue', async () => {
146+
const mockClient = { session: { promptAsync: async () => ({}) } };
147+
const waitSpy = spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any);
148+
const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue();
149+
150+
comms.registerJob(makeJob('target'));
151+
comms.relayFinding('source', 'target', {
152+
finding: 'Schema change detected',
153+
filePath: 'src/schema.ts',
154+
severity: 'error',
155+
});
156+
157+
const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' });
158+
const delivered = await comms.deliverMessages(job);
159+
160+
expect(delivered).toBe(1);
161+
expect(waitSpy).toHaveBeenCalledWith(14100, { timeoutMs: 5000 });
162+
expect(sendSpy).toHaveBeenCalledTimes(1);
163+
expect(sendSpy).toHaveBeenCalledWith(
164+
mockClient,
165+
'session-1',
166+
expect.stringContaining('Schema change detected'),
167+
);
168+
expect(comms.getMessagesForJob('target')).toHaveLength(0);
169+
});
170+
171+
it('returns 0 when no messages exist for job', async () => {
172+
const job = makeJob('empty', { port: 14100, launchSessionID: 'session-1' });
173+
const delivered = await comms.deliverMessages(job);
174+
expect(delivered).toBe(0);
175+
});
176+
177+
it('returns 0 when job has no port', async () => {
178+
comms.registerJob(makeJob('target'));
179+
comms.relayFinding('source', 'target', { finding: 'msg' });
180+
181+
const job = makeJob('target');
182+
const delivered = await comms.deliverMessages(job);
183+
expect(delivered).toBe(0);
184+
});
185+
186+
it('returns 0 when server connection fails', async () => {
187+
spyOn(sdkClientMod, 'waitForServer').mockRejectedValue(new Error('connection refused'));
188+
189+
comms.registerJob(makeJob('target'));
190+
comms.relayFinding('source', 'target', { finding: 'msg' });
191+
192+
const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' });
193+
const delivered = await comms.deliverMessages(job);
194+
expect(delivered).toBe(0);
195+
});
196+
197+
it('filters messages by source when filterFrom is specified', async () => {
198+
const mockClient = { session: { promptAsync: async () => ({}) } };
199+
spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any);
200+
const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue();
201+
202+
comms.registerJob(makeJob('target'));
203+
comms.relayFinding('job-a', 'target', { finding: 'from A' });
204+
comms.relayFinding('job-b', 'target', { finding: 'from B' });
205+
comms.relayFinding('job-c', 'target', { finding: 'from C' });
206+
207+
const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' });
208+
const delivered = await comms.deliverMessages(job, { filterFrom: ['job-a', 'job-c'] });
209+
210+
expect(delivered).toBe(2);
211+
expect(sendSpy).toHaveBeenCalledTimes(2);
212+
});
213+
214+
it('returns 0 when filterFrom matches no messages', async () => {
215+
comms.registerJob(makeJob('target'));
216+
comms.relayFinding('job-a', 'target', { finding: 'from A' });
217+
218+
const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' });
219+
const delivered = await comms.deliverMessages(job, { filterFrom: ['nonexistent'] });
220+
expect(delivered).toBe(0);
221+
});
222+
223+
it('formats relay prompt with all context fields', async () => {
224+
const mockClient = { session: { promptAsync: async () => ({}) } };
225+
spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any);
226+
const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue();
227+
228+
comms.registerJob(makeJob('target'));
229+
comms.relayFinding('api-job', 'target', {
230+
finding: 'Endpoint removed',
231+
filePath: 'src/routes.ts',
232+
lineNumber: 55,
233+
severity: 'error',
234+
});
235+
236+
const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' });
237+
await comms.deliverMessages(job);
238+
239+
const promptArg = sendSpy.mock.calls[0][2];
240+
expect(promptArg).toContain('[Inter-Job Communication from api-job]');
241+
expect(promptArg).toContain('Severity: ERROR');
242+
expect(promptArg).toContain('Finding: Endpoint removed');
243+
expect(promptArg).toContain('File: src/routes.ts');
244+
expect(promptArg).toContain('Line: 55');
245+
});
246+
247+
it('formats relay prompt without optional fields', async () => {
248+
const mockClient = { session: { promptAsync: async () => ({}) } };
249+
spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any);
250+
const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue();
251+
252+
comms.registerJob(makeJob('target'));
253+
comms.relayFinding('source', 'target', { finding: 'Simple message' });
254+
255+
const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' });
256+
await comms.deliverMessages(job);
257+
258+
const promptArg = sendSpy.mock.calls[0][2];
259+
expect(promptArg).toContain('Finding: Simple message');
260+
expect(promptArg).not.toContain('Severity:');
261+
expect(promptArg).not.toContain('File:');
262+
expect(promptArg).not.toContain('Line:');
263+
});
264+
});
265+
266+
describe('getAllRegisteredJobs', () => {
267+
it('returns all registered job names', () => {
268+
comms.registerJob(makeJob('a'));
269+
comms.registerJob(makeJob('b'));
270+
comms.registerJob(makeJob('c'));
271+
272+
const jobs = comms.getAllRegisteredJobs();
273+
expect(jobs).toHaveLength(3);
274+
expect(jobs).toContain('a');
275+
expect(jobs).toContain('b');
276+
expect(jobs).toContain('c');
277+
});
278+
});
279+
});

0 commit comments

Comments
 (0)