From ff21ec921af3965f32124c89ad474888e5ec13be Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sun, 31 May 2026 16:33:30 +0200 Subject: [PATCH] Add test script and initial test suite The test runner bundles TypeScript tests with esbuild and executes them using Node's built-in test runner. Tests cover document downloader behavior, path utilities, rmapi bridge configuration, style refinement, and ZIP file handling. Mock implementations for Obsidian plugin interfaces enable isolated unit testing. --- package.json | 3 +- tests/downloader.test.ts | 136 ++++++++++++++++++++++++++++++++++++ tests/obsidian-mock.ts | 93 ++++++++++++++++++++++++ tests/paths.test.ts | 22 ++++++ tests/rmapi-bridge.test.ts | 26 +++++++ tests/run-tests.mjs | 60 ++++++++++++++++ tests/style-refiner.test.ts | 34 +++++++++ tests/zip.test.ts | 46 ++++++++++++ 8 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 tests/downloader.test.ts create mode 100644 tests/obsidian-mock.ts create mode 100644 tests/paths.test.ts create mode 100644 tests/rmapi-bridge.test.ts create mode 100644 tests/run-tests.mjs create mode 100644 tests/style-refiner.test.ts create mode 100644 tests/zip.test.ts diff --git a/package.json b/package.json index 7d9c2c9..afd5347 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "main.js", "scripts": { "build": "node esbuild.config.mjs", - "dev": "node esbuild.config.mjs --watch" + "dev": "node esbuild.config.mjs --watch", + "test": "node tests/run-tests.mjs" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/tests/downloader.test.ts b/tests/downloader.test.ts new file mode 100644 index 0000000..1178609 --- /dev/null +++ b/tests/downloader.test.ts @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { DocumentDownloader } from "../src/sync/downloader"; +import { RmapiNode } from "../src/types"; + +function createDocument(overrides: Partial = {}): RmapiNode { + return { + id: "doc-1", + name: "Note?1", + type: "DocumentType", + version: 7, + modifiedClient: "2026-05-31T10:00:00Z", + currentPage: 0, + parent: "", + tags: [], + starred: false, + ...overrides, + }; +} + +async function createPluginFixture(options: { synced?: any } = {}) { + const basePath = await mkdtemp(join(tmpdir(), "remarkable-plugin-test-")); + const calls: any = { + mkdir: [], + downloadFile: [], + downloadAnnotatedPdf: [], + processDocument: [], + trackDocument: [], + }; + + const plugin: any = { + settings: { + downloadPath: "remarkable", + convertToPdf: true, + enableHandwritingMd: true, + }, + app: { + vault: { + adapter: { + getBasePath: () => basePath, + mkdir: async (path: string) => { + calls.mkdir.push(path); + }, + exists: async () => true, + }, + }, + }, + tracker: { + getSyncedDocument: async () => options.synced ?? null, + trackDocument: async (...args: any[]) => { + calls.trackDocument.push(args); + }, + }, + rmapi: { + list: async () => [], + downloadFile: async (...args: any[]) => { + calls.downloadFile.push(args); + }, + downloadAnnotatedPdf: async (...args: any[]) => { + calls.downloadAnnotatedPdf.push(args); + }, + }, + ocrPipeline: { + processDocument: async (...args: any[]) => { + calls.processDocument.push(args); + return args[1]; + }, + }, + }; + + return { basePath, calls, plugin }; +} + +test("downloadDocument writes nested remote documents to sanitized vault paths", async () => { + const { basePath, calls, plugin } = await createPluginFixture(); + const downloader = new DocumentDownloader(plugin); + const doc = createDocument(); + + await downloader.downloadDocument({ + node: doc, + remotePath: "/Folder A/Sub:Folder/Note?1", + }); + + assert.deepEqual(calls.mkdir, ["remarkable", "remarkable/Folder A", "remarkable/Folder A/Sub_Folder"]); + assert.deepEqual(calls.downloadFile, [ + ["/Folder A/Sub:Folder/Note?1", join(basePath, "remarkable", "Folder A", "Sub_Folder", "Note_1.rm")], + ]); + assert.deepEqual(calls.downloadAnnotatedPdf, [ + ["/Folder A/Sub:Folder/Note?1", join(basePath, "remarkable", "Folder A", "Sub_Folder", "Note_1.pdf")], + ]); + assert.deepEqual(calls.processDocument, [ + ["remarkable/Folder A/Sub_Folder/Note_1.rm", "remarkable/Folder A/Sub_Folder/Note_1.md"], + ]); + assert.equal(calls.trackDocument.length, 2); + assert.deepEqual(calls.trackDocument[1], [doc, "remarkable/Folder A/Sub_Folder/Note_1.rm", true, true]); +}); + +test("downloadDocument skips documents with matching remote version and modified timestamp", async () => { + const doc = createDocument(); + const { calls, plugin } = await createPluginFixture({ + synced: { + id: doc.id, + remoteVersion: doc.version, + remoteModified: doc.modifiedClient, + }, + }); + const downloader = new DocumentDownloader(plugin); + + await downloader.downloadDocument({ + node: doc, + remotePath: "/Folder A/Note?1", + }); + + assert.deepEqual(calls.downloadFile, []); + assert.deepEqual(calls.trackDocument, []); +}); + +test("syncAll propagates download failures after showing a failure notice", async () => { + const { plugin } = await createPluginFixture(); + const downloader = new DocumentDownloader(plugin); + const originalError = console.error; + plugin.rmapi.list = async () => [createDocument()]; + plugin.rmapi.downloadFile = async () => { + throw new Error("rmapi failed"); + }; + + console.error = () => {}; + try { + await assert.rejects(() => downloader.syncAll(), /rmapi failed/); + } finally { + console.error = originalError; + } +}); diff --git a/tests/obsidian-mock.ts b/tests/obsidian-mock.ts new file mode 100644 index 0000000..33b22d5 --- /dev/null +++ b/tests/obsidian-mock.ts @@ -0,0 +1,93 @@ +export class Notice { + static messages: Array<{ message: string; timeout?: number }> = []; + + constructor(message: string, timeout?: number) { + Notice.messages.push({ message, timeout }); + } +} + +export class Plugin { + app: any; + + constructor(app?: any) { + this.app = app; + } + + async loadData(): Promise { + return {}; + } + + async saveData(): Promise {} + + addRibbonIcon(): void {} + addCommand(): void {} + addSettingTab(): void {} + addStatusBarItem(): { setText: (text: string) => void } { + return { setText: () => {} }; + } + registerInterval(): void {} +} + +export class PluginSettingTab { + app: any; + plugin: any; + containerEl = { + empty: () => {}, + createEl: () => {}, + }; + + constructor(app: any, plugin: any) { + this.app = app; + this.plugin = plugin; + } +} + +export class Setting { + constructor(_containerEl: any) {} + + setName(): this { + return this; + } + + setDesc(): this { + return this; + } + + addText(callback: (text: any) => void): this { + callback(createTextControl()); + return this; + } + + addToggle(callback: (toggle: any) => void): this { + callback(createControl()); + return this; + } + + addDropdown(callback: (dropdown: any) => void): this { + callback({ + ...createControl(), + addOption: () => createControl(), + }); + return this; + } +} + +function createControl(): any { + const control: any = { + inputEl: {}, + setValue: () => control, + onChange: () => control, + addOption: () => control, + }; + return control; +} + +function createTextControl(): any { + const control = createControl(); + control.inputEl = {}; + return control; +} + +export function normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\.\//, ""); +} diff --git a/tests/paths.test.ts b/tests/paths.test.ts new file mode 100644 index 0000000..7a8a78f --- /dev/null +++ b/tests/paths.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "path"; +import { vaultPathToAbsolute, vaultRelativePath } from "../src/utils/paths"; + +test("vaultRelativePath normalizes vault paths and drops traversal segments", () => { + assert.equal(vaultRelativePath("remarkable/", "/Folder", ".", "..", "Note.rm"), "remarkable/Folder/Note.rm"); +}); + +test("vaultPathToAbsolute resolves vault-relative paths under the vault root", () => { + const plugin = { + app: { + vault: { + adapter: { + getBasePath: () => "/tmp/vault", + }, + }, + }, + }; + + assert.equal(vaultPathToAbsolute(plugin as any, "remarkable/Folder/Note.rm"), join("/tmp/vault", "remarkable", "Folder", "Note.rm")); +}); diff --git a/tests/rmapi-bridge.test.ts b/tests/rmapi-bridge.test.ts new file mode 100644 index 0000000..cffce16 --- /dev/null +++ b/tests/rmapi-bridge.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "path"; +import { RmapiBridge } from "../src/rmapi/bridge"; + +test("RmapiBridge stores rmapi config under the vault .obsidian folder", () => { + const plugin = { + settings: { + remarkableHost: "https://10.11.99.1", + }, + app: { + vault: { + adapter: { + getBasePath: () => "/tmp/vault", + }, + }, + }, + }; + + const bridge: any = new RmapiBridge(plugin as any); + + assert.deepEqual(bridge.getEnv(), { + RMAPI_HOST: "https://10.11.99.1", + RMAPI_CONFIG: join("/tmp/vault", ".obsidian", "rmapi"), + }); +}); diff --git a/tests/run-tests.mjs b/tests/run-tests.mjs new file mode 100644 index 0000000..cfe80bc --- /dev/null +++ b/tests/run-tests.mjs @@ -0,0 +1,60 @@ +import esbuild from "esbuild"; +import { mkdtemp, readdir, rm } from "fs/promises"; +import { spawnSync } from "child_process"; +import { dirname, join, relative } from "path"; +import { tmpdir } from "os"; +import { fileURLToPath } from "url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const testsDir = join(root, "tests"); +const outdir = await mkdtemp(join(tmpdir(), "obsidian-remarkable-tests-")); +const obsidianMockPath = join(testsDir, "obsidian-mock.ts"); + +const entries = (await readdir(testsDir)) + .filter((file) => file.endsWith(".test.ts")) + .map((file) => join(testsDir, file)); + +if (entries.length === 0) { + throw new Error("No test files found"); +} + +const obsidianAliasPlugin = { + name: "obsidian-alias", + setup(build) { + build.onResolve({ filter: /^obsidian$/ }, () => ({ path: obsidianMockPath })); + }, +}; + +await esbuild.build({ + entryPoints: entries, + bundle: true, + platform: "node", + format: "esm", + outdir, + outExtension: { ".js": ".mjs" }, + plugins: [obsidianAliasPlugin], + external: ["child_process", "fs/promises", "os", "path", "node:*"], + sourcemap: "inline", +}); + +const testFiles = (await readdir(outdir)) + .filter((file) => file.endsWith(".test.mjs")) + .map((file) => join(outdir, file)); + +try { + const result = spawnSync(process.execPath, ["--test", ...testFiles], { + cwd: root, + stdio: "inherit", + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const relativeFiles = testFiles.map((file) => relative(root, file)).join(", "); + throw new Error(`Tests failed: ${relativeFiles}`); + } +} finally { + await rm(outdir, { recursive: true, force: true }); +} diff --git a/tests/style-refiner.test.ts b/tests/style-refiner.test.ts new file mode 100644 index 0000000..9a27923 --- /dev/null +++ b/tests/style-refiner.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { StyleRefiner } from "../src/ocr/style-refiner"; + +function createRefiner(response: string): any { + const refiner: any = new StyleRefiner({ + settings: { + ollamaHost: "http://localhost:11435", + styleModel: "qwen3:32b", + }, + } as any); + refiner.queryOllama = async () => response; + return refiner; +} + +test("refineMarkdown rejects empty Ollama output", async () => { + const refiner = createRefiner(""); + + await assert.rejects(() => refiner.refineMarkdown("Some source Markdown"), /empty Markdown/); +}); + +test("mergeAndRefine rejects unexpectedly short output for large inputs", async () => { + const refiner = createRefiner("too short"); + const largeInput = "handwritten note ".repeat(80); + + await assert.rejects(() => refiner.mergeAndRefine(largeInput, ""), /unexpectedly short/); +}); + +test("mergeAndRefine returns acceptable Ollama output", async () => { + const output = "# Heading\n\n" + "content ".repeat(120); + const refiner = createRefiner(output); + + assert.equal(await refiner.mergeAndRefine("content ".repeat(80), ""), output.trim()); +}); diff --git a/tests/zip.test.ts b/tests/zip.test.ts new file mode 100644 index 0000000..f1a5c84 --- /dev/null +++ b/tests/zip.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { getPageFiles, isNotebook, listRmContents } from "../src/utils/zip"; + +async function createExtractedDir(files: string[]): Promise { + const dir = await mkdtemp(join(tmpdir(), "remarkable-zip-test-")); + + for (const file of files) { + const filePath = join(dir, ...file.split("/")); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, ""); + } + + return dir; +} + +test("listRmContents recursively lists files without shelling out to find", async () => { + const dir = await createExtractedDir(["content.json", "pages/2.rm", "pages/nested/1.rm"]); + + const contents = (await listRmContents(dir)).map((path) => path.replace(`${dir}/`, "")).sort(); + + assert.deepEqual(contents, ["content.json", "pages/2.rm", "pages/nested/1.rm"]); +}); + +test("getPageFiles returns sorted nested page files", async () => { + const dir = await createExtractedDir(["pages/2.rm", "pages/1.rm", "pages/ignored.zip"]); + + const pages = (await getPageFiles(dir)).map((path) => path.replace(`${dir}/`, "")); + + assert.deepEqual(pages, ["pages/1.rm", "pages/2.rm"]); +}); + +test("isNotebook treats annotated PDFs as non-notebooks", async () => { + const dir = await createExtractedDir(["pages/1.rm", "content.pdf"]); + + assert.equal(await isNotebook(dir), false); +}); + +test("isNotebook accepts extracted notebooks with rm pages and no PDF", async () => { + const dir = await createExtractedDir(["pages/1.rm", "content.json"]); + + assert.equal(await isNotebook(dir), true); +});