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