Files
frontend-helper/tests/theme.test.ts
T

104 lines
3.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { applyTheme, currentTheme, readStoredTheme, THEME_KEY, THEME_SCRIPT } from "@/lib/theme";
import { useTheme } from "@/lib/use-theme";
const memoryStorage = (initial: Record<string, string> = {}) => {
const data = new Map(Object.entries(initial));
return {
data,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
};
};
const throwingStorage = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
};
const fakeRoot = () => ({ dataset: {} as DOMStringMap, style: { colorScheme: "" } });
describe("readStoredTheme", () => {
it("is dark on a first visit", () => {
expect(readStoredTheme(memoryStorage())).toBe("dark");
expect(readStoredTheme(null)).toBe("dark");
});
it("returns a stored light or dark choice", () => {
expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "light" }))).toBe("light");
expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "dark" }))).toBe("dark");
});
it("ignores junk values and storage that throws", () => {
expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "neon" }))).toBe("dark");
expect(readStoredTheme(throwingStorage)).toBe("dark");
});
});
describe("applyTheme", () => {
it("sets the attribute and color-scheme and stores the choice", () => {
const root = fakeRoot();
const storage = memoryStorage();
applyTheme("light", root, storage);
expect(root.dataset.theme).toBe("light");
expect(root.style.colorScheme).toBe("light");
expect(storage.data.get(THEME_KEY)).toBe("light");
expect(currentTheme(root)).toBe("light");
});
it("still changes the theme when storage throws", () => {
const root = fakeRoot();
expect(() => applyTheme("light", root, throwingStorage)).not.toThrow();
expect(root.dataset.theme).toBe("light");
});
it("reads a missing attribute as dark", () => {
expect(currentTheme(fakeRoot())).toBe("dark");
});
});
describe("THEME_SCRIPT (pre-paint)", () => {
const run = (storage: unknown) => {
const documentElement = fakeRoot();
new Function("localStorage", "document", THEME_SCRIPT)(storage, { documentElement });
return documentElement;
};
it("applies a stored light theme", () => {
expect(run(memoryStorage({ [THEME_KEY]: "light" })).dataset.theme).toBe("light");
});
it("falls back to dark for nothing stored, junk, or throwing storage", () => {
expect(run(memoryStorage()).dataset.theme).toBe("dark");
expect(run(memoryStorage({ [THEME_KEY]: "x" })).dataset.theme).toBe("dark");
expect(run(throwingStorage).dataset.theme).toBe("dark");
});
});
describe("useTheme", () => {
it("renders dark on the server", () => {
let seen = "";
const Probe = () => {
seen = useTheme().theme;
return null;
};
renderToStaticMarkup(createElement(Probe));
expect(seen).toBe("dark");
});
});
describe("no raw colors outside the token definitions", () => {
const files = (dir: string): string[] =>
readdirSync(dir).flatMap((f) => {
const p = path.join(dir, f);
return statSync(p).isDirectory() ? files(p) : [p];
});
it("has no hex color in any source file except globals.css", () => {
const src = path.resolve(__dirname, "../src");
const offenders = files(src)
.filter((f) => /\.(tsx?|css)$/.test(f) && !f.endsWith(path.join("app", "globals.css")))
.filter((f) => /#[0-9a-fA-F]{3,8}\b|rgba?\(/.test(readFileSync(f, "utf8")));
expect(offenders).toEqual([]);
});
});