Files
homarr/packages/custom-widgets/src/test/component-catalog.spec.ts
T

142 lines
7.2 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import {
customJsxAuthoringCatalog,
getCustomJsxPropType,
resolveCustomJsxPropDescriptor,
} from "../core/component-catalog";
import {
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
CUSTOM_WIDGET_AUTHORING_VERSION,
} from "../core/component-catalog-types";
import { customJsxComponentRegistry } from "../core/component-registry";
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
const catalogPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/component-catalog.generated.json");
const publicCatalogPath = resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json");
// Keep a reviewed ceiling close to the generated artifact so accidental metadata
// duplication must receive explicit review.
const MAX_CATALOG_BYTES = 800 * 1024;
describe("Custom JSX authoring catalog", () => {
test("is versioned against the installed authoring surface", async () => {
const mantinePackage = JSON.parse(
await readFile(resolve(repositoryRoot, "node_modules/@mantine/core/package.json"), "utf8"),
) as { version: string };
expect(customJsxAuthoringCatalog.schemaVersion).toBe(CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION);
expect(customJsxAuthoringCatalog.mantineVersion).toBe(mantinePackage.version);
expect(customJsxAuthoringCatalog.customWidgetVersion).toBe(CUSTOM_WIDGET_AUTHORING_VERSION);
});
test("publishes deterministic unique component and prop records", () => {
const componentNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
const globalPropNames = customJsxAuthoringCatalog.globalProps.map(({ name }) => name);
const types = customJsxAuthoringCatalog.types;
expect(new Set(componentNames).size).toBe(componentNames.length);
expect(new Set(globalPropNames).size).toBe(globalPropNames.length);
expect(new Set(types).size).toBe(types.length);
expect(componentNames).toEqual([...componentNames].toSorted());
expect(globalPropNames).toEqual([...globalPropNames].toSorted());
expect(types).toEqual([...types].toSorted());
const globals = new Map(customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]));
for (const component of customJsxAuthoringCatalog.components) {
const propNames = component.props.map(({ name }) => name);
expect(new Set(propNames).size, component.name).toBe(propNames.length);
expect(propNames, component.name).toEqual([...propNames].toSorted());
for (const prop of component.props) {
expect(prop.source, `${component.name}.${prop.name}`).toBe("component");
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeGreaterThanOrEqual(0);
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeLessThan(types.length);
expect(getCustomJsxPropType(prop), `${component.name}.${prop.name}`).not.toBe("");
const global = globals.get(prop.name);
if (global) {
expect(
prop.required !== global.required ||
getCustomJsxPropType(prop) !== getCustomJsxPropType(global) ||
JSON.stringify(prop.literalValues ?? []) !== JSON.stringify(global.literalValues ?? []),
`${component.name}.${prop.name} must be a meaningful global-prop override`,
).toBe(true);
}
}
}
for (const prop of customJsxAuthoringCatalog.globalProps) {
expect(prop.source, prop.name).toBe("global");
expect(prop.required, prop.name).toBe(false);
expect(prop.typeRef, prop.name).toBeGreaterThanOrEqual(0);
expect(prop.typeRef, prop.name).toBeLessThan(types.length);
expect(resolveCustomJsxPropDescriptor(prop).type, prop.name).not.toBe("");
}
});
test("covers the complete runtime component registry", () => {
const catalogNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
const runtimeNames = customJsxComponentRegistry.map(({ name }) => name).toSorted();
expect(catalogNames).toEqual(runtimeNames);
});
test("includes prop APIs, bindings, accessibility, and denied reasons", () => {
const textInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "TextInput");
const label = textInput?.props.find(({ name }) => name === "label");
expect(label && getCustomJsxPropType(label)).toContain("ReactNode");
expect(label?.required).toBe(false);
expect(label?.description).toBeTruthy();
expect(textInput?.bind).toEqual({ type: "string", initialProp: "defaultValue" });
const switchComponent = customJsxAuthoringCatalog.components.find(({ name }) => name === "Switch");
expect(switchComponent?.bind).toEqual({ type: "boolean", initialProp: "defaultChecked" });
expect(customJsxAuthoringCatalog.components.some(({ name }) => name === "RecursiveList")).toBe(false);
const portal = customJsxAuthoringCatalog.components.find(({ name }) => name === "Portal");
expect(portal?.safety).toBe("denied");
expect(portal?.deniedReason).toBeTruthy();
expect(portal?.props).toEqual([]);
const tableOfContents = customJsxAuthoringCatalog.components.find(({ name }) => name === "TableOfContents");
expect(tableOfContents?.safety).toBe("denied");
expect(tableOfContents?.deniedReason).toContain("outside the widget root");
const tooltip = customJsxAuthoringCatalog.components.find(({ name }) => name === "Tooltip");
expect(tooltip?.blockedProps).toContainEqual(
expect.objectContaining({ name: "target", reason: expect.stringContaining("outside the widget root") }),
);
expect(tooltip?.props.map(({ name }) => name)).not.toContain("target");
const datePickerInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "DatePickerInput");
expect(datePickerInput?.blockedProps.map(({ name }) => name)).toEqual(["dropdownType", "modalProps"]);
});
test("documents blocked prop capabilities once and excludes them from APIs", () => {
const blockedNames = customJsxAuthoringCatalog.blockedCapabilities.map(({ name }) => name);
expect(new Set(blockedNames).size).toBe(blockedNames.length);
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
expect.objectContaining({ kind: "prop", name: "dangerouslySetInnerHTML" }),
);
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
expect.objectContaining({ kind: "prop-pattern", name: "on*" }),
);
const authoredProps = [
...customJsxAuthoringCatalog.globalProps,
...customJsxAuthoringCatalog.components.flatMap(({ props }) => props),
];
expect(authoredProps.some(({ name }) => /^on/iu.test(name))).toBe(false);
expect(authoredProps.some(({ name }) => name === "dangerouslySetInnerHTML")).toBe(false);
});
test("stays within the reviewed compact byte budget", async () => {
const catalog = await readFile(catalogPath);
expect(catalog.byteLength).toBeLessThanOrEqual(MAX_CATALOG_BYTES);
});
test("publishes a byte-identical static documentation copy", async () => {
const [catalog, publicCatalog] = await Promise.all([readFile(catalogPath), readFile(publicCatalogPath)]);
expect(publicCatalog.equals(catalog)).toBe(true);
});
});