104 lines
3.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|