test: add Vitest, escape-html/conversion tests, and data-testid attributes

- Install vitest and @vitest/coverage-v8; add npm test script.
- Extract remote-draft normalizer to pure module for testability.
- Add tests for escapeHtml, remote draft sanitization, and ConversionManager
  timeout/deduplication.
- Add data-testid to toolbar buttons via setAction().
This commit is contained in:
flash555588 2026-06-15 16:32:47 +08:00
parent 00e3f12f75
commit 6a90d50887
6 changed files with 211 additions and 52 deletions

View file

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

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { escapeHtml } from "./escape-html";
describe("escapeHtml", () => {
it("escapes HTML special characters", () => {
expect(escapeHtml("<script>alert('xss')</script>")).toBe(
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
);
});
it("escapes double quotes", () => {
expect(escapeHtml('value="test"')).toBe("value=&quot;test&quot;");
});
it("leaves plain text unchanged", () => {
expect(escapeHtml("hello world")).toBe("hello world");
});
it("handles empty strings", () => {
expect(escapeHtml("")).toBe("");
});
});

View file

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

View file

@ -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: "<img src=x onerror=alert(1)>",
summary: "Summary with <script>bad()</script>",
sections: [{ heading: "<b>heading</b>", body: "<p>body</p>" }],
suggestedTags: ["<tag>"],
warnings: ["<alert>"],
model: "<model>",
});
expect(result).not.toBeNull();
expect(result?.title).toBe("&lt;img src=x onerror=alert(1)&gt;");
expect(result?.summary).toBe("Summary with &lt;script&gt;bad()&lt;/script&gt;");
expect(result?.sections?.[0].heading).toBe("&lt;b&gt;heading&lt;/b&gt;");
expect(result?.sections?.[0].body).toBe("&lt;p&gt;body&lt;/p&gt;");
expect(result?.suggestedTags?.[0]).toBe("&lt;tag&gt;");
expect(result?.warnings?.[0]).toBe("&lt;alert&gt;");
expect(result?.model).toBe("&lt;model&gt;");
});
it("caps overly long fields", () => {
const long = "a".repeat(10_000);
const result = normalizeRemoteDraftResult({ summary: long });
expect(result?.summary.length).toBeLessThanOrEqual(8001);
});
});

View file

@ -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<string, unknown>;
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<string, unknown>;
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<RemoteDraftResult | null> {
if (!decision.enabled || !decision.endpoint || !decision.request) {
return null;

15
vitest.config.ts Normal file
View file

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