Parse and manipulate CSF and Storybook config files
—
Transform CSF files for integration with testing frameworks, particularly Vitest, enabling automated testing of Storybook stories. This module provides the foundation for running Storybook stories as tests.
Transform CSF files into Vitest-compatible test files that can execute stories as individual test cases.
/**
* Transform CSF file for Vitest testing framework
* @param options - Transformation configuration
* @returns Promise resolving to transformed code with source maps
*/
async function vitestTransform(options: VitestTransformOptions): Promise<ReturnType<typeof formatCsf>>;
interface VitestTransformOptions {
/** Source code of the CSF file to transform */
code: string;
/** File name for the CSF file being transformed */
fileName: string;
/** Storybook configuration directory path */
configDir: string;
/** Stories configuration entries */
stories: StoriesEntry[];
/** Tag-based filtering configuration */
tagsFilter: TagsFilter;
/** Optional preview-level tags to apply */
previewLevelTags?: Tag[];
}
interface TagsFilter {
/** Tags that must be present for test inclusion */
include: string[];
/** Tags that exclude tests from running */
exclude: string[];
/** Tags that mark tests to be skipped */
skip: string[];
}
interface StoriesEntry {
/** Glob pattern for story files */
directory: string;
/** File patterns to match */
files: string;
/** Title prefix for stories */
titlePrefix?: string;
}Usage Examples:
import { vitestTransform } from "@storybook/csf-tools";
const csfCode = `
export default {
title: 'Button',
tags: ['component', 'ui']
};
export const Primary = {
args: { primary: true },
tags: ['test', 'smoke']
};
export const Secondary = {
args: { primary: false },
tags: ['test']
};
`;
const transformed = await vitestTransform({
code: csfCode,
fileName: './src/Button.stories.ts',
configDir: '.storybook',
stories: [
{ directory: './src', files: '**/*.stories.@(js|ts|jsx|tsx)' }
],
tagsFilter: {
include: ['test'],
exclude: ['skip'],
skip: ['wip']
},
previewLevelTags: ['autodocs']
});
console.log(transformed); // Vitest-compatible test codeExtract story sort parameters from Storybook preview configuration for proper test ordering.
/**
* Extract story sort parameter from preview configuration code
* @param previewCode - Source code of preview.js or preview.ts
* @returns Extracted sort function/configuration or undefined if not found
*/
function getStorySortParameter(previewCode: string): any;Usage Examples:
import { getStorySortParameter } from "@storybook/csf-tools";
const previewCode = `
export default {
parameters: {
options: {
storySort: {
order: ['Introduction', 'Example', 'Components', '*'],
method: 'alphabetical'
}
}
}
};
`;
const sortConfig = getStorySortParameter(previewCode);
console.log(sortConfig); // { order: [...], method: 'alphabetical' }Transform a simple CSF file into Vitest tests:
// Input CSF file
const storyCode = `
export default { title: 'Components/Button' };
export const Default = {};
export const WithText = { args: { children: 'Click me' } };
export const Disabled = { args: { disabled: true } };
`;
// Transform for testing
const testCode = await vitestTransform({
code: storyCode,
fileName: 'Button.stories.ts',
configDir: '.storybook',
stories: [{ directory: './src', files: '**/*.stories.ts' }],
tagsFilter: { include: [], exclude: [], skip: [] }
});
// Generated test structure:
// - Imports Vitest test functions
// - Imports testStory helper from Storybook
// - Creates test cases for each valid story
// - Handles story composition and executionControl which stories become tests using tags:
const taggedStories = `
export default {
title: 'Button',
tags: ['component']
};
export const Interactive = {
tags: ['test', 'interaction'],
play: async ({ canvasElement }) => {
// Interaction test
}
};
export const Visual = {
tags: ['visual', 'skip-ci']
};
export const Documentation = {
tags: ['docs-only']
};
`;
// Only include stories tagged for testing
const testCode = await vitestTransform({
code: taggedStories,
fileName: 'Button.stories.ts',
configDir: '.storybook',
stories: [{ directory: './src', files: '**/*.stories.ts' }],
tagsFilter: {
include: ['test'], // Must have 'test' tag
exclude: ['docs-only'], // Exclude documentation-only stories
skip: ['skip-ci'] // Skip in CI environment
}
});Handle automatic title generation based on file paths:
const storiesConfig = [
{
directory: './src/components',
files: '**/*.stories.@(js|ts)',
titlePrefix: 'Components'
},
{
directory: './src/pages',
files: '**/*.stories.@(js|ts)',
titlePrefix: 'Pages'
}
];
// Transform with automatic title generation
const testCode = await vitestTransform({
code: storyCode,
fileName: './src/components/Button/Button.stories.ts',
configDir: '.storybook',
stories: storiesConfig,
tagsFilter: { include: [], exclude: [], skip: [] }
});
// Result: Stories get title "Components/Button/Button"Apply tags at the preview level that affect all stories:
const testCode = await vitestTransform({
code: storyCode,
fileName: 'Button.stories.ts',
configDir: '.storybook',
stories: storiesConfig,
tagsFilter: {
include: ['test'],
exclude: ['dev-only'],
skip: []
},
previewLevelTags: ['autodocs', 'test-utils'] // Applied to all stories
});The Vitest transformation produces test files with the following structure:
// Generated imports
import { test, expect } from 'vitest';
import { testStory } from '@storybook/experimental-addon-test/internal/test-utils';
import { default as _meta, Primary, Secondary } from './Button.stories';Prevents duplicate test execution when stories are imported:
// Generated guard to prevent duplicate execution
const isRunningFromThisFile = import.meta.url.includes(
globalThis.__vitest_worker__?.filepath ?? expect.getState().testPath
);
if (isRunningFromThisFile) {
// Test cases go here
}Each valid story becomes a test case:
// Generated test cases
test('Primary', testStory('Primary', Primary, _meta, skipTags));
test('Secondary', testStory('Secondary', Secondary, _meta, skipTags));Files with no valid tests get a skip block:
// Generated for files with no valid stories
describe.skip('No valid tests found');Extract and apply custom story sorting from preview configuration:
import { getStorySortParameter, vitestTransform } from "@storybook/csf-tools";
// Extract sort configuration
const previewCode = await fs.readFile('.storybook/preview.js', 'utf-8');
const sortConfig = getStorySortParameter(previewCode);
// Use in transformation (sorting logic is handled by Storybook core)
const testCode = await vitestTransform({
code: storyCode,
fileName: 'Button.stories.ts',
configDir: '.storybook',
stories: storiesConfig,
tagsFilter: { include: ['test'], exclude: [], skip: [] }
});Transform multiple story files for testing:
import { glob } from 'glob';
import { vitestTransform } from "@storybook/csf-tools";
import { readFile, writeFile } from 'fs/promises';
async function generateTests(
storyPattern: string,
outputDir: string,
tagsFilter: TagsFilter
) {
const storyFiles = glob.sync(storyPattern);
for (const storyFile of storyFiles) {
const code = await readFile(storyFile, 'utf-8');
const testCode = await vitestTransform({
code,
fileName: storyFile,
configDir: '.storybook',
stories: [{ directory: './src', files: '**/*.stories.@(js|ts)' }],
tagsFilter
});
const testFile = storyFile
.replace('.stories.', '.test.')
.replace('./src/', `${outputDir}/`);
await writeFile(testFile, testCode as string);
console.log(`Generated: ${testFile}`);
}
}
// Generate test files for all stories
await generateTests(
'./src/**/*.stories.ts',
'./tests/generated',
{ include: ['test'], exclude: ['visual'], skip: ['wip'] }
);The transformation system integrates with Storybook's testing utilities:
The generated tests use the testStory helper function:
// From @storybook/experimental-addon-test/internal/test-utils
function testStory(
exportName: string,
story: any,
meta: any,
skipTags: string[]
): () => Promise<void>;This function handles:
Install with Tessl CLI
npx tessl i tessl/npm-storybook--csf-tools