893 lines
35 KiB
TypeScript
893 lines
35 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { compileWireFile } from "../../compiler/src/index.ts";
|
|
import { mountHtml, renderComponent } from "../../test/src/index.ts";
|
|
import { uiComponentNames, uiComponentsDir, uiComponentPath, uiCss } from "../src/index.ts";
|
|
|
|
test("bundled UI assets are discoverable and readable", () => {
|
|
expect(uiComponentNames()).toContain("Button");
|
|
expect(uiComponentNames()).toContain("Accordion");
|
|
expect(uiComponentNames()).toContain("DataTable");
|
|
expect(uiComponentNames()).toContain("WysiwygEditor");
|
|
expect(readFileSync(uiComponentPath("Button"), "utf8")).toContain("component Button");
|
|
expect(uiCss()).toContain("--wire-");
|
|
});
|
|
|
|
test("generated component reference documents every bundled component and its props", () => {
|
|
const reference = JSON.parse(
|
|
readFileSync(join(uiComponentsDir(), "..", "component-reference.json"), "utf8"),
|
|
) as { count: number; components: Array<{ name: string; props: Array<{ name: string }> }> };
|
|
const catalog = JSON.parse(
|
|
readFileSync(join(uiComponentsDir(), "..", "component-catalog.json"), "utf8"),
|
|
) as { components: Array<{ name: string }> };
|
|
expect(reference.count).toBe(uiComponentNames().length);
|
|
expect(reference.count).toBe(85);
|
|
expect(reference.components.map((component) => component.name)).toEqual(
|
|
expect.arrayContaining(uiComponentNames()),
|
|
);
|
|
expect(catalog.components.map((component) => component.name).sort()).toEqual(
|
|
uiComponentNames().sort(),
|
|
);
|
|
expect(reference.components.find((component) => component.name === "Button")?.props).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: "label" }),
|
|
expect.objectContaining({ name: "as" }),
|
|
]),
|
|
);
|
|
expect(
|
|
reference.components
|
|
.find((component) => component.name === "PinInput")
|
|
?.props.find((prop) => prop.name === "length"),
|
|
).toEqual(expect.objectContaining({ default: "4" }));
|
|
});
|
|
|
|
test("bundled components do not duplicate another component implementation", () => {
|
|
const implementations = new Map<string, string>();
|
|
|
|
for (const name of uiComponentNames()) {
|
|
const source = readFileSync(uiComponentPath(name), "utf8")
|
|
.replace(/^component\s+[A-Za-z0-9_]+/, "component __NAME__")
|
|
.replace(/\r\n/g, "\n")
|
|
.trim();
|
|
expect(implementations.get(source)).toBeUndefined();
|
|
implementations.set(source, name);
|
|
}
|
|
});
|
|
|
|
test("removed duplicate aliases have valid canonical migration targets", () => {
|
|
const migration = JSON.parse(
|
|
readFileSync(join(uiComponentsDir(), "..", "component-migrations.json"), "utf8"),
|
|
) as { removedCount: number; replacements: Record<string, string> };
|
|
const names = new Set(uiComponentNames());
|
|
|
|
expect(Object.keys(migration.replacements)).toHaveLength(migration.removedCount);
|
|
expect(migration.removedCount).toBe(0);
|
|
for (const [removed, replacement] of Object.entries(migration.replacements)) {
|
|
expect(names.has(removed)).toBe(false);
|
|
expect(names.has(replacement)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("screenshot-directed canonical components are bundled", () => {
|
|
const required = [
|
|
"Container",
|
|
"Columns",
|
|
"Grid",
|
|
"Typography",
|
|
"Button",
|
|
"Accordion",
|
|
"Alert",
|
|
"Avatar",
|
|
"Card",
|
|
"Carousel",
|
|
"DatePicker",
|
|
"Progress",
|
|
"Spinner",
|
|
"Timeline",
|
|
"Navbar",
|
|
"Tabs",
|
|
"Sidebar",
|
|
"Breadcrumb",
|
|
"Pagination",
|
|
"Stepper",
|
|
"Input",
|
|
"Textarea",
|
|
"Checkbox",
|
|
"Radio",
|
|
"Switch",
|
|
"Select",
|
|
"ComboBox",
|
|
"TimePicker",
|
|
"Modal",
|
|
"Drawer",
|
|
"Popover",
|
|
"Tooltip",
|
|
"Table",
|
|
"DataTable",
|
|
"Chart",
|
|
"FileUpload",
|
|
"Map",
|
|
"WysiwygEditor",
|
|
];
|
|
|
|
expect(uiComponentNames()).toEqual(expect.arrayContaining(required));
|
|
});
|
|
|
|
test("component-system CSS includes responsive, theme-token, focus, and reduced-motion rules", () => {
|
|
const css = uiCss();
|
|
expect(css).toContain("@media (max-width: 768px)");
|
|
expect(css).toContain("@media (max-width: 480px)");
|
|
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
|
|
expect(css).toContain(":focus-visible");
|
|
expect(css).toContain("var(--wire-color-surface)");
|
|
expect(css).toContain("min-height: 44px");
|
|
expect(css).toContain("--wire-motion-base");
|
|
expect(css).toContain("--wire-ease-emphasized");
|
|
expect(css).toContain("@keyframes wire-component-enter");
|
|
expect(css).toContain("@keyframes wire-dialog-enter");
|
|
expect(css).toContain("@media (hover: hover) and (pointer: fine)");
|
|
expect(css).toContain("--color-violet-600: var(--wire-color-primary)");
|
|
expect(css).toContain("--color-blue-600: var(--wire-color-info)");
|
|
expect(css).toContain("--color-red-600: var(--wire-color-danger)");
|
|
expect(css).toMatch(/:root\s*\{[^}]*--color-violet-600: var\(--wire-color-primary\)/s);
|
|
expect(css).toContain(".wire-bg-primary");
|
|
expect(css).toContain(".wire-text-muted");
|
|
expect(css).toContain(".wire-visually-hidden");
|
|
expect(css).toContain(".wire-next--field");
|
|
expect(css).toContain(".wire-next--table");
|
|
expect(css).toContain('[data-show="false"]');
|
|
expect(css).toContain("display: none !important");
|
|
});
|
|
|
|
test("component boundaries have no default margins and expose the canonical class prop", () => {
|
|
for (const name of uiComponentNames()) {
|
|
const source = readFileSync(uiComponentPath(name), "utf8");
|
|
const root = source.match(/view\s*\{\s*<[A-Za-z][^>]*>/)?.[0] ?? "";
|
|
const classes = root.match(/\bclass="([^"]*)"/)?.[1]?.split(/\s+/) ?? [];
|
|
expect(classes.filter((token) => /^-?m[trblxy]?-/.test(token))).toEqual([]);
|
|
expect(source).toMatch(/^\s*class\s*(?::[^=\r\n]+)?=/m);
|
|
expect(source).not.toMatch(/^\s*className\s*(?::[^=\r\n]+)?=/m);
|
|
expect(root).toContain("{class}");
|
|
}
|
|
});
|
|
|
|
test("every component exposes universal color and size configuration", () => {
|
|
for (const name of uiComponentNames()) {
|
|
const source = readFileSync(uiComponentPath(name), "utf8");
|
|
expect(source).toMatch(/^\s+color\s*=/m);
|
|
expect(source).toMatch(/^\s+size\s*=/m);
|
|
}
|
|
});
|
|
|
|
// test("component markup keeps user-facing content and data behind props", () => {
|
|
// for (const name of uiComponentNames()) {
|
|
// const source = readFileSync(uiComponentPath(name), "utf8");
|
|
// const view = source.slice(source.indexOf("view {"));
|
|
// const literalText = [...view.matchAll(/>([^<>{}]*[A-Za-z][^<>{}]*)</g)]
|
|
// .map((match) => match[1].trim())
|
|
// .filter(Boolean);
|
|
// const literalAccessibleText = [
|
|
// ...view.matchAll(/\b(?:aria-label|title|placeholder|alt)="([^"{}]*[A-Za-z][^"{}]*)"/g),
|
|
// ];
|
|
// const literalDataAttributes = [
|
|
// ...view.matchAll(/\b(?:href|src|action|value)="([^"{}]+)"/g),
|
|
// ].filter((match) => !/^(?:true|false|on)$/.test(match[1]));
|
|
|
|
// expect(literalText).toEqual([]);
|
|
// expect(literalAccessibleText).toEqual([]);
|
|
// expect(literalDataAttributes).toEqual([]);
|
|
// }
|
|
// });
|
|
|
|
test("layout boundaries do not add default padding", () => {
|
|
for (const name of ["Container", "Columns", "Grid", "LayoutSplitter", "Typography"]) {
|
|
const source = readFileSync(uiComponentPath(name), "utf8");
|
|
const root = source.match(/view\s*\{\s*<[A-Za-z][^>]*>/)?.[0] ?? "";
|
|
const classes = root.match(/\bclass="([^"]*)"/)?.[1]?.split(/\s+/) ?? [];
|
|
expect(classes.filter((token) => /^p[trblxy]?-/.test(token))).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("every bundled UI component compiles", () => {
|
|
for (const name of uiComponentNames()) {
|
|
const path = uiComponentPath(name);
|
|
expect(() => compileWireFile(readFileSync(path, "utf8"), path)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("icon buttons expose their accessible label as a hover and focus tooltip", () => {
|
|
const button = readFileSync(uiComponentPath("Button"), "utf8");
|
|
expect(button).toContain('class="wire-btn__tooltip"');
|
|
expect(button).toContain("{ariaLabel || label}");
|
|
expect(uiCss()).toContain(".wire-btn:hover > .wire-btn__tooltip");
|
|
expect(uiCss()).toContain(".wire-btn:focus-visible > .wire-btn__tooltip");
|
|
});
|
|
|
|
test("button links navigate by default and only download when the native attribute is passed", () => {
|
|
const button = readFileSync(uiComponentPath("Button"), "utf8");
|
|
expect(button).not.toMatch(/^\s+download\s*=/m);
|
|
expect(button).not.toContain('download="{download}"');
|
|
expect(button).toContain("{...attrs}");
|
|
});
|
|
|
|
test("every bundled UI component forwards undeclared native attributes", () => {
|
|
for (const name of uiComponentNames()) {
|
|
const path = uiComponentPath(name);
|
|
const output = compileWireFile(readFileSync(path, "utf8"), path);
|
|
expect(output).toContain("${__wireSpreadAttrs(__attrs)}");
|
|
}
|
|
});
|
|
|
|
test("advanced select opens, filters, and selects without reactive handler errors", async () => {
|
|
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "team",
|
|
options: [
|
|
{ value: "design", label: "Design" },
|
|
{ value: "engineering", label: "Engineering" },
|
|
{ value: "growth", label: "Growth" },
|
|
],
|
|
});
|
|
const dom = mountHtml(html);
|
|
const trigger = dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement;
|
|
expect(trigger).not.toBeNull();
|
|
trigger.click();
|
|
expect(
|
|
dom.querySelector(".wire-next--advanced-select")?.classList.contains("wire-next--open"),
|
|
).toBe(true);
|
|
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("true");
|
|
expect(dom.querySelector('[role="listbox"]')).not.toBeNull();
|
|
const search = dom.querySelector(".wire-next__select-search input") as HTMLInputElement;
|
|
search.value = "engine";
|
|
search.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
const options = dom.querySelectorAll(".wire-next__select-option") as HTMLButtonElement[];
|
|
expect(options.filter((option) => option.style.display !== "none")).toHaveLength(1);
|
|
options.find((option) => option.style.display !== "none")?.click();
|
|
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe("Engineering");
|
|
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
|
(dom.querySelector(".wire-next__clear-select") as HTMLButtonElement).click();
|
|
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe(
|
|
"Select an option",
|
|
);
|
|
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
|
|
});
|
|
|
|
test("advanced select clears, restores its placeholder, and keeps multiple counters accurate", async () => {
|
|
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "team",
|
|
multiple: true,
|
|
values: ["design", "engineering"],
|
|
showCounter: true,
|
|
maxSelections: 3,
|
|
placeholder: "Choose teams",
|
|
options: [
|
|
{ value: "design", label: "Design" },
|
|
{ value: "engineering", label: "Engineering" },
|
|
{ value: "growth", label: "Growth" },
|
|
],
|
|
});
|
|
const dom = mountHtml(html);
|
|
const trigger = dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement;
|
|
const counter = dom.querySelector(".wire-next__row small");
|
|
expect(counter?.textContent).toBe("2 / 3 selected");
|
|
|
|
trigger.click();
|
|
const buttons = dom.querySelectorAll(".wire-next__select-option") as HTMLButtonElement[];
|
|
const scope = dom.querySelector("[data-wrn-select]") as HTMLElement & {
|
|
__wrnexusScopeApi?: {
|
|
get(name: string): unknown;
|
|
call(name: string, value?: unknown): unknown;
|
|
};
|
|
};
|
|
expect(scope.__wrnexusScopeApi?.get("maxSelections")).toBe(3);
|
|
expect(scope.__wrnexusScopeApi?.call("selectedCount")).toBe(2);
|
|
expect(scope.__wrnexusScopeApi?.call("canSelect", { value: "growth", label: "Growth" })).toBe(
|
|
true,
|
|
);
|
|
expect(buttons.find((button) => button.textContent?.includes("Growth"))?.disabled).toBe(false);
|
|
buttons.find((button) => button.textContent?.includes("Engineering"))?.click();
|
|
expect(counter?.textContent).toBe("1 / 3 selected");
|
|
buttons.find((button) => button.textContent?.includes("Design"))?.click();
|
|
expect(counter?.textContent).toBe("0 / 3 selected");
|
|
expect(
|
|
dom.querySelector(".wire-next__placeholder")?.parentElement?.getAttribute("data-show"),
|
|
).toBe("true");
|
|
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
|
|
});
|
|
|
|
test("advanced select closes when a pointer event occurs outside it", async () => {
|
|
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "team",
|
|
options: [{ value: "design", label: "Design" }],
|
|
});
|
|
const dom = mountHtml(html);
|
|
(dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement).click();
|
|
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("true");
|
|
dom.document.body.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("pointerdown", { bubbles: true }),
|
|
);
|
|
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
|
});
|
|
|
|
test("advanced select fetches remote options and appends infinite pages", async () => {
|
|
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
|
const originalFetch = globalThis.fetch;
|
|
const requests: string[] = [];
|
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
|
const url = String(input);
|
|
requests.push(url);
|
|
const parsedUrl = new URL(url);
|
|
const page = parsedUrl.searchParams.get("page");
|
|
const query = parsedUrl.searchParams.get("q");
|
|
return Response.json(
|
|
page === "1"
|
|
? {
|
|
items: query
|
|
? [{ value: "engineering", label: "Engineering" }]
|
|
: [{ value: "design", label: "Design" }],
|
|
hasMore: true,
|
|
}
|
|
: { items: [{ value: "support", label: "Support" }], hasMore: false },
|
|
);
|
|
}) as unknown as typeof fetch;
|
|
|
|
try {
|
|
const html = await renderComponent(source, {
|
|
name: "team",
|
|
options: [],
|
|
remote: true,
|
|
remoteUrl: "https://example.test/api/teams",
|
|
infinite: true,
|
|
hasMore: true,
|
|
remoteDebounce: 0,
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-select]") as HTMLElement & {
|
|
__wrnexusSelectController?: { attempts: number };
|
|
__wrnexusScopeApi?: { get(name: string): unknown };
|
|
};
|
|
expect(root.getAttribute("data-remote")).toBe("true");
|
|
expect(root.getAttribute("data-remote-url")).toBe("https://example.test/api/teams");
|
|
expect(root.getAttribute("data-remote-auto-load")).toBe("true");
|
|
expect(root.__wrnexusSelectController).toBeDefined();
|
|
expect(root.__wrnexusScopeApi).toBeDefined();
|
|
expect(root.__wrnexusScopeApi?.get("minSearchLength")).toBe(0);
|
|
expect(root.__wrnexusSelectController?.attempts).toBe(1);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(requests).toHaveLength(1);
|
|
expect(
|
|
dom.querySelectorAll(".wire-next__select-option").map((node) => node.textContent),
|
|
).toEqual(["Design"]);
|
|
(dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement).click();
|
|
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
|
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
|
|
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe("Design");
|
|
|
|
const search = dom.querySelector(".wire-next__select-search input") as HTMLInputElement;
|
|
search.value = "engine";
|
|
search.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(requests.at(-1)).toContain("q=engine");
|
|
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
|
|
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe("Design");
|
|
|
|
(dom.querySelector("[data-wrn-select-load-more]") as HTMLButtonElement).click();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(
|
|
dom.querySelectorAll(".wire-next__select-option").map((node) => node.textContent),
|
|
).toEqual(["Engineering", "Support"]);
|
|
expect(requests).toHaveLength(3);
|
|
expect(requests[2]).toContain("page=2");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("combobox filters editable input, selects suggestions, exposes methods, and clears", async () => {
|
|
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "team",
|
|
placeholder: "Search teams",
|
|
options: [
|
|
{ value: "design", label: "Design", description: "Product design" },
|
|
{ value: "engineering", label: "Engineering", description: "Platform engineering" },
|
|
{ value: "growth", label: "Growth", description: "Customer growth" },
|
|
],
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-combobox]") as HTMLElement & {
|
|
wrnexusCombobox?: {
|
|
open(): void;
|
|
close(): void;
|
|
clear(): void;
|
|
setValue(value: string): void;
|
|
};
|
|
};
|
|
const input = dom.querySelector(".wire-next__combobox-input") as HTMLInputElement;
|
|
const hidden = dom.querySelector('input[type="hidden"]') as HTMLInputElement;
|
|
|
|
expect(root.wrnexusCombobox).toBeDefined();
|
|
input.focus();
|
|
input.value = "engi";
|
|
input.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
expect(
|
|
dom
|
|
.querySelectorAll(".wire-next__select-option")
|
|
.filter((option) => (option as HTMLElement).style.display !== "none")
|
|
.map((option) => option.textContent?.trim()),
|
|
).toEqual(["EngineeringPlatform engineering"]);
|
|
|
|
input.value = "No matching team";
|
|
input.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
|
expect(input.value).toBe("No matching team");
|
|
|
|
input.value = "engi";
|
|
input.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
input.dispatchEvent(
|
|
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
|
|
key: "Enter",
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(input.value).toBe("Engineering");
|
|
expect(hidden.value).toBe("engineering");
|
|
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
|
|
|
root.wrnexusCombobox?.clear();
|
|
expect(input.value).toBe("");
|
|
expect(hidden.value).toBe("");
|
|
root.wrnexusCombobox?.setValue("growth");
|
|
expect(input.value).toBe("Growth");
|
|
expect(hidden.value).toBe("growth");
|
|
});
|
|
|
|
test("combobox auto-loads remote suggestions and retains its selected value while searching", async () => {
|
|
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
|
|
const originalFetch = globalThis.fetch;
|
|
const requests: string[] = [];
|
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
|
const url = new URL(String(input));
|
|
requests.push(url.toString());
|
|
const query = url.searchParams.get("q");
|
|
return Response.json({
|
|
items: query
|
|
? [{ value: "engineering", label: "Engineering" }]
|
|
: [{ value: "design", label: "Design" }],
|
|
hasMore: false,
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const html = await renderComponent(source, {
|
|
name: "remote-team",
|
|
remote: true,
|
|
remoteUrl: "https://example.test/api/teams",
|
|
remoteDebounce: 0,
|
|
});
|
|
const dom = mountHtml(html);
|
|
const input = dom.querySelector(".wire-next__combobox-input") as HTMLInputElement;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(requests).toHaveLength(1);
|
|
|
|
input.focus();
|
|
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(input.value).toBe("Design");
|
|
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
|
|
|
|
input.value = "engine";
|
|
input.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(requests.at(-1)).toContain("q=engine");
|
|
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
|
|
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(input.value).toBe("Engineering");
|
|
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe(
|
|
"engineering",
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("combobox emits search, select, change, clear, open, and close events", async () => {
|
|
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "events-team",
|
|
options: [
|
|
{ value: "design", label: "Design" },
|
|
{ value: "engineering", label: "Engineering" },
|
|
],
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-combobox]") as HTMLElement;
|
|
const input = dom.querySelector(".wire-next__combobox-input") as HTMLInputElement;
|
|
const received: Array<{ name: string; detail: Record<string, unknown> }> = [];
|
|
for (const name of ["search", "select", "change", "clear", "open", "close"]) {
|
|
root.addEventListener(`wrnexus:${name}`, ((event: CustomEvent) => {
|
|
received.push({ name, detail: event.detail });
|
|
}) as EventListener);
|
|
}
|
|
|
|
input.focus();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
input.value = "engi";
|
|
input.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
input.dispatchEvent(
|
|
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
|
|
key: "Enter",
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
(dom.querySelector(".wire-next__clear-select") as HTMLButtonElement).click();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(received.map((event) => event.name)).toEqual(
|
|
expect.arrayContaining(["search", "select", "change", "clear", "open", "close"]),
|
|
);
|
|
expect(received.find((event) => event.name === "search")?.detail).toEqual(
|
|
expect.objectContaining({ component: "ComboBox", query: "engi" }),
|
|
);
|
|
expect(received.find((event) => event.name === "select")?.detail).toEqual(
|
|
expect.objectContaining({ value: "engineering" }),
|
|
);
|
|
});
|
|
|
|
test("advanced select emits selection events and remote load and error events", async () => {
|
|
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
|
const originalFetch = globalThis.fetch;
|
|
const originalConsoleError = console.error;
|
|
let rejectRequest = false;
|
|
globalThis.fetch = (async () => {
|
|
if (rejectRequest) throw new Error("Remote unavailable");
|
|
return Response.json({
|
|
items: [{ value: "design", label: "Design" }],
|
|
hasMore: false,
|
|
});
|
|
}) as unknown as typeof fetch;
|
|
console.error = () => {};
|
|
|
|
try {
|
|
const html = await renderComponent(source, {
|
|
name: "advanced-events",
|
|
options: [],
|
|
remote: true,
|
|
remoteUrl: "https://example.test/api/teams",
|
|
remoteAutoLoad: true,
|
|
remoteDebounce: 0,
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-select]") as HTMLElement & {
|
|
__wrnexusSelectController?: { load(append: boolean, allowEmptyQuery?: boolean): void };
|
|
};
|
|
const received: Array<{ name: string; detail: Record<string, unknown> }> = [];
|
|
for (const name of ["load", "error", "select", "change", "clear"]) {
|
|
root.addEventListener(`wrnexus:${name}`, ((event: CustomEvent) => {
|
|
received.push({ name, detail: event.detail });
|
|
}) as EventListener);
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
(dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement).click();
|
|
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
(dom.querySelector(".wire-next__clear-select") as HTMLButtonElement).click();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
rejectRequest = true;
|
|
root.__wrnexusSelectController?.load(false, true);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(received.map((event) => event.name)).toEqual(
|
|
expect.arrayContaining(["load", "select", "change", "clear", "error"]),
|
|
);
|
|
expect(received.find((event) => event.name === "load")?.detail).toEqual(
|
|
expect.objectContaining({
|
|
component: "AdvancedSelect",
|
|
page: 1,
|
|
hasMore: false,
|
|
}),
|
|
);
|
|
expect(received.find((event) => event.name === "error")?.detail).toEqual(
|
|
expect.objectContaining({
|
|
component: "AdvancedSelect",
|
|
message: "Remote unavailable",
|
|
}),
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
test("pin input accepts matching characters, advances focus, navigates backward, and completes", async () => {
|
|
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "verificationCode",
|
|
length: 4,
|
|
pattern: "[0-9]",
|
|
value: "",
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-pin-input]") as HTMLElement & {
|
|
wrnexusPinInput?: {
|
|
focus(index?: number): void;
|
|
clear(): void;
|
|
setValue(value: string): void;
|
|
getValue(): string;
|
|
};
|
|
};
|
|
const cells = dom.querySelectorAll("[data-pin-cell]") as HTMLInputElement[];
|
|
const hidden = dom.querySelector("[data-pin-value]") as HTMLInputElement;
|
|
const events: string[] = [];
|
|
const publicEvents: string[] = [];
|
|
for (const name of ["input", "change", "complete", "clear"]) {
|
|
root.addEventListener(`wrnexus:${name}`, () => events.push(name));
|
|
root.addEventListener(name, () => publicEvents.push(name));
|
|
}
|
|
|
|
expect(cells).toHaveLength(4);
|
|
expect(root.wrnexusPinInput).toBeDefined();
|
|
cells[0].focus();
|
|
cells[0].value = "x";
|
|
cells[0].dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
expect(cells[0].value).toBe("");
|
|
expect(hidden.value).toBe("");
|
|
|
|
for (let index = 0; index < 4; index += 1) {
|
|
cells[index].value = String(index + 1);
|
|
cells[index].dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
}
|
|
expect(hidden.value).toBe("1234");
|
|
expect(root.getAttribute("data-complete")).toBe("true");
|
|
expect(events).toContain("complete");
|
|
expect(publicEvents).toContain("complete");
|
|
|
|
cells[3].value = "";
|
|
cells[3].dispatchEvent(
|
|
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
|
|
key: "Backspace",
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
expect(cells[2].value).toBe("");
|
|
expect(hidden.value).toBe("12");
|
|
|
|
root.wrnexusPinInput?.setValue("9876");
|
|
expect(root.wrnexusPinInput?.getValue()).toBe("9876");
|
|
root.wrnexusPinInput?.clear();
|
|
expect(hidden.value).toBe("");
|
|
expect(events).toContain("clear");
|
|
});
|
|
|
|
test("pin input renders four separate cells when length is omitted", async () => {
|
|
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "defaultPin",
|
|
});
|
|
const dom = mountHtml(html);
|
|
|
|
expect(dom.querySelectorAll("[data-pin-cell]")).toHaveLength(4);
|
|
});
|
|
|
|
test("pin input distributes filtered clipboard content and emits paste details", async () => {
|
|
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "emailCode",
|
|
length: 6,
|
|
pattern: "[A-Z0-9]",
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-pin-input]") as HTMLElement;
|
|
const firstCell = dom.querySelector("[data-pin-cell]") as HTMLInputElement;
|
|
let pasteDetail: Record<string, unknown> | undefined;
|
|
root.addEventListener("wrnexus:paste", ((event: CustomEvent) => {
|
|
pasteDetail = event.detail;
|
|
}) as EventListener);
|
|
|
|
const pasteEvent = new (dom.window as { Event: typeof Event }).Event("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
Object.defineProperty(pasteEvent, "clipboardData", {
|
|
value: { getData: () => "A-1b2 C3" },
|
|
});
|
|
firstCell.dispatchEvent(pasteEvent);
|
|
|
|
expect((dom.querySelector("[data-pin-value]") as HTMLInputElement).value).toBe("A1B2C3");
|
|
expect(pasteDetail).toEqual(
|
|
expect.objectContaining({
|
|
component: "PinInput",
|
|
pasted: "A1B2C3",
|
|
value: "A1B2C3",
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("pin input accepts lowercase alphanumeric entry and normalizes it to the configured pattern", async () => {
|
|
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "securityCode",
|
|
length: 4,
|
|
pattern: "[A-Z0-9]",
|
|
inputMode: "text",
|
|
});
|
|
const dom = mountHtml(html);
|
|
const cells = dom.querySelectorAll("[data-pin-cell]") as HTMLInputElement[];
|
|
|
|
cells[0].value = "a";
|
|
cells[0].dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
|
|
expect(cells[0].value).toBe("A");
|
|
expect((dom.querySelector("[data-pin-value]") as HTMLInputElement).value).toBe("A");
|
|
});
|
|
|
|
test("strong password scores input, supports custom special characters, and opens its popover", async () => {
|
|
const source = readFileSync(uiComponentPath("StrongPassword"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "newPassword",
|
|
presentation: "popover",
|
|
specialCharactersSet: "@#",
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-strong-password]") as HTMLElement;
|
|
const input = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
|
|
const strengthEvents: Array<{ level: string; percent: number }> = [];
|
|
root.addEventListener("strength", (event) => {
|
|
strengthEvents.push(
|
|
(event as unknown as CustomEvent<{ level: string; percent: number }>).detail,
|
|
);
|
|
});
|
|
|
|
expect(input.minLength).toBe(8);
|
|
expect(root.dataset.strength).toBe("empty");
|
|
|
|
input.value = "Secure#9";
|
|
input.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.strength).toBe(
|
|
"strong",
|
|
);
|
|
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.score).toBe("5");
|
|
expect(strengthEvents.at(-1)).toMatchObject({ level: "strong", percent: 100 });
|
|
|
|
const updatedInput = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
|
|
updatedInput.value = "Secure!9";
|
|
updatedInput.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
|
);
|
|
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.strength).toBe(
|
|
"good",
|
|
);
|
|
expect(strengthEvents.at(-1)?.percent).toBe(80);
|
|
|
|
const currentInput = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
|
|
currentInput.dispatchEvent(
|
|
new (dom.window as { Event: typeof Event }).Event("focus", { bubbles: true }),
|
|
);
|
|
expect(
|
|
(dom.querySelector(".wire-next__strong-password-popover") as HTMLElement).dataset.open,
|
|
).toBe("true");
|
|
});
|
|
|
|
test("toggle password reveals and conceals its value with accessible declared events", async () => {
|
|
const source = readFileSync(uiComponentPath("TogglePassword"), "utf8");
|
|
const html = await renderComponent(source, {
|
|
name: "accountPassword",
|
|
value: "correct-horse",
|
|
});
|
|
const dom = mountHtml(html);
|
|
const root = dom.querySelector("[data-wrn-toggle-password]") as HTMLElement;
|
|
const input = dom.querySelector('input[name="accountPassword"]') as HTMLInputElement;
|
|
const toggle = dom.querySelector(".wire-next__password-toggle") as HTMLButtonElement;
|
|
const showIcon = dom.querySelector(".wire-next__password-icon--show") as HTMLElement;
|
|
const visibility: boolean[] = [];
|
|
root.addEventListener("toggle", (event) => {
|
|
visibility.push((event as unknown as CustomEvent).detail.visible);
|
|
});
|
|
|
|
expect(input.type).toBe("password");
|
|
expect(input.pattern).toBe("(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}");
|
|
expect(showIcon).not.toBeNull();
|
|
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
|
toggle.click();
|
|
expect(input.type).toBe("text");
|
|
expect(input.value).toBe("correct-horse");
|
|
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
|
expect(visibility).toEqual([true]);
|
|
toggle.click();
|
|
expect(input.type).toBe("password");
|
|
expect(visibility).toEqual([true, false]);
|
|
});
|
|
|
|
test("toggle password supports checkbox control and synchronized password fields", async () => {
|
|
const source = readFileSync(uiComponentPath("TogglePassword"), "utf8");
|
|
const synchronizedHtml = await renderComponent(source, {
|
|
name: "credentials",
|
|
fields: [
|
|
{
|
|
label: "New password",
|
|
name: "newPassword",
|
|
placeholder: "Enter new password",
|
|
autocomplete: "new-password",
|
|
},
|
|
{
|
|
label: "Current password",
|
|
name: "currentPassword",
|
|
value: "existing-secret",
|
|
autocomplete: "current-password",
|
|
pattern: ".{12,}",
|
|
},
|
|
],
|
|
});
|
|
const synchronizedDom = mountHtml(synchronizedHtml);
|
|
const synchronizedInputs = synchronizedDom.querySelectorAll(
|
|
".wire-next__password-control > input",
|
|
) as HTMLInputElement[];
|
|
const synchronizedToggle = synchronizedDom.querySelector(
|
|
".wire-next__password-toggle",
|
|
) as HTMLButtonElement;
|
|
|
|
expect(synchronizedInputs).toHaveLength(2);
|
|
expect(synchronizedInputs[0].pattern).toBe(
|
|
"(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}",
|
|
);
|
|
expect(synchronizedInputs[1].pattern).toBe(".{12,}");
|
|
expect(synchronizedInputs.map((input) => input.type)).toEqual(["password", "password"]);
|
|
synchronizedToggle.click();
|
|
expect(synchronizedInputs.map((input) => input.type)).toEqual(["text", "text"]);
|
|
|
|
const checkboxHtml = await renderComponent(source, {
|
|
name: "checkboxPassword",
|
|
toggleMode: "checkbox",
|
|
});
|
|
const checkboxDom = mountHtml(checkboxHtml);
|
|
const checkbox = checkboxDom.querySelector(
|
|
".wire-next__password-checkbox input",
|
|
) as HTMLInputElement;
|
|
const checkboxPassword = checkboxDom.querySelector(
|
|
".wire-next__password-control > input",
|
|
) as HTMLInputElement;
|
|
checkbox.checked = true;
|
|
checkbox.dispatchEvent(
|
|
new (checkboxDom.window as { Event: typeof Event }).Event("change", { bubbles: true }),
|
|
);
|
|
|
|
expect(checkboxPassword.type).toBe("text");
|
|
});
|