Skip to content

Commit 30ea99d

Browse files
committed
wip prototype for test fn plugin
1 parent 466e109 commit 30ea99d

File tree

3 files changed

+265
-0
lines changed

3 files changed

+265
-0
lines changed

code/core/src/csf-tools/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from './ConfigFile';
33
export * from './getStorySortParameter';
44
export * from './enrichCsf';
55
export { babelParse } from 'storybook/internal/babel';
6+
export { testTransform } from './test-syntax/transformer';
67
export { vitestTransform } from './vitest-plugin/transformer';
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { testTransform as originalTransform } from './transformer';
4+
5+
vi.mock('storybook/internal/common', async (importOriginal) => {
6+
const actual = await importOriginal<typeof import('storybook/internal/common')>();
7+
return {
8+
...actual,
9+
getStoryTitle: vi.fn(() => 'automatic/calculated/title'),
10+
};
11+
});
12+
13+
expect.addSnapshotSerializer({
14+
serialize: (val: any) => (typeof val === 'string' ? val : val.toString()),
15+
test: (val) => true,
16+
});
17+
18+
const transform = async ({
19+
code = '',
20+
fileName = 'src/components/Button.stories.js',
21+
configDir = '.storybook',
22+
stories = [],
23+
}) => {
24+
const transformed = await originalTransform({
25+
code,
26+
fileName,
27+
configDir,
28+
stories,
29+
});
30+
if (typeof transformed === 'string') {
31+
return { code: transformed, map: null };
32+
}
33+
34+
return transformed;
35+
};
36+
37+
describe('transformer', () => {
38+
describe('test syntax', () => {
39+
it('should add test statement to const declared exported stories', async () => {
40+
const code = `
41+
import { config } from '#.storybook/preview';
42+
const meta = config.meta({ component: Button });
43+
export const Primary = meta.story({
44+
args: {
45+
label: 'Primary Button',
46+
}
47+
});
48+
49+
Primary.test('some test name here', () => {
50+
console.log('test');
51+
});
52+
Primary.test('something else here too', () => {
53+
console.log('test');
54+
});
55+
`;
56+
57+
const result = await transform({ code });
58+
59+
expect(result.code).toMatchInlineSnapshot(`
60+
import { config } from '#.storybook/preview';
61+
const meta = config.meta({
62+
component: Button,
63+
title: "automatic/calculated/title"
64+
});
65+
export const Primary = meta.story({
66+
args: {
67+
label: 'Primary Button'
68+
}
69+
});
70+
export const _test = {
71+
...Primary,
72+
play: async context => {
73+
await (Primary?.play)();
74+
console.log('test');
75+
},
76+
storyName: "Primary: some test name here"
77+
};
78+
export const _test2 = {
79+
...Primary,
80+
play: async context => {
81+
await (Primary?.play)();
82+
console.log('test');
83+
},
84+
storyName: "Primary: something else here too"
85+
};
86+
`);
87+
});
88+
});
89+
});
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/* eslint-disable local-rules/no-uncategorized-errors */
2+
import { types as t } from 'storybook/internal/babel';
3+
import { getStoryTitle } from 'storybook/internal/common';
4+
import type { StoriesEntry } from 'storybook/internal/types';
5+
6+
import { dedent } from 'ts-dedent';
7+
8+
import { formatCsf, loadCsf } from '../CsfFile';
9+
10+
const logger = console;
11+
12+
export async function testTransform({
13+
code,
14+
fileName,
15+
configDir,
16+
stories,
17+
}: {
18+
code: string;
19+
fileName: string;
20+
configDir: string;
21+
stories: StoriesEntry[];
22+
}): Promise<ReturnType<typeof formatCsf>> {
23+
const isStoryFile = /\.stor(y|ies)\./.test(fileName);
24+
if (!isStoryFile) {
25+
return code;
26+
}
27+
28+
const parsed = loadCsf(code, {
29+
fileName,
30+
transformInlineMeta: true,
31+
makeTitle: (title) => {
32+
const result =
33+
getStoryTitle({
34+
storyFilePath: fileName,
35+
configDir,
36+
stories,
37+
userTitle: title,
38+
}) || 'unknown';
39+
40+
if (result === 'unknown') {
41+
logger.warn(
42+
dedent`
43+
[Storybook]: Could not calculate story title for "${fileName}".
44+
Please make sure that this file matches the globs included in the "stories" field in your Storybook configuration at "${configDir}".
45+
`
46+
);
47+
}
48+
return result;
49+
},
50+
}).parse();
51+
52+
const ast = parsed._ast;
53+
54+
const metaNode = parsed._metaNode as t.ObjectExpression;
55+
56+
const metaTitleProperty = metaNode.properties.find(
57+
(prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'title'
58+
);
59+
60+
const metaTitle = t.stringLiteral(parsed._meta?.title || 'unknown');
61+
if (!metaTitleProperty) {
62+
metaNode.properties.push(t.objectProperty(t.identifier('title'), metaTitle));
63+
} else if (t.isObjectProperty(metaTitleProperty)) {
64+
// If the title is present in meta, overwrite it because autotitle can still affect existing titles
65+
metaTitleProperty.value = metaTitle;
66+
}
67+
68+
if (!metaNode || !parsed._meta) {
69+
throw new Error(
70+
'Storybook could not detect the meta (default export) object in the story file. \n\nPlease make sure you have a default export with the meta object. If you are using a different export format that is not supported, please file an issue with details about your use case.'
71+
);
72+
}
73+
74+
// Generate new story exports from tests attached to stories
75+
const newExports: t.ExportNamedDeclaration[] = [];
76+
let testCounter = 1;
77+
78+
// Track nodes to remove from the AST
79+
const nodesToRemove: t.Node[] = [];
80+
81+
// Process each story to find attached tests
82+
Object.entries(parsed._stories).forEach(([storyExportName, storyInfo]) => {
83+
// Find all test calls on this story in the AST
84+
ast.program.body.forEach((node) => {
85+
if (!t.isExpressionStatement(node)) {
86+
return;
87+
}
88+
89+
const { expression } = node;
90+
91+
if (!t.isCallExpression(expression)) {
92+
return;
93+
}
94+
95+
const { callee, arguments: args } = expression;
96+
97+
// Check if it's a call like StoryName.test()
98+
if (
99+
t.isMemberExpression(callee) &&
100+
t.isIdentifier(callee.object) &&
101+
callee.object.name === storyExportName &&
102+
t.isIdentifier(callee.property) &&
103+
callee.property.name === 'test' &&
104+
args.length >= 2 &&
105+
t.isStringLiteral(args[0]) &&
106+
(t.isFunctionExpression(args[1]) || t.isArrowFunctionExpression(args[1]))
107+
) {
108+
// Get test name and body
109+
const testName = (args[0] as t.StringLiteral).value;
110+
const testFunction = args[1] as t.FunctionExpression | t.ArrowFunctionExpression;
111+
112+
// Create unique export name for the test story
113+
const testExportName = `_test${testCounter > 1 ? testCounter : ''}`;
114+
testCounter++;
115+
116+
// Create a new story object with the test function integrated as play function
117+
const newStoryObject = t.objectExpression([
118+
t.spreadElement(t.identifier(storyExportName)),
119+
t.objectProperty(
120+
t.identifier('play'),
121+
t.arrowFunctionExpression(
122+
[t.identifier('context')],
123+
t.blockStatement([
124+
// Add code to call the original story's play function if it exists
125+
t.expressionStatement(
126+
t.awaitExpression(
127+
t.callExpression(
128+
t.optionalMemberExpression(
129+
t.identifier(storyExportName),
130+
t.identifier('play'),
131+
false,
132+
true
133+
),
134+
[]
135+
)
136+
)
137+
),
138+
// Then add the test function body
139+
...(t.isBlockStatement(testFunction.body)
140+
? testFunction.body.body
141+
: [t.expressionStatement(testFunction.body)]),
142+
]),
143+
true // async
144+
)
145+
),
146+
t.objectProperty(
147+
t.identifier('storyName'),
148+
t.stringLiteral(`${storyInfo.name || storyExportName}: ${testName}`)
149+
),
150+
]);
151+
152+
// Create export statement
153+
const exportDeclaration = t.exportNamedDeclaration(
154+
t.variableDeclaration('const', [
155+
t.variableDeclarator(t.identifier(testExportName), newStoryObject),
156+
]),
157+
[]
158+
);
159+
160+
newExports.push(exportDeclaration);
161+
162+
// Mark the original test call for removal
163+
nodesToRemove.push(node);
164+
}
165+
});
166+
});
167+
168+
// Remove the test calls from the AST
169+
ast.program.body = ast.program.body.filter((node) => !nodesToRemove.includes(node));
170+
171+
// Add new exports to the AST
172+
ast.program.body.push(...newExports);
173+
174+
return formatCsf(parsed, { sourceMaps: true, sourceFileName: fileName }, code);
175+
}

0 commit comments

Comments
 (0)