test(ui): derive story inventory from component tree

This commit is contained in:
vince 2026-06-20 06:50:10 +02:00
parent a93ed3d99f
commit c5f8aaeefa

View file

@ -1,5 +1,5 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"; import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { basename, join, relative } from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
const root = process.cwd(); const root = process.cwd();
@ -9,89 +9,13 @@ const packageRoot = existsSync(join(root, "packages/ui/package.json"))
const componentsDir = join(packageRoot, "src/components"); const componentsDir = join(packageRoot, "src/components");
const storiesDir = join(packageRoot, "src/stories"); const storiesDir = join(packageRoot, "src/stories");
const componentDomains = { const allowedComponentDomains = [
foundation: [ "foundation",
"Badge", "frames",
"Button", "operations",
"IconButton", "telemetry",
"IconGlyph",
"ProgressMeter",
"Separator",
"StatusBadge",
"ThemeToggle",
],
frames: [
"CornerBracketFrame",
"DashboardFrame",
"DashboardHeader",
"DiagonalStripeField",
"FooterCell",
"FooterStatusCell",
"GridFrame",
"ModuleCard",
"Panel",
"ScanlineField",
],
operations: [
"ServiceGroupPanel",
"ServicePanel",
"ServiceRow",
"StatusStrip",
"SystemState",
"WeatherModule",
],
telemetry: [
"LineChart",
"SignalTrace",
"Sparkline",
"TelemetryCard",
"TelemetryGrid",
"TelemetryStrip",
],
} as const;
const componentFiles: ReadonlyMap<string, string> = new Map(
Object.entries(componentDomains).flatMap(([domain, components]) =>
components.map((component) => [
component,
join(componentsDir, domain, `${component}.tsx`),
]),
),
);
const requiredStoryFiles = [
"Badge.stories.tsx",
"Button.stories.tsx",
"DashboardFrame.stories.tsx",
"DashboardHeader.stories.tsx",
"DashboardOnePager.stories.tsx",
"CornerBracketFrame.stories.tsx",
"DiagonalStripeField.stories.tsx",
"FooterCell.stories.tsx",
"FooterStatusCell.stories.tsx",
"GridFrame.stories.tsx",
"IconButton.stories.tsx",
"IconGlyph.stories.tsx",
"LineChart.stories.tsx",
"ModuleCard.stories.tsx",
"Panel.stories.tsx",
"ProgressMeter.stories.tsx",
"Separator.stories.tsx",
"ServiceGroupPanel.stories.tsx",
"ServicePanel.stories.tsx",
"ServiceRow.stories.tsx",
"SignalTrace.stories.tsx",
"Sparkline.stories.tsx",
"StatusBadge.stories.tsx",
"StatusStrip.stories.tsx",
"SystemState.stories.tsx",
"ScanlineField.stories.tsx",
"TelemetryCard.stories.tsx",
"TelemetryGrid.stories.tsx",
"TelemetryStrip.stories.tsx",
"ThemeToggle.stories.tsx",
"WeatherModule.stories.tsx",
] as const; ] as const;
const compositionStoryFiles = ["DashboardOnePager.stories.tsx"] as const;
const forbiddenStoryContent = [ const forbiddenStoryContent = [
"dimension lab", "dimension lab",
@ -112,11 +36,23 @@ describe("Storybook inventory", () => {
}); });
test("has a story for every reusable dashboard UI component", () => { test("has a story for every reusable dashboard UI component", () => {
for (const filename of requiredStoryFiles) { for (const component of componentInventory()) {
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true); 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", () => { test("loads dashboard component styles through the global app stylesheet", () => {
const packageStyles = readFileSync(join(packageRoot, "src/styles.css"), "utf8"); const packageStyles = readFileSync(join(packageRoot, "src/styles.css"), "utf8");
const dashboardFrame = readFileSync(requiredComponentPath("DashboardFrame"), "utf8"); const dashboardFrame = readFileSync(requiredComponentPath("DashboardFrame"), "utf8");
@ -134,12 +70,33 @@ describe("Storybook inventory", () => {
}); });
test("keeps component and story files paired as the UI inventory changes", () => { test("keeps component and story files paired as the UI inventory changes", () => {
const componentStoryFiles = [...componentFiles.keys()].map( const componentStoryFiles = new Set(
(component) => `${component}.stories.tsx`, componentInventory().map((component) => component.storyFile),
); );
for (const filename of componentStoryFiles) { for (const filename of componentStoryFiles) {
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true); 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}";`,
);
} }
}); });
@ -183,8 +140,12 @@ describe("Storybook inventory", () => {
}); });
test("does not add deferred form/navigation primitives", () => { test("does not add deferred form/navigation primitives", () => {
const componentNames = new Set(
componentInventory().map((component) => component.name),
);
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) { for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
expect(componentFiles.has(component)).toBe(false); expect(componentNames.has(component)).toBe(false);
expect(existsSync(join(componentsDir, `${component}.tsx`))).toBe(false); expect(existsSync(join(componentsDir, `${component}.tsx`))).toBe(false);
expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false); expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false);
} }
@ -201,11 +162,69 @@ describe("Storybook inventory", () => {
}); });
function requiredComponentPath(component: string): string { function requiredComponentPath(component: string): string {
const componentPath = componentFiles.get(component); const componentPath = componentInventory().find(
(entry) => entry.name === component,
)?.path;
expect(componentPath, `${component} is missing from the UI component map`).toBeTypeOf( expect(componentPath, `${component} is missing from the UI component tree`).toBeTypeOf(
"string", "string",
); );
return componentPath as string; return componentPath as string;
} }
interface ComponentInventoryItem {
name: string;
path: string;
relativeExportPath: string;
storyFile: 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 relativeExportPath = `./${relative(join(packageRoot, "src"), entryPath)
.replace(/\\/g, "/")
.replace(/\.tsx$/, "")}`;
return [
{
name,
path: entryPath,
relativeExportPath,
storyFile: `${name}.stories.tsx`,
},
];
});
}
function storyFiles(): string[] {
return readdirSync(storiesDir)
.filter((filename) => filename.endsWith(".stories.tsx"))
.sort();
}