mirror of
https://github.com/ethanolivertroy/obsidian-markitdown.git
synced 2026-07-22 05:41:54 +00:00
Add unit tests for Python discovery, paths, and postprocessing (ENG-646)
This commit is contained in:
parent
a93ed8341d
commit
6d6a0e6b9a
6 changed files with 4661 additions and 1 deletions
24
jest.config.js
Normal file
24
jest.config.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
'^obsidian$': '<rootDir>/src/__mocks__/obsidian.ts',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', {
|
||||
tsconfig: {
|
||||
module: 'commonjs',
|
||||
moduleResolution: 'node',
|
||||
esModuleInterop: true,
|
||||
target: 'ES2020',
|
||||
strict: true,
|
||||
noImplicitAny: true,
|
||||
baseUrl: '.',
|
||||
lib: ['ES2020'],
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
4157
package-lock.json
generated
4157
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -6,16 +6,25 @@
|
|||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "jest",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": ["obsidian", "markitdown", "conversion", "markdown"],
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"markitdown",
|
||||
"conversion",
|
||||
"markdown"
|
||||
],
|
||||
"author": "Ethan Troy",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.25.0",
|
||||
"jest": "^30.3.0",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
|
|
|
|||
14
src/__mocks__/obsidian.ts
Normal file
14
src/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Mock for the obsidian module used in tests
|
||||
export class App {}
|
||||
export class FileSystemAdapter {
|
||||
getBasePath(): string {
|
||||
return '/mock/vault';
|
||||
}
|
||||
}
|
||||
export class Plugin {}
|
||||
export class PluginSettingTab {}
|
||||
export class Modal {}
|
||||
export class Notice {}
|
||||
export class Setting {}
|
||||
export class TFile {}
|
||||
export class TFolder {}
|
||||
102
src/utils/__tests__/paths.test.ts
Normal file
102
src/utils/__tests__/paths.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { resolveOutputFolder, resolveImageDir, toVaultRelative } from '../paths';
|
||||
|
||||
// Mock fs to avoid touching the filesystem
|
||||
jest.mock('fs');
|
||||
|
||||
const mockedFs = jest.mocked(fs);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.mkdirSync.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ---------- resolveOutputFolder ----------
|
||||
describe('resolveOutputFolder', () => {
|
||||
it('uses the provided output setting when given', () => {
|
||||
const result = resolveOutputFolder('/vault', 'my-output');
|
||||
expect(result).toBe(path.resolve('/vault', 'my-output'));
|
||||
});
|
||||
|
||||
it('defaults to markitdown-output when output setting is empty', () => {
|
||||
const result = resolveOutputFolder('/vault', '');
|
||||
expect(result).toBe(path.join('/vault', 'markitdown-output'));
|
||||
});
|
||||
|
||||
it('prevents path traversal outside the vault', () => {
|
||||
const result = resolveOutputFolder('/vault', '../../outside');
|
||||
// Should fall back to default folder inside the vault
|
||||
expect(result).toBe(path.join('/vault', 'markitdown-output'));
|
||||
});
|
||||
|
||||
it('creates the directory if it does not exist', () => {
|
||||
mockedFs.existsSync.mockReturnValue(false);
|
||||
|
||||
resolveOutputFolder('/vault', 'output');
|
||||
|
||||
expect(mockedFs.mkdirSync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create the directory if it already exists', () => {
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
|
||||
resolveOutputFolder('/vault', 'output');
|
||||
|
||||
expect(mockedFs.mkdirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows output folder equal to the vault root', () => {
|
||||
const result = resolveOutputFolder('/vault', '.');
|
||||
expect(result).toBe(path.resolve('/vault'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- resolveImageDir ----------
|
||||
describe('resolveImageDir', () => {
|
||||
it('replaces {filename} with the markdown file base name', () => {
|
||||
const result = resolveImageDir('/vault/output/report.md', '{filename}-images');
|
||||
expect(result).toBe(path.join('/vault/output', 'report-images'));
|
||||
});
|
||||
|
||||
it('handles templates without {filename} placeholder', () => {
|
||||
const result = resolveImageDir('/vault/output/report.md', 'all-images');
|
||||
expect(result).toBe(path.join('/vault/output', 'all-images'));
|
||||
});
|
||||
|
||||
it('strips path separators from the template to prevent traversal', () => {
|
||||
const result = resolveImageDir('/vault/output/report.md', '../{filename}-images');
|
||||
// Path separators replaced with dashes
|
||||
expect(result).toBe(path.join('/vault/output', '..-report-images'));
|
||||
});
|
||||
|
||||
it('handles backslash separators in template', () => {
|
||||
const result = resolveImageDir('/vault/output/report.md', '..\\{filename}-images');
|
||||
expect(result).toBe(path.join('/vault/output', '..-report-images'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- toVaultRelative ----------
|
||||
describe('toVaultRelative', () => {
|
||||
it('returns a vault-relative path with forward slashes', () => {
|
||||
const result = toVaultRelative('/vault/subdir/file.md', '/vault');
|
||||
expect(result).toBe('subdir/file.md');
|
||||
});
|
||||
|
||||
it('returns just the filename for files at vault root', () => {
|
||||
const result = toVaultRelative('/vault/file.md', '/vault');
|
||||
expect(result).toBe('file.md');
|
||||
});
|
||||
|
||||
it('handles deeply nested paths', () => {
|
||||
const result = toVaultRelative('/vault/a/b/c/d.md', '/vault');
|
||||
expect(result).toBe('a/b/c/d.md');
|
||||
});
|
||||
});
|
||||
354
src/utils/__tests__/python.test.ts
Normal file
354
src/utils/__tests__/python.test.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { EventEmitter } from 'events';
|
||||
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 { checkDependencies, runPythonScript, getPythonScriptPath } from '../python';
|
||||
import type { DependencyStatus } from '../../types/settings';
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 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 }),
|
||||
);
|
||||
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: python3 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');
|
||||
// The resolved path should be the second candidate (python3)
|
||||
expect(resolvedPythonPath).toBe('python3');
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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 paths (does not try the same path twice)', async () => {
|
||||
// Use a specific absolute path that won't generate fallbacks
|
||||
spawnMock.mockReturnValue(
|
||||
fakeChild(
|
||||
checkOutput({ markitdownInstalled: true, markitdownVersion: '1.0.0' }),
|
||||
'',
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
await checkDependencies('/usr/bin/python3', pluginDir);
|
||||
|
||||
// Absolute path doesn't match 'python' or 'python3' so no fallbacks are generated
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates when configured path equals a platform fallback', async () => {
|
||||
// Spy on calls to count unique candidates
|
||||
const paths: string[] = [];
|
||||
spawnMock.mockImplementation((...args: unknown[]) => {
|
||||
paths.push(args[0] as string);
|
||||
return fakeChild(
|
||||
checkOutput({ markitdownInstalled: false }),
|
||||
'',
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
await checkDependencies('python3', pluginDir);
|
||||
|
||||
// 'python3' is both the configured path and a common fallback — it should appear only once
|
||||
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';
|
||||
|
||||
const paths: string[] = [];
|
||||
spawnMock.mockImplementation((...args: unknown[]) => {
|
||||
paths.push(args[0] as string);
|
||||
return fakeChild('', '', 1, new Error('ENOENT'));
|
||||
});
|
||||
|
||||
await checkDependencies('python', pluginDir);
|
||||
|
||||
// Should include Windows-style paths
|
||||
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);
|
||||
|
||||
// Should include Homebrew and framework paths
|
||||
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');
|
||||
});
|
||||
|
||||
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('python3');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue