test(ui): derive story inventory from component tree
This commit is contained in:
parent
32ad2cebe4
commit
fe8d1e5642
1 changed files with 110 additions and 91 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue