494 lines
15 KiB
TypeScript
494 lines
15 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { compileWrnFile } from "@wrnexus/compiler";
|
|
import { parse as parseWrnSyntax } from "@wrnexus/syntax";
|
|
|
|
const root = join(import.meta.dir, "..");
|
|
const pagesDir = join(root, "app", "pages");
|
|
const detailPagesDir = join(pagesDir, "components");
|
|
const reference = JSON.parse(
|
|
readFileSync(
|
|
join(root, "node_modules", "@wrnexus", "ui", "component-reference.json"),
|
|
"utf8",
|
|
),
|
|
) as {
|
|
count: number;
|
|
components: Array<{
|
|
name: string;
|
|
mount: string;
|
|
category: string;
|
|
props: Array<{ name: string }>;
|
|
slots: string[];
|
|
events: string[];
|
|
}>;
|
|
};
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(root, "showcase-manifest.json"), "utf8"),
|
|
) as {
|
|
removed: string[];
|
|
blocks: Array<{ name: string; slug: string; path: string }>;
|
|
componentCount: number;
|
|
categoryCount: number;
|
|
totalDemoCount: number;
|
|
components: Array<{
|
|
name: string;
|
|
mount: string;
|
|
slug: string;
|
|
category: string;
|
|
demoCount: number;
|
|
propCount: number;
|
|
slots: string[];
|
|
events: string[];
|
|
profiled: boolean;
|
|
}>;
|
|
};
|
|
|
|
const detailSource = (slug: string) =>
|
|
readFileSync(join(detailPagesDir, `${slug}.wrn`), "utf8");
|
|
|
|
const interactiveOverlayNames = [
|
|
"ContextMenu",
|
|
"Drawer",
|
|
"Dropdown",
|
|
"Modal",
|
|
"Popover",
|
|
"Tooltip",
|
|
];
|
|
|
|
function mountCount(source: string, mount: string): number {
|
|
const tag = new RegExp(`<${mount}(?:\\s|>|/)`, "g");
|
|
const legacy = new RegExp(`<div\\s+data-component="${mount}"`, "g");
|
|
return (source.match(tag)?.length ?? 0) + (source.match(legacy)?.length ?? 0);
|
|
}
|
|
|
|
test("showcase configures local Lucide icon generation", () => {
|
|
const packageJson = JSON.parse(
|
|
readFileSync(join(root, "package.json"), "utf8"),
|
|
) as {
|
|
devDependencies: Record<string, string>;
|
|
};
|
|
const styles = readFileSync(
|
|
join(root, "app", "styles", "global.css"),
|
|
"utf8",
|
|
);
|
|
|
|
expect(packageJson.devDependencies["@iconify/tailwind4"]).toBeTruthy();
|
|
expect(packageJson.devDependencies["@iconify-json/lucide"]).toBeTruthy();
|
|
expect(styles).toContain('@plugin "@iconify/tailwind4";');
|
|
expect(styles).toContain(
|
|
".playground-preview-source > .wrn-action:not(.w-full)",
|
|
);
|
|
expect(styles).toContain('[data-ui-component="MetricGrid"]');
|
|
expect(styles).toContain("width: 100%;");
|
|
});
|
|
|
|
test("showcase manifest matches the generated UI reference", () => {
|
|
expect(
|
|
manifest.componentCount + manifest.blocks.length + manifest.removed.length,
|
|
).toBe(reference.count);
|
|
expect(manifest.removed).toEqual([
|
|
"AuthForm",
|
|
"AuthSplitLayout",
|
|
"PortalDashboard",
|
|
"PublicPageShell",
|
|
]);
|
|
expect(manifest.blocks.some((block) => block.name === "Hero")).toBe(true);
|
|
expect(manifest.blocks.some((block) => block.name === "CTASection")).toBe(
|
|
true,
|
|
);
|
|
expect(manifest.components).toHaveLength(manifest.componentCount);
|
|
expect(manifest.totalDemoCount).toBeGreaterThanOrEqual(
|
|
manifest.componentCount * 2,
|
|
);
|
|
expect(
|
|
new Set(manifest.components.map((component) => component.slug)).size,
|
|
).toBe(manifest.componentCount);
|
|
|
|
for (const component of reference.components.filter(
|
|
(entry) =>
|
|
!manifest.removed.includes(entry.name) &&
|
|
!manifest.blocks.some((block) => block.name === entry.name),
|
|
)) {
|
|
const documented = manifest.components.find(
|
|
(entry) => entry.name === component.name,
|
|
);
|
|
expect(documented).toBeTruthy();
|
|
expect(documented?.mount).toBe(component.mount);
|
|
expect(documented?.events).toEqual(component.events);
|
|
expect(documented?.slots).toEqual(component.slots);
|
|
}
|
|
});
|
|
|
|
test("showcase uses a searchable documentation shell", () => {
|
|
const layout = readFileSync(
|
|
join(root, "app", "layouts", "showcase.wrn"),
|
|
"utf8",
|
|
);
|
|
const styles = readFileSync(
|
|
join(root, "app", "styles", "global.css"),
|
|
"utf8",
|
|
);
|
|
const runtime = readFileSync(join(root, "public", "playground.js"), "utf8");
|
|
|
|
expect(layout).toContain("data-docs-directory");
|
|
expect(layout).toContain("data-docs-search");
|
|
expect(layout.match(/data-docs-component-link/g)).toHaveLength(
|
|
manifest.componentCount,
|
|
);
|
|
expect(styles).toContain("grid-template-columns: 15rem minmax(0, 1fr)");
|
|
expect(runtime).toContain("data-docs-menu-toggle");
|
|
expect(runtime).toContain('event.key.toLowerCase() === "k"');
|
|
});
|
|
|
|
test("showcase SSR document layout maps persisted design cookies to html attributes", () => {
|
|
const source = readFileSync(
|
|
join(root, "app", "layouts", "document.wrn"),
|
|
"utf8",
|
|
);
|
|
expect(source).toContain("<html");
|
|
expect(source).toContain("<head></head>");
|
|
expect(source).toContain('<div id="app"><slot /></div>');
|
|
for (const setting of ["style", "palette", "mode", "font", "scale"]) {
|
|
expect(source).toContain(`data-ui-${setting}`);
|
|
expect(source).toContain(`cookies['wrn-ui-${setting}']`);
|
|
}
|
|
});
|
|
|
|
test("category pages preview every UI component exactly once", () => {
|
|
const categoryPages = readdirSync(pagesDir)
|
|
.filter((file) =>
|
|
manifest.components.some(
|
|
(component) => `${component.category}.wrn` === file,
|
|
),
|
|
)
|
|
.map((file) => readFileSync(join(pagesDir, file), "utf8"))
|
|
.join("\n");
|
|
|
|
const invalidPreviews = reference.components
|
|
.filter((entry) =>
|
|
manifest.components.some((component) => component.name === entry.name),
|
|
)
|
|
.flatMap((component) => {
|
|
const count = mountCount(categoryPages, component.mount);
|
|
return count === 1
|
|
? []
|
|
: [
|
|
{
|
|
name: component.name,
|
|
mount: component.mount,
|
|
category: component.category,
|
|
count,
|
|
},
|
|
];
|
|
});
|
|
|
|
expect(invalidPreviews).toEqual([]);
|
|
});
|
|
|
|
test("every component has a detail page, playground, and manifest-driven demos", () => {
|
|
const files = readdirSync(detailPagesDir).filter((entry) =>
|
|
entry.endsWith(".wrn"),
|
|
);
|
|
expect(files).toHaveLength(manifest.componentCount);
|
|
|
|
for (const component of manifest.components) {
|
|
const source = detailSource(component.slug);
|
|
expect(source).toContain("data-playground-preview");
|
|
expect(source).toContain("data-playground-form");
|
|
expect(source).toContain("data-playground-code");
|
|
expect(source).toContain('href="#playground"');
|
|
expect(source.match(/class="demo-workbench"/g)).toHaveLength(
|
|
component.demoCount,
|
|
);
|
|
expect(mountCount(source, component.mount)).toBeGreaterThanOrEqual(
|
|
component.demoCount + 1,
|
|
);
|
|
}
|
|
}, 20_000);
|
|
|
|
test("playgrounds preserve typed values, safe DOM replacement, themes, and event output", () => {
|
|
const generator = readFileSync(
|
|
join(root, "scripts", "generate-showcase.mjs"),
|
|
"utf8",
|
|
);
|
|
const runtime = readFileSync(join(root, "public", "playground.js"), "utf8");
|
|
|
|
expect(generator).toContain("JSON.parse(");
|
|
expect(generator).toContain("Number(");
|
|
expect(generator).toContain("data-playground-json");
|
|
expect(generator).toContain("prop.name}='{${playgroundStateName(prop)}}'");
|
|
expect(generator).toContain('data-slot="${escapeAttribute(slot)}"');
|
|
const generatorWithoutDataSlots = generator.replaceAll(
|
|
'data-slot="${escapeAttribute(slot)}"',
|
|
"",
|
|
);
|
|
expect(generatorWithoutDataSlots).not.toContain(
|
|
'slot="${escapeAttribute(slot)}"',
|
|
);
|
|
|
|
expect(runtime).toContain("fetch(url");
|
|
expect(runtime).toContain("navigator.clipboard.writeText");
|
|
expect(runtime).toContain("preview.replaceChildren");
|
|
expect(runtime).not.toContain("preview.innerHTML");
|
|
expect(runtime).toContain("__wrnexusHydrateScopes");
|
|
expect(runtime).toContain("wrnTheme?.bind?.(document)");
|
|
expect(runtime).toContain("bindPlaygroundEvents");
|
|
expect(runtime).toContain('attributes.push(`${name}="${field.checked}"`)');
|
|
});
|
|
|
|
test("playground enum controls use public profiles instead of inferred implementation comparisons", () => {
|
|
const generator = readFileSync(
|
|
join(root, "scripts", "generate-showcase.mjs"),
|
|
"utf8",
|
|
);
|
|
expect(generator).toContain(
|
|
"profileFor(component.name)?.options?.[prop.name]",
|
|
);
|
|
expect(generator).not.toContain("source.matchAll(pattern)");
|
|
|
|
const pageHeader = manifest.components.find(
|
|
(component) => component.name === "PageHeader",
|
|
);
|
|
if (pageHeader) {
|
|
const source = detailSource(pageHeader.slug);
|
|
expect(source).toContain('name="pg_icon" type="text"');
|
|
expect(source).toMatch(
|
|
/<option value="default"(?: selected)?>default<\/option>/,
|
|
);
|
|
expect(source).toMatch(
|
|
/<option value="solid"(?: selected)?>solid<\/option>/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("every declared public event is documented and visible in the playground", () => {
|
|
for (const component of manifest.components.filter(
|
|
(entry) => entry.events.length > 0,
|
|
)) {
|
|
const source = detailSource(component.slug);
|
|
expect(source).toContain('id="events"');
|
|
expect(source).toContain('href="#events"');
|
|
expect(source).toContain("data-playground-event-log");
|
|
for (const event of component.events) {
|
|
expect(source).toContain(`<code>@${event}</code>`);
|
|
expect(source).toContain(`@${event}='console.log(payload)'`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("new and recently repaired components have production use-case profiles", () => {
|
|
const expectedProfiles = [
|
|
"PublicPageShell",
|
|
"AnnouncementBar",
|
|
"Breadcrumb",
|
|
"PageHeader",
|
|
"Hero",
|
|
"HeroActions",
|
|
"SplitHero",
|
|
"Section",
|
|
"SectionHeader",
|
|
"MarketingSectionHeader",
|
|
"TextLink",
|
|
"FeatureGrid",
|
|
"FeatureCard",
|
|
"FeatureIconCard",
|
|
"MetricGrid",
|
|
"MetricCard",
|
|
"StatsBar",
|
|
"CTASection",
|
|
"BackToTop",
|
|
"Footer",
|
|
"ContextMenu",
|
|
"Drawer",
|
|
"Dropdown",
|
|
"Modal",
|
|
"Popover",
|
|
"Tooltip",
|
|
];
|
|
|
|
for (const name of expectedProfiles) {
|
|
const component = manifest.components.find((entry) => entry.name === name);
|
|
if (!component) continue;
|
|
expect(component.profiled).toBe(true);
|
|
const source = detailSource(component.slug);
|
|
expect(
|
|
source.match(/class="demo-workbench"/g)?.length ?? 0,
|
|
).toBeGreaterThanOrEqual(3);
|
|
}
|
|
});
|
|
|
|
test("grid, hero, shell, and overlay detail pages use responsive showcase stages", () => {
|
|
const styles = readFileSync(
|
|
join(root, "app", "styles", "global.css"),
|
|
"utf8",
|
|
);
|
|
for (const name of [
|
|
"MetricGrid",
|
|
"FeatureGrid",
|
|
"StatsBar",
|
|
"Hero",
|
|
"SplitHero",
|
|
"AnnouncementBar",
|
|
"CTASection",
|
|
"Footer",
|
|
]) {
|
|
expect(styles).toContain(`[data-ui-component="${name}"]`);
|
|
}
|
|
|
|
const metricGrid = manifest.components.find(
|
|
(component) => component.name === "MetricGrid",
|
|
);
|
|
if (metricGrid) {
|
|
const source = detailSource(metricGrid.slug);
|
|
expect(source).toContain('columns="3"');
|
|
const metricGridReference = reference.components.find(
|
|
(component) => component.name === "MetricGrid",
|
|
);
|
|
if (metricGridReference?.props.some((prop) => prop.name === "maxWidth")) {
|
|
expect(source).toContain('maxWidth="full"');
|
|
}
|
|
expect(source).toContain("items='[");
|
|
}
|
|
});
|
|
|
|
test("overlay previews start closed and expose usable click, hover, or context triggers", () => {
|
|
const generator = readFileSync(
|
|
join(root, "scripts", "generate-showcase.mjs"),
|
|
"utf8",
|
|
);
|
|
const profiles = readFileSync(
|
|
join(root, "scripts", "showcase-profiles.mjs"),
|
|
"utf8",
|
|
);
|
|
const styles = readFileSync(
|
|
join(root, "app", "styles", "global.css"),
|
|
"utf8",
|
|
);
|
|
|
|
expect(generator).toContain("interactiveOverlayComponents");
|
|
expect(generator).toContain("overlayVisibilityProps.has(name)");
|
|
expect(generator).toContain(
|
|
'data-interactive-preview="${isInteractiveOverlay(component) ? "true" : "false"}"',
|
|
);
|
|
expect(styles).toContain('.catalog-card[data-interactive-preview="true"]');
|
|
expect(styles).toContain('[data-ui-component="Drawer"]');
|
|
expect(styles).toContain('[data-ui-component="Modal"]');
|
|
|
|
const overlaysPage = readFileSync(join(pagesDir, "overlays.wrn"), "utf8");
|
|
for (const name of interactiveOverlayNames) {
|
|
const component = manifest.components.find((entry) => entry.name === name);
|
|
if (!component) continue;
|
|
|
|
const detail = detailSource(component.slug);
|
|
expect(detail).toContain("demo-interaction-hint");
|
|
expect(detail).not.toMatch(
|
|
new RegExp(`<${component.mount}[^>]*(?:open|defaultOpen)="true"`),
|
|
);
|
|
expect(overlaysPage).not.toMatch(
|
|
new RegExp(`<${component.mount}[^>]*(?:open|defaultOpen)="true"`),
|
|
);
|
|
}
|
|
|
|
expect(profiles).toContain('trigger: "contextmenu"');
|
|
expect(profiles).toContain('trigger: "click"');
|
|
expect(profiles).toContain('trigger: "hover"');
|
|
expect(profiles).toContain('trigger: "both"');
|
|
expect(profiles).toContain('triggerLabel: "Open drawer"');
|
|
expect(profiles).toContain('triggerLabel: "Open modal"');
|
|
expect(profiles).toContain('triggerLabel: "View details"');
|
|
});
|
|
|
|
test("advanced select demos use validation schemas and a real remote API", () => {
|
|
const component = manifest.components.find(
|
|
(entry) => entry.name === "AdvancedSelect",
|
|
);
|
|
if (!component) return;
|
|
const page = detailSource(component.slug);
|
|
expect(page).toContain('data-schema="advanced-select-validation"');
|
|
expect(page).toContain('data-schema="advanced-select-dynamic-validation"');
|
|
expect(readFileSync(join(root, "app", "api", "teams.ts"), "utf8")).toContain(
|
|
"export const GET",
|
|
);
|
|
});
|
|
|
|
test("specialized form component examples remain complete", () => {
|
|
const checks: Array<[string, string[]]> = [
|
|
[
|
|
"PinInput",
|
|
["Different lengths", 'length="3"', 'length="5"', 'length="7"'],
|
|
],
|
|
[
|
|
"TogglePassword",
|
|
[
|
|
"Checkbox-controlled toggle",
|
|
"Synchronized password fields",
|
|
'data-schema="toggle-password-validation"',
|
|
],
|
|
],
|
|
[
|
|
"StrongPassword",
|
|
[
|
|
"Live strength meter",
|
|
"Requirements in a popover",
|
|
'specialCharactersSet="@#_-."',
|
|
],
|
|
],
|
|
];
|
|
for (const [name, needles] of checks) {
|
|
const component = manifest.components.find((entry) => entry.name === name);
|
|
if (!component) continue;
|
|
const source = detailSource(component.slug);
|
|
for (const needle of needles) expect(source).toContain(needle);
|
|
}
|
|
});
|
|
|
|
test("complex component props are complete readable multiline values", () => {
|
|
for (const name of [
|
|
"AdvancedSelect",
|
|
"ComboBox",
|
|
"Select",
|
|
"TogglePassword",
|
|
]) {
|
|
const component = manifest.components.find((entry) => entry.name === name);
|
|
if (!component) continue;
|
|
const source = detailSource(component.slug);
|
|
expect(source).not.toMatch(
|
|
/\b(?:options|groups|items|fields)='\[\.\.\.\]'/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("generated playground boolean states avoid multiline-sensitive ternaries", () => {
|
|
const generator = readFileSync(
|
|
join(root, "scripts", "generate-showcase.mjs"),
|
|
"utf8",
|
|
);
|
|
expect(generator).toContain('?? "${initial}") === "true"');
|
|
expect(generator).not.toContain("=== null ? ${initial} :");
|
|
|
|
for (const component of manifest.components) {
|
|
const source = detailSource(component.slug);
|
|
expect(source).not.toMatch(
|
|
/state\s+playground_[A-Za-z0-9_$]+\s*=.*===\s*null\s*\?/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("every generated showcase page compiles and is accepted by the route syntax parser", () => {
|
|
const paths = [
|
|
...readdirSync(pagesDir)
|
|
.filter((entry) => entry.endsWith(".wrn"))
|
|
.map((entry) => join(pagesDir, entry)),
|
|
...readdirSync(detailPagesDir)
|
|
.filter((entry) => entry.endsWith(".wrn"))
|
|
.map((entry) => join(detailPagesDir, entry)),
|
|
];
|
|
|
|
for (const path of paths) {
|
|
const source = readFileSync(path, "utf8");
|
|
expect(() => parseWrnSyntax(source)).not.toThrow();
|
|
expect(() => compileWrnFile(source, path)).not.toThrow();
|
|
}
|
|
});
|