diff --git a/packages/ui/src/storybook.test.ts b/packages/ui/src/storybook.test.ts index 49a9e83..83f51c7 100644 --- a/packages/ui/src/storybook.test.ts +++ b/packages/ui/src/storybook.test.ts @@ -1,5 +1,5 @@ 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"; const root = process.cwd(); @@ -9,89 +9,13 @@ const packageRoot = existsSync(join(root, "packages/ui/package.json")) const componentsDir = join(packageRoot, "src/components"); const storiesDir = join(packageRoot, "src/stories"); -const componentDomains = { - foundation: [ - "Badge", - "Button", - "IconButton", - "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 = 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", +const allowedComponentDomains = [ + "foundation", + "frames", + "operations", + "telemetry", ] as const; +const compositionStoryFiles = ["DashboardOnePager.stories.tsx"] as const; const forbiddenStoryContent = [ "dimension lab", @@ -112,11 +36,23 @@ describe("Storybook inventory", () => { }); test("has a story for every reusable dashboard UI component", () => { - for (const filename of requiredStoryFiles) { - expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true); + 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"); @@ -134,12 +70,33 @@ describe("Storybook inventory", () => { }); test("keeps component and story files paired as the UI inventory changes", () => { - const componentStoryFiles = [...componentFiles.keys()].map( - (component) => `${component}.stories.tsx`, + const componentStoryFiles = new Set( + componentInventory().map((component) => component.storyFile), ); 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", () => { + const componentNames = new Set( + componentInventory().map((component) => component.name), + ); + 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(storiesDir, `${component}.stories.tsx`))).toBe(false); } @@ -201,11 +162,69 @@ describe("Storybook inventory", () => { }); 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", ); 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(); +}