From 2cb223ddbbb146d7576303d989b352d880e71822 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Sat, 9 May 2026 00:05:19 +0800 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 24 ++++ paperforge/plugin/main.js | 152 ++++----------------- paperforge/plugin/package.json | 16 +++ paperforge/plugin/src/commands.js | 147 ++++++++++++++++++++ paperforge/plugin/src/errors.js | 97 +++++++++++++ paperforge/plugin/src/runtime.js | 157 ++++++++++++++++++++++ paperforge/plugin/tests/commands.test.mjs | 131 ++++++++++++++++++ paperforge/plugin/tests/errors.test.mjs | 117 ++++++++++++++++ paperforge/plugin/tests/runtime.test.mjs | 111 +++++++++++++++ paperforge/plugin/vitest.config.ts | 9 ++ tests/e2e/conftest.py | 95 +++++++++++++ tests/e2e/test_multi_domain_sync.py | 61 +++++++++ tests/e2e/test_ocr_e2e.py | 52 +++++++ tests/e2e/test_status_doctor_repair.py | 50 +++++++ tests/e2e/test_sync_pipeline.py | 54 ++++++++ 15 files changed, 1147 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 paperforge/plugin/package.json create mode 100644 paperforge/plugin/src/commands.js create mode 100644 paperforge/plugin/src/errors.js create mode 100644 paperforge/plugin/src/runtime.js create mode 100644 paperforge/plugin/tests/commands.test.mjs create mode 100644 paperforge/plugin/tests/errors.test.mjs create mode 100644 paperforge/plugin/tests/runtime.test.mjs create mode 100644 paperforge/plugin/vitest.config.ts create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_multi_domain_sync.py create mode 100644 tests/e2e/test_ocr_e2e.py create mode 100644 tests/e2e/test_status_doctor_repair.py create mode 100644 tests/e2e/test_sync_pipeline.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3b6c1bfa --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/paperforge/plugin/main.js b/paperforge/plugin/main.js index f786f72e..99ae34d6 100644 --- a/paperforge/plugin/main.js +++ b/paperforge/plugin/main.js @@ -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() { diff --git a/paperforge/plugin/package.json b/paperforge/plugin/package.json new file mode 100644 index 00000000..6d5b506e --- /dev/null +++ b/paperforge/plugin/package.json @@ -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" + } +} diff --git a/paperforge/plugin/src/commands.js b/paperforge/plugin/src/commands.js new file mode 100644 index 00000000..466b6f26 --- /dev/null +++ b/paperforge/plugin/src/commands.js @@ -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 ` 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, +}; diff --git a/paperforge/plugin/src/errors.js b/paperforge/plugin/src/errors.js new file mode 100644 index 00000000..d0570bd1 --- /dev/null +++ b/paperforge/plugin/src/errors.js @@ -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, +}; diff --git a/paperforge/plugin/src/runtime.js b/paperforge/plugin/src/runtime.js new file mode 100644 index 00000000..8e717d25 --- /dev/null +++ b/paperforge/plugin/src/runtime.js @@ -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, +}; diff --git a/paperforge/plugin/tests/commands.test.mjs b/paperforge/plugin/tests/commands.test.mjs new file mode 100644 index 00000000..60ce89ba --- /dev/null +++ b/paperforge/plugin/tests/commands.test.mjs @@ -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); + }); +}); diff --git a/paperforge/plugin/tests/errors.test.mjs b/paperforge/plugin/tests/errors.test.mjs new file mode 100644 index 00000000..282826a6 --- /dev/null +++ b/paperforge/plugin/tests/errors.test.mjs @@ -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'); + }); +}); diff --git a/paperforge/plugin/tests/runtime.test.mjs b/paperforge/plugin/tests/runtime.test.mjs new file mode 100644 index 00000000..0dc1da78 --- /dev/null +++ b/paperforge/plugin/tests/runtime.test.mjs @@ -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); + }); +}); diff --git a/paperforge/plugin/vitest.config.ts b/paperforge/plugin/vitest.config.ts new file mode 100644 index 00000000..e06019ff --- /dev/null +++ b/paperforge/plugin/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['tests/**/*.test.mjs'], + globals: true, + }, +}); diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..e75934e2 --- /dev/null +++ b/tests/e2e/conftest.py @@ -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) diff --git a/tests/e2e/test_multi_domain_sync.py b/tests/e2e/test_multi_domain_sync.py new file mode 100644 index 00000000..2b2a43ab --- /dev/null +++ b/tests/e2e/test_multi_domain_sync.py @@ -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'])}" + ) diff --git a/tests/e2e/test_ocr_e2e.py b/tests/e2e/test_ocr_e2e.py new file mode 100644 index 00000000..a2509704 --- /dev/null +++ b/tests/e2e/test_ocr_e2e.py @@ -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" diff --git a/tests/e2e/test_status_doctor_repair.py b/tests/e2e/test_status_doctor_repair.py new file mode 100644 index 00000000..a9928ea4 --- /dev/null +++ b/tests/e2e/test_status_doctor_repair.py @@ -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}" + ) diff --git a/tests/e2e/test_sync_pipeline.py b/tests/e2e/test_sync_pipeline.py new file mode 100644 index 00000000..6c574bd0 --- /dev/null +++ b/tests/e2e/test_sync_pipeline.py @@ -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}"