/** * Unit tests for storyModuleToFragment and related utilities. * Tests both CSF2 (Template.bind) and CSF3 (object stories) patterns. */ import { describe, it, expect, beforeEach } from "vitest"; import { createElement, type ComponentType } from "react"; import { storyModuleToFragment, setPreviewConfig, toId, storyNameFromExport, isExportStory, type StoryModule, type StoryMeta, type Story, type CSF2Story, type PreviewConfig, } from "./storyAdapter.js"; // Mock component for testing const MockComponent = (({ label = "Test" }: { label?: string }) => createElement("button", null, label)) as ComponentType; (MockComponent as unknown as { displayName: string }).displayName = "MockComponent"; describe("storyModuleToFragment", () => { beforeEach(() => { // Reset preview config before each test setPreviewConfig({}); }); describe("basic conversion", () => { it("should convert a basic story module to a fragment definition", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, Primary: { args: { label: "Primary" }, } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment).not.toBeNull(); expect(fragment!.meta.name).toBe("Button"); expect(fragment!.meta.category).toBe("general"); expect(fragment!.component).toBe(MockComponent); expect(fragment!.variants).toHaveLength(1); expect(fragment!.variants[0].name).toBe("Primary"); }); it("should return null for stories without a component", () => { const storyModule: StoryModule = { default: { title: "Docs/Introduction", }, }; const fragment = storyModuleToFragment(storyModule, "Intro.stories.tsx"); expect(fragment).toBeNull(); }); it("should extract component name from title path", () => { const storyModule: StoryModule = { default: { title: "Design System/Forms/TextField", component: MockComponent, }, Default: { args: {} } as Story, }; const fragment = storyModuleToFragment(storyModule, "TextField.stories.tsx"); expect(fragment!.meta.name).toBe("TextField"); expect(fragment!.meta.category).toBe("forms"); }); it("should use component displayName when title is not provided", () => { const storyModule: StoryModule = { default: { component: MockComponent, }, Default: { args: {} } as Story, }; const fragment = storyModuleToFragment(storyModule, "path/to/Component.stories.tsx"); expect(fragment!.meta.name).toBe("MockComponent"); }); }); describe("CSF2 Template.bind() pattern", () => { it("should handle CSF2 story functions", () => { // Simulate Template.bind({}) pattern const Template: CSF2Story = (args) => createElement(MockComponent, args); const Primary: CSF2Story = Template.bind({}); Primary.args = { label: "Primary Button" }; const Secondary: CSF2Story = Template.bind({}); Secondary.args = { label: "Secondary Button" }; const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, Primary, Secondary, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants).toHaveLength(2); expect(fragment!.variants[0].name).toBe("Primary"); expect(fragment!.variants[1].name).toBe("Secondary"); }); it("should use storyName when provided on CSF2 story", () => { const Template: CSF2Story = (args) => createElement(MockComponent, args); const MyStory: CSF2Story = Template.bind({}); MyStory.args = { label: "Test" }; MyStory.storyName = "Custom Story Name"; const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, MyStory, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants[0].name).toBe("Custom Story Name"); }); }); describe("CSF3 object stories", () => { it("should handle CSF3 story objects with args", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, Primary: { args: { label: "Primary" }, } as Story, WithCustomRender: { args: { label: "Custom" }, render: (args) => createElement("div", null, createElement(MockComponent, args)), } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants).toHaveLength(2); expect(fragment!.variants[0].name).toBe("Primary"); expect(fragment!.variants[1].name).toBe("With Custom Render"); }); it("should use story name property when provided", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, MyStory: { name: "Explicit Name", args: { label: "Test" }, } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants[0].name).toBe("Explicit Name"); }); it("should detect play functions", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, WithInteraction: { args: { label: "Click me" }, play: async () => { // Interaction test }, } as Story, NoInteraction: { args: { label: "No interaction" }, } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants[0].hasPlayFunction).toBe(true); expect(fragment!.variants[1].hasPlayFunction).toBeUndefined(); }); }); describe("argTypes mapping", () => { it("should convert basic argTypes to prop definitions", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, argTypes: { label: { control: "text", description: "Button label text", }, disabled: { control: "boolean", description: "Disable the button", }, size: { control: "select", options: ["small", "medium", "large"], description: "Button size", }, }, }, Default: { args: {} } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.props.label.type).toBe("string"); expect(fragment!.props.label.description).toBe("Button label text"); expect(fragment!.props.disabled.type).toBe("boolean"); expect(fragment!.props.size.type).toBe("enum"); expect(fragment!.props.size.values).toEqual(["small", "medium", "large"]); }); it("should handle all control types", () => { const storyModule: StoryModule = { default: { title: "Components/Test", component: MockComponent, argTypes: { text: { control: "text" }, number: { control: "number" }, range: { control: { type: "range", min: 0, max: 100 } }, boolean: { control: "boolean" }, color: { control: "color" }, date: { control: "date" }, object: { control: "object" }, radio: { control: "radio", options: ["a", "b"] }, inlineRadio: { control: "inline-radio", options: ["x", "y"] }, select: { control: "select", options: ["1", "2"] }, multiSelect: { control: "multi-select", options: ["m", "n"] }, }, }, Default: { args: {} } as Story, }; const fragment = storyModuleToFragment(storyModule, "Test.stories.tsx"); expect(fragment!.props.text.type).toBe("string"); expect(fragment!.props.number.type).toBe("number"); expect(fragment!.props.range.type).toBe("number"); expect(fragment!.props.range.controlType).toBe("range"); expect(fragment!.props.range.controlOptions).toEqual({ min: 0, max: 100 }); expect(fragment!.props.boolean.type).toBe("boolean"); expect(fragment!.props.color.type).toBe("string"); expect(fragment!.props.color.controlType).toBe("color"); expect(fragment!.props.date.type).toBe("string"); expect(fragment!.props.date.controlType).toBe("date"); expect(fragment!.props.object.type).toBe("object"); expect(fragment!.props.radio.type).toBe("enum"); expect(fragment!.props.inlineRadio.type).toBe("enum"); expect(fragment!.props.select.type).toBe("enum"); expect(fragment!.props.multiSelect.type).toBe("enum"); }); it("should handle action argTypes as functions", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, argTypes: { onClick: { action: "clicked", }, }, }, Default: { args: {} } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.props.onClick.type).toBe("function"); }); it("should skip disabled argTypes", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, argTypes: { visible: { control: "boolean" }, hidden: { control: "boolean", table: { disable: true } }, }, }, Default: { args: {} } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.props.visible).toBeDefined(); expect(fragment!.props.hidden).toBeUndefined(); }); }); describe("decorators", () => { it("should apply story decorators", () => { let decoratorCalled = false; const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, WithDecorator: { args: { label: "Test" }, decorators: [ (Story) => { decoratorCalled = true; return createElement("div", { className: "wrapper" }, Story()); }, ], } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); fragment!.variants[0].render(); expect(decoratorCalled).toBe(true); }); it("should apply meta decorators to all stories", () => { let metaDecoratorCount = 0; const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, decorators: [ (Story) => { metaDecoratorCount++; return Story(); }, ], }, Story1: { args: { label: "1" } } as Story, Story2: { args: { label: "2" } } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); fragment!.variants[0].render(); fragment!.variants[1].render(); expect(metaDecoratorCount).toBe(2); }); it("should apply global decorators from preview config", () => { let globalDecoratorCalled = false; setPreviewConfig({ decorators: [ (Story) => { globalDecoratorCalled = true; return Story(); }, ], }); const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, Default: { args: { label: "Test" } } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); fragment!.variants[0].render(); expect(globalDecoratorCalled).toBe(true); }); }); describe("includeStories/excludeStories filtering", () => { it("should filter stories using includeStories array", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, includeStories: ["Primary", "Secondary"], }, Primary: { args: { label: "Primary" } } as Story, Secondary: { args: { label: "Secondary" } } as Story, Excluded: { args: { label: "Excluded" } } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants).toHaveLength(2); expect(fragment!.variants.map((v) => v.name)).toContain("Primary"); expect(fragment!.variants.map((v) => v.name)).toContain("Secondary"); expect(fragment!.variants.map((v) => v.name)).not.toContain("Excluded"); }); it("should filter stories using excludeStories array", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, excludeStories: ["Internal"], }, Primary: { args: { label: "Primary" } } as Story, Internal: { args: { label: "Internal" } } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants).toHaveLength(1); expect(fragment!.variants[0].name).toBe("Primary"); }); it("should filter stories using excludeStories regex", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, excludeStories: /^_/, }, Primary: { args: { label: "Primary" } } as Story, _Private: { args: { label: "Private" } } as Story, _Internal: { args: { label: "Internal" } } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants).toHaveLength(1); expect(fragment!.variants[0].name).toBe("Primary"); }); }); describe("loaders", () => { it("should collect loaders from story", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, }, WithLoader: { args: { label: "Test" }, loaders: [async () => ({ data: "loaded" })], } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); expect(fragment!.variants[0].loaders).toBeDefined(); expect(fragment!.variants[0].loaders).toHaveLength(1); }); it("should collect loaders from meta and story", () => { const storyModule: StoryModule = { default: { title: "Components/Button", component: MockComponent, loaders: [async () => ({ metaData: "meta" })], }, WithLoader: { args: { label: "Test" }, loaders: [async () => ({ storyData: "story" })], } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); // Should have both meta and story loaders expect(fragment!.variants[0].loaders).toHaveLength(2); }); }); describe("story ID generation", () => { it("should generate correct story IDs using toId", () => { const storyModule: StoryModule = { default: { title: "Components/Forms/Button", component: MockComponent, }, Primary: { args: { label: "Primary" } } as Story, WithIcon: { args: { label: "With Icon" } } as Story, }; const fragment = storyModuleToFragment(storyModule, "Button.stories.tsx"); // toId lowercases the export name without adding hyphens expect(fragment!.variants[0].storyId).toBe("components-forms-button--primary"); expect(fragment!.variants[1].storyId).toBe("components-forms-button--withicon"); }); }); }); describe("@storybook/csf utilities", () => { describe("toId", () => { it("should generate kebab-case story IDs", () => { // toId converts title to kebab-case and lowercases export name expect(toId("Components/Button", "Primary")).toBe("components-button--primary"); expect(toId("Design System/Forms/Input", "WithLabel")).toBe( "design-system-forms-input--withlabel" ); }); }); describe("storyNameFromExport", () => { it("should convert export names to display names", () => { expect(storyNameFromExport("Primary")).toBe("Primary"); expect(storyNameFromExport("WithIcon")).toBe("With Icon"); expect(storyNameFromExport("PrimaryButton")).toBe("Primary Button"); }); }); describe("isExportStory", () => { it("should identify valid story exports", () => { const meta: StoryMeta = { title: "Test", component: MockComponent, }; // All named exports are considered potential stories by isExportStory // The actual filtering of non-story exports (like "default") happens // in our extractVariants function where we check for story-like objects expect(isExportStory("Primary", meta)).toBe(true); expect(isExportStory("Secondary", meta)).toBe(true); }); it("should respect includeStories", () => { const meta: StoryMeta = { title: "Test", component: MockComponent, includeStories: ["Primary"], }; expect(isExportStory("Primary", meta)).toBe(true); expect(isExportStory("Secondary", meta)).toBe(false); }); it("should respect excludeStories", () => { const meta: StoryMeta = { title: "Test", component: MockComponent, excludeStories: ["Internal"], }; expect(isExportStory("Primary", meta)).toBe(true); expect(isExportStory("Internal", meta)).toBe(false); }); }); });