Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions code/addons/vitest/src/postinstall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { isConfigAlreadySetup } from './postinstall';

describe('postinstall helpers', () => {
it('detects a fully configured Vitest config with addon plugin', () => {
const config = `
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';

export default defineConfig({
test: {
projects: [
{
extends: true,
plugins: [storybookTest({ configDir: '.storybook' })],
test: {
setupFiles: ['./.storybook/vitest.setup.ts'],
},
},
],
},
});
`;

expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(true);
});

it('returns false when storybookTest plugin is not used', () => {
const config = `
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
projects: [
{
extends: true,
test: {
setupFiles: ['./.storybook/vitest.setup.ts'],
},
},
],
},
});
`;

expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(false);
});
});
96 changes: 78 additions & 18 deletions code/addons/vitest/src/postinstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { writeFile } from 'node:fs/promises';

import { babelParse, generate } from 'storybook/internal/babel';
import { babelParse, generate, traverse } from 'storybook/internal/babel';
import { AddonVitestService } from 'storybook/internal/cli';
import {
JsPackageManagerFactory,
Expand All @@ -15,7 +15,6 @@ import type { StorybookError } from 'storybook/internal/server-errors';
import {
AddonVitestPostinstallConfigUpdateError,
AddonVitestPostinstallError,
AddonVitestPostinstallExistingSetupFileError,
AddonVitestPostinstallFailedAddonA11yError,
AddonVitestPostinstallPrerequisiteCheckError,
AddonVitestPostinstallWorkspaceUpdateError,
Expand All @@ -33,6 +32,7 @@ import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVit

const ADDON_NAME = '@storybook/addon-vitest' as const;
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.cts', '.mts', '.cjs', '.mjs'];
const STORYBOOK_TEST_PLUGIN_SOURCE = `${ADDON_NAME}/vitest-plugin`;

const addonA11yName = '@storybook/addon-a11y';

Expand Down Expand Up @@ -174,17 +174,13 @@ export default async function postInstall(options: PostinstallOptions) {
allDeps.typescript || findFile('tsconfig', [...EXTENSIONS, '.json']) ? 'ts' : 'js';

const vitestSetupFile = resolve(options.configDir, `vitest.setup.${fileExtension}`);
const existingSetupFile =
EXTENSIONS.map((ext) => resolve(options.configDir, `vitest.setup${ext}`)).find(existsSync) ||
null;

if (existsSync(vitestSetupFile)) {
const errorMessage = dedent`
Found an existing Vitest setup file:
${vitestSetupFile}
Please refer to the documentation to complete the setup manually:
https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup-advanced
`;
logger.line();
logger.error(`${errorMessage}\n`);
errors.push(new AddonVitestPostinstallExistingSetupFileError({ filePath: vitestSetupFile }));
if (existingSetupFile) {
logger.step(`Found existing Vitest setup file, reusing:`);
logger.log(`${existingSetupFile}\n`);
} else {
logger.step(`Creating a Vitest setup file for Storybook:`);
logger.log(`${vitestSetupFile}\n`);
Expand Down Expand Up @@ -243,16 +239,25 @@ export default async function postInstall(options: PostinstallOptions) {
// If there's an existing workspace file, we update that file to include the Storybook Addon Vitest plugin.
// We assume the existing workspaces include the Vite(st) config, so we won't add it.
if (vitestWorkspaceFile) {
const workspaceFileContent = await fs.readFile(vitestWorkspaceFile, 'utf8');
const alreadyConfigured = isConfigAlreadySetup(vitestWorkspaceFile, workspaceFileContent);

if (alreadyConfigured) {
logger.step(
CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.')
);
return;
}

const workspaceTemplate = await loadTemplate('vitest.workspace.template.ts', {
EXTENDS_WORKSPACE: viteConfigFile
? relative(dirname(vitestWorkspaceFile), viteConfigFile)
: '',
CONFIG_DIR: options.configDir,
SETUP_FILE: relative(dirname(vitestWorkspaceFile), vitestSetupFile),
SETUP_FILE: relative(dirname(vitestWorkspaceFile), existingSetupFile ?? vitestSetupFile),
}).then((t) => t.replace(`\n 'ROOT_CONFIG',`, '').replace(/\s+extends: '',/, ''));
const workspaceFile = await fs.readFile(vitestWorkspaceFile, 'utf8');
const source = babelParse(workspaceTemplate);
const target = babelParse(workspaceFile);
const target = babelParse(workspaceFileContent);

const updated = updateWorkspaceFile(source, target);
if (updated) {
Expand Down Expand Up @@ -290,18 +295,24 @@ export default async function postInstall(options: PostinstallOptions) {

const templateName = getTemplateName();

if (templateName) {
const alreadyConfigured = isConfigAlreadySetup(rootConfig, configFile);

if (templateName && !alreadyConfigured) {
const configTemplate = await loadTemplate(templateName, {
CONFIG_DIR: options.configDir,
SETUP_FILE: relative(dirname(rootConfig), vitestSetupFile),
SETUP_FILE: relative(dirname(rootConfig), existingSetupFile ?? vitestSetupFile),
});

const source = babelParse(configTemplate);
target = babelParse(configFile);
updated = updateConfigFile(source, target);
}

if (target && updated) {
if (alreadyConfigured) {
logger.step(
CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.')
);
Comment on lines +312 to +314

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could reuse the message above.

} else if (target && updated) {
logger.step(`Updating your ${vitestConfigFile ? 'Vitest' : 'Vite'} config file:`);
logger.log(` ${rootConfig}`);

Expand Down Expand Up @@ -412,3 +423,52 @@ export default async function postInstall(options: PostinstallOptions) {
throw new AddonVitestPostinstallError({ errors });
}
}

function isStorybookTestPluginSource(value: string) {
return value === STORYBOOK_TEST_PLUGIN_SOURCE;
}
Comment on lines +427 to +429

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a bit unnecessary to have a function for such a trivial thing?


export function isConfigAlreadySetup(_configPath: string, configContent: string) {
let ast: ReturnType<typeof babelParse>;
try {
ast = babelParse(configContent);
} catch (e) {
return false;
}

const pluginIdentifiers = new Set<string>();

traverse(ast, {
ImportDeclaration(path) {
const source = path.node.source.value;
if (typeof source === 'string' && isStorybookTestPluginSource(source)) {
path.node.specifiers.forEach((specifier) => {
if ('local' in specifier && specifier.local?.name) {
pluginIdentifiers.add(specifier.local.name);
}
});
}
},
});

let pluginReferenced = false;

traverse(ast, {
CallExpression(path) {
if (pluginReferenced) {
path.stop();
return;
}
const callee = path.node.callee;
if (
callee.type === 'Identifier' &&
(pluginIdentifiers.has(callee.name) || callee.name === 'storybookTest')
) {
pluginReferenced = true;
path.stop();
}
},
});

return pluginReferenced;
Comment thread
valentinpalkovic marked this conversation as resolved.
}
16 changes: 0 additions & 16 deletions code/core/src/server-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,22 +482,6 @@ export class AddonVitestPostinstallFailedAddonA11yError extends StorybookError {
}
}

export class AddonVitestPostinstallExistingSetupFileError extends StorybookError {
constructor(public data: { filePath: string }) {
super({
name: 'AddonVitestPostinstallExistingSetupFileError',
category: Category.CLI_INIT,
isHandledError: true,
code: 7,
documentation: `https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup-advanced`,
message: dedent`
Found an existing Vitest setup file: ${data.filePath}
Please refer to the documentation to complete the setup manually.
`,
});
}
}

export class AddonVitestPostinstallWorkspaceUpdateError extends StorybookError {
constructor(public data: { filePath: string }) {
super({
Expand Down
Loading