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'
This commit is contained in:
Research Assistant 2026-05-10 11:03:31 +08:00
parent ca15b25f96
commit 25c3deba2d
7 changed files with 249 additions and 235 deletions

View file

@ -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;

View file

@ -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,
};

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -10,7 +10,7 @@ const {
resolvePythonExecutable,
getPluginVersion,
checkRuntimeVersion,
} = await import('../main.js');
} = await import('../src/testable.js');
describe('resolvePythonExecutable', () => {
/** Create injected fs + execFileSync mocks */

View file

@ -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: () => {},
}));

View file

@ -5,6 +5,5 @@ export default defineConfig({
environment: 'jsdom',
include: ['tests/**/*.test.mjs'],
globals: true,
setupFiles: ['./tests/setup.mjs'],
},
});