Files
WRNexusJS/packages/ui/test/design-system.test.ts
T

287 lines
12 KiB
TypeScript

import { test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { Window } from "happy-dom";
import {
CORE_COMPONENT_CONTRACTS,
CORE_DESIGN_SYSTEMS,
compileDesignSystem,
validateDesignSystem,
type DesignSystem,
} from "../../design-system/src/index.ts";
import { renderComponent, mountHtml } from "../../test/src/index.ts";
import { renderDocument, renderDocumentStream } from "../../ssr/src/index.ts";
import { resolveThemeConfig, renderThemeCss } from "../../styles/src/theme.ts";
import { designFixtureBody, PREVIEW_CSS } from "./design-system-fixture.ts";
const render = (name: keyof typeof CORE_COMPONENT_CONTRACTS, props: Record<string, unknown> = {}) =>
renderComponent(readFileSync(CORE_COMPONENT_CONTRACTS[name].source, "utf8"), props);
const selector = (name: string, part: string) =>
`[data-wrn-owner="${name}"][data-wrn-part="${part}"]`;
test("every advertised contract part and axis has an additive source hook", () => {
for (const [name, contract] of Object.entries(CORE_COMPONENT_CONTRACTS)) {
const source = readFileSync(contract.source, "utf8");
expect(source).toContain(`data-wrn-component="${name}"`);
for (const part of Object.keys(contract.parts))
expect(source).toContain(`data-wrn-part="${part}"`);
for (const axis of Object.keys(contract.axes))
expect(source).toContain(`data-wrn-axis-${axis}="{${axis}}"`);
}
});
test("application theme tokens and controlClass token overrides retain precedence over recipes", async () => {
const theme = resolveThemeConfig({
designSystem: { system: CORE_DESIGN_SYSTEMS.soft },
default: "light",
themes: { light: { "radius-sm": "5px" } },
});
const body =
(await render("Button", { label: "App token" })) +
(await render("Button", { label: "Local token", controlClass: "local-geometry" }));
const window = new Window();
try {
window.document.write(
renderDocument({
meta: {},
body,
extraHead: `<style>${renderThemeCss(theme)}.local-geometry{--wrn-radius-sm:11px}</style>`,
componentRecipeCss: theme.recipeCss,
}),
);
const buttons = window.document.querySelectorAll("button.wrn-btn");
expect(window.getComputedStyle(buttons[0]!).borderRadius).toBe("5px");
expect(window.getComputedStyle(buttons[1]!).borderRadius).toBe("11px");
expect(buttons[1]!.classList.contains("local-geometry")).toBe(true);
} finally {
await window.happyDOM.close();
}
});
test("the identical composed page consumes all three systems in light and dark", async () => {
const body = await designFixtureBody();
for (const [name, system] of Object.entries(CORE_DESIGN_SYSTEMS))
for (const mode of ["light", "dark"]) {
const theme = resolveThemeConfig({ designSystem: { system }, default: mode });
const html = renderDocument({
meta: {},
body,
htmlAttrs: ` data-theme="${mode}"`,
extraHead: `<style>${renderThemeCss(theme)}${PREVIEW_CSS}</style>`,
componentRecipeCss: theme.recipeCss,
styleNonce: "recipe-test",
});
const window = new Window();
try {
window.document.write(html);
const style = (component: string, part: string) =>
window.getComputedStyle(window.document.querySelector(selector(component, part))!);
expect(style("Button", "control").borderRadius).toBe(name === "soft" ? "16px" : "0px");
expect(style("Input", "control").borderRadius).toBe(name === "soft" ? "16px" : "0px");
expect(style("Card", "panel").borderRadius).toBe(name === "soft" ? "24px" : "0px");
expect(window.document.querySelector("input#display-name")).not.toBeNull();
expect(html.lastIndexOf("data-wrn-design-recipes")).toBeGreaterThan(
html.lastIndexOf('data-wrnexus-style-kind="component"'),
);
expect(html).toContain('data-wrn-design-recipes nonce="recipe-test"');
expect(style("Button", "control").backgroundImage).toBe("none");
} finally {
await window.happyDOM.close();
}
}
});
test("explicit pill, size, color and Card geometry props survive recipes", async () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
const body =
(await render("Button", { label: "Pill", pill: true, size: "sm", color: "danger" })) +
(await render("Card", { title: "Card", radius: "lg", shadow: "none" }));
const theme = resolveThemeConfig({ designSystem: { system } });
const window = new Window();
try {
window.document.write(
renderDocument({
meta: {},
body,
extraHead: `<style>${renderThemeCss(theme)}</style>`,
componentRecipeCss: theme.recipeCss,
}),
);
const button = window.document.querySelector(selector("Button", "control"))!;
expect(window.getComputedStyle(button).borderRadius).toBe("999px");
expect(button.getAttribute("data-wrn-axis-size")).toBe("sm");
expect(
window.getComputedStyle(window.document.querySelector(selector("Card", "panel"))!)
.borderRadius,
).toBe("16px");
expect(
window.getComputedStyle(window.document.querySelector(selector("Card", "panel"))!)
.boxShadow,
).toBe("none");
} finally {
await window.happyDOM.close();
}
}
});
test("typography and Container recipes follow the selected system without changing layout props", async () => {
for (const [name, system] of Object.entries(CORE_DESIGN_SYSTEMS)) {
const theme = resolveThemeConfig({ designSystem: { system }, default: "light" });
const body =
(await render("Container", { columns: 1, maxWidth: "lg" })) +
(await render("PageHeader", {
title: "A long heading that must retain its typography across layout widths",
}));
const window = new Window();
try {
window.document.write(
renderDocument({
meta: {},
body,
extraHead: `<style>${renderThemeCss(theme)}</style>`,
componentRecipeCss: theme.recipeCss,
}),
);
const container = window.document.querySelector(selector("Container", "root"))!;
expect(container.getAttribute("data-columns")).toBe("1");
expect(container.getAttribute("data-max-width")).toBe("lg");
expect(window.getComputedStyle(container).getPropertyValue("padding-inline")).toBe(
name === "soft" ? "1.5rem" : "1rem",
);
const heading = window.document.querySelector(selector("PageHeader", "title"))!;
expect(window.getComputedStyle(heading).fontFamily).toContain(
name === "soft" ? "Georgia" : name === "angular" ? "Times New Roman" : "IBM Plex Mono",
);
} finally {
await window.happyDOM.close();
}
}
});
test("matching compound rules override base and native disabled state rules", async () => {
const system: DesignSystem = {
schemaVersion: 1,
id: "rules",
name: "Rules",
version: "1",
tokens: {},
recipes: {
Button: {
contractVersion: 1,
base: { control: { borderWidth: "1px" } },
variants: [
{ when: { variant: "outline", size: "sm" }, parts: { control: { borderWidth: "3px" } } },
],
states: [{ state: "disabled", parts: { control: { opacity: 0.4, borderWidth: "5px" } } }],
},
},
};
const window = new Window();
try {
const body = await render("Button", {
label: "Unavailable",
variant: "outline",
size: "sm",
disabled: true,
});
window.document.write(
renderDocument({ meta: {}, body, componentRecipeCss: compileDesignSystem(system).recipeCss }),
);
const button = window.document.querySelector(selector("Button", "control"))!;
expect(window.getComputedStyle(button).borderWidth).toBe("5px");
expect(window.getComputedStyle(button).opacity).toBe("0.4");
expect(button.hasAttribute("disabled")).toBe(true);
} finally {
await window.happyDOM.close();
}
});
test("recipes do not leak parent variant conditions into nested instances", async () => {
const system: DesignSystem = {
schemaVersion: 1,
id: "nested",
name: "Nested",
version: "1",
tokens: {},
recipes: {
Button: {
contractVersion: 1,
variants: [{ when: { variant: "outline" }, parts: { control: { borderWidth: "5px" } } }],
},
},
};
const outer = await render("Button", { label: "Outer", variant: "outline" });
const inner = await render("Button", { label: "Inner", variant: "ghost" });
const window = new Window();
try {
window.document.write(
`<style>${compileDesignSystem(system).recipeCss}</style><div data-wrn-owner="Button" data-wrn-axis-variant="outline">${outer}${inner}</div>`,
);
const controls = window.document.querySelectorAll("button.wrn-btn");
expect(window.getComputedStyle(controls[0]!).borderWidth).toBe("5px");
expect(window.getComputedStyle(controls[1]!).borderWidth).not.toBe("5px");
} finally {
await window.happyDOM.close();
}
});
test("nested design islands isolate recipes and inline modal panels regardless of stylesheet order", async () => {
const outer = await render("Button", { label: "Outer" }),
inner = await render("Button", { label: "Inner" });
const modal = await render("Modal", { title: "Nested dialog", defaultOpen: true });
const soft = compileDesignSystem(CORE_DESIGN_SYSTEMS.soft, { scope: "outer", mode: "light" }).css;
const square = compileDesignSystem(CORE_DESIGN_SYSTEMS.square, {
scope: "inner",
mode: "light",
}).css;
for (const css of [soft + square, square + soft]) {
const window = new Window();
try {
window.document.write(
`<style>${css}</style><div data-wrn-design="outer" data-theme="light">${outer}<div data-wrn-design="inner" data-theme="light">${inner}${modal}</div></div>`,
);
const buttons = window.document.querySelectorAll("button.wrn-btn");
expect(window.getComputedStyle(buttons[0]!).borderRadius).toBe("16px");
expect(window.getComputedStyle(buttons[1]!).borderRadius).toBe("0px");
expect(
window.getComputedStyle(window.document.querySelector(selector("Modal", "panel"))!)
.borderRadius,
).toBe("0px");
} finally {
await window.happyDOM.close();
}
}
});
test("all systems retain hydrated switches and modal opening/dismissal", async () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
const theme = resolveThemeConfig({ designSystem: { system } });
const source =
(await render("Switch", { id: "switch", name: "switch", label: "Email updates" })) +
(await render("Modal", { title: "Review changes", triggerLabel: "Open dialog" }));
const dom = mountHtml(`<style>${renderThemeCss(theme)}${theme.recipeCss}</style>${source}`);
const toggle = dom.querySelector('input[role="switch"]') as HTMLInputElement;
toggle.click();
expect(toggle.checked).toBe(true);
const trigger = dom.querySelector(
'[data-wrn-owner="Modal"][data-wrn-part="trigger"]',
) as HTMLButtonElement;
trigger.click();
expect(trigger.getAttribute("aria-expanded")).toBe("true");
const close = dom.querySelector(
'[data-wrn-owner="Modal"][data-wrn-part="close"]',
) as HTMLButtonElement;
close.click();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
}
});
test("custom document shells and streamed documents preserve the nonce-protected late recipe block", async () => {
const css = compileDesignSystem(CORE_DESIGN_SYSTEMS.soft).recipeCss;
const body = await render("Input", { label: "Name" });
const html = renderDocument({
meta: {},
body,
componentRecipeCss: css,
styleNonce: "test-nonce",
documentTemplate: `<html><head></head><body>${body}</body></html>`,
});
expect(html.indexOf("data-wrn-design-recipes")).toBeGreaterThan(
html.indexOf("data-wrnexus-style-kind"),
);
const streamed = await new Response(
renderDocumentStream({ meta: {}, body, componentRecipeCss: css, styleNonce: "test-nonce" }),
).text();
expect(streamed).toContain('data-wrn-design-recipes nonce="test-nonce"');
expect(validateDesignSystem(CORE_DESIGN_SYSTEMS.soft)).toEqual([]);
});