mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
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:
parent
00e3f12f75
commit
6a90d50887
6 changed files with 211 additions and 52 deletions
87
src/io/conversion/manager.test.ts
Normal file
87
src/io/conversion/manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
src/utils/escape-html.test.ts
Normal file
22
src/utils/escape-html.test.ts
Normal 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(
|
||||
"<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("");
|
||||
});
|
||||
});
|
||||
53
src/view/workbench/remote-draft-normalizer.ts
Normal file
53
src/view/workbench/remote-draft-normalizer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
src/view/workbench/remote-draft.test.ts
Normal file
33
src/view/workbench/remote-draft.test.ts
Normal 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("<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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
15
vitest.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue