fix(subprocess): handle Python paths with spaces (#352)

* fix(subprocess): handle Python paths with spaces

Fixes #315

The packaged macOS app uses a Python path inside ~/Library/Application Support/
which contains a space. The subprocess-runner.ts was passing the path directly
to spawn(), causing ENOENT errors.

This fix adds parsePythonCommand() (already used by agent-process.ts) to properly
handle paths with spaces. This also affects Changelog generation and other
GitHub automation features.

Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com>

* test(subprocess): add unit tests for python path spaces and arg ordering

---------

Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
Michael Ludlow
2025-12-27 16:40:37 -05:00
committed by GitHub
parent 1fa7a9c769
commit eabe7c7d1b
2 changed files with 101 additions and 1 deletions
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { runPythonSubprocess } from './subprocess-runner';
import * as childProcess from 'child_process';
import EventEmitter from 'events';
// Mock child_process.spawn
vi.mock('child_process', () => ({
spawn: vi.fn(),
exec: vi.fn(),
}));
// Mock parsePythonCommand
vi.mock('../../../python-detector', () => ({
parsePythonCommand: vi.fn((path) => {
// specific behavior for spaced paths can be mocked here or overwridden in tests
if (path.includes(' ')) {
return [path, []]; // Simple pass-through for test
}
return [path, []];
}),
}));
import { parsePythonCommand } from '../../../python-detector';
describe('runPythonSubprocess', () => {
let mockSpawn: any;
let mockChildProcess: any;
beforeEach(() => {
mockSpawn = vi.mocked(childProcess.spawn);
mockChildProcess = new EventEmitter();
mockChildProcess.stdout = new EventEmitter();
mockChildProcess.stderr = new EventEmitter();
mockChildProcess.kill = vi.fn();
mockSpawn.mockReturnValue(mockChildProcess);
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should handle python path with spaces', async () => {
// Arrange
const pythonPath = '/path/with spaces/python';
const mockArgs = ['-c', 'print("hello")'];
// Mock parsePythonCommand to return the path split logic if needed,
// or just rely on the mock above.
// Let's make sure our mock enables the scenario we want.
vi.mocked(parsePythonCommand).mockReturnValue(['/path/with spaces/python', []]);
// Act
runPythonSubprocess({
pythonPath,
args: mockArgs,
cwd: '/tmp',
});
// Assert
expect(parsePythonCommand).toHaveBeenCalledWith(pythonPath);
expect(mockSpawn).toHaveBeenCalledWith(
'/path/with spaces/python',
expect.arrayContaining(mockArgs),
expect.any(Object)
);
});
it('should pass user arguments AFTER python arguments', async () => {
// Arrange
const pythonPath = 'python';
const pythonBaseArgs = ['-u', '-X', 'utf8'];
const userArgs = ['script.py', '--verbose'];
// Setup mock to simulate what parsePythonCommand would return for a standard python path
vi.mocked(parsePythonCommand).mockReturnValue(['python', pythonBaseArgs]);
// Act
runPythonSubprocess({
pythonPath,
args: userArgs,
cwd: '/tmp',
});
// Assert
// The critical check: verify the ORDER of arguments in the second parameter of spawn
// expect call to be: spawn('python', ['-u', '-X', 'utf8', 'script.py', '--verbose'], ...)
const expectedArgs = [...pythonBaseArgs, ...userArgs];
expect(mockSpawn).toHaveBeenCalledWith(
expect.any(String),
expectedArgs, // Exact array match verifies order
expect.any(Object)
);
});
});
@@ -11,6 +11,7 @@ import { promisify } from 'util';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import type { Project } from '../../../../shared/types'; import type { Project } from '../../../../shared/types';
import { parsePythonCommand } from '../../../python-detector';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -68,7 +69,9 @@ export function runPythonSubprocess<T = unknown>(
} }
} }
const child = spawn(options.pythonPath, options.args, { // Parse Python command to handle paths with spaces (e.g., ~/Library/Application Support/...)
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(options.pythonPath);
const child = spawn(pythonCommand, [...pythonBaseArgs, ...options.args], {
cwd: options.cwd, cwd: options.cwd,
env: filteredEnv, env: filteredEnv,
}); });