From 06c2ce5f9d243b6352b06070ebe34018e2c0b683 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Sun, 10 May 2026 11:03:31 +0800 Subject: [PATCH] fix(plugin): extract testable functions to src/testable.js, fix vitest imports - Move resolvePythonExecutable, getPluginVersion, classifyError, ACTIONS, etc. to src/testable.js - main.js now requires from ./src/testable.js instead of inline duplication - Tests import from ../src/testable.js (no obsidian dependency) - Remove vitest obsidian mock setup (no longer needed) - Fixes L3 Plugin Tests that failed because main.js requires 'obsidian' --- paperforge/plugin/main.js | 232 ++------------------- paperforge/plugin/src/testable.js | 234 ++++++++++++++++++++++ paperforge/plugin/tests/commands.test.mjs | 2 +- paperforge/plugin/tests/errors.test.mjs | 2 +- paperforge/plugin/tests/runtime.test.mjs | 2 +- paperforge/plugin/tests/setup.mjs | 11 - paperforge/plugin/vitest.config.ts | 1 - 7 files changed, 249 insertions(+), 235 deletions(-) create mode 100644 paperforge/plugin/src/testable.js delete mode 100644 paperforge/plugin/tests/setup.mjs diff --git a/paperforge/plugin/main.js b/paperforge/plugin/main.js index b0b92044..b443e80a 100644 --- a/paperforge/plugin/main.js +++ b/paperforge/plugin/main.js @@ -4,132 +4,19 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -// ── Runtime helpers (inlined from src/runtime.js) ── +const { + resolvePythonExecutable, + getPluginVersion, + checkRuntimeVersion, + classifyError, + buildRuntimeInstallCommand, + parseRuntimeStatus, + ACTIONS, + buildCommandArgs, + runSubprocess, +} = require('./src/testable'); -function resolvePythonExecutable(vaultPath, settings, _fs, _execFileSync) { - const f = _fs || fs; - const execSync = _execFileSync || require("node:child_process").execFileSync; - 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: [] }; - } - } - - 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 {} - } - - 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 {} - } - - return { path: "python", source: "auto-detected", extraArgs: [] }; -} - -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; - } -} - -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 }); - } - }); - }); -} - -// ── Error helpers (inlined from src/errors.js) ── - -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 }; -} - -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 }; -} - -function parseRuntimeStatus(err, stdout, stderr) { - if (!err && stdout) { - return { status: "ok", version: stdout.trim() }; - } - 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 }; -} // ── Cross-platform Python and BBT detection (macOS/Linux) ── @@ -277,93 +164,7 @@ function scanBbtUnderProfiles(profilesDir) { return false; } -// ── Action definitions and subprocess dispatch (inlined from src/commands.js) ── -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", - }, - { - 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 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; -} - -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) => { - resolve({ stdout: stdoutChunks.join(""), stderr: stderrChunks.join(""), - exitCode: code, elapsed: Date.now() - startTime }); - }); - - child.on("error", (err) => { - resolve({ stdout: stdoutChunks.join(""), - stderr: stderrChunks.join("") + "\n" + err.message, - exitCode: -1, elapsed: Date.now() - startTime }); - }); - }); -} const VIEW_TYPE_PAPERFORGE = 'paperforge-status'; const PF_ICON_ID = 'paperforge'; @@ -3237,13 +3038,4 @@ module.exports = class PaperForgePlugin extends Plugin { } }; -// ── Named exports for Vitest (functions inlined from src/ after refactor) ── -module.exports.resolvePythonExecutable = resolvePythonExecutable; -module.exports.getPluginVersion = getPluginVersion; -module.exports.checkRuntimeVersion = checkRuntimeVersion; -module.exports.classifyError = classifyError; -module.exports.buildRuntimeInstallCommand = buildRuntimeInstallCommand; -module.exports.parseRuntimeStatus = parseRuntimeStatus; -module.exports.ACTIONS = ACTIONS; -module.exports.buildCommandArgs = buildCommandArgs; -module.exports.runSubprocess = runSubprocess; + diff --git a/paperforge/plugin/src/testable.js b/paperforge/plugin/src/testable.js new file mode 100644 index 00000000..8cbef65d --- /dev/null +++ b/paperforge/plugin/src/testable.js @@ -0,0 +1,234 @@ +/** + * Testable functions extracted from main.js for Vitest. + * No obsidian dependency — safe to import in Node test environment. + */ +const fs = require('fs'); +const path = require('path'); +const { execFile, spawn } = require('node:child_process'); + +// ── Runtime helpers ── + +function resolvePythonExecutable(vaultPath, settings, _fs, _execFileSync) { + const f = _fs || fs; + const execSync = _execFileSync || require("node:child_process").execFileSync; + + 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: [] }; + } + } + + 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 {} + } + + 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 {} + } + + return { path: "python", source: "auto-detected", extraArgs: [] }; +} + +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; + } +} + +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 }); + } + }); + }); +} + +// ── Error helpers ── + +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 }; +} + +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 }; +} + +function parseRuntimeStatus(err, stdout, stderr) { + if (!err && stdout) { + return { status: "ok", version: stdout.trim() }; + } + 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 }; +} + +// ── Action definitions ── + +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", + }, + { + 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 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; +} + +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) => { + resolve({ stdout: stdoutChunks.join(""), stderr: stderrChunks.join(""), + exitCode: code, elapsed: Date.now() - startTime }); + }); + + child.on("error", (err) => { + resolve({ stdout: stdoutChunks.join(""), + stderr: stderrChunks.join("") + "\n" + err.message, + exitCode: -1, elapsed: Date.now() - startTime }); + }); + }); +} + +module.exports = { + resolvePythonExecutable, + getPluginVersion, + checkRuntimeVersion, + classifyError, + buildRuntimeInstallCommand, + parseRuntimeStatus, + ACTIONS, + buildCommandArgs, + runSubprocess, +}; diff --git a/paperforge/plugin/tests/commands.test.mjs b/paperforge/plugin/tests/commands.test.mjs index a0ff536b..c9d3b680 100644 --- a/paperforge/plugin/tests/commands.test.mjs +++ b/paperforge/plugin/tests/commands.test.mjs @@ -6,7 +6,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { ACTIONS, buildCommandArgs, runSubprocess } = await import('../main.js'); +const { ACTIONS, buildCommandArgs, runSubprocess } = await import('../src/testable.js'); describe('ACTIONS', () => { it('has exactly 6 entries', () => { diff --git a/paperforge/plugin/tests/errors.test.mjs b/paperforge/plugin/tests/errors.test.mjs index 79717aba..5c257dc5 100644 --- a/paperforge/plugin/tests/errors.test.mjs +++ b/paperforge/plugin/tests/errors.test.mjs @@ -7,7 +7,7 @@ const { classifyError, buildRuntimeInstallCommand, parseRuntimeStatus, -} = await import('../main.js'); +} = await import('../src/testable.js'); describe('classifyError', () => { it('classifies ENOENT as python_missing', () => { diff --git a/paperforge/plugin/tests/runtime.test.mjs b/paperforge/plugin/tests/runtime.test.mjs index e986792e..93e0da08 100644 --- a/paperforge/plugin/tests/runtime.test.mjs +++ b/paperforge/plugin/tests/runtime.test.mjs @@ -10,7 +10,7 @@ const { resolvePythonExecutable, getPluginVersion, checkRuntimeVersion, -} = await import('../main.js'); +} = await import('../src/testable.js'); describe('resolvePythonExecutable', () => { /** Create injected fs + execFileSync mocks */ diff --git a/paperforge/plugin/tests/setup.mjs b/paperforge/plugin/tests/setup.mjs deleted file mode 100644 index 696364bd..00000000 --- a/paperforge/plugin/tests/setup.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { vi } from 'vitest'; - -vi.mock('obsidian', () => ({ - Plugin: class {}, - Notice: class { setMessage() {} }, - ItemView: class {}, - Modal: class { onOpen() {} onClose() {} close() {} }, - Setting: class { setName() { return this; } setDesc() { return this; } addText() { return this; } addButton() { return this; } addDropdown() { return this; } addToggle() { return this; } }, - PluginSettingTab: class { display() {} }, - addIcon: () => {}, -})); diff --git a/paperforge/plugin/vitest.config.ts b/paperforge/plugin/vitest.config.ts index 2011c145..e06019ff 100644 --- a/paperforge/plugin/vitest.config.ts +++ b/paperforge/plugin/vitest.config.ts @@ -5,6 +5,5 @@ export default defineConfig({ environment: 'jsdom', include: ['tests/**/*.test.mjs'], globals: true, - setupFiles: ['./tests/setup.mjs'], }, });