ethanolivertroy_obsidian-ma.../src/utils/__tests__/python.test.ts
Ethan Troy f74856b711 Address review feedback before merge
Probe modern pipx homes, buffer install stdout for JSON, and clean up
failed venv creates so Install Markitdown retries reliably.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 16:08:59 -04:00

495 lines
15 KiB
TypeScript

import { EventEmitter } from 'events';
import * as path from 'path';
import type { ChildProcess } from 'child_process';
// ---- mock child_process.spawn before importing the module under test ----
const spawnMock = jest.fn();
jest.mock('child_process', () => ({
spawn: spawnMock,
}));
import {
buildPythonCandidates,
buildSpawnEnv,
checkDependencies,
getPluginVenvPython,
getPythonScriptPath,
runPythonScript,
} from '../python';
// Helper: create a fake ChildProcess that emits events
function fakeChild(
stdoutData: string,
stderrData: string,
exitCode: number,
shouldError?: Error
): ChildProcess {
const child = new EventEmitter() as ChildProcess & {
stdout: EventEmitter;
stderr: EventEmitter;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
// @ts-expect-error — minimal stub
child.stdin = null;
// Emit data and close on next tick so the caller can attach listeners first
process.nextTick(() => {
if (shouldError) {
child.emit('error', shouldError);
return;
}
if (stdoutData) child.stdout.emit('data', Buffer.from(stdoutData));
if (stderrData) child.stderr.emit('data', Buffer.from(stderrData));
child.emit('close', exitCode);
});
return child as ChildProcess;
}
// Helper: create stdout JSON that check_install.py would return
function checkOutput(opts: {
pythonVersion?: string;
markitdownInstalled?: boolean;
markitdownVersion?: string | null;
}): string {
return JSON.stringify({
python_version: opts.pythonVersion ?? '3.12.0',
packages: {
markitdown: {
installed: opts.markitdownInstalled ?? false,
version: opts.markitdownVersion ?? null,
},
},
});
}
afterEach(() => {
jest.restoreAllMocks();
spawnMock.mockReset();
});
// ---------- getPythonScriptPath ----------
describe('getPythonScriptPath', () => {
it('joins pluginDir with python/ and the script name', () => {
const result = getPythonScriptPath('check_install.py', '/home/user/.obsidian/plugins/markitdown');
expect(result).toMatch(/check_install\.py$/);
expect(result).toContain('python');
});
});
// ---------- buildSpawnEnv ----------
describe('buildSpawnEnv', () => {
it('prepends common user/brew bin dirs to PATH', () => {
const env = buildSpawnEnv();
expect(env.PYTHONUTF8).toBe('1');
expect(env.PATH).toBeTruthy();
if (process.platform !== 'win32') {
expect(env.PATH).toContain('/opt/homebrew/bin');
}
});
});
// ---------- buildPythonCandidates ----------
describe('buildPythonCandidates', () => {
const pluginDir = '/plugins/markitdown';
const originalPlatform = process.platform;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalLocalAppData = process.env.LOCALAPPDATA;
const originalPipxHome = process.env.PIPX_HOME;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
const restore = (key: string, value: string | undefined) => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
};
restore('HOME', originalHome);
restore('USERPROFILE', originalUserProfile);
restore('LOCALAPPDATA', originalLocalAppData);
restore('PIPX_HOME', originalPipxHome);
});
it('includes plugin venv and pipx python on darwin', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
process.env.HOME = '/Users/test';
delete process.env.PIPX_HOME;
const paths = buildPythonCandidates('python', pluginDir);
expect(paths[0]).toBe('python');
expect(paths).toContain(getPluginVenvPython(pluginDir));
expect(paths).toContain('/Users/test/.local/pipx/venvs/markitdown/bin/python');
expect(paths).toContain(
'/Users/test/Library/Application Support/pipx/venvs/markitdown/bin/python'
);
expect(paths).toContain('/opt/homebrew/bin/python3');
expect(paths).toContain('/opt/homebrew/bin/python3.11');
expect(paths).toContain('/opt/homebrew/opt/python@3.11/bin/python3');
expect(paths).toContain('/usr/local/bin/python3.12');
});
it('still adds fallbacks when configured path is absolute', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
process.env.HOME = '/Users/test';
delete process.env.PIPX_HOME;
const paths = buildPythonCandidates('/opt/homebrew/bin/python3', pluginDir);
expect(paths[0]).toBe('/opt/homebrew/bin/python3');
expect(paths).toContain('/opt/homebrew/bin/python3.11');
expect(paths).toContain('/Users/test/.local/pipx/venvs/markitdown/bin/python');
});
it('respects PIPX_HOME override', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
process.env.HOME = '/Users/test';
process.env.PIPX_HOME = '/custom/pipx';
const paths = buildPythonCandidates('python3', pluginDir);
expect(paths).toContain('/custom/pipx/venvs/markitdown/bin/python');
});
it('includes Windows pipx and LocalAppData paths', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
process.env.HOME = 'C:\\Users\\test';
process.env.USERPROFILE = 'C:\\Users\\test';
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
delete process.env.PIPX_HOME;
const paths = buildPythonCandidates('python', pluginDir);
expect(paths).toContain(path.join(pluginDir, '.venv', 'Scripts', 'python.exe'));
expect(paths).toContain(
path.join('C:\\Users\\test', '.local', 'pipx', 'venvs', 'markitdown', 'Scripts', 'python.exe')
);
expect(paths.some(p => p.includes('Programs\\Python\\Python311'))).toBe(true);
});
});
// ---------- runPythonScript ----------
describe('runPythonScript', () => {
it('resolves with stdout, stderr, and exitCode on success', async () => {
spawnMock.mockReturnValue(fakeChild('hello world', '', 0));
const result = await runPythonScript('/usr/bin/python3', 'script.py', ['--flag']);
expect(spawnMock).toHaveBeenCalledWith(
'/usr/bin/python3',
['script.py', '--flag'],
expect.objectContaining({
shell: false,
env: expect.objectContaining({ PYTHONUTF8: '1' }),
}),
);
expect(result).toEqual({
stdout: 'hello world',
stderr: '',
exitCode: 0,
});
});
it('rejects when spawn emits an error', async () => {
spawnMock.mockReturnValue(fakeChild('', '', 1, new Error('ENOENT')));
await expect(runPythonScript('/bad/path', 'script.py', []))
.rejects.toThrow('ENOENT');
});
it('returns exitCode 1 when close code is null', async () => {
const child = new EventEmitter() as ChildProcess & {
stdout: EventEmitter;
stderr: EventEmitter;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
process.nextTick(() => child.emit('close', null));
spawnMock.mockReturnValue(child);
const result = await runPythonScript('python', 'script.py', []);
expect(result.exitCode).toBe(1);
});
});
// ---------- checkDependencies ----------
describe('checkDependencies', () => {
const pluginDir = '/plugins/markitdown';
it('returns empty status immediately for empty path', async () => {
const { status, resolvedPythonPath } = await checkDependencies('', pluginDir);
expect(spawnMock).not.toHaveBeenCalled();
expect(status.pythonInstalled).toBe(false);
expect(status.markitdownInstalled).toBe(false);
expect(resolvedPythonPath).toBe('');
});
it('returns empty status immediately for whitespace-only path', async () => {
const { status, resolvedPythonPath } = await checkDependencies(' ', pluginDir);
expect(spawnMock).not.toHaveBeenCalled();
expect(status.pythonInstalled).toBe(false);
expect(resolvedPythonPath).toBe(' ');
});
it('returns immediately when the configured Python has markitdown', async () => {
spawnMock.mockReturnValue(
fakeChild(
checkOutput({ markitdownInstalled: true, markitdownVersion: '0.1.0' }),
'',
0,
),
);
const { status, resolvedPythonPath } = await checkDependencies('/usr/bin/python3', pluginDir);
expect(status.pythonInstalled).toBe(true);
expect(status.markitdownInstalled).toBe(true);
expect(status.markitdownVersion).toBe('0.1.0');
expect(resolvedPythonPath).toBe('/usr/bin/python3');
// Should have spawned only once (no fallback needed)
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('tries fallback paths when first Python lacks markitdown, prefers one with markitdown', async () => {
// First call: python found but no markitdown
// Second call: next candidate found with markitdown
let callCount = 0;
spawnMock.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return fakeChild(
checkOutput({ markitdownInstalled: false }),
'',
0,
);
}
return fakeChild(
checkOutput({ markitdownInstalled: true, markitdownVersion: '0.2.0' }),
'',
0,
);
});
const { status, resolvedPythonPath } = await checkDependencies('python', pluginDir);
expect(status.markitdownInstalled).toBe(true);
expect(status.markitdownVersion).toBe('0.2.0');
// Second candidate is the plugin venv path
expect(resolvedPythonPath).toBe(getPluginVenvPython(pluginDir));
expect(spawnMock).toHaveBeenCalledTimes(2);
});
it('finds markitdown on a versioned Homebrew Python after generic python3 lacks it', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
process.env.HOME = '/Users/test';
const paths: string[] = [];
spawnMock.mockImplementation((...args: unknown[]) => {
const tryPath = args[0] as string;
paths.push(tryPath);
if (tryPath === '/opt/homebrew/bin/python3.11') {
return fakeChild(
checkOutput({ markitdownInstalled: true, markitdownVersion: '0.1.6' }),
'',
0,
);
}
return fakeChild(
checkOutput({ markitdownInstalled: false }),
'',
0,
);
});
const { status, resolvedPythonPath } = await checkDependencies(
'/opt/homebrew/bin/python3',
pluginDir,
);
expect(status.markitdownInstalled).toBe(true);
expect(resolvedPythonPath).toBe('/opt/homebrew/bin/python3.11');
expect(paths).toContain('/opt/homebrew/bin/python3.11');
});
it('finds markitdown in a pipx venv', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
process.env.HOME = '/Users/test';
const pipxPython = '/Users/test/.local/pipx/venvs/markitdown/bin/python';
spawnMock.mockImplementation((...args: unknown[]) => {
const tryPath = args[0] as string;
if (tryPath === pipxPython) {
return fakeChild(
checkOutput({ markitdownInstalled: true, markitdownVersion: '0.1.6' }),
'',
0,
);
}
return fakeChild(
checkOutput({ markitdownInstalled: false }),
'',
0,
);
});
const { status, resolvedPythonPath } = await checkDependencies('python3', pluginDir);
expect(status.markitdownInstalled).toBe(true);
expect(resolvedPythonPath).toBe(pipxPython);
});
it('falls back to first working Python when none has markitdown', async () => {
// All candidates: Python works but no markitdown
spawnMock.mockImplementation(() => {
return fakeChild(
checkOutput({ markitdownInstalled: false, pythonVersion: '3.11.0' }),
'',
0,
);
});
const { status, resolvedPythonPath } = await checkDependencies('python', pluginDir);
expect(status.pythonInstalled).toBe(true);
expect(status.markitdownInstalled).toBe(false);
// Resolved to the first candidate that worked
expect(resolvedPythonPath).toBe('python');
});
it('returns default status when all candidates fail', async () => {
spawnMock.mockImplementation(() => {
return fakeChild('', '', 0, new Error('ENOENT'));
});
const { status, resolvedPythonPath } = await checkDependencies('python', pluginDir);
expect(status.pythonInstalled).toBe(false);
expect(status.markitdownInstalled).toBe(false);
expect(resolvedPythonPath).toBe('python');
});
it('deduplicates when configured path equals a platform fallback', async () => {
const paths: string[] = [];
spawnMock.mockImplementation((...args: unknown[]) => {
paths.push(args[0] as string);
return fakeChild(
checkOutput({ markitdownInstalled: false }),
'',
0,
);
});
await checkDependencies('python3', pluginDir);
const python3Count = paths.filter(p => p === 'python3').length;
expect(python3Count).toBe(1);
});
describe('platform-specific paths', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('adds Windows-specific paths when platform is win32', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
const origEnv = process.env.LOCALAPPDATA;
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
process.env.USERPROFILE = 'C:\\Users\\test';
const paths: string[] = [];
spawnMock.mockImplementation((...args: unknown[]) => {
paths.push(args[0] as string);
return fakeChild('', '', 1, new Error('ENOENT'));
});
await checkDependencies('python', pluginDir);
expect(paths.some(p => p.includes('Programs\\Python'))).toBe(true);
expect(paths.some(p => p.includes('WindowsApps'))).toBe(true);
if (origEnv === undefined) {
delete process.env.LOCALAPPDATA;
} else {
process.env.LOCALAPPDATA = origEnv;
}
});
it('adds macOS/Linux-specific paths when platform is darwin', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const paths: string[] = [];
spawnMock.mockImplementation((...args: unknown[]) => {
paths.push(args[0] as string);
return fakeChild('', '', 1, new Error('ENOENT'));
});
await checkDependencies('python', pluginDir);
expect(paths).toContain('/opt/homebrew/bin/python3');
expect(paths).toContain('/usr/local/bin/python3');
expect(paths.some(p => p.includes('Python.framework'))).toBe(true);
expect(paths).toContain('/usr/bin/python3');
expect(paths).toContain('/opt/homebrew/bin/python3.11');
});
it('adds Linux-specific paths when platform is linux', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const paths: string[] = [];
spawnMock.mockImplementation((...args: unknown[]) => {
paths.push(args[0] as string);
return fakeChild('', '', 1, new Error('ENOENT'));
});
await checkDependencies('python3', pluginDir);
expect(paths).toContain('/usr/bin/python3');
expect(paths).toContain('/opt/homebrew/bin/python3');
});
});
it('handles non-zero exit codes by skipping the candidate', async () => {
let callCount = 0;
spawnMock.mockImplementation(() => {
callCount++;
if (callCount === 1) {
// First candidate returns non-zero exit code
return fakeChild('', 'error', 1);
}
// Second candidate works
return fakeChild(
checkOutput({ markitdownInstalled: true, markitdownVersion: '1.0.0' }),
'',
0,
);
});
const { status, resolvedPythonPath } = await checkDependencies('python', pluginDir);
expect(status.markitdownInstalled).toBe(true);
expect(resolvedPythonPath).toBe(getPluginVenvPython(pluginDir));
});
it('handles invalid JSON output by skipping the candidate', async () => {
let callCount = 0;
spawnMock.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return fakeChild('not json', '', 0);
}
return fakeChild(
checkOutput({ markitdownInstalled: true, markitdownVersion: '1.0.0' }),
'',
0,
);
});
const { status } = await checkDependencies('python', pluginDir);
expect(status.markitdownInstalled).toBe(true);
});
});