211 lines
6.3 KiB
TypeScript
211 lines
6.3 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import { join } 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 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<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;
|
|
|
|
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 filename of requiredStoryFiles) {
|
|
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true);
|
|
}
|
|
});
|
|
|
|
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 = [...componentFiles.keys()].map(
|
|
(component) => `${component}.stories.tsx`,
|
|
);
|
|
|
|
for (const filename of componentStoryFiles) {
|
|
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true);
|
|
}
|
|
});
|
|
|
|
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", () => {
|
|
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
|
|
expect(componentFiles.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 = componentFiles.get(component);
|
|
|
|
expect(componentPath, `${component} is missing from the UI component map`).toBeTypeOf(
|
|
"string",
|
|
);
|
|
|
|
return componentPath as string;
|
|
}
|