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.
This commit is contained in:
2026-05-31 16:33:30 +02:00
parent 49278723b1
commit ff21ec921a
8 changed files with 419 additions and 1 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"build": "node esbuild.config.mjs", "build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch" "dev": "node esbuild.config.mjs --watch",
"test": "node tests/run-tests.mjs"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
+136
View File
@@ -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> = {}): 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;
}
});
+93
View File
@@ -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<any> {
return {};
}
async saveData(): Promise<void> {}
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(/^\.\//, "");
}
+22
View File
@@ -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"));
});
+26
View File
@@ -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"),
});
});
+60
View File
@@ -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 });
}
+34
View File
@@ -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());
});
+46
View File
@@ -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<string> {
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);
});