diff --git a/src/io/conversion/manager.test.ts b/src/io/conversion/manager.test.ts
new file mode 100644
index 0000000..54a96cf
--- /dev/null
+++ b/src/io/conversion/manager.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from "vitest";
+import { ConversionManager, ConversionTimeoutError } from "./manager";
+import type { ConversionRequest, ConversionResult, ModelConverter } from "./types";
+
+function createMockConverter(
+ delayMs: number,
+ result: ConversionResult,
+): ModelConverter {
+ return {
+ id: "mock",
+ sourceExts: [".test"],
+ targetExt: "glb",
+ getCacheKey: async () => "mock-cache-key",
+ convert: async () => {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ return result;
+ },
+ };
+}
+
+const successResult: ConversionResult = {
+ outputPath: "/out/mock.glb",
+ outputExt: "glb",
+ fromCache: false,
+ warnings: [],
+};
+
+describe("ConversionManager", () => {
+ it("converts a supported file", async () => {
+ const manager = new ConversionManager();
+ manager.registerConverter(createMockConverter(0, successResult));
+ const result = await manager.convert({
+ sourcePath: "/in/model.test",
+ sourceExt: ".test",
+ targetExt: "glb",
+ });
+ expect(result.outputPath).toBe("/out/mock.glb");
+ });
+
+ it("throws when no converter is registered", async () => {
+ const manager = new ConversionManager();
+ await expect(
+ manager.convert({
+ sourcePath: "/in/model.unknown",
+ sourceExt: ".unknown",
+ targetExt: "glb",
+ }),
+ ).rejects.toThrow("No converter registered");
+ });
+
+ it("times out a hanging conversion", async () => {
+ const manager = new ConversionManager();
+ manager.registerConverter(createMockConverter(10_000, successResult));
+ await expect(
+ manager.convert({
+ sourcePath: "/in/model.test",
+ sourceExt: ".test",
+ targetExt: "glb",
+ timeoutMs: 50,
+ }),
+ ).rejects.toThrow(ConversionTimeoutError);
+ });
+
+ it("deduplicates concurrent conversions for the same source", async () => {
+ const manager = new ConversionManager();
+ let calls = 0;
+ manager.registerConverter({
+ id: "mock",
+ sourceExts: [".test"],
+ targetExt: "glb",
+ getCacheKey: async () => "key",
+ convert: async () => {
+ calls++;
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ return successResult;
+ },
+ });
+ const req: ConversionRequest = {
+ sourcePath: "/in/model.test",
+ sourceExt: ".test",
+ targetExt: "glb",
+ };
+ const [a, b] = await Promise.all([manager.convert(req), manager.convert(req)]);
+ expect(a).toBe(b);
+ expect(calls).toBe(1);
+ });
+});
diff --git a/src/utils/escape-html.test.ts b/src/utils/escape-html.test.ts
new file mode 100644
index 0000000..f22b3ae
--- /dev/null
+++ b/src/utils/escape-html.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect } from "vitest";
+import { escapeHtml } from "./escape-html";
+
+describe("escapeHtml", () => {
+ it("escapes HTML special characters", () => {
+ expect(escapeHtml("")).toBe(
+ "<script>alert('xss')</script>",
+ );
+ });
+
+ it("escapes double quotes", () => {
+ expect(escapeHtml('value="test"')).toBe("value="test"");
+ });
+
+ it("leaves plain text unchanged", () => {
+ expect(escapeHtml("hello world")).toBe("hello world");
+ });
+
+ it("handles empty strings", () => {
+ expect(escapeHtml("")).toBe("");
+ });
+});
diff --git a/src/view/workbench/remote-draft-normalizer.ts b/src/view/workbench/remote-draft-normalizer.ts
new file mode 100644
index 0000000..e9f6081
--- /dev/null
+++ b/src/view/workbench/remote-draft-normalizer.ts
@@ -0,0 +1,53 @@
+import { escapeHtml } from "../../utils/escape-html";
+import type { RemoteDraftResult } from "../../domain/models";
+
+const MAX_REMOTE_DRAFT_FIELD_LENGTH = 8000;
+
+function sanitizeRemoteDraftString(value: string): string {
+ const trimmed = value.trim();
+ if (trimmed.length > MAX_REMOTE_DRAFT_FIELD_LENGTH) {
+ return escapeHtml(trimmed.slice(0, MAX_REMOTE_DRAFT_FIELD_LENGTH)) + "…";
+ }
+ return escapeHtml(trimmed);
+}
+
+export function normalizeRemoteDraftResult(value: unknown): RemoteDraftResult | null {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+ const record = value as Record;
+ const summaryRaw = typeof record.summary === "string" ? record.summary.trim() : "";
+ const summary = sanitizeRemoteDraftString(summaryRaw);
+ if (!summary) {
+ return null;
+ }
+ const sections = Array.isArray(record.sections)
+ ? record.sections.flatMap((section) => {
+ if (!section || typeof section !== "object") return [];
+ const sectionRecord = section as Record;
+ const headingRaw = typeof sectionRecord.heading === "string" ? sectionRecord.heading.trim() : "";
+ const bodyRaw = typeof sectionRecord.body === "string" ? sectionRecord.body.trim() : "";
+ const heading = sanitizeRemoteDraftString(headingRaw);
+ const body = sanitizeRemoteDraftString(bodyRaw);
+ return heading && body ? [{ heading, body }] : [];
+ })
+ : undefined;
+ const suggestedTags = Array.isArray(record.suggestedTags)
+ ? record.suggestedTags
+ .filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
+ .map((tag) => sanitizeRemoteDraftString(tag))
+ : undefined;
+ const warnings = Array.isArray(record.warnings)
+ ? record.warnings
+ .filter((warning): warning is string => typeof warning === "string" && warning.trim().length > 0)
+ .map((warning) => sanitizeRemoteDraftString(warning))
+ : undefined;
+ return {
+ title: typeof record.title === "string" ? sanitizeRemoteDraftString(record.title) : undefined,
+ summary,
+ sections,
+ suggestedTags,
+ warnings,
+ model: typeof record.model === "string" ? sanitizeRemoteDraftString(record.model) : undefined,
+ };
+}
diff --git a/src/view/workbench/remote-draft.test.ts b/src/view/workbench/remote-draft.test.ts
new file mode 100644
index 0000000..efbdc5a
--- /dev/null
+++ b/src/view/workbench/remote-draft.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from "vitest";
+import { normalizeRemoteDraftResult } from "./remote-draft-normalizer";
+
+describe("normalizeRemoteDraftResult", () => {
+ it("returns null for missing summary", () => {
+ expect(normalizeRemoteDraftResult({ title: "test" })).toBeNull();
+ });
+
+ it("sanitizes HTML in remote draft fields", () => {
+ const result = normalizeRemoteDraftResult({
+ title: "
",
+ summary: "Summary with ",
+ sections: [{ heading: "heading", body: "body
" }],
+ suggestedTags: [""],
+ warnings: [""],
+ model: "",
+ });
+ expect(result).not.toBeNull();
+ expect(result?.title).toBe("<img src=x onerror=alert(1)>");
+ expect(result?.summary).toBe("Summary with <script>bad()</script>");
+ expect(result?.sections?.[0].heading).toBe("<b>heading</b>");
+ expect(result?.sections?.[0].body).toBe("<p>body</p>");
+ expect(result?.suggestedTags?.[0]).toBe("<tag>");
+ expect(result?.warnings?.[0]).toBe("<alert>");
+ expect(result?.model).toBe("<model>");
+ });
+
+ it("caps overly long fields", () => {
+ const long = "a".repeat(10_000);
+ const result = normalizeRemoteDraftResult({ summary: long });
+ expect(result?.summary.length).toBeLessThanOrEqual(8001);
+ });
+});
diff --git a/src/view/workbench/remote-draft.ts b/src/view/workbench/remote-draft.ts
index 0fe8755..56a2dac 100644
--- a/src/view/workbench/remote-draft.ts
+++ b/src/view/workbench/remote-draft.ts
@@ -1,6 +1,6 @@
import { requestUrl } from "obsidian";
-import { escapeHtml } from "../../utils/escape-html";
+import { normalizeRemoteDraftResult } from "./remote-draft-normalizer";
import type {
AnalysisDraftingInput,
PluginSettings,
@@ -123,57 +123,6 @@ export function createRemoteDraftDecision(
};
}
-const MAX_REMOTE_DRAFT_FIELD_LENGTH = 8000;
-
-function sanitizeRemoteDraftString(value: string): string {
- const trimmed = value.trim();
- if (trimmed.length > MAX_REMOTE_DRAFT_FIELD_LENGTH) {
- return escapeHtml(trimmed.slice(0, MAX_REMOTE_DRAFT_FIELD_LENGTH)) + "…";
- }
- return escapeHtml(trimmed);
-}
-
-function normalizeRemoteDraftResult(value: unknown): RemoteDraftResult | null {
- if (!value || typeof value !== "object") {
- return null;
- }
- const record = value as Record;
- const summaryRaw = typeof record.summary === "string" ? record.summary.trim() : "";
- const summary = sanitizeRemoteDraftString(summaryRaw);
- if (!summary) {
- return null;
- }
- const sections = Array.isArray(record.sections)
- ? record.sections.flatMap((section) => {
- if (!section || typeof section !== "object") return [];
- const sectionRecord = section as Record;
- const headingRaw = typeof sectionRecord.heading === "string" ? sectionRecord.heading.trim() : "";
- const bodyRaw = typeof sectionRecord.body === "string" ? sectionRecord.body.trim() : "";
- const heading = sanitizeRemoteDraftString(headingRaw);
- const body = sanitizeRemoteDraftString(bodyRaw);
- return heading && body ? [{ heading, body }] : [];
- })
- : undefined;
- const suggestedTags = Array.isArray(record.suggestedTags)
- ? record.suggestedTags
- .filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
- .map((tag) => sanitizeRemoteDraftString(tag))
- : undefined;
- const warnings = Array.isArray(record.warnings)
- ? record.warnings
- .filter((warning): warning is string => typeof warning === "string" && warning.trim().length > 0)
- .map((warning) => sanitizeRemoteDraftString(warning))
- : undefined;
- return {
- title: typeof record.title === "string" ? sanitizeRemoteDraftString(record.title) : undefined,
- summary,
- sections,
- suggestedTags,
- warnings,
- model: typeof record.model === "string" ? sanitizeRemoteDraftString(record.model) : undefined,
- };
-}
-
export async function requestRemoteDraft(decision: RemoteDraftDecision): Promise {
if (!decision.enabled || !decision.endpoint || !decision.request) {
return null;
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..c1d4aee
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ globals: false,
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "html"],
+ include: ["src/**/*.ts"],
+ exclude: ["src/**/*.test.ts", "src/**/*.d.ts"],
+ },
+ },
+});