feat(53-001): extract src/ modules from main.js and add Vitest test infrastructure

- runtime.js: resolvePythonExecutable, getPluginVersion, checkRuntimeVersion
- errors.js: classifyError, buildRuntimeInstallCommand, parseRuntimeStatus
- commands.js: ACTIONS, buildCommandArgs, runSubprocess
- package.json with vitest + obsidian-test-mocks + jsdom
- vitest.config.ts with jsdom environment
- 42 Vitest tests across 3 test files
- Refactored main.js to import from src/ modules
This commit is contained in:
Research Assistant 2026-05-09 00:05:19 +08:00
parent 18deeb24d0
commit 2cb223ddbb
15 changed files with 1147 additions and 126 deletions

24
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
plugin-tests:
name: Plugin Tests (L3)
runs-on: ubuntu-latest
if: contains(join(github.event.head_commit.modified, ' '), 'paperforge/plugin/') || contains(join(github.event.head_commit.added, ' '), 'paperforge/plugin/') || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: paperforge/plugin/package-lock.json
- run: npm ci
working-directory: paperforge/plugin
- run: npx vitest run --reporter=verbose
working-directory: paperforge/plugin

View file

@ -3,6 +3,11 @@ const { exec, execFile } = require('node:child_process');
const fs = require('fs');
const path = require('path');
// Extracted modules for testability (Plan 53-001)
const { resolvePythonExecutable, getPluginVersion, checkRuntimeVersion } = require('./src/runtime');
const { classifyError, buildRuntimeInstallCommand, parseRuntimeStatus } = require('./src/errors');
const { ACTIONS, buildCommandArgs, runSubprocess } = require('./src/commands');
const VIEW_TYPE_PAPERFORGE = 'paperforge-status';
const PF_ICON_ID = 'paperforge';
const PF_RIBBON_SVG = `
@ -186,106 +191,7 @@ const DEFAULT_SETTINGS = {
python_path: '',
};
const ACTIONS = [
{
id: 'paperforge-sync',
title: 'Sync Library',
desc: 'Pull new references from Zotero and generate literature notes',
icon: '\u21BB', // ↻
cmd: 'sync',
okMsg: 'Sync complete',
},
{
id: 'paperforge-ocr',
title: 'Run OCR',
desc: 'Extract full text and figures from PDFs via PaddleOCR',
icon: '\u229E', // ⊞
cmd: 'ocr',
okMsg: 'OCR started',
},
{
id: 'paperforge-doctor',
title: 'Run Doctor',
desc: 'Verify PaperForge setup — check configs, Zotero, paths, and index health',
icon: '\u2695', // ⚕
cmd: 'doctor',
okMsg: 'Doctor complete',
},
{
id: 'paperforge-repair',
title: 'Repair Issues',
desc: 'Fix three-way state divergence, path errors, and rebuild index',
icon: '\u21BA', // ↺
cmd: 'repair',
args: ['--fix', '--fix-paths'],
okMsg: 'Repair complete',
disabled: true,
disabledMsg: 'Repair Issues will be available in a future update.',
},
{
id: 'paperforge-copy-context',
title: 'Copy Context',
desc: 'Copy this paper\u2019s canonical index entry JSON to clipboard for AI use',
icon: '\u2139', //
cmd: 'context',
needsKey: true,
okMsg: 'Context copied',
},
{
id: 'paperforge-copy-collection-context',
title: 'Copy Collection Context',
desc: 'Copy canonical index entries for all visible papers to clipboard',
icon: '\u2261', // ≡
cmd: 'context',
needsFilter: true,
okMsg: 'Collection context copied',
},
];
function resolvePythonExecutable(vaultPath, settings) {
// 1. Manual override — absolute source of truth
if (settings && settings.python_path && settings.python_path.trim()) {
const manualPath = settings.python_path.trim();
if (fs.existsSync(manualPath)) {
return { path: manualPath, source: 'manual', extraArgs: [] };
}
}
// 2. Venv candidates
const venvCandidates = [
path.join(vaultPath, '.paperforge-test-venv', 'Scripts', 'python.exe'),
path.join(vaultPath, '.venv', 'Scripts', 'python.exe'),
path.join(vaultPath, 'venv', 'Scripts', 'python.exe'),
];
for (const candidate of venvCandidates) {
try {
if (fs.existsSync(candidate)) return { path: candidate, source: 'auto-detected', extraArgs: [] };
} catch {}
}
// 3. System candidates — test each with --version, pick first that succeeds
const { execFileSync } = require('node:child_process');
const systemCandidates = [
{ path: 'py', extraArgs: ['-3'] },
{ path: 'python', extraArgs: [] },
{ path: 'python3', extraArgs: [] },
];
for (const candidate of systemCandidates) {
try {
const verOut = execFileSync(candidate.path, [...candidate.extraArgs, '--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
});
if (verOut && verOut.toLowerCase().includes('python')) {
return { path: candidate.path, source: 'auto-detected', extraArgs: candidate.extraArgs };
}
} catch {}
}
// 4. Last-resort fallback
return { path: 'python', source: 'auto-detected', extraArgs: [] };
}
// ACTIONS, resolvePythonExecutable extracted to src/ modules (Plan 53-001)
function overlayEntryWorkflowState(app, entry) {
if (!entry || !entry.note_path) return entry;
@ -407,23 +313,24 @@ class PaperForgeStatusView extends ItemView {
_fetchVersion() {
const vp = this.app.vault.adapter.basePath;
const plugin = this.app.plugins.plugins['paperforge'];
const pluginVer = plugin?.manifest?.version || '?';
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, plugin?.settings);
execFile(pythonExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'], { cwd: vp, timeout: 10000 }, (err, stdout) => {
if (!err && stdout) {
const v = stdout.trim();
this._paperforgeVersion = v.startsWith('v') ? v : 'v' + v;
if (this._versionBadge) this._versionBadge.setText(this._paperforgeVersion);
checkRuntimeVersion(pythonExe, pluginVer, vp, 10000).then((result) => {
if (result.status === 'not-installed') {
return;
}
const v = result.pyVersion || '';
this._paperforgeVersion = v.startsWith('v') ? v : 'v' + v;
if (this._versionBadge) this._versionBadge.setText(this._paperforgeVersion);
// Check drift for dashboard banner
const pluginVer = this.app.plugins.plugins['paperforge']?.manifest?.version;
if (this._driftBannerEl && pluginVer && this._paperforgeVersion !== 'v' + pluginVer.replace(/^v/, '')) {
this._driftBannerEl.style.display = 'block';
this._driftBannerEl.setText(t('dashboard_drift_warning')
.replace('{0}', this._paperforgeVersion)
.replace('{1}', 'v' + pluginVer.replace(/^v/, '')));
} else if (this._driftBannerEl) {
this._driftBannerEl.style.display = 'none';
}
// Check drift for dashboard banner
if (this._driftBannerEl && pluginVer && this._paperforgeVersion !== 'v' + pluginVer.replace(/^v/, '')) {
this._driftBannerEl.style.display = 'block';
this._driftBannerEl.setText(t('dashboard_drift_warning')
.replace('{0}', this._paperforgeVersion)
.replace('{1}', 'v' + pluginVer.replace(/^v/, '')));
} else if (this._driftBannerEl) {
this._driftBannerEl.style.display = 'none';
}
});
}
@ -1931,28 +1838,21 @@ class PaperForgeSettingTab extends PluginSettingTab {
const vp = this.app.vault.adapter.basePath;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, this.plugin.settings);
const ver = this.plugin.manifest.version || '1.4.17rc2';
const url = `git+https://github.com/LLLin000/PaperForge.git@${ver}`;
const spawn = require('node:child_process').spawn;
const installCmd = buildRuntimeInstallCommand(pythonExe, ver, extraArgs);
btn.setDisabled(true);
btn.setButtonText(t('runtime_health_syncing'));
const child = spawn(pythonExe, [...extraArgs, '-m', 'pip', 'install', '--upgrade', url], { cwd: vp, timeout: 120000 });
child.on('close', (code) => {
if (code === 0) {
runSubprocess(installCmd.cmd, installCmd.args, vp, installCmd.timeout).then((result) => {
if (result.exitCode === 0) {
new Notice(t('runtime_health_sync_done').replace('{0}', ver), 5000);
this.display();
} else {
btn.setDisabled(false);
btn.setButtonText(t('runtime_health_sync'));
new Notice(t('runtime_health_sync_fail').replace('{0}', 'pip exit code ' + code), 8000);
new Notice(t('runtime_health_sync_fail').replace('{0}', 'pip exit code ' + result.exitCode), 8000);
}
});
child.on('error', (e) => {
btn.setDisabled(false);
btn.setButtonText(t('runtime_health_sync'));
new Notice(t('runtime_health_sync_fail').replace('{0}', e.message), 8000);
});
}
_debouncedSave() {

View file

@ -0,0 +1,16 @@
{
"name": "paperforge-plugin",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^2.1.0",
"obsidian-test-mocks": "^2.0.0",
"jsdom": "^25.0.0",
"obsidian": "^1.12.0"
}
}

View file

@ -0,0 +1,147 @@
/**
* Action definitions and subprocess dispatch for PaperForge plugin commands.
*
* Extracted from main.js for testability. Provides:
* - ACTIONS array (same as main.js lines 189-243)
* - buildCommandArgs resolves action arguments (key, filter flags)
* - runSubprocess Promisified spawn with stdout/stderr capture
*
* `runSubprocess` accepts an optional `_spawn` parameter for testing.
*/
"use strict";
const { spawn } = require("node:child_process");
/**
* Action definitions for the PaperForge dashboard quick-action buttons.
*
* Each action maps to a `paperforge <cmd>` CLI invocation.
*/
const ACTIONS = [
{
id: "paperforge-sync",
title: "Sync Library",
desc: "Pull new references from Zotero and generate literature notes",
icon: "\u21BB",
cmd: "sync",
okMsg: "Sync complete",
},
{
id: "paperforge-ocr",
title: "Run OCR",
desc: "Extract full text and figures from PDFs via PaddleOCR",
icon: "\u229E",
cmd: "ocr",
okMsg: "OCR started",
},
{
id: "paperforge-doctor",
title: "Run Doctor",
desc: "Verify PaperForge setup \u2014 check configs, Zotero, paths, and index health",
icon: "\u2695",
cmd: "doctor",
okMsg: "Doctor complete",
},
{
id: "paperforge-repair",
title: "Repair Issues",
desc: "Fix three-way state divergence, path errors, and rebuild index",
icon: "\u21BA",
cmd: "repair",
args: ["--fix", "--fix-paths"],
okMsg: "Repair complete",
disabled: true,
disabledMsg: "Repair Issues will be available in a future update.",
},
{
id: "paperforge-copy-context",
title: "Copy Context",
desc: "Copy this paper\u2019s canonical index entry JSON to clipboard for AI use",
icon: "\u2139",
cmd: "context",
needsKey: true,
okMsg: "Context copied",
},
{
id: "paperforge-copy-collection-context",
title: "Copy Collection Context",
desc: "Copy canonical index entries for all visible papers to clipboard",
icon: "\u2261",
cmd: "context",
needsFilter: true,
okMsg: "Collection context copied",
},
];
/**
* Resolve extra CLI arguments for an action based on needsKey and needsFilter flags.
*
* @param {object} action action object from ACTIONS array
* @param {string|null} key zotero_key for per-paper actions
* @param {boolean} [filter] whether to add --all filter
* @returns {string[]}
*/
function buildCommandArgs(action, key, filter) {
const args = Array.isArray(action.args) ? [...action.args] : [];
if (action.needsKey && key) {
args.push(key);
}
if (action.needsFilter || filter) {
args.push("--all");
}
return args;
}
/**
* Run a subprocess with full stdout/stderr capture, wrapped in a Promise.
*
* @param {string} pythonExe resolved Python executable path
* @param {string[]} args CLI arguments
* @param {string} cwd working directory (vault root)
* @param {number} [timeout] timeout in ms
* @param {Function} [_spawn] optional injected spawn (for testing)
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number|null, elapsed: number }>}
*/
function runSubprocess(pythonExe, args, cwd, timeout, _spawn) {
const sp = _spawn || spawn;
return new Promise((resolve) => {
const startTime = Date.now();
const child = sp(pythonExe, args, { cwd, timeout, windowsHide: true });
const stdoutChunks = [];
const stderrChunks = [];
child.stdout.on("data", (data) => {
stdoutChunks.push(data.toString("utf-8"));
});
child.stderr.on("data", (data) => {
stderrChunks.push(data.toString("utf-8"));
});
child.on("close", (code) => {
const elapsed = Date.now() - startTime;
resolve({
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
exitCode: code,
elapsed,
});
});
child.on("error", (err) => {
const elapsed = Date.now() - startTime;
resolve({
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join("") + "\n" + err.message,
exitCode: -1,
elapsed,
});
});
});
}
module.exports = {
ACTIONS,
buildCommandArgs,
runSubprocess,
};

View file

@ -0,0 +1,97 @@
/**
* Error classification and runtime install command builder for PaperForge plugin.
*
* Extracted from main.js for testability. Covers 5 error patterns:
* 1. Python missing ENOENT or python-missing
* 2. Import failed MODULE_NOT_FOUND or import-failed
* 3. Version mismatch version-mismatch
* 4. Pip install failure pip-failed
* 5. Timeout ETIMEDOUT or timeout
*/
"use strict";
/**
* Classify a subprocess error code/string into a structured error object.
*
* @param {string|number} errorCode error.code from subprocess, or a string label
* @returns {{ type: string, message: string, recoverable: boolean, action?: string }}
*/
function classifyError(errorCode) {
const code = String(errorCode);
const patterns = {
ENOENT: { type: "python_missing", message: "Python executable not found", recoverable: true },
"python-missing": { type: "python_missing", message: "Python executable not found", recoverable: true },
MODULE_NOT_FOUND: { type: "import_failed", message: "PaperForge package not installed", recoverable: true },
"import-failed": { type: "import_failed", message: "PaperForge package not installed", recoverable: true },
"version-mismatch": { type: "version_mismatch", message: "Plugin and package versions differ", recoverable: true, action: "sync-runtime" },
"pip-failed": { type: "pip_install_failure", message: "pip install command failed", recoverable: true },
ETIMEDOUT: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" },
timeout: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" },
};
const match = patterns[code];
if (match) {
return { ...match };
}
return { type: "unknown", message: String(errorCode), recoverable: false };
}
/**
* Build the command and arguments for installing a specific version of paperforge via pip.
*
* @param {string} pythonExe resolved Python executable
* @param {string} version version tag (e.g., "1.4.17rc3")
* @param {string[]} [extraArgs=[]] extra Python CLI args (e.g., venv activation flags)
* @returns {{ cmd: string, args: string[], url: string, timeout: number }}
*/
function buildRuntimeInstallCommand(pythonExe, version, extraArgs) {
if (extraArgs === undefined) extraArgs = [];
const url = `git+https://github.com/LLLin000/PaperForge.git@${version}`;
const args = [...extraArgs, "-m", "pip", "install", "--upgrade", url];
return { cmd: pythonExe, args, url, timeout: 120000 };
}
/**
* Parse the result of a subprocess version check into a structured status object.
*
* @param {Error|null} err subprocess error object (or null on success)
* @param {string|null} stdout subprocess stdout
* @param {string|null} stderr subprocess stderr
* @returns {{ status: string, version: string|null, type?: string, message?: string, recoverable?: boolean, action?: string }}
*/
function parseRuntimeStatus(err, stdout, stderr) {
if (!err && stdout) {
const version = stdout.trim();
return { status: "ok", version };
}
if (err && err.code === "ENOENT") {
const classified = classifyError("ENOENT");
return { status: "error", version: null, ...classified };
}
if (stderr && stderr.includes("No module named paperforge")) {
const classified = classifyError("import-failed");
return { status: "error", version: null, ...classified };
}
if (err && err.killed) {
const classified = classifyError("timeout");
return { status: "error", version: null, ...classified };
}
if (stderr && stderr.includes("ModuleNotFoundError")) {
const classified = classifyError("import-failed");
return { status: "error", version: null, ...classified };
}
return { status: "error", version: null, type: "unknown", message: err ? err.message : String(stderr), recoverable: false };
}
module.exports = {
classifyError,
buildRuntimeInstallCommand,
parseRuntimeStatus,
};

View file

@ -0,0 +1,157 @@
/**
* Runtime environment helpers Python executable resolution and version checking.
*
* Extracted from main.js for testability. These functions handle:
* - Finding a usable Python interpreter (manual, venv, system)
* - Reading the plugin version from the Obsidian manifest
* - Checking the installed paperforge package version against the plugin
*
* Dependencies are accepted as optional parameters for testability.
* When omitted, they are eagerly required at the top level.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const { execFile } = require("node:child_process");
/**
* Resolve a usable Python executable by trying candidates in priority order.
*
* 1. Manual override (settings.python_path)
* 2. Venv candidates (common venv locations under vaultPath)
* 3. System candidates (py -3, python, python3)
* 4. Last-resort fallback
*
* @param {string} vaultPath root path of the Obsidian vault
* @param {object} settings plugin settings (may contain python_path)
* @param {object} [_fs] optional injected fs module (for testing)
* @param {Function} [_execFileSync] optional injected execFileSync (for testing)
* @returns {{ path: string, source: string, extraArgs: string[] }}
*/
function resolvePythonExecutable(vaultPath, settings, _fs, _execFileSync) {
const f = _fs || fs;
const execSync = _execFileSync || require("node:child_process").execFileSync;
// 1. Manual override — absolute source of truth
if (settings && settings.python_path && settings.python_path.trim()) {
const manualPath = settings.python_path.trim();
if (f.existsSync(manualPath)) {
return { path: manualPath, source: "manual", extraArgs: [] };
}
}
// 2. Venv candidates
const venvCandidates = [
path.join(vaultPath, ".paperforge-test-venv", "Scripts", "python.exe"),
path.join(vaultPath, ".venv", "Scripts", "python.exe"),
path.join(vaultPath, "venv", "Scripts", "python.exe"),
];
for (const candidate of venvCandidates) {
try {
if (f.existsSync(candidate)) {
return { path: candidate, source: "auto-detected", extraArgs: [] };
}
} catch {}
}
// 3. System candidates — test each with --version, pick first that succeeds
const systemCandidates = [
{ path: "py", extraArgs: ["-3"] },
{ path: "python", extraArgs: [] },
{ path: "python3", extraArgs: [] },
];
for (const candidate of systemCandidates) {
try {
const verOut = execSync(candidate.path, [...candidate.extraArgs, "--version"], {
encoding: "utf-8",
timeout: 5000,
windowsHide: true,
});
if (verOut && verOut.toLowerCase().includes("python")) {
return { path: candidate.path, source: "auto-detected", extraArgs: candidate.extraArgs };
}
} catch {}
}
// 4. Last-resort fallback
return { path: "python", source: "auto-detected", extraArgs: [] };
}
/**
* Read the plugin version from the Obsidian app manifest.
*
* @param {object} app Obsidian App instance (from plugin)
* @returns {string|null}
*/
function getPluginVersion(app) {
try {
const manifest =
app &&
app.plugins &&
app.plugins.plugins &&
app.plugins.plugins["paperforge"] &&
app.plugins.plugins["paperforge"].manifest;
return (manifest && manifest.version) || null;
} catch {
return null;
}
}
/**
* Check whether the installed paperforge Python package matches the plugin version.
*
* Spawns: pythonExe -c "import paperforge; print(paperforge.__version__)"
*
* @param {string} pythonExe resolved Python executable path
* @param {string} pluginVersion expected version from manifest
* @param {string} cwd working directory (vault root)
* @param {number} [timeout=10000] timeout in ms
* @param {Function} [_execFile] optional injected execFile (for testing)
* @returns {Promise<{ status: string, pyVersion: string|null, pluginVersion: string, error: string|null }>}
*/
function checkRuntimeVersion(pythonExe, pluginVersion, cwd, timeout, _execFile) {
if (timeout === undefined) timeout = 10000;
const exe = _execFile || execFile;
return new Promise((resolve) => {
exe(
pythonExe,
["-c", "import paperforge; print(paperforge.__version__)"],
{ cwd, timeout },
(err, stdout) => {
if (err) {
resolve({
status: "not-installed",
pyVersion: null,
pluginVersion,
error: err.message,
});
return;
}
const pyVer = (stdout && stdout.trim()) || null;
if (pyVer === pluginVersion) {
resolve({
status: "match",
pyVersion: pyVer,
pluginVersion,
error: null,
});
} else {
resolve({
status: "mismatch",
pyVersion: pyVer,
pluginVersion,
error: null,
});
}
},
);
});
}
module.exports = {
resolvePythonExecutable,
getPluginVersion,
checkRuntimeVersion,
};

View file

@ -0,0 +1,131 @@
/**
* Vitest tests for commands.js ACTIONS, buildCommandArgs, runSubprocess.
*
* runSubprocess uses dependency injection (last _spawn param) instead of
* vi.mock to avoid CJS/ESM module mocking limitations in vitest v2.1.x.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { ACTIONS, buildCommandArgs, runSubprocess } = await import('../src/commands.js');
describe('ACTIONS', () => {
it('has exactly 6 entries', () => {
expect(ACTIONS).toHaveLength(6);
});
it('every entry has id, title, cmd, okMsg', () => {
for (const a of ACTIONS) {
expect(a).toHaveProperty('id');
expect(a).toHaveProperty('title');
expect(a).toHaveProperty('cmd');
expect(a).toHaveProperty('okMsg');
}
});
it('sync action has cmd: sync', () => {
expect(ACTIONS.find(a => a.id === 'paperforge-sync')?.cmd).toBe('sync');
});
it('repair action has disabled: true', () => {
expect(ACTIONS.find(a => a.id === 'paperforge-repair')?.disabled).toBe(true);
});
it('copy-context action has needsKey', () => {
expect(ACTIONS.find(a => a.id === 'paperforge-copy-context')?.needsKey).toBe(true);
});
it('copy-collection-context action has needsFilter', () => {
expect(ACTIONS.find(a => a.id === 'paperforge-copy-collection-context')?.needsFilter).toBe(true);
});
});
describe('buildCommandArgs', () => {
it('appends key when needsKey', () => {
expect(buildCommandArgs({ args: ['--json'], needsKey: true }, 'ABC123'))
.toEqual(['--json', 'ABC123']);
});
it('appends --all when needsFilter', () => {
expect(buildCommandArgs({ needsFilter: true }, null)).toEqual(['--all']);
});
it('returns empty array when no flags', () => {
expect(buildCommandArgs({})).toEqual([]);
});
it('copies args to avoid mutation', () => {
const a = { args: ['--json'], needsKey: true };
expect(buildCommandArgs(a, 'K1')).toEqual(['--json', 'K1']);
expect(buildCommandArgs(a, 'K2')).toEqual(['--json', 'K2']);
});
});
describe('runSubprocess', () => {
let mockSpawn;
function makeMockChild() {
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
};
}
beforeEach(() => {
mockSpawn = vi.fn(makeMockChild);
});
it('returns stdout, stderr, exitCode from spawned process', async () => {
const promise = runSubprocess('python', ['--version'], '/vault', undefined, mockSpawn);
const child = mockSpawn.mock.results[0]?.value;
expect(child).toBeDefined();
const closeCb = child.on.mock.calls.find(([e]) => e === 'close')?.[1];
const dataCb = child.stdout.on.mock.calls.find(([e]) => e === 'data')?.[1];
expect(closeCb).toBeDefined();
expect(dataCb).toBeDefined();
dataCb('Python 3.11.0\n');
closeCb(0);
const r = await promise;
expect(r.stdout).toBe('Python 3.11.0\n');
expect(r.exitCode).toBe(0);
expect(r.elapsed).toBeGreaterThanOrEqual(0);
});
it('captures stderr on non-zero exit', async () => {
const promise = runSubprocess('python', ['bad'], '/vault', undefined, mockSpawn);
const child = mockSpawn.mock.results[0]?.value;
const closeCb = child.on.mock.calls.find(([e]) => e === 'close')?.[1];
const dataCb = child.stderr.on.mock.calls.find(([e]) => e === 'data')?.[1];
dataCb('Error: unknown');
closeCb(1);
const r = await promise;
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain('Error');
});
it('captures spawn error events', async () => {
const promise = runSubprocess('python', ['bad'], '/vault', undefined, mockSpawn);
const child = mockSpawn.mock.results[0]?.value;
const errCb = child.on.mock.calls.find(([e]) => e === 'error')?.[1];
expect(errCb).toBeDefined();
errCb(new Error('ENOENT'));
const r = await promise;
expect(r.exitCode).toBe(-1);
expect(r.stderr).toContain('ENOENT');
});
it('passes timeout and windowsHide options to spawn', async () => {
runSubprocess('python', ['cmd'], '/vault', 30000, mockSpawn);
const opts = mockSpawn.mock.calls[0]?.[2];
expect(opts).toHaveProperty('timeout', 30000);
expect(opts).toHaveProperty('windowsHide', true);
});
it('resolves on spawn error with exitCode -1', async () => {
const promise = runSubprocess('py', [], '/vault', 5000, mockSpawn);
const child = mockSpawn.mock.results[0]?.value;
const errCb = child.on.mock.calls.find(([e]) => e === 'error')?.[1];
errCb(new Error('spawn ENOENT'));
const r = await promise;
expect(r.exitCode).toBe(-1);
});
});

View file

@ -0,0 +1,117 @@
/**
* Vitest tests for errors.js classifyError, buildRuntimeInstallCommand, parseRuntimeStatus.
*/
import { describe, it, expect } from 'vitest';
const {
classifyError,
buildRuntimeInstallCommand,
parseRuntimeStatus,
} = await import('../src/errors.js');
describe('classifyError', () => {
it('classifies ENOENT as python_missing', () => {
const result = classifyError('ENOENT');
expect(result.type).toBe('python_missing');
expect(result.recoverable).toBe(true);
expect(result.message).toContain('Python');
});
it('classifies MODULE_NOT_FOUND as import_failed', () => {
const result = classifyError('MODULE_NOT_FOUND');
expect(result.type).toBe('import_failed');
expect(result.recoverable).toBe(true);
});
it('classifies version-mismatch with sync-runtime action', () => {
const result = classifyError('version-mismatch');
expect(result.type).toBe('version_mismatch');
expect(result.recoverable).toBe(true);
expect(result.action).toBe('sync-runtime');
});
it('classifies pip-failed as pip_install_failure', () => {
const result = classifyError('pip-failed');
expect(result.type).toBe('pip_install_failure');
expect(result.recoverable).toBe(true);
});
it('classifies ETIMEDOUT as timeout with retry action', () => {
const result = classifyError('ETIMEDOUT');
expect(result.type).toBe('timeout');
expect(result.recoverable).toBe(true);
expect(result.action).toBe('retry');
});
it('classifies unknown error strings as unknown', () => {
const result = classifyError('SOME_RANDOM_ERROR');
expect(result.type).toBe('unknown');
expect(result.recoverable).toBe(false);
expect(result.message).toBe('SOME_RANDOM_ERROR');
});
it('classifies numeric exit codes as unknown', () => {
const result = classifyError(1);
expect(result.type).toBe('unknown');
expect(result.recoverable).toBe(false);
});
});
describe('buildRuntimeInstallCommand', () => {
it('constructs correct URL with version tag', () => {
const result = buildRuntimeInstallCommand('python', '1.4.17rc3');
expect(result.cmd).toBe('python');
expect(result.url).toBe('git+https://github.com/LLLin000/PaperForge.git@1.4.17rc3');
expect(result.args).toContain('-m');
expect(result.args).toContain('pip');
expect(result.args).toContain('install');
expect(result.args).toContain('--upgrade');
});
it('includes extraArgs when provided', () => {
const result = buildRuntimeInstallCommand('python', '1.4.17rc3', ['-3']);
expect(result.args[0]).toBe('-3');
});
it('timeout is 120000', () => {
const result = buildRuntimeInstallCommand('python', '1.4.17rc3');
expect(result.timeout).toBe(120000);
});
});
describe('parseRuntimeStatus', () => {
it('returns ok with version on success', () => {
const result = parseRuntimeStatus(null, '1.4.17rc3\n', '');
expect(result.status).toBe('ok');
expect(result.version).toBe('1.4.17rc3');
});
it('classifies ENOENT as python_missing', () => {
const err = new Error('spawn ENOENT');
err.code = 'ENOENT';
const result = parseRuntimeStatus(err, null, '');
expect(result.status).toBe('error');
expect(result.type).toBe('python_missing');
});
it('classifies ModuleNotFoundError in stderr as import_failed', () => {
const result = parseRuntimeStatus(new Error('fail'), null, 'No module named paperforge');
expect(result.status).toBe('error');
expect(result.type).toBe('import_failed');
});
it('classifies killed/timeout subprocess as timeout', () => {
const err = new Error('timeout');
err.killed = true;
const result = parseRuntimeStatus(err, null, '');
expect(result.status).toBe('error');
expect(result.type).toBe('timeout');
});
it('classifies generic error as unknown', () => {
const err = new Error('Something went wrong');
const result = parseRuntimeStatus(err, null, 'some output');
expect(result.status).toBe('error');
expect(result.type).toBe('unknown');
});
});

View file

@ -0,0 +1,111 @@
/**
* Vitest tests for runtime.js resolvePythonExecutable, getPluginVersion, checkRuntimeVersion.
*
* Uses dependency injection (last parameter) instead of vi.mock to avoid
* CJS/ESM module mocking limitations in vitest v2.1.x.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
resolvePythonExecutable,
getPluginVersion,
checkRuntimeVersion,
} = await import('../src/runtime.js');
describe('resolvePythonExecutable', () => {
/** Create injected fs + execFileSync mocks */
function mockDeps(existsSyncImpl, execFileSyncImpl) {
return {
fs: { existsSync: vi.fn(existsSyncImpl) },
execFileSync: vi.fn(execFileSyncImpl),
};
}
it('returns manual override when python_path is set and exists', () => {
const d = mockDeps(() => true);
const r = resolvePythonExecutable('/vault', { python_path: 'C:\\custom\\python.exe' }, d.fs, d.execFileSync);
expect(r).toEqual({ path: 'C:\\custom\\python.exe', source: 'manual', extraArgs: [] });
expect(d.fs.existsSync).toHaveBeenCalledWith('C:\\custom\\python.exe');
});
it('falls through when manual path does not exist and all candidates fail', () => {
const d = mockDeps(() => false, () => { throw new Error('not found'); });
const r = resolvePythonExecutable('/vault', { python_path: '/bad' }, d.fs, d.execFileSync);
expect(r.path).toBe('python');
expect(r.source).toBe('auto-detected');
});
it('returns venv path when .venv/Scripts/python.exe exists', () => {
const d = mockDeps((p) => p.includes('.venv\\Scripts\\python.exe'));
const r = resolvePythonExecutable('/vault', {}, d.fs, d.execFileSync);
expect(r.path).toContain('.venv');
expect(r.source).toBe('auto-detected');
});
it('returns system candidate py -3 when it responds with python version', () => {
const d = mockDeps(() => false, () => 'Python 3.11.0');
const r = resolvePythonExecutable('/vault', {}, d.fs, d.execFileSync);
expect(r).toEqual({ path: 'py', source: 'auto-detected', extraArgs: ['-3'] });
});
it('returns fallback python when nothing works', () => {
const d = mockDeps(() => false, () => { throw new Error('not found'); });
const r = resolvePythonExecutable('/vault', {}, d.fs, d.execFileSync);
expect(r).toEqual({ path: 'python', source: 'auto-detected', extraArgs: [] });
});
});
describe('getPluginVersion', () => {
it('returns version from app.plugins manifest', () => {
const app = {
plugins: { plugins: { paperforge: { manifest: { version: '1.4.17rc3' } } } },
};
expect(getPluginVersion(app)).toBe('1.4.17rc3');
});
it('returns null when plugin not loaded', () => {
expect(getPluginVersion({ plugins: { plugins: {} } })).toBeNull();
});
it('returns null for null app', () => {
expect(getPluginVersion(null)).toBeNull();
});
});
describe('checkRuntimeVersion', () => {
let mockExecFile;
beforeEach(() => {
mockExecFile = vi.fn();
});
it('returns match when versions align', async () => {
mockExecFile.mockImplementation((_e, _a, _o, cb) => cb(null, '1.4.17rc3\n'));
const r = await checkRuntimeVersion('python', '1.4.17rc3', '/vault', 10000, mockExecFile);
expect(r.status).toBe('match');
expect(r.pyVersion).toBe('1.4.17rc3');
});
it('returns mismatch when versions differ', async () => {
mockExecFile.mockImplementation((_e, _a, _o, cb) => cb(null, '1.4.16\n'));
const r = await checkRuntimeVersion('python', '1.4.17rc3', '/vault', 10000, mockExecFile);
expect(r.status).toBe('mismatch');
expect(r.pyVersion).toBe('1.4.16');
});
it('returns not-installed on execFile error', async () => {
mockExecFile.mockImplementation((_e, _a, _o, cb) => cb(new Error('ENOENT'), null));
const r = await checkRuntimeVersion('python', '1.4.17rc3', '/vault', 10000, mockExecFile);
expect(r.status).toBe('not-installed');
expect(r.pyVersion).toBeNull();
expect(r.error).toContain('ENOENT');
});
it('uses default timeout of 10000 when not provided', async () => {
mockExecFile.mockImplementation((_e, _a, opts, cb) => {
expect(opts.timeout).toBe(10000);
cb(null, '1.4.17rc3\n');
});
await checkRuntimeVersion('python', '1.4.17rc3', '/vault', undefined, mockExecFile);
});
});

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/**/*.test.mjs'],
globals: true,
},
});

95
tests/e2e/conftest.py Normal file
View file

@ -0,0 +1,95 @@
"""E2E test fixtures — temp vault builder, CLI invoker, mock OCR backend, Windows-safe cleanup."""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
import sys
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Windows-safe recursive directory removal
# ---------------------------------------------------------------------------
def _force_rmtree(root: Path) -> None:
"""Remove a directory tree, handling Windows file-locking issues."""
for dirpath, dirnames, filenames in os.walk(str(root)):
for f in filenames:
try:
os.chmod(os.path.join(dirpath, f), stat.S_IWRITE)
except OSError:
pass
for d in dirnames:
try:
os.chmod(os.path.join(dirpath, d), stat.S_IWRITE)
except OSError:
pass
shutil.rmtree(str(root), ignore_errors=True)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def vault_builder() -> object:
"""Session-scoped VaultBuilder instance connected to fixtures/ directory."""
from fixtures.vault_builder import VaultBuilder
return VaultBuilder()
@pytest.fixture
def temp_vault(vault_builder: object, tmp_path: Path) -> Path:
"""Function-scoped temp vault at 'standard' level."""
vault = Path(vault_builder.build("standard"))
yield vault
if vault.exists():
_force_rmtree(vault)
@pytest.fixture
def full_vault(vault_builder: object, tmp_path: Path) -> Path:
"""Function-scoped temp vault at 'full' level (includes OCR fixtures, formal notes)."""
vault = Path(vault_builder.build("full"))
yield vault
if vault.exists():
_force_rmtree(vault)
@pytest.fixture
def e2e_cli_invoker(temp_vault: Path) -> callable:
"""Returns (invoke_fn, vault_path) tuple for running CLI in temp vault.
Usage:
invoker, vault = e2e_cli_invoker
result = invoker(["sync"])
"""
vault = temp_vault
def _invoke(
args: list[str],
input_text: str | None = None,
env: dict | None = None,
) -> subprocess.CompletedProcess:
cmd = [sys.executable, "-m", "paperforge", "--vault", str(vault)] + list(args)
base_env = os.environ.copy()
base_env["PAPERFORGE_VAULT"] = str(vault)
if env:
base_env.update(env)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
input=input_text,
env=base_env,
)
return result
return (_invoke, vault)

View file

@ -0,0 +1,61 @@
"""E2E test: multi-domain sync — separate collections sync to separate domain directories."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
pytestmark = pytest.mark.e2e
def test_multi_domain_sync(e2e_cli_invoker: tuple) -> None:
"""Verify that syncing multiple Zotero collections creates domain-separated notes."""
invoker, vault = e2e_cli_invoker
result = invoker(["sync"])
assert result.returncode == 0, f"First sync failed:\nstdout:{result.stdout}\nstderr:{result.stderr}"
lit_dir = vault / "Resources" / "Literature"
# Both domain directories exist
ortho_dir = lit_dir / "orthopedic"
sports_dir = lit_dir / "sports_medicine"
assert ortho_dir.exists(), f"Orthopedic domain directory not found: {ortho_dir}"
assert sports_dir.exists(), f"Sports_medicine domain directory not found: {sports_dir}"
# Both have formal notes (in subdirectories)
ortho_notes = list(ortho_dir.rglob("*.md"))
sports_notes = list(sports_dir.rglob("*.md"))
assert len(ortho_notes) >= 1, f"No formal notes in orthopedic: {ortho_dir}"
assert len(sports_notes) >= 1, f"No formal notes in sports_medicine: {sports_dir}"
# Canonical index has items from both domains
index_path = vault / "System" / "PaperForge" / "indexes" / "formal-library.json"
assert index_path.exists(), f"Canonical index not found: {index_path}"
index = json.loads(index_path.read_text(encoding="utf-8"))
assert len(index["items"]) >= 2, f"Expected >= 2 items, got {len(index['items'])}"
domains = {item["domain"] for item in index["items"]}
assert "orthopedic" in domains, "Orthopedic domain missing from index"
assert "sports_medicine" in domains, "Sports_medicine domain missing from index"
# Verify note_path points to correct domain directories
for item in index["items"]:
assert "note_path" in item, f"Entry missing note_path: {item.get('zotero_key', '?')}"
expected_prefix = f"Resources/Literature/{item['domain']}/"
assert item["note_path"].startswith(
expected_prefix
), f"note_path '{item['note_path']}' does not start with '{expected_prefix}'"
# Sync is idempotent
result2 = invoker(["sync"])
assert result2.returncode == 0, f"Second sync failed:\nstdout:{result2.stdout}\nstderr:{result2.stderr}"
index2 = json.loads(index_path.read_text(encoding="utf-8"))
assert len(index2["items"]) == len(index["items"]), (
f"Item count changed after second sync: {len(index['items'])} -> {len(index2['items'])}"
)

52
tests/e2e/test_ocr_e2e.py Normal file
View file

@ -0,0 +1,52 @@
"""E2E test: OCR pipeline verification.
Uses the 'full' vault level which includes pre-populated OCR fixtures.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
pytestmark = pytest.mark.e2e
def test_ocr_fixtures_present(full_vault: Path) -> None:
"""Verify OCR fixture files exist in the full vault level."""
ocr_dir = full_vault / "System" / "PaperForge" / "ocr" / "FIXT0001"
assert ocr_dir.exists(), f"OCR directory not found: {ocr_dir}"
# meta.json exists with ocr_status: done
meta_path = ocr_dir / "meta.json"
assert meta_path.exists(), f"meta.json not found: {meta_path}"
meta = json.loads(meta_path.read_text(encoding="utf-8"))
assert meta.get("ocr_status") == "done", f"Expected ocr_status 'done', got '{meta.get('ocr_status')}'"
# extracted_fulltext.md exists (fixture uses this name)
fulltext_path = ocr_dir / "extracted_fulltext.md"
assert fulltext_path.exists(), f"extracted_fulltext.md not found: {fulltext_path}"
assert len(fulltext_path.read_text(encoding="utf-8")) > 0, "extracted_fulltext.md is empty"
# figure_map.json exists
figure_map_path = ocr_dir / "figure_map.json"
assert figure_map_path.exists(), f"figure_map.json not found: {figure_map_path}"
def test_ocr_formal_note_has_ocr_reference(full_vault: Path) -> None:
"""Verify the formal note frontmatter references OCR status."""
lit_dir = full_vault / "Resources" / "Literature" / "orthopedic"
note_files = list(lit_dir.rglob("*.md"))
assert len(note_files) >= 1, f"No formal notes found in {lit_dir}"
# Read the first note and verify frontmatter
note_content = note_files[0].read_text(encoding="utf-8")
frontmatter_start = note_content.find("---\n")
frontmatter_end = note_content.find("---\n", frontmatter_start + 4)
assert frontmatter_start >= 0 and frontmatter_end > frontmatter_start, "No valid frontmatter found"
frontmatter = note_content[frontmatter_start:frontmatter_end + 4]
assert "do_ocr:" in frontmatter, "Frontmatter missing do_ocr field"
assert "ocr_status:" in frontmatter, "Frontmatter missing ocr_status field"

View file

@ -0,0 +1,50 @@
"""E2E test: status, doctor, and repair CLI commands in temp vault with known state."""
from __future__ import annotations
import json
import pytest
pytestmark = pytest.mark.e2e
def test_status_json(e2e_cli_invoker: tuple) -> None:
"""Verify `paperforge status --json` returns valid JSON with expected keys."""
invoker, vault = e2e_cli_invoker
result = invoker(["status", "--json"])
assert result.returncode == 0, (
f"Status command failed:\nstdout:{result.stdout}\nstderr:{result.stderr}"
)
data = json.loads(result.stdout)
assert "formal_notes" in data, "Status JSON missing 'formal_notes' key"
assert "version" in data, "Status JSON missing 'version' key"
def test_doctor_runs(e2e_cli_invoker: tuple) -> None:
"""Verify `paperforge doctor` runs without error and produces output."""
invoker, vault = e2e_cli_invoker
result = invoker(["doctor"])
assert result.returncode in (0, 1), (
f"Doctor command unexpected exit code {result.returncode}:\n"
f"stdout:{result.stdout}\nstderr:{result.stderr}"
)
# Output should mention setup-related checks
output = (result.stdout + result.stderr).lower()
assert "python" in output or "paperforge" in output or "config" in output or "status" in output or "ok" in output or "fail" in output or result.returncode == 0, (
"Doctor output should contain setup-related content"
)
def test_repair_dry_run(e2e_cli_invoker: tuple) -> None:
"""Verify `paperforge repair` dry run completes (0 exit code expected for dry-run)."""
invoker, vault = e2e_cli_invoker
result = invoker(["repair"])
assert result.returncode == 0, (
f"Repair dry-run failed:\nstdout:{result.stdout}\nstderr:{result.stderr}"
)

View file

@ -0,0 +1,54 @@
"""E2E test: full sync pipeline — BBT JSON -> formal notes -> canonical index -> Base views."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
pytestmark = pytest.mark.e2e
def test_full_sync_pipeline(e2e_cli_invoker: tuple) -> None:
"""Verify that `paperforge sync` creates formal notes, canonical index, and Base views."""
invoker, vault = e2e_cli_invoker
result = invoker(["sync"])
assert result.returncode == 0, f"Sync failed:\nstdout:{result.stdout}\nstderr:{result.stderr}"
# Formal notes exist in the orthopedic domain directory (in subdirectories)
lit_dir = vault / "Resources" / "Literature"
assert lit_dir.exists(), f"Literature directory not found: {lit_dir}"
ortho_dir = lit_dir / "orthopedic"
assert ortho_dir.exists(), f"Orthopedic domain directory not found: {ortho_dir}"
# Formal notes are in subdirectories: FIXT0001 - Title/FIXT0001 - Title.md
note_files = list(ortho_dir.rglob("*.md"))
assert len(note_files) >= 1, f"No formal notes found in {ortho_dir}"
# Canonical index exists
index_path = vault / "System" / "PaperForge" / "indexes" / "formal-library.json"
assert index_path.exists(), f"Canonical index not found: {index_path}"
# Parse index and verify structure
index = json.loads(index_path.read_text(encoding="utf-8"))
assert "items" in index, "Index missing 'items' key"
assert len(index["items"]) >= 1, "Index has no items"
entries = [e for e in index["items"] if e.get("domain") == "orthopedic"]
assert len(entries) >= 1, "No orthopedic entries in index"
entry = entries[0]
assert "zotero_key" in entry, "Entry missing zotero_key"
assert entry["domain"] == "orthopedic", f"Expected domain 'orthopedic', got '{entry['domain']}'"
assert "note_path" in entry, "Entry missing note_path"
assert entry["note_path"] and "orthopedic" in entry["note_path"], (
f"note_path not pointing to orthopedic: {entry['note_path']}"
)
# Base views exist
base_dir = vault / "Bases"
assert base_dir.exists(), f"Base directory not found: {base_dir}"
base_files = list(base_dir.glob("*.base"))
assert len(base_files) >= 1, f"No .base files in {base_dir}"