agent-ecosystem/test/main/services/team/TaskChangeWorkerClient.test.ts
iliya 507bf798eb improvement(task-change): improve task change presence tracking and related IPC handlers
- Added support for tracking task change presence with new IPC channels: TEAM_GET_TASK_CHANGE_PRESENCE and TEAM_SET_CHANGE_PRESENCE_TRACKING.
- Introduced JsonTaskChangePresenceRepository and TeamLogSourceTracker to manage task change presence data.
- Enhanced ChangeExtractorService to utilize task change presence services for improved task change detection.
- Updated TeamDataService to integrate task change presence tracking and resolve task change presence states.
- Modified UI components to reflect task change presence status in Kanban and task detail views.

This feature aims to provide real-time insights into task changes, enhancing user experience and task management capabilities.
2026-03-27 17:52:39 +02:00

255 lines
7.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { TaskChangeWorkerClient } from '../../../../src/main/services/team/TaskChangeWorkerClient';
import type { TaskChangeSetV2 } from '../../../../src/shared/types';
import type { TaskChangeWorkerRequest, TaskChangeWorkerResponse } from '../../../../src/main/services/team/taskChangeWorkerTypes';
class FakeWorker {
readonly posted: TaskChangeWorkerRequest[] = [];
readonly terminate = vi.fn(async () => 0);
private readonly listeners: {
message: Array<(message: TaskChangeWorkerResponse) => void>;
error: Array<(error: Error) => void>;
exit: Array<(code: number) => void>;
} = {
message: [],
error: [],
exit: [],
};
on(event: 'message' | 'error' | 'exit', listener: ((value: any) => void) & ((value: any) => void)) {
if (event === 'message') this.listeners.message.push(listener as (message: TaskChangeWorkerResponse) => void);
if (event === 'error') this.listeners.error.push(listener as (error: Error) => void);
if (event === 'exit') this.listeners.exit.push(listener as (code: number) => void);
return this;
}
postMessage(message: TaskChangeWorkerRequest): void {
this.posted.push(message);
}
emitMessage(message: TaskChangeWorkerResponse): void {
for (const listener of this.listeners.message) {
listener(message);
}
}
emitError(error: Error): void {
for (const listener of this.listeners.error) {
listener(error);
}
}
emitExit(code: number): void {
for (const listener of this.listeners.exit) {
listener(code);
}
}
}
function makePayload(taskId = 'task-1') {
return {
teamName: 'team-a',
taskId,
taskMeta: {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
reviewState: 'none' as const,
historyEvents: [],
},
effectiveOptions: {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
},
projectPath: '/repo',
includeDetails: false,
};
}
function makeResult(taskId = 'task-1', filePath = '/repo/src/file.ts'): TaskChangeSetV2 {
return {
teamName: 'team-a',
taskId,
files: [
{
filePath,
relativePath: 'src/file.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'high' as const,
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId,
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [filePath],
confidence: { tier: 1, label: 'high', reason: 'test fixture' },
},
warnings: [],
};
}
describe('TaskChangeWorkerClient', () => {
afterEach(() => {
vi.useRealTimers();
});
it('resolves successful worker responses', async () => {
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const promise = client.computeTaskChanges(makePayload());
const request = workers[0]!.posted[0]!;
workers[0]!.emitMessage({ id: request.id, ok: true, result: makeResult() });
await expect(promise).resolves.toEqual(makeResult());
});
it('times out the active request, terminates the worker, and recreates it on the next call', async () => {
vi.useFakeTimers();
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
timeoutMs: 25,
enabled: true,
});
const firstPromise = client.computeTaskChanges(makePayload('task-timeout'));
const firstExpectation = expect(firstPromise).rejects.toThrow('Worker call timeout');
await vi.advanceTimersByTimeAsync(25);
await firstExpectation;
expect(workers[0]!.terminate).toHaveBeenCalledTimes(1);
const secondPromise = client.computeTaskChanges(makePayload('task-next'));
const request = workers[1]!.posted[0]!;
workers[1]!.emitMessage({
id: request.id,
ok: true,
result: makeResult('task-next', '/repo/src/next.ts'),
});
await expect(secondPromise).resolves.toEqual(makeResult('task-next', '/repo/src/next.ts'));
expect(workers).toHaveLength(2);
});
it('rejects all pending requests on worker error and clears queued work', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const first = client.computeTaskChanges(makePayload('task-1'));
const second = client.computeTaskChanges(makePayload('task-2'));
workers[0]!.emitError(new Error('boom'));
await expect(first).rejects.toThrow('boom');
await expect(second).rejects.toThrow('boom');
const third = client.computeTaskChanges(makePayload('task-3'));
const request = workers[1]!.posted[0]!;
workers[1]!.emitMessage({ id: request.id, ok: true, result: makeResult('task-3') });
await expect(third).resolves.toEqual(makeResult('task-3'));
});
it('rejects all pending requests on worker exit', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const first = client.computeTaskChanges(makePayload('task-1'));
const second = client.computeTaskChanges(makePayload('task-2'));
workers[0]!.emitExit(9);
await expect(first).rejects.toThrow('Worker exited with code 9');
await expect(second).rejects.toThrow('Worker exited with code 9');
});
it('executes queued requests sequentially in FIFO order', async () => {
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const first = client.computeTaskChanges(makePayload('task-1'));
const second = client.computeTaskChanges(makePayload('task-2'));
expect(workers[0]!.posted).toHaveLength(1);
expect(workers[0]!.posted[0]!.payload.taskId).toBe('task-1');
workers[0]!.emitMessage({
id: workers[0]!.posted[0]!.id,
ok: true,
result: makeResult('task-1'),
});
expect(workers[0]!.posted).toHaveLength(2);
expect(workers[0]!.posted[1]!.payload.taskId).toBe('task-2');
workers[0]!.emitMessage({
id: workers[0]!.posted[1]!.id,
ok: true,
result: makeResult('task-2'),
});
await expect(first).resolves.toEqual(makeResult('task-1'));
await expect(second).resolves.toEqual(makeResult('task-2'));
});
it('reports unavailable when the worker file is missing', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new TaskChangeWorkerClient({
workerPath: null,
enabled: true,
});
expect(client.isAvailable()).toBe(false);
});
});