- Updated `dev:kill` script to use a dedicated Node.js script for improved process termination. - Enhanced `TeamProvisioningService` to trigger team refresh events for live lead replies, improving message handling. - Refactored message deduplication logic in `handleGetData` to prevent duplicate messages from lead sessions and lead processes. - Introduced `validateOpenPathUserSelected` function to allow user-selected paths while enforcing security checks. - Improved UI components in `TeamListView` and `ActivityItem` for better user experience and accessibility. - Added progress bar for task completion in `DashboardView`, enhancing task tracking visibility.
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import * as os from 'os';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@preload/constants/ipcChannels', () => ({
|
|
TEAM_LIST: 'team:list',
|
|
TEAM_GET_DATA: 'team:getData',
|
|
TEAM_DELETE_TEAM: 'team:deleteTeam',
|
|
TEAM_PREPARE_PROVISIONING: 'team:prepareProvisioning',
|
|
TEAM_CREATE: 'team:create',
|
|
TEAM_LAUNCH: 'team:launch',
|
|
TEAM_CREATE_CONFIG: 'team:createConfig',
|
|
TEAM_CREATE_TASK: 'team:createTask',
|
|
TEAM_PROVISIONING_STATUS: 'team:provisioningStatus',
|
|
TEAM_CANCEL_PROVISIONING: 'team:cancelProvisioning',
|
|
TEAM_PROVISIONING_PROGRESS: 'team:provisioningProgress',
|
|
TEAM_SEND_MESSAGE: 'team:sendMessage',
|
|
TEAM_REQUEST_REVIEW: 'team:requestReview',
|
|
TEAM_UPDATE_KANBAN: 'team:updateKanban',
|
|
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
|
|
TEAM_PROCESS_SEND: 'team:processSend',
|
|
TEAM_PROCESS_ALIVE: 'team:processAlive',
|
|
TEAM_ALIVE_LIST: 'team:aliveList',
|
|
TEAM_STOP: 'team:stop',
|
|
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
|
|
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
|
|
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
|
|
TEAM_UPDATE_CONFIG: 'team:updateConfig',
|
|
TEAM_START_TASK: 'team:startTask',
|
|
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
|
|
TEAM_ADD_TASK_COMMENT: 'team:addTaskComment',
|
|
}));
|
|
|
|
import {
|
|
TEAM_ALIVE_LIST,
|
|
TEAM_STOP,
|
|
TEAM_CANCEL_PROVISIONING,
|
|
TEAM_CREATE,
|
|
TEAM_CREATE_CONFIG,
|
|
TEAM_CREATE_TASK,
|
|
TEAM_DELETE_TEAM,
|
|
TEAM_GET_DATA,
|
|
TEAM_LAUNCH,
|
|
TEAM_LIST,
|
|
TEAM_PREPARE_PROVISIONING,
|
|
TEAM_PROCESS_ALIVE,
|
|
TEAM_PROCESS_SEND,
|
|
TEAM_PROVISIONING_STATUS,
|
|
TEAM_REQUEST_REVIEW,
|
|
TEAM_SEND_MESSAGE,
|
|
TEAM_GET_ALL_TASKS,
|
|
TEAM_GET_LOGS_FOR_TASK,
|
|
TEAM_GET_MEMBER_LOGS,
|
|
TEAM_GET_MEMBER_STATS,
|
|
TEAM_START_TASK,
|
|
TEAM_UPDATE_CONFIG,
|
|
TEAM_UPDATE_KANBAN,
|
|
TEAM_UPDATE_TASK_STATUS,
|
|
TEAM_ADD_TASK_COMMENT,
|
|
} from '../../../src/preload/constants/ipcChannels';
|
|
import {
|
|
initializeTeamHandlers,
|
|
registerTeamHandlers,
|
|
removeTeamHandlers,
|
|
} from '../../../src/main/ipc/teams';
|
|
|
|
describe('ipc teams handlers', () => {
|
|
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
const ipcMain = {
|
|
handle: vi.fn((channel: string, fn: (...args: unknown[]) => Promise<unknown>) => {
|
|
handlers.set(channel, fn);
|
|
}),
|
|
removeHandler: vi.fn((channel: string) => {
|
|
handlers.delete(channel);
|
|
}),
|
|
};
|
|
|
|
const service = {
|
|
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
|
getTeamData: vi.fn(async () => ({ teamName: 'my-team' })),
|
|
deleteTeam: vi.fn(async () => undefined),
|
|
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
|
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
|
requestReview: vi.fn(async () => undefined),
|
|
updateKanban: vi.fn(async () => undefined),
|
|
updateTaskStatus: vi.fn(async () => undefined),
|
|
startTask: vi.fn(async () => undefined),
|
|
addTaskComment: vi.fn(async () => ({
|
|
id: 'c1',
|
|
author: 'user',
|
|
text: 'test comment',
|
|
createdAt: new Date().toISOString(),
|
|
})),
|
|
};
|
|
const provisioningService = {
|
|
prepareForProvisioning: vi.fn(async () => ({
|
|
ready: true,
|
|
message: 'CLI прогрет и готов к запуску',
|
|
})),
|
|
createTeam: vi.fn(async () => ({ runId: 'run-1' })),
|
|
getProvisioningStatus: vi.fn(async () => ({
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Starting',
|
|
startedAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})),
|
|
cancelProvisioning: vi.fn(async () => undefined),
|
|
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
|
|
sendMessageToTeam: vi.fn(async () => undefined),
|
|
isTeamAlive: vi.fn(() => true),
|
|
relayLeadInboxMessages: vi.fn(async () => 0),
|
|
getLiveLeadProcessMessages: vi.fn(() => []),
|
|
getAliveTeams: vi.fn(() => ['my-team']),
|
|
stopTeam: vi.fn(() => undefined),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
handlers.clear();
|
|
vi.clearAllMocks();
|
|
initializeTeamHandlers(service as never, provisioningService as never);
|
|
registerTeamHandlers(ipcMain as never);
|
|
});
|
|
|
|
it('registers all expected handlers', () => {
|
|
expect(handlers.has(TEAM_LIST)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
|
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
|
|
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(true);
|
|
expect(handlers.has(TEAM_CREATE)).toBe(true);
|
|
expect(handlers.has(TEAM_LAUNCH)).toBe(true);
|
|
expect(handlers.has(TEAM_CREATE_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_PROVISIONING_STATUS)).toBe(true);
|
|
expect(handlers.has(TEAM_CANCEL_PROVISIONING)).toBe(true);
|
|
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
|
|
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
|
|
expect(handlers.has(TEAM_START_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
|
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
|
|
expect(handlers.has(TEAM_STOP)).toBe(true);
|
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
|
|
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
|
|
});
|
|
|
|
it('returns success false on invalid sendMessage args', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
const result = (await sendHandler!({} as never, '../bad', {
|
|
member: 'alice',
|
|
text: 'hi',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('calls service and returns success on happy paths', async () => {
|
|
const listResult = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: unknown[];
|
|
};
|
|
expect(listResult.success).toBe(true);
|
|
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
|
|
|
const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'my-team',
|
|
members: [{ name: 'alice' }],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean };
|
|
expect(createResult.success).toBe(true);
|
|
expect(provisioningService.createTeam).toHaveBeenCalledTimes(1);
|
|
|
|
const statusResult = (await handlers.get(TEAM_PROVISIONING_STATUS)!({} as never, 'run-1')) as {
|
|
success: boolean;
|
|
};
|
|
expect(statusResult.success).toBe(true);
|
|
expect(provisioningService.getProvisioningStatus).toHaveBeenCalledWith('run-1');
|
|
|
|
const cancelResult = (await handlers.get(TEAM_CANCEL_PROVISIONING)!({} as never, 'run-1')) as {
|
|
success: boolean;
|
|
};
|
|
expect(cancelResult.success).toBe(true);
|
|
expect(provisioningService.cancelProvisioning).toHaveBeenCalledWith('run-1');
|
|
|
|
const reviewResult = (await handlers.get(TEAM_REQUEST_REVIEW)!(
|
|
{} as never,
|
|
'my-team',
|
|
'12'
|
|
)) as {
|
|
success: boolean;
|
|
};
|
|
expect(reviewResult.success).toBe(true);
|
|
expect(service.requestReview).toHaveBeenCalledWith('my-team', '12');
|
|
|
|
const kanbanResult = (await handlers.get(TEAM_UPDATE_KANBAN)!({} as never, 'my-team', '12', {
|
|
op: 'set_column',
|
|
column: 'approved',
|
|
})) as { success: boolean };
|
|
expect(kanbanResult.success).toBe(true);
|
|
expect(service.updateKanban).toHaveBeenCalledWith('my-team', '12', {
|
|
op: 'set_column',
|
|
column: 'approved',
|
|
});
|
|
});
|
|
|
|
it('dedups live lead replies when lead_session already has same text', async () => {
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
to: 'user',
|
|
text: 'Hello there',
|
|
timestamp: '2026-02-23T10:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session',
|
|
},
|
|
],
|
|
});
|
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
|
{
|
|
from: 'team-lead',
|
|
to: 'user',
|
|
text: 'Hello there',
|
|
timestamp: '2026-02-23T10:00:01.000Z',
|
|
read: true,
|
|
source: 'lead_process',
|
|
messageId: 'live-1',
|
|
},
|
|
]);
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data: { messages: { source?: string }[] };
|
|
};
|
|
expect(result.success).toBe(true);
|
|
const sources = result.data.messages.map((m) => m.source);
|
|
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
|
|
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
|
|
});
|
|
|
|
describe('createTask prompt validation', () => {
|
|
it('accepts valid prompt string', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
prompt: 'Custom instructions here',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTask).toHaveBeenCalledWith('my-team', {
|
|
subject: 'Do something',
|
|
description: undefined,
|
|
owner: undefined,
|
|
blockedBy: undefined,
|
|
prompt: 'Custom instructions here',
|
|
startImmediately: undefined,
|
|
});
|
|
});
|
|
|
|
it('rejects non-string prompt', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
prompt: 42,
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('prompt must be a string');
|
|
});
|
|
|
|
it('rejects prompt exceeding max length', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
prompt: 'x'.repeat(5001),
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('prompt exceeds max length');
|
|
});
|
|
|
|
it('passes undefined prompt when not provided', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTask).toHaveBeenCalledWith('my-team', {
|
|
subject: 'Do something',
|
|
description: undefined,
|
|
owner: undefined,
|
|
blockedBy: undefined,
|
|
prompt: undefined,
|
|
startImmediately: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createTeam prompt validation', () => {
|
|
it('accepts valid prompt in team create request', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'test-team',
|
|
members: [{ name: 'alice' }],
|
|
cwd: os.tmpdir(),
|
|
prompt: 'Build a web app',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
const callArg = provisioningService.createTeam.mock.calls[0][0];
|
|
expect(callArg.prompt).toBe('Build a web app');
|
|
});
|
|
|
|
it('rejects non-string prompt in team create request', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'test-team',
|
|
members: [{ name: 'alice' }],
|
|
cwd: os.tmpdir(),
|
|
prompt: 123,
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('prompt must be a string');
|
|
});
|
|
});
|
|
|
|
it('removes handlers', () => {
|
|
removeTeamHandlers(ipcMain as never);
|
|
expect(handlers.has(TEAM_LIST)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_DATA)).toBe(false);
|
|
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(false);
|
|
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(false);
|
|
expect(handlers.has(TEAM_CREATE)).toBe(false);
|
|
expect(handlers.has(TEAM_LAUNCH)).toBe(false);
|
|
expect(handlers.has(TEAM_CREATE_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_PROVISIONING_STATUS)).toBe(false);
|
|
expect(handlers.has(TEAM_CANCEL_PROVISIONING)).toBe(false);
|
|
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
|
|
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
|
|
expect(handlers.has(TEAM_START_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
|
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
|
|
expect(handlers.has(TEAM_STOP)).toBe(false);
|
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
|
|
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
|
|
});
|
|
});
|