dimensionlab-ui/src/storybook.test.ts

309 lines
9.8 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { describe, expect, test } from "vitest";
const root = process.cwd();
const packageRoot = existsSync(join(root, "packages/ui/package.json"))
? join(root, "packages/ui")
: root;
const componentsDir = join(packageRoot, "src/components");
const storiesDir = join(packageRoot, "src/stories");
const allowedComponentDomains = [
"foundation",
"frames",
"operations",
"telemetry",
] as const;
const compositionStoryFiles = ["DashboardOnePager.stories.tsx"] as const;
const forbiddenStoryContent = [
"dimension lab",
"dimensionlab",
"vince",
"homepage",
] as const;
describe("Storybook inventory", () => {
test("exposes scripts for local and static Storybook review", () => {
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
scripts?: Record<string, string>;
};
expect(packageJson.scripts?.storybook).toBeTypeOf("string");
expect(packageJson.scripts?.storybook).toContain("storybook dev");
expect(packageJson.scripts?.["build-storybook"]).toBe("storybook build");
});
test("has a story for every reusable dashboard UI component", () => {
for (const component of componentInventory()) {
expect(
existsSync(join(storiesDir, component.storyFile)),
`${component.storyFile} is missing`,
).toBe(true);
}
});
test("uses only approved component domain folders", () => {
const actualDomains = readdirSync(componentsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
expect(actualDomains).toEqual([...allowedComponentDomains].sort());
});
test("loads dashboard component styles through the global app stylesheet", () => {
const packageStyles = readFileSync(join(packageRoot, "src/styles.css"), "utf8");
const dashboardFrame = readFileSync(requiredComponentPath("DashboardFrame"), "utf8");
expect(packageStyles).toContain('./components/styles.css');
expect(dashboardFrame).not.toContain('./styles.css');
});
test("configures Storybook theme switching for reusable components", () => {
const previewSource = readFileSync(join(packageRoot, ".storybook/preview.ts"), "utf8");
expect(previewSource).toContain("globalTypes");
expect(previewSource).toContain("data-ui-theme");
expect(previewSource).toContain("../src/styles.css");
});
test("keeps component and story files paired as the UI inventory changes", () => {
const componentStoryFiles = new Set(
componentInventory().map((component) => component.storyFile),
);
for (const filename of componentStoryFiles) {
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(
true,
);
}
const unpairedStoryFiles = storyFiles().filter(
(filename) =>
!componentStoryFiles.has(filename) &&
!compositionStoryFiles.includes(
filename as (typeof compositionStoryFiles)[number],
),
);
expect(unpairedStoryFiles).toEqual([]);
});
test("exports every reusable component through the package barrel", () => {
const indexSource = readFileSync(join(packageRoot, "src/index.ts"), "utf8");
for (const component of componentInventory()) {
expect(indexSource).toContain(
`export { ${component.name} } from "${component.relativeExportPath}";`,
);
}
});
test("exposes grouped package entrypoints for each component domain", () => {
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
exports?: Record<string, WorkspacePackageExport>;
};
const rootIndexSource = readFileSync(join(packageRoot, "src/index.ts"), "utf8");
const componentsByDomain = new Map<(typeof allowedComponentDomains)[number], string[]>();
for (const domain of allowedComponentDomains) {
componentsByDomain.set(domain, []);
}
for (const component of componentInventory()) {
componentsByDomain.get(component.domain)?.push(component.name);
}
for (const domain of allowedComponentDomains) {
const domainIndexPath = join(componentsDir, domain, "index.ts");
const domainIndexSource = existsSync(domainIndexPath)
? readFileSync(domainIndexPath, "utf8")
: "";
expect(existsSync(domainIndexPath), `${domain} index is missing`).toBe(true);
expect(rootIndexSource).toContain(`export * from "./components/${domain}";`);
expect(packageJson.exports?.[`./${domain}`]).toMatchObject({
types: `./dist/components/${domain}/index.d.ts`,
development: `./src/components/${domain}/index.ts`,
default: `./dist/components/${domain}/index.js`,
});
for (const componentName of componentsByDomain.get(domain) ?? []) {
expect(domainIndexSource).toContain(
`export * from "./${componentName}";`,
);
}
}
});
test("exports component prop interfaces through grouped package entrypoints", () => {
let propInterfaceCount = 0;
for (const component of componentInventory()) {
const source = readFileSync(component.path, "utf8");
const propTypeName = `${component.name}Props`;
const propExportPattern = new RegExp(
`export\\s+(interface|type)\\s+${propTypeName}\\b`,
);
if (!propExportPattern.test(source)) {
continue;
}
propInterfaceCount += 1;
const domainIndexSource = readFileSync(
join(componentsDir, component.domain, "index.ts"),
"utf8",
);
expect(domainIndexSource).toContain(
`export * from "./${component.name}";`,
);
}
expect(propInterfaceCount).toBeGreaterThan(0);
});
test("keeps Storybook fixtures generic and content-free", () => {
const storyText = readdirSync(storiesDir)
.filter((filename) => filename.endsWith(".tsx") || filename.endsWith(".ts"))
.map((filename) => readFileSync(join(storiesDir, filename), "utf8").toLowerCase())
.join("\n");
for (const forbidden of forbiddenStoryContent) {
expect(storyText).not.toContain(forbidden);
}
});
test("includes style-only decorative primitives in the reusable UI inventory", () => {
for (const component of [
"CornerBracketFrame",
"DiagonalStripeField",
"ScanlineField",
"SignalTrace",
]) {
expect(existsSync(requiredComponentPath(component)), `${component} is missing`).toBe(true);
expect(
existsSync(join(storiesDir, `${component}.stories.tsx`)),
`${component}.stories.tsx is missing`,
).toBe(true);
}
});
test("keeps decorative primitives hidden from assistive technology", () => {
for (const component of [
"CornerBracketFrame",
"DiagonalStripeField",
"ScanlineField",
"SignalTrace",
]) {
const source = readFileSync(requiredComponentPath(component), "utf8");
expect(source).toContain('aria-hidden="true"');
}
});
test("does not add deferred form/navigation primitives", () => {
const componentNames = new Set(
componentInventory().map((component) => component.name),
);
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
expect(componentNames.has(component)).toBe(false);
expect(existsSync(join(componentsDir, `${component}.tsx`))).toBe(false);
expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false);
}
});
test("does not keep legacy stories in the React Storybook inventory", () => {
const legacyStoryExtension = [".stories", ".sve", "lte"].join("");
const legacyStories = readdirSync(storiesDir).filter((filename) =>
filename.endsWith(legacyStoryExtension),
);
expect(legacyStories).toEqual([]);
});
});
function requiredComponentPath(component: string): string {
const componentPath = componentInventory().find(
(entry) => entry.name === component,
)?.path;
expect(componentPath, `${component} is missing from the UI component tree`).toBeTypeOf(
"string",
);
return componentPath as string;
}
interface ComponentInventoryItem {
domain: (typeof allowedComponentDomains)[number];
name: string;
path: string;
relativeExportPath: string;
storyFile: string;
}
type WorkspacePackageExport =
| string
| {
types?: string;
development?: string;
default?: string;
};
function componentInventory(): ComponentInventoryItem[] {
const components = allowedComponentDomains
.flatMap((domain) => collectComponentFiles(join(componentsDir, domain)))
.sort((a, b) => a.name.localeCompare(b.name));
const names = components.map((component) => component.name);
expect(names).toEqual([...new Set(names)]);
return components;
}
function collectComponentFiles(directory: string): ComponentInventoryItem[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = join(directory, entry.name);
if (entry.isDirectory()) {
return collectComponentFiles(entryPath);
}
if (
!entry.isFile() ||
!entry.name.endsWith(".tsx") ||
entry.name.endsWith(".test.tsx")
) {
return [];
}
const name = basename(entry.name, ".tsx");
const relativeComponentPath = relative(componentsDir, entryPath).replace(/\\/g, "/");
const domain = relativeComponentPath.split(
"/",
)[0] as (typeof allowedComponentDomains)[number];
const relativeExportPath = `./${relative(join(packageRoot, "src"), entryPath)
.replace(/\\/g, "/")
.replace(/\.tsx$/, "")}`;
return [
{
domain,
name,
path: entryPath,
relativeExportPath,
storyFile: `${name}.stories.tsx`,
},
];
});
}
function storyFiles(): string[] {
return readdirSync(storiesDir)
.filter((filename) => filename.endsWith(".stories.tsx"))
.sort();
}