test(highlightjs): take coverage to 100%

index.ts had no spec at all: ensureMimeTypes, highlight and loadTheme were entirely
untested, leaving the package at 62% lines and 10% branches. Adds index.spec.ts covering
mime registration and dedup, disabled and definition-less entries, both null paths of
highlight(), and the loadTheme style-element lifecycle including removal on "none".

Note the tests address highlight() by the CKEditor-normalised mime (application/json ->
application-json), because that is what ensureMimeTypes keys on and what callers pass.

themes.ts gains coverage for getThemeVariant plus a loop invoking all 84 theme loaders. That
asserts module shape only — Vitest resolves ?raw CSS to an empty string, so there is no text
to check; a mistyped path is caught earlier still, when Vite fails to transform themes.ts.

100% lines, branches and functions, enforced by coverage thresholds.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Elian Doran
2026-07-25 20:34:15 +03:00
co-authored by Claude Opus 5
parent c440ab687f
commit 4e54f3d92d
3 changed files with 133 additions and 1 deletions
+103
View File
@@ -0,0 +1,103 @@
import type { MimeType } from "@triliumnext/commons";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Theme } from "./themes.js";
/**
* `ensureMimeTypes`, `highlight` and `loadTheme` all read module-level state (the registered /
* unsupported mime sets and the injected `<style>` element), so every test gets a fresh copy of
* the module rather than inheriting whatever the previous one registered.
*/
async function freshModule() {
vi.resetModules();
return await import("./index.js");
}
const mime = (mimeType: string, enabled = true): MimeType => ({
title: mimeType,
mime: mimeType,
enabled
});
beforeEach(() => {
document.head.innerHTML = "";
vi.restoreAllMocks();
});
describe("ensureMimeTypes", () => {
// ensureMimeTypes keys everything by the CKEditor-normalised mime ("application/json" ->
// "application-json"), and highlight() looks up `options.language` verbatim — so callers pass
// the normalised form. The tests use it directly to keep that contract visible.
it("registers a supported mime type once and ignores disabled ones", async () => {
const { ensureMimeTypes, highlight } = await freshModule();
await ensureMimeTypes([mime("application/json"), mime("text/plain", false)]);
expect(highlight("{}", { language: "application-json" })).not.toBeNull();
// The disabled entry was skipped outright: neither registered nor remembered as
// unsupported, so asking for it warns and yields nothing.
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
expect(highlight("x", { language: "text-plain" })).toBeNull();
expect(warn).toHaveBeenCalledOnce();
// A second pass is a no-op for an already-registered mime type.
await ensureMimeTypes([mime("application/json")]);
expect(highlight("{}", { language: "application-json" })).not.toBeNull();
});
it("remembers a mime type with no highlight.js definition as unsupported", async () => {
const { ensureMimeTypes, highlight } = await freshModule();
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
// `application/mbox` is present in the table but maps to null (no highlighter).
await ensureMimeTypes([mime("application/mbox")]);
expect(highlight("x", { language: "application-mbox" })).toBeNull();
// Known-unsupported short-circuits before the "unable to find highlighting" warning.
expect(warn).not.toHaveBeenCalled();
});
});
describe("loadTheme", () => {
const theme = (css: string): Theme => ({
name: "Test (Dark)",
load: () => Promise.resolve({ default: css }) as ReturnType<Theme["load"]>
});
it("injects one style element and reuses it across theme switches", async () => {
const { loadTheme } = await freshModule();
await loadTheme(theme(".hljs { color: red; }"));
const injected = document.head.querySelectorAll("style");
expect(injected.length).toBe(1);
// The CSS is normalised on the way in, so CKEditor's code blocks pick the theme up.
expect(injected[0]?.textContent).toContain(".hljs, .ck-content pre.hljs {");
await loadTheme(theme(".hljs { color: blue; }"));
expect(document.head.querySelectorAll("style").length).toBe(1);
expect(document.head.querySelector("style")?.textContent).toContain("blue");
});
it("removes the style element for \"none\", and tolerates a repeat", async () => {
const { loadTheme } = await freshModule();
await loadTheme(theme(".hljs { color: red; }"));
expect(document.head.querySelectorAll("style").length).toBe(1);
await loadTheme("none");
expect(document.head.querySelectorAll("style").length).toBe(0);
// Nothing left to remove — must not throw.
await loadTheme("none");
expect(document.head.querySelectorAll("style").length).toBe(0);
});
it("does nothing when \"none\" is requested before any theme was loaded", async () => {
const { loadTheme } = await freshModule();
await loadTheme("none");
expect(document.head.querySelectorAll("style").length).toBe(0);
});
});
+24 -1
View File
@@ -1,4 +1,4 @@
import themeDefinitions from "./themes.js";
import themeDefinitions, { getThemeVariant } from "./themes.js";
import { describe, expect, it } from "vitest";
describe("Themes", () => {
@@ -7,4 +7,27 @@ describe("Themes", () => {
expect(id).not.toMatch(/\s/);
}
});
// Each entry lazily imports a stylesheet by path. Invoking every loader proves they all resolve
// and hand back the `{ default }` shape loadTheme expects. Note the CSS text itself cannot be
// asserted here: Vitest resolves `?raw` CSS to an empty string, so only the shape is checked.
// A mistyped path is caught even earlier — Vite fails to transform themes.ts at all.
it("every theme loader resolves to a stylesheet module", async () => {
const entries = Object.entries(themeDefinitions);
expect(entries.length).toBeGreaterThan(0);
const loaded = await Promise.all(
entries.map(async ([id, theme]) => [id, (await theme.load()).default] as const)
);
for (const [id, css] of loaded) {
expect(typeof css, id).toBe("string");
}
});
it("derives the variant from the display name", () => {
// Stringly-typed on purpose: the name carries "(Dark)"/"(Light)" and nothing else does.
expect(getThemeVariant({ name: "Atom One (Dark)", load: themeDefinitions["xt256"].load })).toBe("dark");
expect(getThemeVariant({ name: "Xcode (Light)", load: themeDefinitions["xcode"].load })).toBe("light");
});
});
+6
View File
@@ -36,6 +36,12 @@ export default defineConfig(() => ({
["junit", { outputFile: "./test-output/vitest/junit.xml", addFileAttribute: true }]
],
'coverage': {
'thresholds': {
'lines': 100,
'functions': 100,
'branches': 100,
'statements': 100
},
'reportsDirectory': './test-output/vitest/coverage',
'provider': 'v8' as const,
'include': ["src/**/*.{ts,tsx}"],