Files
Aperant/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts
T
StillKnotKnown 1c6266025f fix(frontend): resolve TerminalFontSettings infinite re-render loop (#1536)
* fix(frontend): resolve TerminalFontSettings infinite re-render loop (ACS-393)

The TerminalFontSettings component was causing an infinite re-render loop
due to a Zustand selector creating a new object reference on every render.
When combined with LivePreviewTerminal's useEffect that watches the settings
object, this created a cascade where each render triggered the next.

Solution: Replace object selector with individual selectors and useMemo.
- Each selector now only re-renders when its specific value changes
- useMemo creates a stable object reference that only updates when actual values change
- Maintains existing component interface (child components still receive settings object)

This fixes the "Maximum update depth exceeded" error that occurred when
navigating to Settings > Terminal section.

Related console noise about PhaseProgressIndicator is unrelated to this fix.

* test(frontend): add TerminalFontSettings tests for infinite re-render loop fix (ACS-393)

Add comprehensive tests for the TerminalFontSettings component to verify
the infinite re-render loop fix. Tests cover:

- Component rendering without errors
- All expected sections render correctly
- Render cycle completes within reasonable time
- Store integration and state updates
- Rapid state changes without infinite loop
- Preset application and reset to defaults
- Concurrent updates without race conditions
- Import/export functionality
- Child component integration
- xterm.js terminal initialization
- Regression prevention (getSnapshot caching, maximum depth errors)
- Memoization with stable references

All 16 tests pass, confirming the individual selectors + useMemo
implementation correctly prevents infinite re-render loops.

* test(frontend): fix TerminalFontSettings tests for platform-independent defaults (ACS-393)

Fixed 2 failing tests that were checking for OS-specific default values
that don't apply in jsdom test environment. Tests now verify that
resetToDefaults() works correctly without assuming specific platform
defaults.

Changes:
- "should handle reset to defaults without infinite loop": Now verifies
  reset restores defaults without checking specific OS values
- "should render FontConfigPanel with current settings": Now validates
  fontSize is within valid range instead of checking specific value

All 15 tests now pass.

* cleanup & chores

* test(frontend): clean up TerminalFontSettings tests per PR review feedback (ACS-393)

- Replace function-based ResizeObserver mock with class-based mock
- Add note about platform-agnostic nature of infinite re-render fix
- Remove redundant manual state resets (beforeEach already handles cleanup)
- Remove artificial setTimeout delay in rapid state changes test
- Fix rerender() to include I18nextProvider wrapper in memoization test

Addresses CodeRabbit comments and AI PR review feedback.

* test(frontend): fix flaky subprocess-spawn test tracking multiple tasks

The "should track running tasks" test was failing because both tasks
shared the same mock process, but emitting exit once only removed
one task from tracking. Fixed by emitting exit separately for each
task to properly simulate both processes completing.

* test(frontend): address CodeRabbit feedback on TerminalFontSettings tests (ACS-393)

- Capture default values before mutating instead of hardcoding expected values
- Add afterEach hook to restore mocks instead of per-test manual restores
- Import afterEach from vitest for proper test cleanup

Addresses 2 additional CodeRabbit review comments.

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: AndyMik90 <andre@mikalsenutvikling.no>
2026-01-27 11:02:16 +01:00

476 lines
17 KiB
TypeScript

/**
* Integration tests for subprocess spawning
* Tests AgentManager spawning Python processes correctly
*
* NOTE: Some pre-existing test failures in the full test suite (e.g., @testing-library/react
* v16 missing exports) are NOT related to changes in this file. This test file focuses on
* subprocess spawning and AgentManager functionality only.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { mkdirSync, rmSync, existsSync, writeFileSync, mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import { findPythonCommand, parsePythonCommand } from '../../main/python-detector';
import { isWindows } from '../../main/platform';
// Test directories - use secure temp directory with random suffix
let TEST_DIR: string;
let TEST_PROJECT_PATH: string;
function initTestDirectories(): void {
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'subprocess-spawn-test-'));
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
}
// Detect the Python command that will actually be used
const DETECTED_PYTHON_CMD = findPythonCommand() || 'python';
const [EXPECTED_PYTHON_COMMAND, EXPECTED_PYTHON_BASE_ARGS] = parsePythonCommand(DETECTED_PYTHON_CMD);
// Mock child_process spawn
const mockStdout = new EventEmitter();
const mockStderr = new EventEmitter();
const mockProcess = Object.assign(new EventEmitter(), {
stdout: mockStdout,
stderr: mockStderr,
pid: 12345,
killed: false,
kill: vi.fn(() => {
mockProcess.killed = true;
// Emit exit event synchronously to simulate process termination
// (needed for killAllProcesses wait - using nextTick for more predictable timing)
process.nextTick(() => mockProcess.emit('exit', 0, null));
return true;
})
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(() => mockProcess)
};
});
// Mock claude-profile-manager to bypass auth checks in tests
// Profile shape must match ClaudeProfile interface (id, name, isDefault, etc.)
const mockProfile = {
id: 'default',
name: 'Default',
isDefault: true,
oauthToken: 'mock-encrypted-token'
};
const mockProfileManager = {
hasValidAuth: () => true,
getActiveProfile: () => mockProfile,
getProfile: (_profileId: string) => mockProfile,
// Token decryption methods - return mock token for tests
getActiveProfileToken: () => 'mock-decrypted-token-for-testing',
getProfileToken: (_profileId: string) => 'mock-decrypted-token-for-testing',
// Environment methods for rate-limit-detector delegation
getActiveProfileEnv: () => ({}),
getProfileEnv: (_profileId: string) => ({})
};
vi.mock('../../main/claude-profile-manager', () => ({
getClaudeProfileManager: () => mockProfileManager,
initializeClaudeProfileManager: () => Promise.resolve(mockProfileManager)
}));
// Mock validatePythonPath to allow test paths (security validation is tested separately)
vi.mock('../../main/python-detector', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../main/python-detector')>();
return {
...actual,
validatePythonPath: (path: string) => ({ valid: true, sanitizedPath: path })
};
});
// Mock python-env-manager for ensurePythonEnvReady (ACS-254)
vi.mock('../../main/python-env-manager', () => ({
pythonEnvManager: {
isEnvReady: vi.fn(() => true),
initialize: vi.fn(() => Promise.resolve({ ready: true })),
getPythonEnv: vi.fn(() => ({}))
},
getConfiguredPythonPath: vi.fn(() => DETECTED_PYTHON_CMD)
}));
// Mock rate-limit-detector for getBestAvailableProfileEnv
vi.mock('../../main/rate-limit-detector', () => ({
getBestAvailableProfileEnv: vi.fn(() => ({
env: {},
profileId: 'default',
profileName: 'Default',
wasSwapped: false
})),
getProfileEnv: vi.fn(() => ({})),
detectRateLimit: vi.fn(() => ({ isRateLimited: false })),
detectAuthFailure: vi.fn(() => ({ isAuthFailure: false }))
}));
// Auto-claude source path (for getAutoBuildSourcePath to find)
let AUTO_CLAUDE_SOURCE: string;
// Setup test directories
function setupTestDirs(): void {
initTestDirectories();
AUTO_CLAUDE_SOURCE = path.join(TEST_DIR, 'auto-claude-source');
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
// Create auto-claude source directory that getAutoBuildSourcePath looks for
mkdirSync(AUTO_CLAUDE_SOURCE, { recursive: true });
// Create runners subdirectory with spec_runner.py marker (used by getAutoBuildSourcePath)
mkdirSync(path.join(AUTO_CLAUDE_SOURCE, 'runners'), { recursive: true });
// Create mock spec_runner.py in runners/ subdirectory (used as backend marker)
writeFileSync(
path.join(AUTO_CLAUDE_SOURCE, 'runners', 'spec_runner.py'),
'# Mock spec runner\nprint("Starting spec creation")'
);
// Create mock run.py
writeFileSync(
path.join(AUTO_CLAUDE_SOURCE, 'run.py'),
'# Mock run.py\nprint("Starting task execution")'
);
}
// Cleanup test directories
function cleanupTestDirs(): void {
if (TEST_DIR && existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
}
describe('Subprocess Spawn Integration', () => {
beforeEach(async () => {
cleanupTestDirs();
setupTestDirs();
vi.clearAllMocks();
// Reset mock process state
mockProcess.killed = false;
mockProcess.removeAllListeners();
mockStdout.removeAllListeners();
mockStderr.removeAllListeners();
});
afterEach(() => {
cleanupTestDirs();
vi.clearAllMocks();
});
describe('AgentManager', () => {
it('should spawn Python process for spec creation', async () => {
const { spawn } = await import('child_process');
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
// Start the async operation
const promise = manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description');
// Wait for spawn to complete (ensures listeners are attached), then emit exit
await new Promise(resolve => setImmediate(resolve));
mockProcess.emit('exit', 0);
await promise;
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
expect.arrayContaining([
...EXPECTED_PYTHON_BASE_ARGS,
expect.stringContaining('spec_runner.py'),
'--task',
'Test task description'
]),
expect.objectContaining({
cwd: AUTO_CLAUDE_SOURCE, // Process runs from auto-claude source directory
env: expect.objectContaining({
PYTHONUNBUFFERED: '1'
})
})
);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should spawn Python process for task execution', async () => {
const { spawn } = await import('child_process');
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
// Start the async operation
const promise = manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001');
// Wait for spawn to complete (ensures listeners are attached), then emit exit
await new Promise(resolve => setImmediate(resolve));
mockProcess.emit('exit', 0);
await promise;
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
expect.arrayContaining([
...EXPECTED_PYTHON_BASE_ARGS,
expect.stringContaining('run.py'),
'--spec',
'spec-001'
]),
expect.objectContaining({
cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory
})
);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should spawn Python process for QA process', async () => {
const { spawn } = await import('child_process');
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
// Start the async operation
const promise = manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001');
// Wait for spawn to complete (ensures listeners are attached), then emit exit
await new Promise(resolve => setImmediate(resolve));
mockProcess.emit('exit', 0);
await promise;
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
expect.arrayContaining([
...EXPECTED_PYTHON_BASE_ARGS,
expect.stringContaining('run.py'),
'--spec',
'spec-001',
'--qa'
]),
expect.objectContaining({
cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory
})
);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should accept parallel options without affecting spawn args', async () => {
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
const { spawn } = await import('child_process');
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
// Start the async operation
const promise = manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', {
parallel: true,
workers: 4
});
// Wait for spawn to complete (ensures listeners are attached), then emit exit
await new Promise(resolve => setImmediate(resolve));
mockProcess.emit('exit', 0);
await promise;
// Should spawn normally - parallel options don't affect CLI args anymore
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
expect.arrayContaining([
...EXPECTED_PYTHON_BASE_ARGS,
expect.stringContaining('run.py'),
'--spec',
'spec-001'
]),
expect.any(Object)
);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit log events from stdout', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const logHandler = vi.fn();
manager.on('log', logHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate stdout data (must include newline for buffered output processing)
mockStdout.emit('data', Buffer.from('Test log output\n'));
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n');
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit log events from stderr', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const logHandler = vi.fn();
manager.on('log', logHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate stderr data (must include newline for buffered output processing)
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n');
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit exit event when process exits', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const exitHandler = vi.fn();
manager.on('exit', exitHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate process exit
mockProcess.emit('exit', 0);
// Exit event includes taskId, exit code, and process type
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit error event when process errors', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const errorHandler = vi.fn();
manager.on('error', errorHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate process error
mockProcess.emit('error', new Error('Spawn failed'));
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed');
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should kill task and remove from tracking', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
expect(manager.isRunning('task-1')).toBe(true);
const result = manager.killTask('task-1');
expect(result).toBe(true);
// On Windows, kill() is called without arguments; on Unix, kill('SIGTERM') is used
if (isWindows()) {
expect(mockProcess.kill).toHaveBeenCalled();
} else {
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
}
expect(manager.isRunning('task-1')).toBe(false);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should return false when killing non-existent task', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
const result = manager.killTask('nonexistent');
expect(result).toBe(false);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should track running tasks', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
expect(manager.getRunningTasks()).toHaveLength(0);
// Start tasks in parallel
const promise1 = manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
const promise2 = manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
// Wait for both tasks to be tracked (spawn happens after async operations)
await vi.waitFor(() => {
expect(manager.getRunningTasks()).toHaveLength(2);
}, { timeout: 5000 });
// Wait for spawn to complete (ensures exit handlers are attached)
await new Promise(resolve => setImmediate(resolve));
// Emit exit for task-1 (first task's handler)
mockProcess.emit('exit', 0);
await promise1;
// Emit exit for task-2 (second task's handler replaces first due to shared mock process)
mockProcess.emit('exit', 0);
await promise2;
// Tasks should be removed from tracking after exit
expect(manager.getRunningTasks()).toHaveLength(0);
}, 15000);
it('should use configured Python path', async () => {
const { spawn } = await import('child_process');
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure('/custom/python3', AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
expect(spawn).toHaveBeenCalledWith(
'/custom/python3',
expect.any(Array),
expect.any(Object)
);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should kill all running tasks', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
// Start two async operations
const promise1 = manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
const promise2 = manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
// Wait for spawn to complete (ensures listeners are attached), then emit exit
await new Promise(resolve => setImmediate(resolve));
mockProcess.emit('exit', 0);
await promise1;
mockProcess.emit('exit', 0);
await promise2;
await manager.killAll();
expect(manager.getRunningTasks()).toHaveLength(0);
}, 10000); // Increase timeout for Windows CI
it('should allow sequential execution of same task', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
// Start first operation
const promise1 = manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
// Wait for spawn, then emit exit
await new Promise(resolve => setImmediate(resolve));
mockProcess.emit('exit', 0);
await promise1;
// Start another process for same task (first was already completed)
const promise2 = manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 2');
// Wait for spawn, then emit exit
await new Promise(resolve => setImmediate(resolve));
mockProcess.emit('exit', 0);
await promise2;
// Both processes completed successfully
// (the first process was already done before the second started)
}, 10000); // Increase timeout for Windows CI
});
});