mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
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:
parent
18deeb24d0
commit
2cb223ddbb
15 changed files with 1147 additions and 126 deletions
24
.github/workflows/ci.yml
vendored
Normal file
24
.github/workflows/ci.yml
vendored
Normal 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
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
16
paperforge/plugin/package.json
Normal file
16
paperforge/plugin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
147
paperforge/plugin/src/commands.js
Normal file
147
paperforge/plugin/src/commands.js
Normal 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,
|
||||
};
|
||||
97
paperforge/plugin/src/errors.js
Normal file
97
paperforge/plugin/src/errors.js
Normal 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,
|
||||
};
|
||||
157
paperforge/plugin/src/runtime.js
Normal file
157
paperforge/plugin/src/runtime.js
Normal 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,
|
||||
};
|
||||
131
paperforge/plugin/tests/commands.test.mjs
Normal file
131
paperforge/plugin/tests/commands.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
117
paperforge/plugin/tests/errors.test.mjs
Normal file
117
paperforge/plugin/tests/errors.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
111
paperforge/plugin/tests/runtime.test.mjs
Normal file
111
paperforge/plugin/tests/runtime.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
9
paperforge/plugin/vitest.config.ts
Normal file
9
paperforge/plugin/vitest.config.ts
Normal 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
95
tests/e2e/conftest.py
Normal 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)
|
||||
61
tests/e2e/test_multi_domain_sync.py
Normal file
61
tests/e2e/test_multi_domain_sync.py
Normal 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
52
tests/e2e/test_ocr_e2e.py
Normal 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"
|
||||
50
tests/e2e/test_status_doctor_repair.py
Normal file
50
tests/e2e/test_status_doctor_repair.py
Normal 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}"
|
||||
)
|
||||
54
tests/e2e/test_sync_pipeline.py
Normal file
54
tests/e2e/test_sync_pipeline.py
Normal 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}"
|
||||
Loading…
Reference in a new issue