281 lines
9 KiB
TypeScript
281 lines
9 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 { ${componentName} } from "./${componentName}";`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|