225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
import { createHash } from 'crypto';
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
|
|
import { tmpdir } from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
shouldIgnoreLogSourceWatcherPath,
|
|
TeamLogSourceTracker,
|
|
} from '../../../../src/main/services/team/TeamLogSourceTracker';
|
|
|
|
import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder';
|
|
import type { TeamChangeEvent } from '../../../../src/shared/types';
|
|
|
|
function safeTaskIdSegment(taskId: string): string {
|
|
return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`;
|
|
}
|
|
|
|
describe('TeamLogSourceTracker', () => {
|
|
let tempDir: string | null = null;
|
|
|
|
afterEach(async () => {
|
|
if (tempDir) {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
tempDir = null;
|
|
}
|
|
});
|
|
|
|
it('emits task-log-change for matching runtime freshness signals without broad log-source-change', async () => {
|
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-'));
|
|
|
|
const logsFinder = {
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir: tempDir!,
|
|
sessionIds: [],
|
|
})),
|
|
} as unknown as TeamMemberLogsFinder;
|
|
|
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
|
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
|
tracker.setEmitter(emitter);
|
|
|
|
await tracker.enableTracking('demo', 'change_presence');
|
|
emitter.mockClear();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const taskId = '123e4567-e89b-12d3-a456-426614174999';
|
|
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
|
await mkdir(signalDir, { recursive: true });
|
|
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(emitter).toHaveBeenCalledWith({
|
|
type: 'task-log-change',
|
|
teamName: 'demo',
|
|
taskId,
|
|
});
|
|
});
|
|
|
|
expect(emitter.mock.calls.map(([event]) => event.type)).not.toContain('log-source-change');
|
|
|
|
await tracker.disableTracking('demo', 'change_presence');
|
|
});
|
|
|
|
it('keeps task-log tracking alive until the last consumer unsubscribes', async () => {
|
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-'));
|
|
|
|
const logsFinder = {
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir: tempDir!,
|
|
sessionIds: [],
|
|
})),
|
|
} as unknown as TeamMemberLogsFinder;
|
|
|
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
|
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
|
tracker.setEmitter(emitter);
|
|
|
|
await tracker.enableTracking('demo', 'task_log_stream');
|
|
await tracker.enableTracking('demo', 'task_log_stream');
|
|
emitter.mockClear();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
await tracker.disableTracking('demo', 'task_log_stream');
|
|
|
|
const taskId = '223e4567-e89b-12d3-a456-426614174999';
|
|
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
|
await mkdir(signalDir, { recursive: true });
|
|
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(emitter).toHaveBeenCalledWith({
|
|
type: 'task-log-change',
|
|
teamName: 'demo',
|
|
taskId,
|
|
});
|
|
});
|
|
|
|
emitter.mockClear();
|
|
await tracker.disableTracking('demo', 'task_log_stream');
|
|
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":false}');
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
|
|
expect(emitter).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reinitialize when another consumer joins an already tracked team', async () => {
|
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-init-'));
|
|
|
|
const logsFinder = {
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir: tempDir!,
|
|
sessionIds: [],
|
|
})),
|
|
} as unknown as TeamMemberLogsFinder;
|
|
|
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
|
|
|
await tracker.enableTracking('demo', 'tool_activity');
|
|
await tracker.enableTracking('demo', 'task_log_stream');
|
|
|
|
expect(logsFinder.getLogSourceWatchContext).toHaveBeenCalledTimes(1);
|
|
|
|
await tracker.disableTracking('demo', 'task_log_stream');
|
|
await tracker.disableTracking('demo', 'tool_activity');
|
|
});
|
|
|
|
it('supports stall_monitor as an independent tracking consumer', async () => {
|
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-stall-monitor-'));
|
|
|
|
const logsFinder = {
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir: tempDir!,
|
|
sessionIds: [],
|
|
})),
|
|
} as unknown as TeamMemberLogsFinder;
|
|
|
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
|
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
|
tracker.setEmitter(emitter);
|
|
|
|
await tracker.enableTracking('demo', 'stall_monitor');
|
|
emitter.mockClear();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const taskId = '323e4567-e89b-12d3-a456-426614174999';
|
|
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
|
await mkdir(signalDir, { recursive: true });
|
|
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(emitter).toHaveBeenCalledWith({
|
|
type: 'task-log-change',
|
|
teamName: 'demo',
|
|
taskId,
|
|
});
|
|
});
|
|
|
|
await tracker.disableTracking('demo', 'stall_monitor');
|
|
});
|
|
|
|
it('emits the task id from Windows-safe hashed task-change freshness files', async () => {
|
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-task-'));
|
|
|
|
const logsFinder = {
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir: tempDir!,
|
|
sessionIds: [],
|
|
})),
|
|
} as unknown as TeamMemberLogsFinder;
|
|
|
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
|
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
|
tracker.setEmitter(emitter);
|
|
|
|
await tracker.enableTracking('demo', 'change_presence');
|
|
emitter.mockClear();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const taskId = 'CON';
|
|
const signalDir = path.join(tempDir, '.board-task-change-freshness');
|
|
await mkdir(signalDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`),
|
|
JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }),
|
|
'utf8'
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(emitter).toHaveBeenCalledWith({
|
|
type: 'task-log-change',
|
|
teamName: 'demo',
|
|
taskId,
|
|
});
|
|
});
|
|
expect(emitter.mock.calls).not.toContainEqual([
|
|
expect.objectContaining({ type: 'task-log-change', taskId: safeTaskIdSegment(taskId) }),
|
|
]);
|
|
|
|
await tracker.disableTracking('demo', 'change_presence');
|
|
});
|
|
|
|
it('ignores internal ledger artifact paths but keeps freshness signals visible', () => {
|
|
const projectDir = '/tmp/demo-project';
|
|
|
|
expect(
|
|
shouldIgnoreLogSourceWatcherPath(
|
|
projectDir,
|
|
path.join(projectDir, '.board-task-changes', 'events', 'task.jsonl')
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
shouldIgnoreLogSourceWatcherPath(
|
|
projectDir,
|
|
path.join(projectDir, '.board-task-changes', 'locks', 'task.lock', 'owner.json')
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
shouldIgnoreLogSourceWatcherPath(
|
|
projectDir,
|
|
path.join(projectDir, '.board-task-change-freshness', 'task.json')
|
|
)
|
|
).toBe(false);
|
|
});
|
|
});
|