Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 397 additions & 0 deletions apps/frontend/src/main/__tests__/task-log-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,397 @@
/**
* TaskLogService Unit Tests
* ==========================
* Tests the service-level bug fixes:
* 1. mergeLogs() uses ?? to prefer worktree coding/validation even when pending with empty entries
* 2. startWatching() poll skips emission when loadLogsFromPath returns cached data on parse failure
* 3. clearCache() and stopWatching() clean up cacheVersions map
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { TaskLogs, TaskPhaseLog } from '../../shared/types';

// Mock fs before importing the service
vi.mock('fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn()
}));

// Mock worktree-paths
vi.mock('../worktree-paths', () => ({
findTaskWorktree: vi.fn()
}));

// Mock debug-logger
vi.mock('../../shared/utils/debug-logger', () => ({
debugLog: vi.fn(),
debugWarn: vi.fn(),
debugError: vi.fn()
}));

function makePhaseLogs(overrides: Partial<TaskPhaseLog> = {}): TaskPhaseLog {
return {
phase: 'coding',
status: 'pending',
started_at: null,
completed_at: null,
entries: [],
...overrides
};
}

function makeTaskLogs(overrides: Partial<TaskLogs> & { phases?: Partial<TaskLogs['phases']> } = {}): TaskLogs {
const { phases, ...rest } = overrides;
return {
spec_id: '001-test',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T01:00:00Z',
phases: {
planning: makePhaseLogs({ phase: 'planning', status: 'completed', entries: [{ type: 'text', content: 'Plan done', phase: 'planning', timestamp: '2024-01-01T00:00:00Z' }] }),
coding: makePhaseLogs({ phase: 'coding' }),
validation: makePhaseLogs({ phase: 'validation' }),
...phases
},
...rest
};
}

describe('TaskLogService', () => {
let TaskLogService: typeof import('../task-log-service').TaskLogService;
let existsSync: ReturnType<typeof vi.fn>;
let readFileSync: ReturnType<typeof vi.fn>;
let findTaskWorktree: ReturnType<typeof vi.fn>;

beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();

const fs = await import('fs');
existsSync = fs.existsSync as ReturnType<typeof vi.fn>;
readFileSync = fs.readFileSync as ReturnType<typeof vi.fn>;

const worktreePaths = await import('../worktree-paths');
findTaskWorktree = worktreePaths.findTaskWorktree as ReturnType<typeof vi.fn>;

const mod = await import('../task-log-service');
TaskLogService = mod.TaskLogService;
});

afterEach(() => {
vi.useRealTimers();
vi.resetModules();
});

describe('mergeLogs() - ?? operator fix for worktree phase preference', () => {
it('should use worktree coding phase even when status is pending with empty entries', () => {
const service = new TaskLogService();

// Main logs have coding phase with entries from a previous run
const mainLogs = makeTaskLogs({
phases: {
planning: makePhaseLogs({ phase: 'planning', status: 'completed' }),
coding: makePhaseLogs({
phase: 'coding',
status: 'completed',
entries: [
{ type: 'text', content: 'Old coding work', phase: 'coding', timestamp: '2024-01-01T00:30:00Z' }
]
}),
validation: makePhaseLogs({
phase: 'validation',
status: 'completed',
entries: [
{ type: 'text', content: 'Old validation', phase: 'validation', timestamp: '2024-01-01T01:00:00Z' }
]
})
}
});

// Worktree logs have coding/validation reset to pending (agent retry scenario)
const worktreeLogs = makeTaskLogs({
phases: {
planning: makePhaseLogs({ phase: 'planning', status: 'completed' }),
coding: makePhaseLogs({
phase: 'coding',
status: 'pending',
entries: []
}),
validation: makePhaseLogs({
phase: 'validation',
status: 'pending',
entries: []
})
}
});

// Use loadLogsFromPath to populate internal state, then test mergeLogs indirectly via loadLogs
// We access mergeLogs through loadLogs by setting up the right conditions.
// However, mergeLogs is private, so we test through the public API.

const mainSpecDir = '/project/.auto-claude/specs/001-test';
const worktreeSpecDir = '/project/.auto-claude/worktrees/tasks/001-test/.auto-claude/specs/001-test';

// Set up fs mocks for both paths
existsSync.mockImplementation((filePath: string) => {
return filePath.includes('task_logs.json');
});

readFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(worktreeSpecDir)) {
return JSON.stringify(worktreeLogs);
}
return JSON.stringify(mainLogs);
});

// Set up worktree discovery
findTaskWorktree.mockReturnValue('/project/.auto-claude/worktrees/tasks/001-test');

// Load merged logs
const result = service.loadLogs(mainSpecDir, '/project', '.auto-claude/specs', '001-test');

expect(result).not.toBeNull();
// The key assertion: worktree's pending coding phase should be used (not main's completed one)
// because ?? only falls back to main when worktree value is null/undefined, not when it's
// a valid object with status 'pending'
expect(result!.phases.coding.status).toBe('pending');

Check failure on line 155 in apps/frontend/src/main/__tests__/task-log-service.test.ts

View workflow job for this annotation

GitHub Actions / test-frontend (windows-latest)

src/main/__tests__/task-log-service.test.ts > TaskLogService > mergeLogs() - ?? operator fix for worktree phase preference > should use worktree coding phase even when status is pending with empty entries

AssertionError: expected 'completed' to be 'pending' // Object.is equality Expected: "pending" Received: "completed" ❯ src/main/__tests__/task-log-service.test.ts:155:44
expect(result!.phases.coding.entries).toHaveLength(0);
expect(result!.phases.validation.status).toBe('pending');
expect(result!.phases.validation.entries).toHaveLength(0);
});

it('should fall back to main coding phase when worktree coding is null', () => {
const service = new TaskLogService();

const mainLogs = makeTaskLogs({
phases: {
planning: makePhaseLogs({ phase: 'planning', status: 'completed' }),
coding: makePhaseLogs({
phase: 'coding',
status: 'active',
entries: [{ type: 'text', content: 'Main coding', phase: 'coding', timestamp: '2024-01-01T00:30:00Z' }]
}),
validation: makePhaseLogs({ phase: 'validation' })
}
});

// Worktree logs where coding is explicitly null (not yet started in worktree)
const worktreeLogsRaw = {
spec_id: '001-test',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T01:00:00Z',
phases: {
planning: makePhaseLogs({ phase: 'planning', status: 'completed' }),
coding: null,
validation: null
}
};

const mainSpecDir = '/project/.auto-claude/specs/001-test';
const worktreeSpecDir = '/project/.auto-claude/worktrees/tasks/001-test/.auto-claude/specs/001-test';

existsSync.mockReturnValue(true);
readFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(worktreeSpecDir)) {
return JSON.stringify(worktreeLogsRaw);
}
return JSON.stringify(mainLogs);
});

findTaskWorktree.mockReturnValue('/project/.auto-claude/worktrees/tasks/001-test');

const result = service.loadLogs(mainSpecDir, '/project', '.auto-claude/specs', '001-test');

expect(result).not.toBeNull();
// With null worktree coding, should fall back to main
expect(result!.phases.coding.status).toBe('active');
expect(result!.phases.coding.entries).toHaveLength(1);
});
Comment on lines +161 to +207
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Good null-fallback test, but the worktreeLogsRaw object bypasses TypeScript's TaskLogs type.

worktreeLogsRaw is declared as an untyped object literal with coding: null and validation: null (lines 183-184). Since TaskLogs.phases.coding is typed as TaskPhaseLog (not TaskPhaseLog | null), this only works because readFileSync returns a stringified version that's parsed via JSON.parse(...) as TaskLogs — a runtime cast that doesn't enforce the type at compile time. This is intentional for testing the ?? behavior with truly-null phase values (which the Python backend can produce), but it's worth a brief comment noting this deliberate type bypass.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/main/__tests__/task-log-service.test.ts` around lines 161 -
207, The test creates an untyped worktreeLogsRaw object with null phase fields
to simulate backend-produced nulls that bypass TypeScript's TaskLogs typing; add
a short inline comment above the worktreeLogsRaw declaration (referencing
worktreeLogsRaw and TaskLogs) stating this is an intentional type bypass to test
runtime null-fallback behavior (the stringified readFileSync/JSON.parse path),
so future readers know the nulls are deliberate and not a typo.

});

describe('startWatching() - cacheVersions gating skips emission on parse failure', () => {
it('should skip emission when loadLogsFromPath returns cached data due to parse failure', () => {
const service = new TaskLogService();
const specId = '001-test';
const specDir = '/project/.auto-claude/specs/001-test';

const validLogs = makeTaskLogs();
const validContent = JSON.stringify(validLogs);

// Track emissions
const emittedEvents: Array<{ event: string; args: unknown[] }> = [];
service.on('logs-changed', (...args: unknown[]) => {
emittedEvents.push({ event: 'logs-changed', args });
});

// Initial state: valid file exists
let currentContent = validContent;
existsSync.mockReturnValue(true);
readFileSync.mockImplementation(() => currentContent);
findTaskWorktree.mockReturnValue(null);

// Start watching
service.startWatching(specId, specDir);

// Simulate file change to corrupted content (mid-write)
// The raw content changes so the poll detects a change,
// but JSON.parse fails, returning cached data instead
currentContent = '{"spec_id": "001-test", CORRUPTED';
readFileSync.mockImplementation(() => {
// Simulate the loadLogsFromPath path: readFileSync returns corrupted content,
// JSON.parse will throw, and the service returns cached data
return currentContent;
});

// Advance past one poll interval
vi.advanceTimersByTime(1100);

// No emission should have occurred because the parse failure means
// cacheVersions didn't increment — the service returned cached data
expect(emittedEvents).toHaveLength(0);

// Now simulate recovery: file becomes valid again with new content
const updatedLogs = makeTaskLogs({
updated_at: '2024-01-01T02:00:00Z',
phases: {
planning: makePhaseLogs({ phase: 'planning', status: 'completed' }),
coding: makePhaseLogs({
phase: 'coding',
status: 'active',
entries: [{ type: 'text', content: 'Coding started', phase: 'coding', timestamp: '2024-01-01T01:30:00Z' }]
}),
validation: makePhaseLogs({ phase: 'validation' })
}
});
const newValidContent = JSON.stringify(updatedLogs);
currentContent = newValidContent;
readFileSync.mockImplementation(() => currentContent);

// Advance past another poll interval
vi.advanceTimersByTime(1100);

// Now emission should occur because a fresh parse succeeded
expect(emittedEvents.length).toBeGreaterThanOrEqual(1);

// Clean up
service.stopWatching(specId);
});
});

describe('clearCache() - cacheVersions cleanup', () => {
it('should delete cacheVersions entry when clearing cache', () => {
const service = new TaskLogService();
const specDir = '/project/.auto-claude/specs/001-test';

// Load logs to populate both logCache and cacheVersions
existsSync.mockReturnValue(true);
readFileSync.mockReturnValue(JSON.stringify(makeTaskLogs()));
findTaskWorktree.mockReturnValue(null);

const result = service.loadLogsFromPath(specDir);
expect(result).not.toBeNull();

// Verify cache is populated
expect(service.getCachedLogs(specDir)).not.toBeNull();

// Clear cache
service.clearCache(specDir);

// Verify both logCache and cacheVersions are cleaned
expect(service.getCachedLogs(specDir)).toBeNull();

// Verify cacheVersions was cleaned by loading again and checking version starts fresh
// We do this by loading logs again - the version should start from 1 (not continue from previous)
readFileSync.mockReturnValue(JSON.stringify(makeTaskLogs()));
service.loadLogsFromPath(specDir);

// If clearCache properly cleaned cacheVersions, loading again resets the counter.
// We can verify this indirectly: after clearCache + reload, the cache should be fresh
expect(service.getCachedLogs(specDir)).not.toBeNull();
});
});

describe('stopWatching() - cacheVersions cleanup', () => {
it('should delete cacheVersions entries for watched paths when stopping', () => {
const service = new TaskLogService();
const specId = '001-test';
const specDir = '/project/.auto-claude/specs/001-test';
const worktreeSpecDir = '/project/.auto-claude/worktrees/tasks/001-test/.auto-claude/specs/001-test';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note test

Unused variable worktreeSpecDir.

Copilot Autofix

AI 18 days ago

In general, unused variables should be removed to reduce clutter and avoid confusion. If the variable was meant to be used but isn't, the alternative is to restore or add the missing usage; but without evidence of such intended use, the safest fix is deletion.

The best minimal fix here is to remove the declaration of worktreeSpecDir from the stopWatching() - cacheVersions cleanup test. The rest of the code already hardcodes the corresponding worktree path when setting up findTaskWorktree.mockReturnValue, so removing the unused const will not affect behavior. Specifically, in apps/frontend/src/main/__tests__/task-log-service.test.ts, delete the line that declares const worktreeSpecDir = '/project/.auto-claude/worktrees/tasks/001-test/.auto-claude/specs/001-test'; (line 317 in the provided snippet). No additional imports, methods, or definitions are required.

Suggested changeset 1
apps/frontend/src/main/__tests__/task-log-service.test.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/frontend/src/main/__tests__/task-log-service.test.ts b/apps/frontend/src/main/__tests__/task-log-service.test.ts
--- a/apps/frontend/src/main/__tests__/task-log-service.test.ts
+++ b/apps/frontend/src/main/__tests__/task-log-service.test.ts
@@ -314,7 +314,6 @@
       const service = new TaskLogService();
       const specId = '001-test';
       const specDir = '/project/.auto-claude/specs/001-test';
-      const worktreeSpecDir = '/project/.auto-claude/worktrees/tasks/001-test/.auto-claude/specs/001-test';
 
       existsSync.mockReturnValue(true);
       readFileSync.mockReturnValue(JSON.stringify(makeTaskLogs()));
EOF
@@ -314,7 +314,6 @@
const service = new TaskLogService();
const specId = '001-test';
const specDir = '/project/.auto-claude/specs/001-test';
const worktreeSpecDir = '/project/.auto-claude/worktrees/tasks/001-test/.auto-claude/specs/001-test';

existsSync.mockReturnValue(true);
readFileSync.mockReturnValue(JSON.stringify(makeTaskLogs()));
Copilot is powered by AI and may make mistakes. Always verify output.

existsSync.mockReturnValue(true);
readFileSync.mockReturnValue(JSON.stringify(makeTaskLogs()));
findTaskWorktree.mockReturnValue('/project/.auto-claude/worktrees/tasks/001-test');

// Start watching (this will load initial logs, populating cacheVersions)
service.startWatching(specId, specDir, '/project', '.auto-claude/specs');

// Verify cache is populated for main spec dir
expect(service.getCachedLogs(specDir)).not.toBeNull();

// Stop watching
service.stopWatching(specId);

// After stopping, cacheVersions should be cleaned for both main and worktree paths.
// We verify indirectly: loading from the same path should start fresh version counting.
// The logCache may still have data (stopWatching doesn't clear logCache),
// but the cacheVersions entries should be gone.

// Load again to verify versions start fresh
const beforeSecondLoad = service.getCachedLogs(specDir);

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note test

Unused variable beforeSecondLoad.

Copilot Autofix

AI 18 days ago

In general, unused variables in tests (or production code) should be removed, unless they are needed for side effects or to clarify behavior. Here, beforeSecondLoad is simply assigned the return value of service.getCachedLogs(specDir) and never used, and the call itself has no side effects beyond reading internal state. The test’s purpose—to ensure cacheVersions is cleared on stopWatching and that watching can restart and emit events—is fully exercised by the rest of the code.

The minimal, behavior-preserving fix is to remove the beforeSecondLoad variable binding while preserving the call only if we still want to exercise getCachedLogs at that point. However, even that call is not necessary for the test’s assertions, and the comments already explain the intended behavior. The cleanest fix is to delete the entire line const beforeSecondLoad = service.getCachedLogs(specDir); and leave the surrounding comments intact. This change is localized to apps/frontend/src/main/__tests__/task-log-service.test.ts around line 338 and requires no new imports, methods, or definitions.

Suggested changeset 1
apps/frontend/src/main/__tests__/task-log-service.test.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/frontend/src/main/__tests__/task-log-service.test.ts b/apps/frontend/src/main/__tests__/task-log-service.test.ts
--- a/apps/frontend/src/main/__tests__/task-log-service.test.ts
+++ b/apps/frontend/src/main/__tests__/task-log-service.test.ts
@@ -335,7 +335,7 @@
       // but the cacheVersions entries should be gone.
 
       // Load again to verify versions start fresh
-      const beforeSecondLoad = service.getCachedLogs(specDir);
+      service.getCachedLogs(specDir);
       // logCache is NOT cleared by stopWatching (only cacheVersions), so this may still exist
       // The important thing is that cacheVersions was cleaned
 
EOF
@@ -335,7 +335,7 @@
// but the cacheVersions entries should be gone.

// Load again to verify versions start fresh
const beforeSecondLoad = service.getCachedLogs(specDir);
service.getCachedLogs(specDir);
// logCache is NOT cleared by stopWatching (only cacheVersions), so this may still exist
// The important thing is that cacheVersions was cleaned

Copilot is powered by AI and may make mistakes. Always verify output.
// logCache is NOT cleared by stopWatching (only cacheVersions), so this may still exist
// The important thing is that cacheVersions was cleaned

// Start watching again - should not skip due to stale cacheVersions
service.startWatching(specId, specDir, '/project', '.auto-claude/specs');

const emittedEvents: Array<{ event: string; args: unknown[] }> = [];
service.on('logs-changed', (...args: unknown[]) => {
emittedEvents.push({ event: 'logs-changed', args });
});

// Change file content and advance timer
const updatedLogs = makeTaskLogs({ updated_at: '2024-01-01T03:00:00Z' });
readFileSync.mockReturnValue(JSON.stringify(updatedLogs));
vi.advanceTimersByTime(1100);

// Should emit because cacheVersions was cleaned and fresh parse succeeds
expect(emittedEvents.length).toBeGreaterThanOrEqual(1);

service.stopWatching(specId);
});
Comment on lines +312 to +359
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

stopWatching cleanup test doesn't strongly prove cacheVersions was actually reset.

After stopWatching + startWatching again, the emission test (line 356) would pass even if cacheVersions were not cleaned — the version would simply continue incrementing from its previous value, and versionAfter > versionBefore would still hold. To make this test truly validate cleanup, you could expose a getCacheVersion(specDir) method for testing, or assert that after stopWatching, re-loading starts the version counter from a known baseline (e.g., by interleaving a clearCache + loadLogsFromPath and checking the version is 1, not a higher number).

As-is, the test serves as a smoke test for the watch-stop-rewatch cycle, which still has value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/main/__tests__/task-log-service.test.ts` around lines 312 -
359, The test doesn't actually verify cacheVersions was reset because versions
could keep incrementing; update either the TaskLogService to expose a test-only
accessor (e.g., getCacheVersion(specDir) or getCacheVersions()) or add a
deterministic reload method (e.g., loadLogsFromPath returning its computed
version) and then change the test to read the version before stopWatching, call
stopWatching(specId), then assert the version for specDir (and worktree path)
equals the baseline (e.g., 0 or 1) rather than only asserting that events fire
after replay; reference stopWatching, startWatching, getCachedLogs, and the
internal cacheVersions when making the change.

});

describe('loadLogsFromPath() - cache fallback on parse error', () => {
it('should return cached logs when JSON parse fails', () => {
const service = new TaskLogService();
const specDir = '/project/.auto-claude/specs/001-test';

existsSync.mockReturnValue(true);

// First load: valid JSON
const validLogs = makeTaskLogs();
readFileSync.mockReturnValue(JSON.stringify(validLogs));
const result1 = service.loadLogsFromPath(specDir);
expect(result1).not.toBeNull();
expect(result1!.spec_id).toBe('001-test');

// Second load: corrupted JSON
readFileSync.mockReturnValue('NOT VALID JSON{{{');
const result2 = service.loadLogsFromPath(specDir);

// Should return cached version
expect(result2).not.toBeNull();
expect(result2!.spec_id).toBe('001-test');
expect(result2).toEqual(validLogs);
});

it('should return null when JSON parse fails and no cache exists', () => {
const service = new TaskLogService();
const specDir = '/project/.auto-claude/specs/001-test';

existsSync.mockReturnValue(true);
readFileSync.mockReturnValue('CORRUPTED JSON');

const result = service.loadLogsFromPath(specDir);
expect(result).toBeNull();
});
});
});
Loading
Loading