Merge pull request #28768 from storybookjs/vitest-integration

Addon Vitest: Add experimental vitest integration
This commit is contained in:
Yann Braga 2024-08-14 07:29:51 -07:00 committed by GitHub
commit bdea6d23c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
161 changed files with 3760 additions and 982 deletions

View File

@ -233,7 +233,7 @@ jobs:
name: Run tests
command: |
cd scripts
yarn test --coverage --coverage.all=false
yarn test --coverage
- store_test_results:
path: scripts/junit.xml
- report-workflow-on-failure
@ -241,7 +241,7 @@ jobs:
unit-tests:
executor:
class: xlarge
name: sb_node_22_browsers
name: sb_playwright
steps:
- git-shallow-clone/checkout_advanced:
clone_options: "--depth 1 --verbose"
@ -251,7 +251,7 @@ jobs:
name: Test
command: |
cd code
yarn test --coverage --coverage.all=false
yarn test --coverage
- store_test_results:
path: code/junit.xml
- persist_to_workspace:
@ -398,6 +398,26 @@ jobs:
template: $(yarn get-template --cadence << pipeline.parameters.workflow >> --task test-runner)
- store_test_results:
path: test-results
vitest-integration:
parameters:
parallelism:
type: integer
executor:
class: large
name: sb_playwright
parallelism: << parameters.parallelism >>
steps:
- git-shallow-clone/checkout_advanced:
clone_options: "--depth 1 --verbose"
- attach_workspace:
at: .
- run:
name: Running story tests in Vitest
command: yarn task --task vitest-integration --template $(yarn get-template --cadence << pipeline.parameters.workflow >> --task vitest-integration) --no-link --start-from=never --junit
- report-workflow-on-failure:
template: $(yarn get-template --cadence << pipeline.parameters.workflow >> --task vitest-integration)
- store_test_results:
path: test-results
test-runner-dev:
parameters:
parallelism:
@ -679,19 +699,19 @@ workflows:
requires:
- unit-tests
- create-sandboxes:
parallelism: 13
parallelism: 14
requires:
- build
- build-sandboxes:
parallelism: 13
parallelism: 14
requires:
- create-sandboxes
- chromatic-sandboxes:
parallelism: 10
parallelism: 11
requires:
- build-sandboxes
- e2e-production:
parallelism: 8
parallelism: 9
requires:
- build-sandboxes
- e2e-dev:
@ -699,9 +719,13 @@ workflows:
requires:
- create-sandboxes
- test-runner-production:
parallelism: 8
parallelism: 9
requires:
- build-sandboxes
- vitest-integration:
parallelism: 4
requires:
- create-sandboxes
- bench:
parallelism: 5
requires:
@ -741,19 +765,19 @@ workflows:
requires:
- unit-tests
- create-sandboxes:
parallelism: 19
parallelism: 20
requires:
- build
- build-sandboxes:
parallelism: 19
parallelism: 20
requires:
- create-sandboxes
- chromatic-sandboxes:
parallelism: 16
parallelism: 17
requires:
- build-sandboxes
- e2e-production:
parallelism: 14
parallelism: 15
requires:
- build-sandboxes
- e2e-dev:
@ -761,9 +785,13 @@ workflows:
requires:
- create-sandboxes
- test-runner-production:
parallelism: 14
parallelism: 15
requires:
- build-sandboxes
- vitest-integration:
parallelism: 4
requires:
- create-sandboxes
- test-portable-stories:
requires:
- build
@ -827,6 +855,10 @@ workflows:
parallelism: 33
requires:
- build-sandboxes
- vitest-integration:
parallelism: 8
requires:
- create-sandboxes
- test-portable-stories:
requires:
- build

View File

@ -24,5 +24,9 @@ jobs:
- name: install and compile
run: yarn task --task compile --start-from=auto
- name: Install Playwright Dependencies
run: cd code && yarn exec playwright install chromium --with-deps
- name: test
run: yarn test

View File

@ -1,6 +1,7 @@
<h1>Migration</h1>
- [From version 8.2.x to 8.3.x](#from-version-82x-to-83x)
- [Removed `experimental_SIDEBAR_BOTTOM` and deprecated `experimental_SIDEBAR_TOP` addon types](#removed-experimental_sidebar_bottom-and-deprecated-experimental_sidebar_top-addon-types)
- [New parameters format for addon backgrounds](#new-parameters-format-for-addon-backgrounds)
- [New parameters format for addon viewport](#new-parameters-format-for-addon-viewport)
- [From version 8.1.x to 8.2.x](#from-version-81x-to-82x)
@ -104,17 +105,17 @@
- [Tab addons cannot manually route, Tool addons can filter their visibility via tabId](#tab-addons-cannot-manually-route-tool-addons-can-filter-their-visibility-via-tabid)
- [Removed `config` preset](#removed-config-preset-1)
- [From version 7.5.0 to 7.6.0](#from-version-750-to-760)
- [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated)
- [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated)
- [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated)
- [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop)
- [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react)
- [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated)
- [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated)
- [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated)
- [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop)
- [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react)
- [From version 7.4.0 to 7.5.0](#from-version-740-to-750)
- [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated)
- [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers)
- [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated)
- [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers)
- [From version 7.0.0 to 7.2.0](#from-version-700-to-720)
- [Addon API is more type-strict](#addon-api-is-more-type-strict)
- [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated)
- [Addon API is more type-strict](#addon-api-is-more-type-strict)
- [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated)
- [From version 6.5.x to 7.0.0](#from-version-65x-to-700)
- [7.0 breaking changes](#70-breaking-changes)
- [Dropped support for Node 15 and below](#dropped-support-for-node-15-and-below)
@ -140,7 +141,7 @@
- [Deploying build artifacts](#deploying-build-artifacts)
- [Dropped support for file URLs](#dropped-support-for-file-urls)
- [Serving with nginx](#serving-with-nginx)
- [Ignore story files from node\_modules](#ignore-story-files-from-node_modules)
- [Ignore story files from node_modules](#ignore-story-files-from-node_modules)
- [7.0 Core changes](#70-core-changes)
- [7.0 feature flags removed](#70-feature-flags-removed)
- [Story context is prepared before for supporting fine grained updates](#story-context-is-prepared-before-for-supporting-fine-grained-updates)
@ -154,7 +155,7 @@
- [Addon-interactions: Interactions debugger is now default](#addon-interactions-interactions-debugger-is-now-default)
- [7.0 Vite changes](#70-vite-changes)
- [Vite builder uses Vite config automatically](#vite-builder-uses-vite-config-automatically)
- [Vite cache moved to node\_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook)
- [Vite cache moved to node_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook)
- [7.0 Webpack changes](#70-webpack-changes)
- [Webpack4 support discontinued](#webpack4-support-discontinued)
- [Babel mode v7 exclusively](#babel-mode-v7-exclusively)
@ -204,7 +205,7 @@
- [Dropped addon-docs manual babel configuration](#dropped-addon-docs-manual-babel-configuration)
- [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration)
- [Autoplay in docs](#autoplay-in-docs)
- [Removed STORYBOOK\_REACT\_CLASSES global](#removed-storybook_react_classes-global)
- [Removed STORYBOOK_REACT_CLASSES global](#removed-storybook_react_classes-global)
- [7.0 Deprecations and default changes](#70-deprecations-and-default-changes)
- [storyStoreV7 enabled by default](#storystorev7-enabled-by-default)
- [`Story` type deprecated](#story-type-deprecated)
@ -419,6 +420,12 @@
## From version 8.2.x to 8.3.x
### Removed `experimental_SIDEBAR_BOTTOM` and deprecated `experimental_SIDEBAR_TOP` addon types
The experimental SIDEBAR_BOTTOM addon type was removed in favor of a built-in filter UI. The enum type definition will remain available until Storybook 9.0 but will be ignored. Similarly the experimental SIDEBAR_TOP addon type is deprecated and will be removed in a future version.
These APIs allowed addons to render arbitrary content in the Storybook sidebar. Due to potential conflicts between addons and challenges regarding styling, these APIs are/will be removed. In the future, Storybook will provide declarative API hooks to allow addons to add content to the sidebar without risk of conflicts or UI inconsistencies. One such API is `experimental_updateStatus` which allow addons to set a status for stories. The SIDEBAR_BOTTOM slot is now used to allow filtering stories with a given status.
### New parameters format for addon backgrounds
The `addon-backgrounds` addon now uses a new format for parameters. The `backgrounds` parameter is now an object with a `values` key that contains the background values.
@ -448,7 +455,7 @@ Setting an override value should now be done via a `globals` property on your co
export default {
component: Button,
globals: {
backgrounds: { value: 'twitter' },
backgrounds: { value: "twitter" },
},
};
```
@ -494,7 +501,7 @@ Setting an override value should now be done via a `globals` property on your co
export default {
component: Button,
globals: {
viewport: { value: 'phone' },
viewport: { value: "phone" },
},
};
```
@ -2411,8 +2418,8 @@ export default config;
#### Vite builder uses Vite config automatically
When using a [Vite-based framework](#framework-field-mandatory), Storybook will automatically use your `vite.config.(ctm)js` config file starting in 7.0.
Some settings will be overridden by Storybook so that it can function properly, and the merged settings can be modified using `viteFinal` in `.storybook/main.js` (see the [Storybook Vite configuration docs](https://storybook.js.org/docs/react/builders/vite#configuration)).
When using a [Vite-based framework](#framework-field-mandatory), Storybook will automatically use your `vite.config.(ctm)js` config file starting in 7.0.
Some settings will be overridden by Storybook so that it can function properly, and the merged settings can be modified using `viteFinal` in `.storybook/main.js` (see the [Storybook Vite configuration docs](https://storybook.js.org/docs/react/builders/vite#configuration)).
If you were using `viteFinal` in 6.5 to simply merge in your project's standard Vite config, you can now remove it.
For Svelte projects this means that the `svelteOptions` property in the `main.js` config should be omitted, as it will be loaded automatically via the project's `vite.config.js`.

3
code/.gitignore vendored
View File

@ -1 +1,2 @@
.nx/cache
.nx/cache
.vite-inspect

View File

@ -94,6 +94,7 @@ const config: StorybookConfig = {
'@storybook/addon-interactions',
'@storybook/addon-storysource',
'@storybook/addon-designs',
'@storybook/experimental-addon-vitest',
'@storybook/addon-a11y',
'@chromatic-com/storybook',
],

View File

@ -1,4 +1,5 @@
import React, { Fragment, useEffect } from 'react';
import * as React from 'react';
import { Fragment, useEffect } from 'react';
import type { Channel } from 'storybook/internal/channels';
import { DocsContext as DocsContextProps, useArgs } from 'storybook/internal/preview-api';
@ -160,7 +161,7 @@ export const loaders = [
}
return { docsContext };
},
];
] as Loader[];
export const decorators = [
// This decorator adds the DocsContext created in the loader above

View File

@ -0,0 +1,38 @@
import { beforeAll, vi, expect as vitestExpect } from 'vitest';
import { setProjectAnnotations } from '@storybook/react';
import { userEvent as storybookEvent, expect as storybookExpect } from '@storybook/test';
import * as coreAnnotations from '../addons/toolbars/template/stories/preview';
import * as componentAnnotations from '../core/template/stories/preview';
// register global components used in many stories
import '../renderers/react/template/components';
import * as projectAnnotations from './preview';
vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args));
const annotations = setProjectAnnotations([
// @ts-expect-error check type errors later
projectAnnotations,
// @ts-expect-error check type errors later
componentAnnotations,
coreAnnotations,
{
// experiment with injecting Vitest's interactivity API over our userEvent while tests run in browser mode
// https://vitest.dev/guide/browser/interactivity-api.html
loaders: async (context) => {
// eslint-disable-next-line no-underscore-dangle
if (globalThis.__vitest_browser__) {
const vitest = await import('@vitest/browser/context');
const { userEvent: browserEvent } = vitest;
context.userEvent = browserEvent.setup();
context.expect = vitestExpect;
} else {
context.userEvent = storybookEvent.setup();
context.expect = storybookExpect;
}
},
},
]);
beforeAll(annotations.beforeAll);

View File

@ -0,0 +1,56 @@
import { defaultExclude, defineProject, mergeConfig } from 'vitest/config';
import Inspect from 'vite-plugin-inspect';
import { vitestCommonConfig } from '../vitest.workspace';
const extraPlugins: any[] = [];
if (process.env.INSPECT === 'true') {
// this plugin assists in inspecting the Storybook Vitest plugin's transformation and sourcemaps
extraPlugins.push(
Inspect({
outputDir: '../.vite-inspect',
build: true,
open: true,
include: ['**/*.stories.*'],
})
);
}
export default mergeConfig(
vitestCommonConfig,
defineProject({
plugins: [
import('@storybook/experimental-addon-vitest/plugin').then(({ storybookTest }) =>
storybookTest({
configDir: process.cwd(),
})
),
...extraPlugins,
],
test: {
name: 'storybook-ui',
include: [
// TODO: test all core and addon stories later
// './core/**/components/**/*.{story,stories}.?(c|m)[jt]s?(x)',
'../addons/interactions/src/**/*.{story,stories}.?(c|m)[jt]s?(x)',
],
exclude: [
...defaultExclude,
'../node_modules/**',
'**/__mockdata__/**',
// expected to fail in Vitest because of fetching /iframe.html to cause ECONNREFUSED
'**/Zoom.stories.tsx',
],
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
headless: true,
screenshotFailures: false,
},
setupFiles: ['./storybook.setup.ts'],
environment: 'happy-dom',
},
})
);

View File

@ -61,7 +61,7 @@
},
"devDependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"@testing-library/react": "^14.0.0",
"lodash": "^4.17.21",
"react": "^18.2.0",

View File

@ -61,7 +61,7 @@
"ts-dedent": "^2.0.0"
},
"devDependencies": {
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.3.2"

View File

@ -57,7 +57,7 @@
},
"devDependencies": {
"@storybook/blocks": "workspace:*",
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},

View File

@ -2,7 +2,7 @@ import { global as globalThis } from '@storybook/global';
export default {
component: globalThis.Components.Button,
tags: ['autodocs', '!test'],
tags: ['autodocs', '!test', '!vitest'],
args: { label: 'Click Me!' },
parameters: { chromatic: { disable: true } },
};

View File

@ -100,7 +100,7 @@ export const WithLoaders = {
},
};
export const UserEventSetup = {
const UserEventSetup = {
play: async (context) => {
const { args, canvasElement, step } = context;
const user = userEvent.setup();
@ -123,3 +123,5 @@ export const UserEventSetup = {
});
},
};
export { UserEventSetup };

View File

@ -14,7 +14,7 @@ export default {
actions: { argTypesRegex: '^on[A-Z].*' },
chromatic: { disable: true },
},
tags: ['!test'],
tags: ['!test', '!vitest'],
};
export const Default = {

View File

@ -59,7 +59,7 @@
"upath": "^2.0.1"
},
"devDependencies": {
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resize-detector": "^7.1.2",

View File

@ -72,7 +72,7 @@
"tiny-invariant": "^1.3.1"
},
"devDependencies": {
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.3.2"

View File

@ -50,7 +50,7 @@
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"@storybook/react": "workspace:*",
"framer-motion": "^11.0.3",
"react": "^18.2.0",

View File

@ -62,7 +62,7 @@
"ts-dedent": "^2.0.0"
},
"devDependencies": {
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.3.2"

View File

@ -60,7 +60,7 @@
"ts-dedent": "^2.0.0"
},
"devDependencies": {
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"typescript": "^5.3.2"
},
"peerDependencies": {

View File

@ -56,7 +56,7 @@
},
"devDependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.3.2"

View File

@ -1,4 +1,5 @@
import { global as globalThis } from '@storybook/global';
import { expect } from '@storybook/test';
import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport';
@ -30,6 +31,21 @@ export const Selected = {
defaultViewport: first,
},
},
play: async () => {
const viewportStyles = MINIMAL_VIEWPORTS[first].styles;
const viewportDimensions = {
width: typeof viewportStyles === 'object' && Number.parseInt(viewportStyles!.width, 10),
height: typeof viewportStyles === 'object' && Number.parseInt(viewportStyles!.height, 10),
};
const windowDimensions = {
width: window.innerWidth,
height: window.innerHeight,
};
await expect(viewportDimensions).toEqual(windowDimensions);
},
tags: ['!test'],
};
export const Orientation = {

View File

@ -0,0 +1,3 @@
# Storybook Addon Vitest (Experimental)
Addon to integrate Vitest test results with Storybook.

View File

@ -0,0 +1,104 @@
{
"name": "@storybook/experimental-addon-vitest",
"version": "8.3.0-alpha.5",
"description": "Integrate Vitest with Storybook",
"keywords": [
"storybook-addons",
"addon-vitest",
"vitest",
"testing"
],
"homepage": "https://github.com/storybookjs/storybook/tree/next/code/addons/vitest",
"bugs": {
"url": "https://github.com/storybookjs/storybook/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/storybookjs/storybook.git",
"directory": "code/addons/vitest"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/storybook"
},
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"node": "./dist/index.cjs"
},
"./plugin": {
"types": "./dist/plugin/index.d.ts",
"import": "./dist/plugin/index.js",
"require": "./dist/plugin/index.cjs"
},
"./internal/global-setup": {
"types": "./dist/plugin/global-setup.d.ts",
"import": "./dist/plugin/global-setup.js",
"require": "./dist/plugin/global-setup.cjs"
},
"./internal/setup-file": {
"types": "./dist/plugin/setup-file.d.ts",
"import": "./dist/plugin/setup-file.js"
},
"./internal/test-utils": {
"types": "./dist/plugin/test-utils.d.ts",
"import": "./dist/plugin/test-utils.js",
"require": "./dist/plugin/test-utils.cjs"
},
"./manager": "./dist/manager.js",
"./preset": "./dist/preset.cjs",
"./postinstall": "./dist/postinstall.cjs",
"./package.json": "./package.json"
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**/*",
"README.md",
"*.mjs",
"*.js",
"*.cjs",
"*.d.ts",
"!src/**/*"
],
"scripts": {
"check": "jiti ../../../scripts/prepare/check.ts",
"prep": "jiti ../../../scripts/prepare/addon-bundle.ts"
},
"dependencies": {
"@storybook/csf": "^0.1.11"
},
"devDependencies": {
"@vitest/browser": "^2.0.0",
"vitest": "^2.0.0"
},
"peerDependencies": {
"@vitest/browser": "^2.0.0",
"storybook": "workspace:^",
"vitest": "^2.0.0"
},
"publishConfig": {
"access": "public"
},
"bundler": {
"exportEntries": [
"./src/index.ts",
"./src/plugin/test-utils.ts",
"./src/plugin/setup-file.ts"
],
"managerEntries": [
"./src/manager.tsx"
],
"nodeEntries": [
"./src/preset.ts",
"./src/plugin/index.ts",
"./src/plugin/global-setup.ts",
"./src/postinstall.ts"
]
}
}

View File

@ -0,0 +1 @@
module.exports = require('./dist/postinstall.js');

View File

@ -0,0 +1,8 @@
function managerEntries(entry = []) {
return [...entry, require.resolve('./dist/manager.js')];
}
module.exports = {
managerEntries,
...require('./dist/preset'),
};

View File

@ -0,0 +1,8 @@
{
"name": "vitest",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"targets": {
"build": {}
}
}

View File

@ -0,0 +1 @@
export const ADDON_ID = 'storybook/vitest';

View File

@ -0,0 +1,2 @@
// make it work with --isolatedModules
export default {};

View File

@ -0,0 +1,5 @@
import { type API, addons } from 'storybook/internal/manager-api';
import { ADDON_ID } from './constants';
addons.register(ADDON_ID, () => {});

View File

@ -0,0 +1,69 @@
/* eslint-disable no-underscore-dangle */
import { type ChildProcess, spawn } from 'node:child_process';
import type { GlobalSetupContext } from 'vitest/node';
import { logger } from 'storybook/internal/node-logger';
let storybookProcess: ChildProcess | null = null;
const checkStorybookRunning = async (storybookUrl: string): Promise<boolean> => {
try {
const response = await fetch(`${storybookUrl}/iframe.html`, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
};
const startStorybookIfNotRunning = async () => {
const storybookScript = process.env.__STORYBOOK_SCRIPT__ as string;
const storybookUrl = process.env.__STORYBOOK_URL__ as string;
const isRunning = await checkStorybookRunning(storybookUrl);
if (isRunning) {
logger.verbose('Storybook is already running');
return;
}
logger.verbose(`Starting Storybook with command: ${storybookScript}`);
try {
// We don't await the process because we don't want Vitest to hang while Storybook is starting
storybookProcess = spawn(storybookScript, [], {
stdio: process.env.DEBUG === 'storybook' ? 'pipe' : 'ignore',
cwd: process.cwd(),
shell: true,
});
storybookProcess.on('error', (error) => {
logger.verbose('Failed to start Storybook:' + error.message);
throw error;
});
} catch (error: unknown) {
logger.verbose('Failed to start Storybook:' + (error as any).message);
throw error;
}
};
const killProcess = (process: ChildProcess) => {
return new Promise((resolve, reject) => {
process.on('close', resolve);
process.on('error', reject);
process.kill();
});
};
export const setup = async ({ config }: GlobalSetupContext) => {
if (config.watch) {
await startStorybookIfNotRunning();
}
};
export const teardown = async () => {
if (storybookProcess) {
logger.verbose('Stopping Storybook process');
await killProcess(storybookProcess);
}
};

View File

@ -0,0 +1,128 @@
/* eslint-disable no-underscore-dangle */
import { join, resolve } from 'node:path';
import type { Plugin } from 'vitest/config';
import { loadAllPresets, validateConfigurationFiles } from 'storybook/internal/common';
import { vitestTransform } from 'storybook/internal/csf-tools';
import { MainFileMissingError } from 'storybook/internal/server-errors';
import type { StoriesEntry } from 'storybook/internal/types';
import type { InternalOptions, UserOptions } from './types';
const defaultOptions: UserOptions = {
storybookScript: undefined,
configDir: undefined,
storybookUrl: 'http://localhost:6006',
};
export const storybookTest = (options?: UserOptions): Plugin => {
const finalOptions = {
...defaultOptions,
...options,
tags: {
include: options?.tags?.include ?? ['test'],
exclude: options?.tags?.exclude ?? [],
skip: options?.tags?.skip ?? [],
},
} as InternalOptions;
if (process.env.DEBUG) {
finalOptions.debug = true;
}
const storybookUrl = finalOptions.storybookUrl || defaultOptions.storybookUrl;
// To be accessed by the global setup file
process.env.__STORYBOOK_URL__ = storybookUrl;
process.env.__STORYBOOK_SCRIPT__ = finalOptions.storybookScript;
let stories: StoriesEntry[];
if (!finalOptions.configDir) {
finalOptions.configDir = resolve(join(process.cwd(), '.storybook'));
} else {
finalOptions.configDir = resolve(process.cwd(), finalOptions.configDir);
}
return {
name: 'vite-plugin-storybook-test',
enforce: 'pre',
async buildStart() {
try {
await validateConfigurationFiles(finalOptions.configDir);
} catch (err) {
throw new MainFileMissingError({
location: finalOptions.configDir,
source: 'vitest',
});
}
const presets = await loadAllPresets({
configDir: finalOptions.configDir,
corePresets: [],
overridePresets: [],
packageJson: {},
});
stories = await presets.apply('stories', []);
},
async config(config) {
// If we end up needing to know if we are running in browser mode later
// const isRunningInBrowserMode = config.plugins.find((plugin: Plugin) =>
// plugin.name?.startsWith('vitest:browser')
// )
config.test ??= {};
config.test.env ??= {};
config.test.env = {
...config.test.env,
// To be accessed by the setup file
__STORYBOOK_URL__: storybookUrl,
};
config.resolve ??= {};
config.resolve.conditions ??= [];
config.resolve.conditions.push('storybook', 'stories', 'test');
config.test.setupFiles ??= [];
if (typeof config.test.setupFiles === 'string') {
config.test.setupFiles = [config.test.setupFiles];
}
config.test.setupFiles.push('@storybook/experimental-addon-vitest/internal/setup-file');
// when a Storybook script is provided, we spawn Storybook for the user when in watch mode
if (finalOptions.storybookScript) {
config.test.globalSetup = config.test.globalSetup ?? [];
if (typeof config.test.globalSetup === 'string') {
config.test.globalSetup = [config.test.globalSetup];
}
config.test.globalSetup.push('@storybook/experimental-addon-vitest/internal/global-setup');
}
config.test.server ??= {};
config.test.server.deps ??= {};
config.test.server.deps.inline ??= [];
if (Array.isArray(config.test.server.deps.inline)) {
config.test.server.deps.inline.push('@storybook/experimental-addon-vitest');
}
},
async transform(code, id) {
if (process.env.VITEST !== 'true') {
return code;
}
if (id.match(/(story|stories)\.[cm]?[jt]sx?$/)) {
return vitestTransform({
code,
fileName: id,
configDir: finalOptions.configDir,
tagsFilter: finalOptions.tags,
stories,
});
}
},
};
};
export default storybookTest;

View File

@ -0,0 +1,40 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable no-underscore-dangle */
import { afterAll, vi } from 'vitest';
import type { RunnerTask, TaskMeta } from 'vitest';
import { Channel } from 'storybook/internal/channels';
declare global {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - The module is augmented elsewhere but we need to duplicate it to avoid issues in no-link mode.
// eslint-disable-next-line no-var
var __STORYBOOK_ADDONS_CHANNEL__: Channel;
}
type ExtendedMeta = TaskMeta & { storyId: string; hasPlayFunction: boolean };
const transport = { setHandler: vi.fn(), send: vi.fn() };
globalThis.__STORYBOOK_ADDONS_CHANNEL__ = new Channel({ transport });
// The purpose of this set up file is to modify the error message of failed tests
// and inject a link to the story in Storybook
const modifyErrorMessage = (currentTask: RunnerTask) => {
const meta = currentTask.meta as ExtendedMeta;
if (
currentTask.type === 'test' &&
currentTask.result?.state === 'fail' &&
meta.storyId &&
currentTask.result.errors?.[0]
) {
const currentError = currentTask.result.errors[0];
const storybookUrl = import.meta.env.__STORYBOOK_URL__;
const storyUrl = `${storybookUrl}/?path=/story/${meta.storyId}&addonPanel=storybook/interactions/panel`;
currentError.message = `\n\x1B[34mClick to debug the error directly in Storybook: ${storyUrl}\x1B[39m\n\n${currentError.message}`;
}
};
afterAll((suite) => {
suite.tasks.forEach(modifyErrorMessage);
});

View File

@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable no-underscore-dangle */
import { type RunnerTask, type TaskContext, type TaskMeta, type TestContext } from 'vitest';
import type { ComposedStoryFn } from 'storybook/internal/types';
import type { UserOptions } from './types';
import { setViewport } from './viewports';
type TagsFilter = Required<UserOptions['tags']>;
export const isValidTest = (storyTags: string[], tagsFilter: TagsFilter) => {
const isIncluded =
tagsFilter?.include.length === 0 || tagsFilter?.include.some((tag) => storyTags.includes(tag));
const isNotExcluded = tagsFilter?.exclude.every((tag) => !storyTags.includes(tag));
return isIncluded && isNotExcluded;
};
export const testStory = (Story: ComposedStoryFn, tagsFilter: TagsFilter) => {
return async (context: TestContext & TaskContext & { story: ComposedStoryFn }) => {
if (Story === undefined || tagsFilter?.skip.some((tag) => Story.tags.includes(tag))) {
context.skip();
}
context.story = Story;
const _task = context.task as RunnerTask & { meta: TaskMeta & { storyId: string } };
_task.meta.storyId = Story.id;
await setViewport(Story.parameters.viewport);
await Story.run();
};
};

View File

@ -0,0 +1,33 @@
export type UserOptions = {
/**
* The directory where the Storybook configuration is located, relative to the vitest configuration file.
* If not provided, the plugin will use '.storybook' in the current working directory.
* @default '.storybook'
*/
configDir?: string;
/**
* Optional script to run Storybook.
* If provided, Vitest will start Storybook using this script when ran in watch mode.
* @default undefined
*/
storybookScript?: string;
/**
* The URL where Storybook is hosted.
* This is used to provide a link to the story in the test output on failures.
* @default 'http://localhost:6006'
*/
storybookUrl?: string;
/**
* Tags to include, exclude, or skip. These tags are defined as annotations in your story or meta.
*/
tags?: {
include?: string[];
exclude?: string[];
skip?: string[];
};
};
export type InternalOptions = Required<UserOptions> & {
debug: boolean;
tags: Required<UserOptions['tags']>;
};

View File

@ -0,0 +1,38 @@
/* eslint-disable no-underscore-dangle */
import { page } from '@vitest/browser/context';
import { INITIAL_VIEWPORTS } from '../../../viewport/src/defaults';
import type { ViewportMap, ViewportStyles } from '../../../viewport/src/types';
declare global {
// eslint-disable-next-line no-var, @typescript-eslint/naming-convention
var __vitest_browser__: boolean;
}
interface ViewportsParam {
defaultViewport: string;
viewports: ViewportMap;
}
export const setViewport = async (viewportsParam: ViewportsParam = {} as ViewportsParam) => {
const defaultViewport = viewportsParam.defaultViewport;
if (!page || !globalThis.__vitest_browser__ || !defaultViewport) return null;
const viewports = {
...INITIAL_VIEWPORTS,
...viewportsParam.viewports,
};
if (defaultViewport in viewports) {
const styles = viewports[defaultViewport].styles as ViewportStyles;
if (styles?.width && styles?.height) {
const { width, height } = {
width: Number.parseInt(styles.width, 10),
height: Number.parseInt(styles.height, 10),
};
await page.viewport(width, height);
}
}
return null;
};

View File

@ -0,0 +1,3 @@
export default async function postinstall(context: any) {
console.log('[addon-vitest] postinstall with', context);
}

View File

@ -0,0 +1,7 @@
import type { Channel } from 'storybook/internal/channels';
import type { Options } from 'storybook/internal/types';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const experimental_serverChannel = async (channel: Channel, options: Options) => {
return channel;
};

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "../../../",
"module": "Preserve",
"moduleResolution": "Bundler",
"types": ["vitest"]
},
"include": ["src/**/*", "./typings.d.ts"]
}

7
code/addons/vitest/typings.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
interface ImportMetaEnv {
__STORYBOOK_URL__?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,10 @@
import { defineConfig, mergeConfig } from 'vitest/config';
import { vitestCommonConfig } from '../../vitest.workspace';
export default mergeConfig(
vitestCommonConfig,
defineConfig({
// Add custom config here
})
);

View File

@ -302,7 +302,7 @@
"@radix-ui/react-slot": "^1.0.2",
"@storybook/docs-mdx": "4.0.0-next.1",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.5",
"@storybook/icons": "^1.2.10",
"@tanstack/react-virtual": "^3.3.0",
"@testing-library/react": "^14.0.0",
"@types/compression": "^1.7.0",
@ -397,6 +397,7 @@
"require-from-string": "^2.0.2",
"resolve-from": "^5.0.0",
"slash": "^5.0.0",
"source-map": "^0.7.4",
"store2": "^2.14.2",
"strip-json-comments": "^5.0.1",
"telejson": "^7.2.0",

View File

@ -40,6 +40,8 @@ export * from './utils/validate-configuration-files';
export * from './utils/satisfies';
export * from './utils/strip-abs-node-modules-path';
export * from './utils/formatter';
export * from './utils/get-story-id';
export * from './utils/posix';
export * from './js-package-manager';
export { versions };

View File

@ -1,7 +1,7 @@
import { relative } from 'node:path';
import { normalizeStories, normalizeStoryPath } from '@storybook/core/common';
import type { Options } from '@storybook/core/types';
import type { Options, StoriesEntry } from '@storybook/core/types';
import { sanitize, storyNameFromExport, toId } from '@storybook/csf';
import { userOrAutoTitleFromSpecifier } from '@storybook/core/preview-api';
@ -15,23 +15,23 @@ interface StoryIdData {
exportedStoryName: string;
}
type GetStoryIdOptions = StoryIdData & {
configDir: string;
stories: StoriesEntry[];
workingDir?: string;
userTitle?: string;
storyFilePath: string;
};
export async function getStoryId(data: StoryIdData, options: Options) {
const stories = await options.presets.apply('stories', [], options);
const workingDir = process.cwd();
const normalizedStories = normalizeStories(stories, {
const autoTitle = getStoryTitle({
...data,
stories,
configDir: options.configDir,
workingDir,
});
const relativePath = relative(workingDir, data.storyFilePath);
const importPath = posix(normalizeStoryPath(relativePath));
const autoTitle = normalizedStories
.map((normalizeStory) => userOrAutoTitleFromSpecifier(importPath, normalizeStory))
.filter(Boolean)[0];
if (autoTitle === undefined) {
// eslint-disable-next-line local-rules/no-uncategorized-errors
throw new Error(dedent`
@ -46,3 +46,23 @@ export async function getStoryId(data: StoryIdData, options: Options) {
return { storyId, kind };
}
export function getStoryTitle({
storyFilePath,
configDir,
stories,
workingDir = process.cwd(),
userTitle,
}: Omit<GetStoryIdOptions, 'exportedStoryName'>) {
const normalizedStories = normalizeStories(stories, {
configDir,
workingDir,
});
const relativePath = relative(workingDir, storyFilePath);
const importPath = posix(normalizeStoryPath(relativePath));
return normalizedStories
.map((normalizeStory) => userOrAutoTitleFromSpecifier(importPath, normalizeStory, userTitle))
.filter(Boolean)[0];
}

View File

@ -18,6 +18,7 @@ export default {
'@storybook/addon-themes': '8.3.0-alpha.5',
'@storybook/addon-toolbars': '8.3.0-alpha.5',
'@storybook/addon-viewport': '8.3.0-alpha.5',
'@storybook/experimental-addon-vitest': '8.3.0-alpha.5',
'@storybook/builder-vite': '8.3.0-alpha.5',
'@storybook/builder-webpack5': '8.3.0-alpha.5',
'@storybook/core': '8.3.0-alpha.5',

View File

@ -1,4 +1,4 @@
import type { ComponentProps, ReactNode } from 'react';
import type { ComponentProps, ReactNode, SyntheticEvent } from 'react';
import React from 'react';
import { styled } from '@storybook/core/theming';
@ -115,15 +115,19 @@ const Left = styled.span<LeftProps>(
export interface ItemProps {
disabled?: boolean;
href?: string;
onClick?: (event: SyntheticEvent, ...args: any[]) => any;
}
const Item = styled.a<ItemProps>(
const Item = styled.div<ItemProps>(
({ theme }) => ({
width: '100%',
border: 'none',
background: 'none',
fontSize: theme.typography.size.s1,
transition: 'all 150ms ease-out',
color: theme.color.dark,
textDecoration: 'none',
cursor: 'pointer',
justifyContent: 'space-between',
lineHeight: '18px',
@ -134,43 +138,34 @@ const Item = styled.a<ItemProps>(
'& > * + *': {
paddingLeft: 10,
},
'&:hover': {
background: theme.background.hoverable,
},
'&:hover svg': {
opacity: 1,
},
}),
({ disabled }) =>
disabled
? {
cursor: 'not-allowed',
}
: {}
({ theme, href, onClick }) =>
(href || onClick) && {
cursor: 'pointer',
'&:hover': {
background: theme.background.hoverable,
},
'&:hover svg': {
opacity: 1,
},
},
({ disabled }) => disabled && { cursor: 'not-allowed' }
);
const getItemProps = memoize(100)((onClick, href, LinkWrapper) => {
const result = {};
if (onClick) {
Object.assign(result, {
onClick,
});
}
if (href) {
Object.assign(result, {
href,
});
}
if (LinkWrapper && href) {
Object.assign(result, {
to: href,
const getItemProps = memoize(100)((onClick, href, LinkWrapper) => ({
...(onClick && {
as: 'button',
onClick,
}),
...(href && {
as: 'a',
href,
...(LinkWrapper && {
as: LinkWrapper,
});
}
return result;
});
to: href,
}),
}),
}));
export type LinkWrapperType = (props: any) => ReactNode;
@ -202,23 +197,25 @@ const ListItem = ({
LinkWrapper = undefined,
...rest
}: ListItemProps) => {
const itemProps = getItemProps(onClick, href, LinkWrapper);
const commonProps = { active, disabled };
const itemProps = getItemProps(onClick, href, LinkWrapper);
return (
<Item {...commonProps} {...rest} {...itemProps}>
{icon && <Left {...commonProps}>{icon}</Left>}
{title || center ? (
<Center isIndented={!!(!icon && isIndented)}>
{title && (
<Title {...commonProps} loading={loading}>
{title}
</Title>
)}
{center && <CenterText {...commonProps}>{center}</CenterText>}
</Center>
) : null}
{right && <Right {...commonProps}>{right}</Right>}
<Item {...rest} {...commonProps} {...itemProps}>
<>
{icon && <Left {...commonProps}>{icon}</Left>}
{title || center ? (
<Center isIndented={!!(!icon && isIndented)}>
{title && (
<Title {...commonProps} loading={loading}>
{title}
</Title>
)}
{center && <CenterText {...commonProps}>{center}</CenterText>}
</Center>
) : null}
{right && <Right {...commonProps}>{right}</Right>}
</>
</Item>
);
};

View File

@ -1,5 +1,4 @@
import type { FunctionComponent, MouseEvent, PropsWithChildren, ReactElement } from 'react';
import React, { Children, cloneElement } from 'react';
import React from 'react';
import { LinkIcon, LinuxIcon } from '@storybook/icons';
import type { Meta, StoryObj } from '@storybook/react';
@ -12,26 +11,6 @@ import ellipseUrl from './assets/ellipse.png';
const onLinkClick = action('onLinkClick');
interface StoryLinkWrapperProps {
href: string;
passHref?: boolean;
}
const StoryLinkWrapper: FunctionComponent<PropsWithChildren<StoryLinkWrapperProps>> = ({
href,
passHref = false,
children,
}) => {
const child = Children.only(children) as ReactElement;
return cloneElement(child, {
href: passHref && href,
onClick: (e: MouseEvent) => {
e.preventDefault();
onLinkClick(href);
},
});
};
export default {
component: TooltipLinkList,
decorators: [
@ -60,15 +39,16 @@ export const WithoutIcons = {
title: 'Link 1',
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
title: 'Link 2',
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;
@ -81,15 +61,16 @@ export const WithOneIcon = {
center: 'This is an addition description',
icon: <LinkIcon />,
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
title: 'Link 2',
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;
@ -102,15 +83,16 @@ export const ActiveWithoutAnyIcons = {
active: true,
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
title: 'Link 2',
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;
@ -123,6 +105,7 @@ export const ActiveWithSeparateIcon = {
icon: <LinkIcon />,
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
@ -130,9 +113,9 @@ export const ActiveWithSeparateIcon = {
active: true,
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;
@ -146,15 +129,16 @@ export const ActiveAndIcon = {
icon: <LinkIcon />,
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
title: 'Link 2',
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;
@ -169,6 +153,7 @@ export const WithIllustration = {
right: <img src={ellipseUrl} width="16" height="16" alt="ellipse" />,
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
@ -176,9 +161,9 @@ export const WithIllustration = {
center: 'This is an addition description',
right: <img src={ellipseUrl} width="16" height="16" alt="ellipse" />,
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;
@ -193,6 +178,7 @@ export const WithCustomIcon = {
right: <img src={ellipseUrl} width="16" height="16" alt="ellipse" />,
center: 'This is an addition description',
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
@ -200,8 +186,8 @@ export const WithCustomIcon = {
center: 'This is an addition description',
right: <img src={ellipseUrl} width="16" height="16" alt="ellipse" />,
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;

View File

@ -1,4 +1,4 @@
import type { SyntheticEvent } from 'react';
import type { ComponentProps, SyntheticEvent } from 'react';
import React, { useCallback } from 'react';
import { styled } from '@storybook/core/theming';
@ -20,54 +20,38 @@ const List = styled.div(
export interface Link extends Omit<ListItemProps, 'onClick'> {
id: string;
isGatsby?: boolean;
onClick?: (event: SyntheticEvent, item: ListItemProps) => void;
onClick?: (
event: SyntheticEvent,
item: Pick<ListItemProps, 'id' | 'active' | 'disabled' | 'title'>
) => void;
}
interface ItemProps extends Link {
isIndented?: boolean;
}
const Item = (props: ItemProps) => {
const { LinkWrapper, onClick: onClickFromProps, id, isIndented, ...rest } = props;
const { title, href, active } = rest;
const onClick = useCallback(
(event: SyntheticEvent) => {
// @ts-expect-error (non strict)
onClickFromProps(event, rest);
},
[onClickFromProps]
const Item = ({ id, onClick, ...rest }: ItemProps) => {
const { active, disabled, title } = rest;
const handleClick = useCallback(
(event: SyntheticEvent) => onClick?.(event, { id, active, disabled, title }),
[onClick, id, active, disabled, title]
);
const hasOnClick = !!onClickFromProps;
return (
<ListItem
title={title}
active={active}
href={href}
id={`list-item-${id}`}
LinkWrapper={LinkWrapper}
isIndented={isIndented}
{...rest}
{...(hasOnClick ? { onClick } : {})}
/>
);
return <ListItem id={`list-item-${id}`} {...rest} {...(onClick && { onClick: handleClick })} />;
};
export interface TooltipLinkListProps {
export interface TooltipLinkListProps extends ComponentProps<typeof List> {
links: Link[];
LinkWrapper?: LinkWrapperType;
}
// @ts-expect-error (non strict)
export const TooltipLinkList = ({ links, LinkWrapper = null }: TooltipLinkListProps) => {
const hasIcon = links.some((link) => link.icon);
export const TooltipLinkList = ({ links, LinkWrapper, ...props }: TooltipLinkListProps) => {
const isIndented = links.some((link) => link.icon);
return (
<List>
{links.map(({ isGatsby, ...p }) => (
// @ts-expect-error (non strict)
<Item key={p.id} LinkWrapper={isGatsby ? LinkWrapper : null} isIndented={hasIcon} {...p} />
<List {...props}>
{links.map((link) => (
<Item key={link.id} isIndented={isIndented} LinkWrapper={LinkWrapper} {...link} />
))}
</List>
);

View File

@ -47,6 +47,8 @@ enum events {
STORY_ARGS_UPDATED = 'storyArgsUpdated',
// Reset either a single arg of a story all args of a story
RESET_STORY_ARGS = 'resetStoryArgs',
// Emitted after a filter is set
SET_FILTER = 'setFilter',
// Emitted by the preview at startup once it knows the initial set of globals+globalTypes
SET_GLOBALS = 'setGlobals',
// Tell the preview to update the value of a global
@ -114,6 +116,7 @@ export const {
SELECT_STORY,
SET_CONFIG,
SET_CURRENT_STORY,
SET_FILTER,
SET_GLOBALS,
SET_INDEX,
SET_STORIES,

View File

@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises';
import { relative } from 'node:path';
import type { Channel } from '@storybook/core/channels';
import { getStoryId } from '@storybook/core/common';
import { telemetry } from '@storybook/core/telemetry';
import type { CoreConfig, Options } from '@storybook/core/types';
@ -19,7 +20,6 @@ import {
} from '@storybook/core/core-events';
import { getNewStoryFile } from '../utils/get-new-story-file';
import { getStoryId } from '../utils/get-story-id';
export function initCreateNewStoryChannel(
channel: Channel,

View File

@ -3,6 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChannelTransport } from '@storybook/core/channels';
import { Channel } from '@storybook/core/channels';
import {
extractProperRendererNameFromFramework,
getFrameworkName,
getProjectRoot,
} from '@storybook/core/common';
import type { FileComponentSearchRequestPayload, RequestData } from '@storybook/core/core-events';
import {
@ -10,30 +15,22 @@ import {
FILE_COMPONENT_SEARCH_RESPONSE,
} from '@storybook/core/core-events';
import { searchFiles } from '../utils/search-files';
import { initFileSearchChannel } from './file-search-channel';
const mocks = vi.hoisted(() => {
return {
searchFiles: vi.fn(),
};
});
vi.mock(import('../utils/search-files'), async (importOriginal) => ({
searchFiles: vi.fn((await importOriginal()).searchFiles),
}));
vi.mock('../utils/search-files', () => {
return {
searchFiles: mocks.searchFiles,
};
});
vi.mock('@storybook/core/common');
vi.mock('@storybook/core/common', async (importOriginal) => {
const actual = await importOriginal<typeof import('@storybook/core/common')>();
return {
...actual,
getFrameworkName: vi.fn().mockResolvedValue('@storybook/react'),
extractProperRendererNameFromFramework: vi.fn().mockResolvedValue('react'),
getProjectRoot: vi
.fn()
.mockReturnValue(require('path').join(__dirname, '..', 'utils', '__search-files-tests__')),
};
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(getFrameworkName).mockResolvedValue('@storybook/react');
vi.mocked(extractProperRendererNameFromFramework).mockResolvedValue('react');
vi.mocked(getProjectRoot).mockReturnValue(
require('path').join(__dirname, '..', 'utils', '__search-files-tests__')
);
});
describe('file-search-channel', () => {
@ -41,18 +38,12 @@ describe('file-search-channel', () => {
const mockChannel = new Channel({ transport });
const searchResultChannelListener = vi.fn();
beforeEach(() => {
transport.setHandler.mockClear();
transport.send.mockClear();
searchResultChannelListener.mockClear();
});
describe('initFileSearchChannel', async () => {
it('should emit search result event with the search result', async () => {
it('should emit search result event with the search result', { timeout: 10000 }, async () => {
const mockOptions = {};
const data = { searchQuery: 'es-module' };
initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
await initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
@ -60,18 +51,10 @@ describe('file-search-channel', () => {
payload: {},
} satisfies RequestData<FileComponentSearchRequestPayload>);
mocks.searchFiles.mockImplementation(async (...args) => {
// @ts-expect-error Ignore type issue
return (await vi.importActual('../utils/search-files')).searchFiles(...args);
await vi.waitFor(() => expect(searchResultChannelListener).toHaveBeenCalled(), {
timeout: 8000,
});
await vi.waitFor(
() => {
expect(searchResultChannelListener).toHaveBeenCalled();
},
{ timeout: 2000 }
);
expect(searchResultChannelListener).toHaveBeenCalledWith({
id: data.searchQuery,
error: null,
@ -113,59 +96,57 @@ describe('file-search-channel', () => {
});
});
it('should emit search result event with an empty search result', async () => {
const mockOptions = {};
const data = { searchQuery: 'no-file-for-search-query' };
it(
'should emit search result event with an empty search result',
{ timeout: 10000 },
async () => {
const mockOptions = {};
const data = { searchQuery: 'no-file-for-search-query' };
initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
await initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
id: data.searchQuery,
payload: {},
} satisfies RequestData<FileComponentSearchRequestPayload>);
mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
id: data.searchQuery,
payload: {},
} satisfies RequestData<FileComponentSearchRequestPayload>);
mocks.searchFiles.mockImplementation(async (...args) => {
// @ts-expect-error Ignore type issue
return (await vi.importActual('../utils/search-files')).searchFiles(...args);
});
await vi.waitFor(
() => {
expect(searchResultChannelListener).toHaveBeenCalled();
},
{
timeout: 8000,
}
);
await vi.waitFor(
() => {
expect(searchResultChannelListener).toHaveBeenCalled();
},
{ timeout: 2000 }
);
expect(searchResultChannelListener).toHaveBeenCalledWith({
id: data.searchQuery,
error: null,
payload: {
files: [],
},
success: true,
});
});
expect(searchResultChannelListener).toHaveBeenCalledWith({
id: data.searchQuery,
error: null,
payload: {
files: [],
},
success: true,
});
}
);
it('should emit an error message if an error occurs while searching for components in the project', async () => {
const mockOptions = {};
const mockOptions = {} as any;
const data = { searchQuery: 'commonjs' };
initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
await initFileSearchChannel(mockChannel, mockOptions, { disableTelemetry: true });
mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
id: data.searchQuery,
payload: {},
} satisfies RequestData<FileComponentSearchRequestPayload>);
mocks.searchFiles.mockRejectedValue(new Error('ENOENT: no such file or directory'));
await vi.waitFor(() => {
expect(searchResultChannelListener).toHaveBeenCalled();
});
vi.mocked(searchFiles).mockRejectedValue(new Error('ENOENT: no such file or directory'));
await vi.waitFor(() => expect(searchResultChannelListener).toHaveBeenCalled());
expect(searchResultChannelListener).toHaveBeenCalledWith({
id: data.searchQuery,
error:

View File

@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import yaml from 'js-yaml';
import { dedent } from 'ts-dedent';
import { isModuleMock, loadCsf } from './CsfFile';
import { type CsfOptions, formatCsf, isModuleMock, loadCsf } from './CsfFile';
expect.addSnapshotSerializer({
print: (val: any) => yaml.dump(val).trimEnd(),
@ -21,52 +21,46 @@ const parse = (code: string, includeParameters?: boolean) => {
return { meta, stories: filtered };
};
//
const transform = (code: string, options: Partial<CsfOptions> = { makeTitle }) => {
const parsed = loadCsf(code, { ...options, makeTitle }).parse();
return formatCsf(parsed);
};
describe('CsfFile', () => {
describe('basic', () => {
it('args stories', () => {
it('filters out non-story exports', () => {
const code = `
export default { title: 'foo/bar', excludeStories: ['invalidStory'] };
export const invalidStory = {};
export const validStory = {};
`;
const parsed = loadCsf(code, { makeTitle }).parse();
expect(Object.keys(parsed._stories)).toEqual(['validStory']);
});
it('filters out non-story exports', () => {
const code = `
export default { title: 'foo/bar', excludeStories: ['invalidStory'] };
export const invalidStory = {};
export const A = {}
const B = {};
export { B };
`;
const parsed = loadCsf(code, { makeTitle }).parse();
expect(Object.keys(parsed._stories)).toEqual(['A', 'B']);
});
it('transforms inline default exports to constant declarations', () => {
expect(
parse(
transform(
dedent`
export default { title: 'foo/bar' };
export const A = () => {};
export const B = (args) => {};
`,
true
{ transformInlineMeta: true }
)
).toMatchInlineSnapshot(`
meta:
title: foo/bar
stories:
- id: foo-bar--a
name: A
parameters:
__isArgsStory: false
__id: foo-bar--a
__stats:
play: false
render: false
loaders: false
beforeEach: false
globals: false
storyFn: true
mount: false
moduleMock: false
- id: foo-bar--b
name: B
parameters:
__isArgsStory: true
__id: foo-bar--b
__stats:
play: false
render: false
loaders: false
beforeEach: false
globals: false
storyFn: true
mount: false
moduleMock: false
"const _meta = {
title: 'foo/bar'
};
export default _meta;"
`);
});

View File

@ -11,6 +11,8 @@ import type {
} from '@storybook/core/types';
import { isExportStory, storyNameFromExport, toId } from '@storybook/csf';
// @ts-expect-error File is not yet exposed, see https://github.com/babel/babel/issues/11350#issuecomment-644118606
import { File as BabelFileClass } from '@babel/core';
import bg, { type GeneratorOptions } from '@babel/generator';
import bt from '@babel/traverse';
import * as t from '@babel/types';
@ -29,6 +31,18 @@ const generate = (bg.default || bg) as typeof bg;
const logger = console;
// We add this BabelFile as a temporary workaround to deal with a BabelFileClass "ImportEquals should have a literal source" issue in no link mode with tsup
interface BabelFile {
ast: t.File;
opts: any;
hub: any;
metadata: object;
path: any;
scope: any;
inputMap: object | null;
code: string;
}
function parseIncludeExclude(prop: t.Node) {
if (t.isArrayExpression(prop)) {
return prop.elements.map((e) => {
@ -140,6 +154,11 @@ const MODULE_MOCK_REGEX = /^[.\/#].*\.mock($|\.[^.]*$)/i;
export interface CsfOptions {
fileName?: string;
makeTitle: (userTitle: string) => string;
/**
* If an inline meta is detected e.g. `export default { title: 'foo' }`
* it will be transformed into a constant format e.g. `export const _meta = { title: 'foo' }; export default _meta;`
*/
transformInlineMeta?: boolean;
}
export class NoMetaError extends Error {
@ -169,12 +188,12 @@ export interface StaticStory extends Pick<StoryAnnotations, 'name' | 'parameters
export class CsfFile {
_ast: t.File;
_fileName: string;
_file: BabelFile;
_options: CsfOptions;
_rawComponentPath?: string;
_makeTitle: (title: string) => string;
_meta?: StaticMeta;
_stories: Record<string, StaticStory> = {};
@ -187,7 +206,9 @@ export class CsfFile {
_metaNode: t.Expression | undefined;
_storyStatements: Record<string, t.ExportNamedDeclaration> = {};
_metaVariableName: string | undefined;
_storyStatements: Record<string, t.ExportNamedDeclaration | t.Expression> = {};
_storyAnnotations: Record<string, Record<string, t.Node>> = {};
@ -197,11 +218,25 @@ export class CsfFile {
imports: string[];
constructor(ast: t.File, { fileName, makeTitle }: CsfOptions) {
/**
* @deprecated use `_options.fileName` instead
*/
get _fileName() {
return this._options.fileName;
}
/**
* @deprecated use `_options.makeTitle` instead
*/
get _makeTitle() {
return this._options.makeTitle;
}
constructor(ast: t.File, options: CsfOptions, file: BabelFile) {
this._ast = ast;
this._fileName = fileName as string;
this._file = file;
this._options = options;
this.imports = [];
this._makeTitle = makeTitle;
}
_parseTitle(value: t.Node) {
@ -216,7 +251,7 @@ export class CsfFile {
}
throw new Error(dedent`
CSF: unexpected dynamic title ${formatLocation(node, this._fileName)}
CSF: unexpected dynamic title ${formatLocation(node, this._options.fileName)}
More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#string-literal-titles
`);
@ -295,14 +330,34 @@ export class CsfFile {
const self = this;
traverse(this._ast, {
ExportDefaultDeclaration: {
enter({ node, parent }) {
let metaNode: t.ObjectExpression | undefined;
enter(path) {
const { node, parent } = path;
const isVariableReference = t.isIdentifier(node.declaration) && t.isProgram(parent);
if (
self._options.transformInlineMeta &&
!isVariableReference &&
t.isExpression(node.declaration)
) {
const metaId = path.scope.generateUidIdentifier('meta');
self._metaVariableName = metaId.name;
const nodes = [
t.variableDeclaration('const', [t.variableDeclarator(metaId, node.declaration)]),
t.exportDefaultDeclaration(metaId),
];
// Preserve sourcemaps location
nodes.forEach((_node: t.Node) => (_node.loc = path.node.loc));
path.replaceWithMultiple(nodes);
}
let metaNode: t.ObjectExpression | undefined;
let decl;
if (isVariableReference) {
// const meta = { ... };
// export default meta;
const variableName = (node.declaration as t.Identifier).name;
self._metaVariableName = variableName;
const isVariableDeclarator = (declaration: t.VariableDeclarator) =>
t.isIdentifier(declaration.id) && declaration.id.name === variableName;
@ -339,7 +394,7 @@ export class CsfFile {
throw new NoMetaError(
'default export must be an object',
self._metaStatement,
self._fileName
self._options.fileName
);
}
},
@ -429,11 +484,12 @@ export class CsfFile {
node.specifiers.forEach((specifier) => {
if (t.isExportSpecifier(specifier) && t.isIdentifier(specifier.exported)) {
const { name: exportName } = specifier.exported;
const decl = t.isProgram(parent)
? findVarInitialization(specifier.local.name, parent)
: specifier.local;
if (exportName === 'default') {
let metaNode: t.ObjectExpression | undefined;
const decl = t.isProgram(parent)
? findVarInitialization(specifier.local.name, parent)
: specifier.local;
if (t.isObjectExpression(decl)) {
// export default { ... };
@ -451,6 +507,7 @@ export class CsfFile {
}
} else {
self._storyAnnotations[exportName] = {};
self._storyStatements[exportName] = decl;
self._stories[exportName] = {
id: 'FIXME',
name: exportName,
@ -507,7 +564,7 @@ export class CsfFile {
const { callee } = node;
if (t.isIdentifier(callee) && callee.name === 'storiesOf') {
throw new Error(dedent`
Unexpected \`storiesOf\` usage: ${formatLocation(node, self._fileName)}.
Unexpected \`storiesOf\` usage: ${formatLocation(node, self._options.fileName)}.
SB8 does not support \`storiesOf\`.
`);
@ -527,12 +584,12 @@ export class CsfFile {
});
if (!self._meta) {
throw new NoMetaError('missing default export', self._ast, self._fileName);
throw new NoMetaError('missing default export', self._ast, self._options.fileName);
}
// default export can come at any point in the file, so we do this post processing last
const entries = Object.entries(self._stories);
self._meta.title = this._makeTitle(self._meta?.title as string);
self._meta.title = this._options.makeTitle(self._meta?.title as string);
if (self._metaAnnotations.play) {
self._meta.tags = [...(self._meta.tags || []), 'play-fn'];
}
@ -586,6 +643,7 @@ export class CsfFile {
if (!isExportStory(key, self._meta as StaticMeta)) {
delete self._storyExports[key];
delete self._storyAnnotations[key];
delete self._storyStatements[key];
}
});
@ -616,7 +674,8 @@ export class CsfFile {
}
public get indexInputs(): IndexInput[] {
if (!this._fileName) {
const { fileName } = this._options;
if (!fileName) {
throw new Error(
dedent`Cannot automatically create index inputs with CsfFile.indexInputs because the CsfFile instance was created without a the fileName option.
Either add the fileName option when creating the CsfFile instance, or create the index inputs manually.`
@ -628,7 +687,7 @@ export class CsfFile {
const tags = [...(this._meta?.tags ?? []), ...(story.tags ?? [])];
return {
type: 'story',
importPath: this._fileName,
importPath: fileName,
rawComponentPath: this._rawComponentPath,
exportName,
name: story.name,
@ -642,9 +701,25 @@ export class CsfFile {
}
}
/**
* Using new babel.File is more powerful and give access to API such as buildCodeFrameError
*/
export const babelParseFile = ({
code,
filename = '',
ast,
}: {
code: string;
filename?: string;
ast?: t.File;
}): BabelFile => {
return new BabelFileClass({ filename }, { code, ast: ast ?? babelParse(code) });
};
export const loadCsf = (code: string, options: CsfOptions) => {
const ast = babelParse(code);
return new CsfFile(ast, options);
const file = babelParseFile({ code, filename: options.fileName, ast });
return new CsfFile(ast, options, file);
};
export const formatCsf = (
@ -672,7 +747,7 @@ export const readCsf = async (fileName: string, options: CsfOptions) => {
};
export const writeCsf = async (csf: CsfFile, fileName?: string) => {
const fname = fileName || csf._fileName;
const fname = fileName || csf._options.fileName;
if (!fname) throw new Error('Please specify a fileName for writeCsf');
await writeFile(fileName as string, printCsf(csf).code);
};

View File

@ -3,3 +3,4 @@ export * from './ConfigFile';
export * from './getStorySortParameter';
export * from './enrichCsf';
export * from './babelParse';
export { vitestTransform } from './vitest-plugin/transformer';

View File

@ -0,0 +1,351 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getStoryTitle } from '@storybook/core/common';
import { type RawSourceMap, SourceMapConsumer } from 'source-map';
import { vitestTransform as originalTransform } from './transformer';
vi.mock('@storybook/core/common', async (importOriginal) => {
const actual = await importOriginal<typeof import('@storybook/core/common')>();
return {
...actual,
getStoryTitle: vi.fn(() => 'automatic/calculated/title'),
};
});
expect.addSnapshotSerializer({
serialize: (val: any) => (typeof val === 'string' ? val : val.toString()),
test: (val) => true,
});
const transform = async ({
code = '',
fileName = 'src/components/Button.stories.js',
tagsFilter = {
include: ['test'],
exclude: [],
skip: [],
},
configDir = '.storybook',
stories = [],
}) => {
const transformed = await originalTransform({ code, fileName, configDir, stories, tagsFilter });
if (typeof transformed === 'string') {
return { code: transformed, map: null };
}
return transformed;
};
describe('transformer', () => {
describe('no-op', () => {
it('should return original code if the file is not a story file', async () => {
const code = `console.log('Not a story file');`;
const fileName = 'src/components/Button.js';
const result = await transform({ code, fileName });
expect(result.code).toMatchInlineSnapshot(`console.log('Not a story file');`);
});
});
describe('default exports (meta)', () => {
it('should add title to inline default export if not present', async () => {
const code = `
import { _test } from 'bla';
export default {
component: Button,
};
`;
const result = await transform({ code });
expect(getStoryTitle).toHaveBeenCalled();
expect(result.code).toMatchInlineSnapshot(`
import { test as _test2 } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
import { _test } from 'bla';
const _meta = {
component: Button,
title: "automatic/calculated/title"
};
export default _meta;
`);
});
it('should overwrite title to inline default export if already present', async () => {
const code = `
export default {
title: 'Button',
component: Button,
};
`;
const result = await transform({ code });
expect(getStoryTitle).toHaveBeenCalled();
expect(result.code).toMatchInlineSnapshot(`
import { test as _test } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
const _meta = {
title: "automatic/calculated/title",
component: Button
};
export default _meta;
`);
});
it('should add title to const declared default export if not present', async () => {
const code = `
const meta = {
component: Button,
};
export default meta;
`;
const result = await transform({ code });
expect(getStoryTitle).toHaveBeenCalled();
expect(result.code).toMatchInlineSnapshot(`
import { test as _test } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
const meta = {
component: Button,
title: "automatic/calculated/title"
};
export default meta;
`);
});
it('should overwrite title to const declared default export if already present', async () => {
const code = `
const meta = {
title: 'Button',
component: Button,
};
export default meta;
`;
const result = await transform({ code });
expect(getStoryTitle).toHaveBeenCalled();
expect(result.code).toMatchInlineSnapshot(`
import { test as _test } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
const meta = {
title: "automatic/calculated/title",
component: Button
};
export default meta;
`);
});
});
describe('named exports (stories)', () => {
it('should add test statement to inline exported stories', async () => {
const code = `
export default {
component: Button,
}
export const Primary = {
args: {
label: 'Primary Button',
},
};
`;
const result = await transform({ code });
expect(result.code).toMatchInlineSnapshot(`
import { test as _test } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
const _meta = {
component: Button,
title: "automatic/calculated/title"
};
export default _meta;
export const Primary = {
args: {
label: 'Primary Button'
}
};
const _composedPrimary = _composeStory(Primary, _meta, undefined, undefined, "Primary");
if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) {
_test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]}));
}
`);
});
it('should add test statement to const declared exported stories', async () => {
const code = `
export default {};
const Primary = {
args: {
label: 'Primary Button',
},
};
export { Primary };
`;
const result = await transform({ code });
expect(result.code).toMatchInlineSnapshot(`
import { test as _test } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
const _meta = {
title: "automatic/calculated/title"
};
export default _meta;
const Primary = {
args: {
label: 'Primary Button'
}
};
export { Primary };
const _composedPrimary = _composeStory(Primary, _meta, undefined, undefined, "Primary");
if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) {
_test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]}));
}
`);
});
it('should exclude exports via excludeStories', async () => {
const code = `
export default {
title: 'Button',
component: Button,
excludeStories: ['nonStory'],
}
export const nonStory = 123
`;
const result = await transform({ code });
expect(result.code).toMatchInlineSnapshot(`
import { test as _test } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
const _meta = {
title: "automatic/calculated/title",
component: Button,
excludeStories: ['nonStory']
};
export default _meta;
export const nonStory = 123;
`);
});
});
describe('source map calculation', () => {
it('should remap the location of an inline named export to its relative testStory function', async () => {
const originalCode = `
const meta = {
title: 'Button',
component: Button,
}
export default meta;
export const Primary = {};
`;
const { code: transformedCode, map } = await transform({
code: originalCode,
});
expect(transformedCode).toMatchInlineSnapshot(`
import { test as _test } from "vitest";
import { composeStory as _composeStory } from "storybook/internal/preview-api";
import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils";
const meta = {
title: "automatic/calculated/title",
component: Button
};
export default meta;
export const Primary = {};
const _composedPrimary = _composeStory(Primary, meta, undefined, undefined, "Primary");
if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) {
_test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]}));
}
`);
const consumer = await new SourceMapConsumer(map as unknown as RawSourceMap);
// Locate `__test("Primary"...` in the transformed code
const testPrimaryLine =
transformedCode.split('\n').findIndex((line) => line.includes('_test("Primary"')) + 1;
const testPrimaryColumn = transformedCode
.split('\n')
[testPrimaryLine - 1].indexOf('_test("Primary"');
// Get the original position from the source map for `__test("Primary"...`
const originalPosition = consumer.originalPositionFor({
line: testPrimaryLine,
column: testPrimaryColumn,
});
// Locate `export const Primary` in the original code
const originalPrimaryLine =
originalCode.split('\n').findIndex((line) => line.includes('export const Primary')) + 1;
const originalPrimaryColumn = originalCode
.split('\n')
[originalPrimaryLine - 1].indexOf('export const Primary');
// The original locations of the transformed code should match with the ones of the original code
expect(originalPosition.line, 'original line location').toBe(originalPrimaryLine);
expect(originalPosition.column, 'original column location').toBe(originalPrimaryColumn);
});
});
describe('error handling', () => {
const warnSpy = vi.spyOn(console, 'warn');
beforeEach(() => {
vi.mocked(getStoryTitle).mockRestore();
warnSpy.mockReset();
});
it('should warn when autotitle is not successful', async () => {
const code = `
export default {}
export const Primary = {};
`;
vi.mocked(getStoryTitle).mockImplementation(() => undefined);
warnSpy.mockImplementation(() => {});
await transform({ code });
expect(warnSpy.mock.calls[0]).toMatchInlineSnapshot(`
[Storybook]: Could not calculate story title for "src/components/Button.stories.js".
Please make sure that this file matches the globs included in the "stories" field in your Storybook configuration at ".storybook".
`);
});
it('should warn when on unsupported story formats', async () => {
const code = `
export default {}
export { Primary } from './Button.stories';
`;
warnSpy.mockImplementation(() => {});
await transform({ code });
expect(warnSpy.mock.calls[0]).toMatchInlineSnapshot(`
[Storybook]: Could not transform "Primary" story into test at "src/components/Button.stories.js".
Please make sure to define stories in the same file and not re-export stories coming from other files".
`);
});
});
});

View File

@ -0,0 +1,170 @@
/* eslint-disable local-rules/no-uncategorized-errors */
/* eslint-disable no-underscore-dangle */
import { getStoryTitle } from '@storybook/core/common';
import type { StoriesEntry } from '@storybook/core/types';
import * as t from '@babel/types';
import { dedent } from 'ts-dedent';
import { formatCsf, loadCsf } from '../CsfFile';
const logger = console;
export async function vitestTransform({
code,
fileName,
configDir,
stories,
tagsFilter,
}: {
code: string;
fileName: string;
configDir: string;
tagsFilter: {
include: string[];
exclude: string[];
skip: string[];
};
stories: StoriesEntry[];
}) {
const isStoryFile = /\.stor(y|ies)\./.test(fileName);
if (!isStoryFile) {
return code;
}
const parsed = loadCsf(code, {
fileName,
transformInlineMeta: true,
makeTitle: (title) => {
const result =
getStoryTitle({
storyFilePath: fileName,
configDir,
stories,
userTitle: title,
}) || 'unknown';
if (result === 'unknown') {
logger.warn(
dedent`
[Storybook]: Could not calculate story title for "${fileName}".
Please make sure that this file matches the globs included in the "stories" field in your Storybook configuration at "${configDir}".
`
);
}
return result;
},
}).parse();
const ast = parsed._ast;
const metaExportName = parsed._metaVariableName!;
const metaNode = parsed._metaNode as t.ObjectExpression;
const metaTitleProperty = metaNode.properties.find(
(prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'title'
);
const metaTitle = t.stringLiteral(parsed._meta?.title || 'unknown');
if (!metaTitleProperty) {
metaNode.properties.push(t.objectProperty(t.identifier('title'), metaTitle));
} else if (t.isObjectProperty(metaTitleProperty)) {
// If the title is present in meta, overwrite it because autotitle can still affect existing titles
metaTitleProperty.value = metaTitle;
}
if (!metaNode || !parsed._meta) {
throw new Error(
'The Storybook vitest plugin 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.'
);
}
const vitestTestId = parsed._file.path.scope.generateUidIdentifier('test');
const composeStoryId = parsed._file.path.scope.generateUidIdentifier('composeStory');
const testStoryId = parsed._file.path.scope.generateUidIdentifier('testStory');
const isValidTestId = parsed._file.path.scope.generateUidIdentifier('isValidTest');
const tagsFilterId = t.identifier(JSON.stringify(tagsFilter));
const getTestStatementForStory = ({ exportName, node }: { exportName: string; node: t.Node }) => {
const composedStoryId = parsed._file.path.scope.generateUidIdentifier(`composed${exportName}`);
const composeStoryCall = t.variableDeclaration('const', [
t.variableDeclarator(
composedStoryId,
t.callExpression(composeStoryId, [
t.identifier(exportName),
t.identifier(metaExportName),
t.identifier('undefined'),
t.identifier('undefined'),
t.stringLiteral(exportName),
])
),
]);
// Preserve sourcemaps location
composeStoryCall.loc = node.loc;
const isValidTestCall = t.ifStatement(
t.callExpression(isValidTestId, [
t.memberExpression(composedStoryId, t.identifier('tags')),
tagsFilterId,
]),
t.blockStatement([
t.expressionStatement(
t.callExpression(vitestTestId, [
t.stringLiteral(exportName),
t.callExpression(testStoryId, [composedStoryId, tagsFilterId]),
])
),
])
);
// Preserve sourcemaps location
isValidTestCall.loc = node.loc;
return [composeStoryCall, isValidTestCall];
};
Object.entries(parsed._storyStatements).forEach(([exportName, node]) => {
if (node === null) {
logger.warn(
dedent`
[Storybook]: Could not transform "${exportName}" story into test at "${fileName}".
Please make sure to define stories in the same file and not re-export stories coming from other files".
`
);
return;
}
ast.program.body.push(
...getTestStatementForStory({
exportName,
node,
})
);
});
const imports = [
t.importDeclaration(
[t.importSpecifier(vitestTestId, t.identifier('test'))],
t.stringLiteral('vitest')
),
t.importDeclaration(
[t.importSpecifier(composeStoryId, t.identifier('composeStory'))],
t.stringLiteral('storybook/internal/preview-api')
),
t.importDeclaration(
[
t.importSpecifier(testStoryId, t.identifier('testStory')),
t.importSpecifier(isValidTestId, t.identifier('isValidTest')),
],
t.stringLiteral('@storybook/experimental-addon-vitest/internal/test-utils')
),
];
ast.program.body.unshift(...imports);
return formatCsf(parsed, { sourceMaps: true, sourceFileName: fileName }, code);
}

View File

@ -34,6 +34,7 @@ import {
SELECT_STORY,
SET_CONFIG,
SET_CURRENT_STORY,
SET_FILTER,
SET_INDEX,
SET_STORIES,
STORY_ARGS_UPDATED,
@ -617,11 +618,14 @@ export const init: ModuleFn<SubAPI, SubState> = ({
const update = typeof input === 'function' ? input(status) : input;
if (Object.keys(update).length === 0) {
if (!id || Object.keys(update).length === 0) {
return;
}
Object.entries(update).forEach(([storyId, value]) => {
if (!storyId || typeof value !== 'object') {
return;
}
newStatus[storyId] = { ...(newStatus[storyId] || {}) };
if (value === null) {
delete newStatus[storyId][id];
@ -661,6 +665,8 @@ export const init: ModuleFn<SubAPI, SubState> = ({
Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => {
fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true);
});
provider.channel?.emit(SET_FILTER, { id });
},
};

View File

@ -0,0 +1,41 @@
import { fn } from '@storybook/test';
import { FilterToggle } from './FilterToggle';
export default {
component: FilterToggle,
args: {
active: false,
onClick: fn(),
},
};
export const Errors = {
args: {
count: 2,
label: 'Error',
status: 'critical',
},
};
export const ErrorsActive = {
args: {
...Errors.args,
active: true,
},
};
export const Warning = {
args: {
count: 12,
label: 'Warning',
status: 'warning',
},
};
export const WarningActive = {
args: {
...Warning.args,
active: true,
},
};

View File

@ -0,0 +1,60 @@
import React, { type ComponentProps } from 'react';
import { Badge as BaseBadge, IconButton } from '@storybook/components';
import { css, styled } from '@storybook/theming';
const Badge = styled(BaseBadge)(({ theme }) => ({
padding: '4px 8px',
fontSize: theme.typography.size.s1,
}));
const Button = styled(IconButton)(
({ theme }) => ({
fontSize: theme.typography.size.s2,
'&:hover [data-badge][data-status=warning], [data-badge=true][data-status=warning]': {
background: '#E3F3FF',
borderColor: 'rgba(2, 113, 182, 0.1)',
color: '#0271B6',
},
'&:hover [data-badge][data-status=critical], [data-badge=true][data-status=critical]': {
background: theme.background.negative,
boxShadow: `inset 0 0 0 1px rgba(182, 2, 2, 0.1)`,
color: theme.color.negativeText,
},
}),
({ active, theme }) =>
!active &&
css({
'&:hover': {
color: theme.base === 'light' ? theme.color.defaultText : theme.color.light,
},
})
);
const Label = styled.span(({ theme }) => ({
color: theme.base === 'light' ? theme.color.defaultText : theme.color.light,
}));
interface FilterToggleProps {
active: boolean;
count: number;
label: string;
status: ComponentProps<typeof Badge>['status'];
}
export const FilterToggle = ({
active,
count,
label,
status,
...props
}: FilterToggleProps & Omit<ComponentProps<typeof Button>, 'status'>) => {
return (
<Button active={active} {...props}>
<Badge status={status} data-badge={active} data-status={status}>
{count}
</Badge>
<Label>{`${label}${count === 1 ? '' : 's'}`}</Label>
</Button>
);
};

View File

@ -19,6 +19,10 @@ const GROUP_ID = 'icon--group';
const COMPONENT_ID = 'icon--component';
const DOCUMENT_ID = 'icon--document';
const STORY_ID = 'icon--story';
const SUCCESS_ID = 'icon--success';
const ERROR_ID = 'icon--error';
const WARNING_ID = 'icon--warning';
const DOT_ID = 'icon--dot';
export const IconSymbols: FC = () => {
return (
@ -63,14 +67,47 @@ export const IconSymbols: FC = () => {
fill="currentColor"
/>
</symbol>
<symbol id={SUCCESS_ID}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.854 4.146a.5.5 0 010 .708l-5 5a.5.5 0 01-.708 0l-2-2a.5.5 0 11.708-.708L5.5 8.793l4.646-4.647a.5.5 0 01.708 0z"
fill="currentColor"
/>
</symbol>
<symbol id={ERROR_ID}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 4a3 3 0 100 6 3 3 0 000-6zM3 7a4 4 0 118 0 4 4 0 01-8 0z"
fill="currentColor"
/>
</symbol>
<symbol id={WARNING_ID}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.206 3.044a.498.498 0 01.23.212l3.492 5.985a.494.494 0 01.006.507.497.497 0 01-.443.252H3.51a.499.499 0 01-.437-.76l3.492-5.984a.497.497 0 01.642-.212zM7 4.492L4.37 9h5.26L7 4.492z"
fill="currentColor"
/>
</symbol>
<symbol id={DOT_ID}>
<circle cx="3" cy="3" r="3" fill="currentColor" />
</symbol>
</Svg>
);
};
export const UseSymbol: FC<{ type: 'group' | 'component' | 'document' | 'story' }> = ({ type }) => {
export const UseSymbol: FC<{
type: 'group' | 'component' | 'document' | 'story' | 'success' | 'error' | 'warning' | 'dot';
}> = ({ type }) => {
if (type === 'group') return <use xlinkHref={`#${GROUP_ID}`} />;
if (type === 'component') return <use xlinkHref={`#${COMPONENT_ID}`} />;
if (type === 'document') return <use xlinkHref={`#${DOCUMENT_ID}`} />;
if (type === 'story') return <use xlinkHref={`#${STORY_ID}`} />;
if (type === 'success') return <use xlinkHref={`#${SUCCESS_ID}`} />;
if (type === 'error') return <use xlinkHref={`#${ERROR_ID}`} />;
if (type === 'warning') return <use xlinkHref={`#${WARNING_ID}`} />;
if (type === 'dot') return <use xlinkHref={`#${DOT_ID}`} />;
return null;
};

View File

@ -15,6 +15,7 @@ import { transparentize } from 'polished';
import { matchesKeyCode, matchesModifiers } from '../../keybinding';
import { statusMapping } from '../../utils/status';
import { UseSymbol } from './IconSymbols';
import { StatusLabel } from './StatusButton';
import { TypeIcon } from './TreeNode';
import type { DownshiftItem, Match, SearchResult } from './types';
import { isExpandType } from './types';
@ -33,6 +34,7 @@ const ResultRow = styled.li<{ isHighlighted: boolean }>(({ theme, isHighlighted
cursor: 'pointer',
display: 'flex',
alignItems: 'start',
justifyContent: 'space-between',
textAlign: 'left',
color: 'inherit',
fontSize: `${theme.typography.size.s2}px`,
@ -56,6 +58,7 @@ const IconWrapper = styled.div({
});
const ResultRowContent = styled.div({
flex: 1,
display: 'flex',
flexDirection: 'column',
});
@ -183,7 +186,7 @@ const Result: FC<
const nameMatch = matches.find((match: Match) => match.key === 'name');
const pathMatches = matches.filter((match: Match) => match.key === 'path');
const [i] = item.status ? statusMapping[item.status] : [];
const [icon] = item.status ? statusMapping[item.status] : [];
return (
<ResultRow {...props} onClick={click}>
@ -218,7 +221,7 @@ const Result: FC<
))}
</Path>
</ResultRowContent>
{item.status ? i : null}
{item.status ? <StatusLabel status={item.status}>{icon}</StatusLabel> : null}
</ResultRow>
);
});

View File

@ -1,13 +1,11 @@
import React from 'react';
import { Button, IconButton } from '@storybook/core/components';
import type { Addon_SidebarTopType } from '@storybook/core/types';
import { FaceHappyIcon } from '@storybook/icons';
import type { API_StatusState, Addon_SidebarTopType } from '@storybook/core/types';
import type { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import type { IndexHash, State } from '@storybook/core/manager-api';
import { ManagerContext, types } from '@storybook/core/manager-api';
import { ManagerContext } from '@storybook/core/manager-api';
import { LayoutProvider } from '../layout/LayoutProvider';
import { standardData as standardHeaderData } from './Heading.stories';
@ -28,6 +26,26 @@ const storyId = 'root-1-child-a2--grandchild-a1-1';
export const simpleData = { menu, index, storyId };
export const loadingData = { menu };
const managerContext: any = {
state: {
docsOptions: {
defaultName: 'Docs',
autodocs: 'tag',
docsMode: false,
},
},
api: {
emit: fn().mockName('api::emit'),
on: fn().mockName('api::on'),
off: fn().mockName('api::off'),
getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName(
'api::getShortcutKeys'
),
selectStory: fn().mockName('api::selectStory'),
experimental_setFilter: fn().mockName('api::experimental_setFilter'),
},
};
const meta = {
component: Sidebar,
title: 'Sidebar/Sidebar',
@ -46,28 +64,7 @@ const meta = {
},
decorators: [
(storyFn) => (
<ManagerContext.Provider
value={
{
state: {
docsOptions: {
defaultName: 'Docs',
autodocs: 'tag',
docsMode: false,
},
},
api: {
emit: fn().mockName('api::emit'),
on: fn().mockName('api::on'),
off: fn().mockName('api::off'),
getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName(
'api::getShortcutKeys'
),
selectStory: fn().mockName('api::selectStory'),
},
} as any
}
>
<ManagerContext.Provider value={managerContext}>
<LayoutProvider>
<IconSymbols />
{storyFn()}
@ -229,41 +226,29 @@ export const Searching: Story = {
};
export const Bottom: Story = {
args: {
bottom: [
{
id: '1',
type: types.experimental_SIDEBAR_BOTTOM,
render: () => (
<Button>
<FaceHappyIcon />
Custom addon A
</Button>
),
},
{
id: '2',
type: types.experimental_SIDEBAR_BOTTOM,
render: () => (
<Button>
{' '}
<FaceHappyIcon />
Custom addon B
</Button>
),
},
{
id: '3',
type: types.experimental_SIDEBAR_BOTTOM,
render: () => (
<IconButton>
{' '}
<FaceHappyIcon />
</IconButton>
),
},
],
},
decorators: [
(storyFn) => (
<ManagerContext.Provider
value={{
...managerContext,
state: {
...managerContext.state,
status: {
[storyId]: {
vitest: { status: 'warn', title: '', description: '' },
vta: { status: 'error', title: '', description: '' },
},
'root-1-child-a2--grandchild-a1-2': {
vitest: { status: 'warn', title: '', description: '' },
},
} satisfies API_StatusState,
},
}}
>
{storyFn()}
</ManagerContext.Provider>
),
],
};
/**

View File

@ -2,11 +2,7 @@ import React, { useMemo } from 'react';
import { ScrollArea, Spaced } from '@storybook/core/components';
import { styled } from '@storybook/core/theming';
import type {
API_LoadedRefData,
Addon_SidebarBottomType,
Addon_SidebarTopType,
} from '@storybook/core/types';
import type { API_LoadedRefData, Addon_SidebarTopType } from '@storybook/core/types';
import type { State } from '@storybook/core/manager-api';
@ -16,6 +12,7 @@ import type { HeadingProps } from './Heading';
import { Heading } from './Heading';
import { Search } from './Search';
import { SearchResults } from './SearchResults';
import { SidebarBottom } from './SidebarBottom';
import type { CombinedDataset, Selection } from './types';
import { useLastViewed } from './useLastViewed';
@ -107,7 +104,6 @@ export interface SidebarProps extends API_LoadedRefData {
status: State['status'];
menu: any[];
extra: Addon_SidebarTopType[];
bottom?: Addon_SidebarBottomType[];
storyId?: string;
refId?: string;
menuHighlighted?: boolean;
@ -126,7 +122,6 @@ export const Sidebar = React.memo(function Sidebar({
previewInitialized,
menu,
extra,
bottom = [],
menuHighlighted = false,
enableShortcuts = true,
refs = {},
@ -192,9 +187,7 @@ export const Sidebar = React.memo(function Sidebar({
</ScrollArea>
{isLoading ? null : (
<Bottom className="sb-bar">
{bottom.map(({ id, render: Render }) => (
<Render key={id} />
))}
<SidebarBottom />
</Bottom>
)}
</Container>

View File

@ -0,0 +1,42 @@
import { fn } from '@storybook/test';
import { SidebarBottomBase } from './SidebarBottom';
export default {
component: SidebarBottomBase,
args: {
api: {
experimental_setFilter: fn(),
emit: fn(),
},
},
};
export const Errors = {
args: {
status: {
one: { 'sidebar-bottom-filter': { status: 'error' } },
two: { 'sidebar-bottom-filter': { status: 'error' } },
},
},
};
export const Warnings = {
args: {
status: {
one: { 'sidebar-bottom-filter': { status: 'warn' } },
two: { 'sidebar-bottom-filter': { status: 'warn' } },
},
},
};
export const Both = {
args: {
status: {
one: { 'sidebar-bottom-filter': { status: 'warn' } },
two: { 'sidebar-bottom-filter': { status: 'warn' } },
three: { 'sidebar-bottom-filter': { status: 'error' } },
four: { 'sidebar-bottom-filter': { status: 'error' } },
},
},
};

View File

@ -0,0 +1,93 @@
import React, { useCallback, useEffect } from 'react';
import { styled } from '@storybook/core/theming';
import type { API_FilterFunction } from '@storybook/types';
import {
type API,
type State,
useStorybookApi,
useStorybookState,
} from '@storybook/core/manager-api';
import { FilterToggle } from './FilterToggle';
const filterNone: API_FilterFunction = () => true;
const filterWarn: API_FilterFunction = ({ status = {} }) =>
Object.values(status).some((value) => value?.status === 'warn');
const filterError: API_FilterFunction = ({ status = {} }) =>
Object.values(status).some((value) => value?.status === 'error');
const filterBoth: API_FilterFunction = ({ status = {} }) =>
Object.values(status).some((value) => value?.status === 'warn' || value?.status === 'error');
const getFilter = (showWarnings = false, showErrors = false) => {
if (showWarnings && showErrors) return filterBoth;
if (showWarnings) return filterWarn;
if (showErrors) return filterError;
return filterNone;
};
const Wrapper = styled.div({
display: 'flex',
gap: 5,
});
interface SidebarBottomProps {
api: API;
status: State['status'];
}
export const SidebarBottomBase = ({ api, status = {} }: SidebarBottomProps) => {
const [showWarnings, setShowWarnings] = React.useState(false);
const [showErrors, setShowErrors] = React.useState(false);
const warnings = Object.values(status).filter((statusByAddonId) =>
Object.values(statusByAddonId).some((value) => value?.status === 'warn')
);
const errors = Object.values(status).filter((statusByAddonId) =>
Object.values(statusByAddonId).some((value) => value?.status === 'error')
);
const hasWarnings = warnings.length > 0;
const hasErrors = errors.length > 0;
const toggleWarnings = useCallback(() => setShowWarnings((shown) => !shown), []);
const toggleErrors = useCallback(() => setShowErrors((shown) => !shown), []);
useEffect(() => {
const filter = getFilter(hasWarnings && showWarnings, hasErrors && showErrors);
api.experimental_setFilter('sidebar-bottom-filter', filter);
}, [api, hasWarnings, hasErrors, showWarnings, showErrors]);
if (!hasWarnings && !hasErrors) return null;
return (
<Wrapper id="sidebar-bottom-wrapper">
{hasErrors && (
<FilterToggle
id="errors-found-filter"
active={showErrors}
count={errors.length}
label="Error"
status="critical"
onClick={toggleErrors}
/>
)}
{hasWarnings && (
<FilterToggle
id="warnings-found-filter"
active={showWarnings}
count={warnings.length}
label="Warning"
status="warning"
onClick={toggleWarnings}
/>
)}
</Wrapper>
);
};
export const SidebarBottom = () => {
const api = useStorybookApi();
const { status } = useStorybookState();
return <SidebarBottomBase api={api} status={status} />;
};

View File

@ -0,0 +1,64 @@
import { IconButton } from '@storybook/core/components';
import { styled } from '@storybook/core/theming';
import type { API_StatusValue } from '@storybook/types';
import type { Theme } from '@emotion/react';
import { transparentize } from 'polished';
const withStatusColor = ({ theme, status }: { theme: Theme; status: API_StatusValue }) => {
const defaultColor =
theme.base === 'light'
? transparentize(0.3, theme.color.defaultText)
: transparentize(0.6, theme.color.defaultText);
return {
color: {
pending: defaultColor,
success: theme.color.positive,
error: theme.color.negative,
warn: theme.color.warning,
unknown: defaultColor,
}[status],
};
};
export const StatusLabel = styled.div<{ status: API_StatusValue }>(withStatusColor, {
margin: 3,
});
export const StatusButton = styled(IconButton)<{
height?: number;
width?: number;
status: API_StatusValue;
selectedItem?: boolean;
}>(
withStatusColor,
({ theme, height, width }) => ({
transition: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: width || 28,
height: height || 28,
'&:hover': {
color: theme.color.secondary,
},
'&:focus': {
color: theme.color.secondary,
borderColor: theme.color.secondary,
'&:not(:focus-visible)': {
borderColor: 'transparent',
},
},
}),
({ theme, selectedItem }) =>
selectedItem && {
'&:hover': {
boxShadow: `inset 0 0 0 2px ${theme.color.secondary}`,
background: 'rgba(255, 255, 255, 0.2)',
},
}
);

View File

@ -0,0 +1,40 @@
import { createContext, useContext } from 'react';
import type { API_StatusObject, API_StatusState, API_StatusValue, StoryId } from '@storybook/types';
import type { ComponentEntry, GroupEntry, StoriesHash } from '../../../manager-api';
import { getDescendantIds } from '../../utils/tree';
export const StatusContext = createContext<{
data?: StoriesHash;
status?: API_StatusState;
groupStatus?: Record<StoryId, API_StatusValue>;
}>({});
export const useStatusSummary = (item: GroupEntry | ComponentEntry) => {
const { data, status, groupStatus } = useContext(StatusContext);
const summary: {
counts: Record<API_StatusValue, number>;
statuses: Record<API_StatusValue, Record<StoryId, API_StatusObject[]>>;
} = {
counts: { pending: 0, success: 0, error: 0, warn: 0, unknown: 0 },
statuses: { pending: {}, success: {}, error: {}, warn: {}, unknown: {} },
};
if (
data &&
status &&
groupStatus &&
['pending', 'warn', 'error'].includes(groupStatus[item.id])
) {
for (const storyId of getDescendantIds(data, item.id, false)) {
for (const value of Object.values(status[storyId] || {})) {
summary.counts[value.status]++;
summary.statuses[value.status][storyId] = summary.statuses[value.status][storyId] || [];
summary.statuses[value.status][storyId].push(value);
}
}
}
return summary;
};

View File

@ -1,12 +1,19 @@
import type { MutableRefObject } from 'react';
import type { ComponentProps, MutableRefObject } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { Button, IconButton, TooltipLinkList, WithTooltip } from '@storybook/core/components';
import { styled } from '@storybook/core/theming';
import { CollapseIcon as CollapseIconSvg, ExpandAltIcon } from '@storybook/icons';
import { styled, useTheme } from '@storybook/core/theming';
import {
CollapseIcon as CollapseIconSvg,
ExpandAltIcon,
StatusFailIcon,
StatusPassIcon,
StatusWarnIcon,
SyncIcon,
} from '@storybook/icons';
import type { API_StatusValue, StoryId } from '@storybook/types';
import { PRELOAD_ENTRIES } from '@storybook/core/core-events';
import { useStorybookApi } from '@storybook/core/manager-api';
import type {
API,
ComponentEntry,
@ -15,6 +22,7 @@ import type {
StoriesHash,
StoryEntry,
} from '@storybook/core/manager-api';
import { useStorybookApi } from '@storybook/core/manager-api';
import { transparentize } from 'polished';
@ -27,7 +35,9 @@ import {
isStoryHoistable,
} from '../../utils/tree';
import { useLayout } from '../layout/LayoutProvider';
import { IconSymbols } from './IconSymbols';
import { IconSymbols, UseSymbol } from './IconSymbols';
import { StatusButton } from './StatusButton';
import { StatusContext, useStatusSummary } from './StatusContext';
import { ComponentNode, DocumentNode, GroupNode, RootNode, StoryNode } from './TreeNode';
import { CollapseIcon } from './components/CollapseIcon';
import type { Highlight, Item } from './types';
@ -39,49 +49,6 @@ const Container = styled.div<{ hasOrphans: boolean }>((props) => ({
marginBottom: 20,
}));
export const Action = styled.button<{ height?: number; width?: number }>(
({ theme, height, width }) => ({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: width || 20,
height: height || 20,
boxSizing: 'border-box',
margin: 0,
marginLeft: 'auto',
padding: 0,
outline: 0,
lineHeight: 'normal',
background: 'none',
border: `1px solid transparent`,
borderRadius: '100%',
cursor: 'pointer',
transition: 'all 150ms ease-out',
color:
theme.base === 'light'
? transparentize(0.3, theme.color.defaultText)
: transparentize(0.6, theme.color.defaultText),
'&:hover': {
color: theme.color.secondary,
},
'&:focus': {
color: theme.color.secondary,
borderColor: theme.color.secondary,
'&:not(:focus-visible)': {
borderColor: 'transparent',
},
},
svg: {
width: 10,
height: 10,
},
})
);
const CollapseButton = styled.button(({ theme }) => ({
all: 'unset',
display: 'flex',
@ -104,15 +71,14 @@ export const LeafNodeStyleWrapper = styled.div(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: 20,
color: theme.color.defaultText,
background: 'transparent',
minHeight: 28,
borderRadius: 4,
'&:hover, &:focus': {
outline: 'none',
background: transparentize(0.93, theme.color.secondary),
outline: 'none',
},
'&[data-selected="true"]': {
@ -120,7 +86,7 @@ export const LeafNodeStyleWrapper = styled.div(({ theme }) => ({
background: theme.color.secondary,
fontWeight: theme.typography.weight.bold,
'&:hover, &:focus': {
'&&:hover, &&:focus': {
background: theme.color.secondary,
},
svg: { color: theme.color.lightest },
@ -165,19 +131,20 @@ interface NodeProps {
setFullyExpanded?: () => void;
onSelectStoryId: (itemId: string) => void;
status: State['status'][keyof State['status']];
groupStatus: Record<StoryId, API_StatusValue>;
api: API;
}
const Node = React.memo<NodeProps>(function Node({
item,
status,
groupStatus,
refId,
docsMode,
isOrphan,
isDisplayed,
isSelected,
isFullyExpanded,
color,
setFullyExpanded,
isExpanded,
setExpanded,
@ -185,6 +152,7 @@ const Node = React.memo<NodeProps>(function Node({
api,
}) {
const { isDesktop, isMobile, setMobileMenuOpen } = useLayout();
const theme = useTheme();
if (!isDisplayed) {
return null;
@ -197,20 +165,22 @@ const Node = React.memo<NodeProps>(function Node({
const statusValue = getHighestStatus(Object.values(status || {}).map((s) => s.status));
const [icon, textColor] = statusMapping[statusValue];
const statusOrder: API_StatusValue[] = ['success', 'error', 'warn', 'pending', 'unknown'];
return (
<LeafNodeStyleWrapper
key={id}
className="sidebar-item"
data-selected={isSelected}
data-ref-id={refId}
data-item-id={item.id}
data-parent-id={item.parent}
data-nodetype={item.type === 'docs' ? 'document' : 'story'}
data-highlightable={isDisplayed}
className="sidebar-item"
>
<LeafNode
// @ts-expect-error (non strict)
style={isSelected ? {} : { color: textColor }}
key={id}
href={getLink(item, refId)}
id={id}
depth={isOrphan ? item.depth : item.depth - 1}
@ -231,23 +201,37 @@ const Node = React.memo<NodeProps>(function Node({
)}
{icon ? (
<WithTooltip
placement="top"
style={{ display: 'flex' }}
closeOnOutsideClick
onClick={(event) => event.stopPropagation()}
placement="bottom"
tooltip={() => (
<TooltipLinkList
links={Object.entries(status || {}).map(([k, v]) => ({
id: k,
title: v.title,
description: v.description,
right: statusMapping[v.status][0],
}))}
links={Object.entries(status || {})
.sort(
(a, b) => statusOrder.indexOf(a[1].status) - statusOrder.indexOf(b[1].status)
)
.map(([addonId, value]) => ({
id: addonId,
title: value.title,
description: value.description,
icon: {
success: <StatusPassIcon color={theme.color.positive} />,
error: <StatusFailIcon color={theme.color.negative} />,
warn: <StatusWarnIcon color={theme.color.warning} />,
pending: <SyncIcon size={12} color={theme.color.defaultText} />,
unknown: null,
}[value.status],
onClick: () => {
onSelectStoryId(item.id);
value.onClick?.();
},
}))}
/>
)}
closeOnOutsideClick
>
<Action type="button" height={22}>
<StatusButton type="button" status={statusValue} selectedItem={isSelected}>
{icon}
</Action>
</StatusButton>
</WithTooltip>
) : null}
</LeafNodeStyleWrapper>
@ -296,41 +280,96 @@ const Node = React.memo<NodeProps>(function Node({
}
if (item.type === 'component' || item.type === 'group') {
const { counts, statuses } = useStatusSummary(item);
const itemStatus = groupStatus?.[item.id];
const color = itemStatus ? statusMapping[itemStatus][1] : null;
const BranchNode = item.type === 'component' ? ComponentNode : GroupNode;
const createLinks: (onHide: () => void) => ComponentProps<typeof TooltipLinkList>['links'] = (
onHide
) => {
const links = [];
if (counts.error) {
links.push({
id: 'errors',
icon: <StatusFailIcon color={theme.color.negative} />,
title: `${counts.error} ${counts.error === 1 ? 'story' : 'stories'} with errors`,
onClick: () => {
const [firstStoryId, [firstError]] = Object.entries(statuses.error)[0];
onSelectStoryId(firstStoryId);
firstError.onClick?.();
onHide();
},
});
}
if (counts.warn) {
links.push({
id: 'warnings',
icon: <StatusWarnIcon color={theme.color.gold} />,
title: `${counts.warn} ${counts.warn === 1 ? 'story' : 'stories'} with warnings`,
onClick: () => {
const [firstStoryId, [firstWarning]] = Object.entries(statuses.warn)[0];
onSelectStoryId(firstStoryId);
firstWarning.onClick?.();
onHide();
},
});
}
return links;
};
return (
<BranchNode
<LeafNodeStyleWrapper
key={id}
id={id}
style={color ? { color } : {}}
className="sidebar-item"
data-ref-id={refId}
data-item-id={item.id}
data-parent-id={item.parent}
data-nodetype={item.type === 'component' ? 'component' : 'group'}
data-highlightable={isDisplayed}
aria-controls={item.children && item.children[0]}
aria-expanded={isExpanded}
depth={isOrphan ? item.depth : item.depth - 1}
isComponent={item.type === 'component'}
isExpandable={item.children && item.children.length > 0}
isExpanded={isExpanded}
onClick={(event) => {
event.preventDefault();
setExpanded({ ids: [item.id], value: !isExpanded });
if (item.type === 'component' && !isExpanded && isDesktop) onSelectStoryId(item.id);
}}
onMouseEnter={() => {
if (item.type === 'component') {
api.emit(PRELOAD_ENTRIES, {
ids: [item.children[0]],
options: { target: refId },
});
}
}}
>
{(item.renderLabel as (i: typeof item, api: API) => React.ReactNode)?.(item, api) ||
item.name}
</BranchNode>
<BranchNode
id={id}
style={color ? { color } : {}}
aria-controls={item.children && item.children[0]}
aria-expanded={isExpanded}
depth={isOrphan ? item.depth : item.depth - 1}
isComponent={item.type === 'component'}
isExpandable={item.children && item.children.length > 0}
isExpanded={isExpanded}
onClick={(event) => {
event.preventDefault();
setExpanded({ ids: [item.id], value: !isExpanded });
if (item.type === 'component' && !isExpanded && isDesktop) onSelectStoryId(item.id);
}}
onMouseEnter={() => {
if (item.type === 'component') {
api.emit(PRELOAD_ENTRIES, {
ids: [item.children[0]],
options: { target: refId },
});
}
}}
>
{(item.renderLabel as (i: typeof item, api: API) => React.ReactNode)?.(item, api) ||
item.name}
</BranchNode>
{['error', 'warn'].includes(itemStatus) && (
<WithTooltip
closeOnOutsideClick
onClick={(event) => event.stopPropagation()}
placement="bottom"
tooltip={({ onHide }) => <TooltipLinkList links={createLinks(onHide)} />}
>
<StatusButton type="button" status={itemStatus}>
<svg key="icon" viewBox="0 0 6 6" width="6" height="6" type="dot">
<UseSymbol type="dot" />
</svg>
</StatusButton>
</WithTooltip>
)}
</LeafNodeStyleWrapper>
);
}
@ -514,7 +553,6 @@ export const Tree = React.memo<{
}
const isDisplayed = !item.parent || ancestry[itemId].every((a: string) => expanded[a]);
const color = groupStatus[itemId] ? statusMapping[groupStatus[itemId]][1] : null;
return (
<Node
@ -522,11 +560,9 @@ export const Tree = React.memo<{
key={id}
item={item}
// @ts-expect-error (non strict)
status={status?.[itemId]}
groupStatus={groupStatus}
refId={refId}
// @ts-expect-error (non strict)
color={color}
docsMode={docsMode}
isOrphan={orphanIds.some((oid) => itemId === oid || itemId.startsWith(`${oid}-`))}
isDisplayed={isDisplayed}
@ -554,9 +590,11 @@ export const Tree = React.memo<{
status,
]);
return (
<Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
<IconSymbols />
{treeItems}
</Container>
<StatusContext.Provider value={{ data, status, groupStatus }}>
<Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
<IconSymbols />
{treeItems}
</Container>
</StatusContext.Provider>
);
});

View File

@ -46,14 +46,10 @@ const BranchNode = styled.button<{
gap: 6,
paddingTop: 5,
paddingBottom: 4,
'&:hover, &:focus': {
background: transparentize(0.93, theme.color.secondary),
outline: 'none',
},
}));
const LeafNode = styled.a<{ depth?: number }>(({ theme, depth = 0 }) => ({
width: '100%',
cursor: 'pointer',
color: 'inherit',
display: 'flex',

View File

@ -1,5 +1,4 @@
import type { FC, MouseEvent, PropsWithChildren, ReactElement } from 'react';
import React, { Children, cloneElement } from 'react';
import React from 'react';
import { TooltipLinkList, WithTooltip } from '@storybook/core/components';
import type { Meta, StoryObj } from '@storybook/react';
@ -10,26 +9,6 @@ import { Shortcut } from './Menu';
const onLinkClick = action('onLinkClick');
interface StoryLinkWrapperProps {
href: string;
passHref?: boolean;
}
const StoryLinkWrapper: FC<PropsWithChildren<StoryLinkWrapperProps>> = ({
href,
passHref = false,
children,
}) => {
const child = Children.only(children) as ReactElement;
return cloneElement(child, {
href: passHref && href,
onClick: (e: MouseEvent) => {
e.preventDefault();
onLinkClick(href);
},
});
};
export default {
component: TooltipLinkList,
decorators: [
@ -59,6 +38,7 @@ export const WithShortcuts = {
center: 'This is an addition description',
right: <Shortcut keys={['⌘']} />,
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
@ -66,9 +46,9 @@ export const WithShortcuts = {
center: 'This is an addition description',
right: <Shortcut keys={['⌘', 'K']} />,
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;
@ -82,6 +62,7 @@ export const WithShortcutsActive = {
active: true,
right: <Shortcut keys={['⌘']} />,
href: 'http://google.com',
onClick: onLinkClick,
},
{
id: '2',
@ -89,8 +70,8 @@ export const WithShortcutsActive = {
center: 'This is an addition description',
right: <Shortcut keys={['⌘', 'K']} />,
href: 'http://google.com',
onClick: onLinkClick,
},
],
LinkWrapper: StoryLinkWrapper,
},
} satisfies Story;

View File

@ -43,11 +43,8 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) {
const whatsNewNotificationsEnabled =
state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications;
const bottomItems = api.getElements(Addon_TypesEnum.experimental_SIDEBAR_BOTTOM);
const topItems = api.getElements(Addon_TypesEnum.experimental_SIDEBAR_TOP);
// eslint-disable-next-line react-hooks/exhaustive-deps
const bottom = useMemo(() => Object.values(bottomItems), [Object.keys(bottomItems).join('')]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const top = useMemo(() => Object.values(topItems), [Object.keys(topItems).join('')]);
return {
@ -64,7 +61,6 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) {
menu,
menuHighlighted: whatsNewNotificationsEnabled && api.isWhatsNewUnread(),
enableShortcuts,
bottom,
extra: top,
};
};

View File

@ -65,6 +65,8 @@ export default {
'AlignLeftIcon',
'AlignRightIcon',
'AppleIcon',
'ArrowBottomLeftIcon',
'ArrowBottomRightIcon',
'ArrowDownIcon',
'ArrowLeftIcon',
'ArrowRightIcon',
@ -72,6 +74,8 @@ export default {
'ArrowSolidLeftIcon',
'ArrowSolidRightIcon',
'ArrowSolidUpIcon',
'ArrowTopLeftIcon',
'ArrowTopRightIcon',
'ArrowUpIcon',
'AzureDevOpsIcon',
'BackIcon',
@ -244,6 +248,9 @@ export default {
'StackedIcon',
'StarHollowIcon',
'StarIcon',
'StatusFailIcon',
'StatusPassIcon',
'StatusWarnIcon',
'StickerIcon',
'StopAltIcon',
'StopIcon',
@ -279,6 +286,7 @@ export default {
'WatchIcon',
'WindowsIcon',
'WrenchIcon',
'XIcon',
'YoutubeIcon',
'ZoomIcon',
'ZoomOutIcon',
@ -777,6 +785,7 @@ export default {
'SELECT_STORY',
'SET_CONFIG',
'SET_CURRENT_STORY',
'SET_FILTER',
'SET_GLOBALS',
'SET_INDEX',
'SET_STORIES',
@ -833,6 +842,7 @@ export default {
'SELECT_STORY',
'SET_CONFIG',
'SET_CURRENT_STORY',
'SET_FILTER',
'SET_GLOBALS',
'SET_INDEX',
'SET_STORIES',
@ -889,6 +899,7 @@ export default {
'SELECT_STORY',
'SET_CONFIG',
'SET_CURRENT_STORY',
'SET_FILTER',
'SET_GLOBALS',
'SET_INDEX',
'SET_STORIES',

View File

@ -1,10 +1,11 @@
import React from 'react';
import type { ReactElement } from 'react';
import React from 'react';
import { styled } from '@storybook/core/theming';
import type { API_HashEntry, API_StatusState, API_StatusValue } from '@storybook/core/types';
import { CircleIcon } from '@storybook/icons';
import { UseSymbol } from '../components/sidebar/IconSymbols';
import { getDescendantIds } from './tree';
const SmallIcons = styled(CircleIcon)({
@ -25,9 +26,24 @@ export const statusPriority: API_StatusValue[] = ['unknown', 'pending', 'success
export const statusMapping: Record<API_StatusValue, [ReactElement | null, string | null]> = {
unknown: [null, null],
pending: [<LoadingIcons key="icon" />, 'currentColor'],
success: [<SmallIcons key="icon" style={{ color: 'green' }} />, 'currentColor'],
warn: [<SmallIcons key="icon" style={{ color: 'orange' }} />, '#A15C20'],
error: [<SmallIcons key="icon" style={{ color: 'red' }} />, 'brown'],
success: [
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
<UseSymbol type="success" />
</svg>,
'currentColor',
],
warn: [
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
<UseSymbol type="warning" />
</svg>,
'#A15C20',
],
error: [
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
<UseSymbol type="error" />
</svg>,
'brown',
],
};
export const getHighestStatus = (statuses: API_StatusValue[]): API_StatusValue => {

View File

@ -54,6 +54,7 @@ export {
normalizeStory,
filterArgTypes,
sanitizeStoryContextUpdate,
setDefaultProjectAnnotations,
setProjectAnnotations,
inferControls,
userOrAutoTitleFromSpecifier,

View File

@ -1,4 +1,4 @@
import type { ModuleExports, ProjectAnnotations } from '@storybook/core/types';
import type { ModuleExports, NormalizedProjectAnnotations } from '@storybook/core/types';
import type { Renderer } from '@storybook/core/types';
import { global } from '@storybook/global';
@ -41,7 +41,7 @@ export function getSingletonField<TFieldType = any>(
export function composeConfigs<TRenderer extends Renderer>(
moduleExportList: ModuleExports[]
): ProjectAnnotations<TRenderer> {
): NormalizedProjectAnnotations<TRenderer> {
const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers');
const stepRunners = getField(moduleExportList, 'runStep');
const beforeAllHooks = getArrayField(moduleExportList, 'beforeAll');

View File

@ -9,6 +9,7 @@ import type {
ComposedStoryFn,
LegacyStoryAnnotationsOrFn,
NamedOrDefaultProjectAnnotations,
NormalizedProjectAnnotations,
Parameters,
PreparedStory,
ProjectAnnotations,
@ -32,7 +33,20 @@ import { normalizeProjectAnnotations } from './normalizeProjectAnnotations';
import { normalizeStory } from './normalizeStory';
import { prepareContext, prepareStory } from './prepareStory';
let globalProjectAnnotations: ProjectAnnotations<any> = {};
// TODO we should get to the bottom of the singleton issues caused by dual ESM/CJS modules
declare global {
// eslint-disable-next-line no-var
var globalProjectAnnotations: NormalizedProjectAnnotations<any>;
// eslint-disable-next-line no-var
var defaultProjectAnnotations: ProjectAnnotations<any>;
}
export function setDefaultProjectAnnotations<TRenderer extends Renderer = Renderer>(
_defaultProjectAnnotations: ProjectAnnotations<TRenderer>
) {
// Use a variable once we figure out the ESM/CJS issues
globalThis.defaultProjectAnnotations = _defaultProjectAnnotations;
}
const DEFAULT_STORY_TITLE = 'ComposedStory';
const DEFAULT_STORY_NAME = 'Unnamed Story';
@ -52,11 +66,11 @@ export function setProjectAnnotations<TRenderer extends Renderer = Renderer>(
projectAnnotations:
| NamedOrDefaultProjectAnnotations<TRenderer>
| NamedOrDefaultProjectAnnotations<TRenderer>[]
): ProjectAnnotations<TRenderer> {
): NormalizedProjectAnnotations<TRenderer> {
const annotations = Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations];
globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));
globalThis.globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));
return globalProjectAnnotations;
return globalThis.globalProjectAnnotations;
}
const cleanups: CleanupCallback[] = [];
@ -93,7 +107,13 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
);
const normalizedProjectAnnotations = normalizeProjectAnnotations<TRenderer>(
composeConfigs([defaultConfig ?? {}, globalProjectAnnotations, projectAnnotations ?? {}])
composeConfigs([
defaultConfig && Object.keys(defaultConfig).length > 0
? defaultConfig
: globalThis.defaultProjectAnnotations ?? {},
globalThis.globalProjectAnnotations ?? {},
projectAnnotations ?? {},
])
);
const story = prepareStory<TRenderer>(
@ -219,10 +239,13 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
return composedStory;
}
const defaultComposeStory: ComposeStoryFn = (story, component, project, exportsName) =>
composeStory(story, component, project, {}, exportsName);
export function composeStories<TModule extends Store_CSFExports>(
storiesImport: TModule,
globalConfig: ProjectAnnotations<Renderer>,
composeStoryFn: ComposeStoryFn
composeStoryFn: ComposeStoryFn = defaultComposeStory
) {
const { default: meta, __esModule, __namedExportsOrder, ...stories } = storiesImport;
const composedStories = Object.entries(stories).reduce((storiesMap, [exportsName, story]) => {

View File

@ -236,42 +236,6 @@ export class MountMustBeDestructuredError extends StorybookError {
}
}
export class TestingLibraryMustBeConfiguredError extends StorybookError {
constructor() {
super({
category: Category.PREVIEW_API,
code: 13,
message: dedent`
You must configure testingLibraryRender to use play in portable stories.
import { render } from '@testing-library/[renderer]';
setProjectAnnotations({
testingLibraryRender: render,
});
For other testing renderers, you can configure \`renderToCanvas\` like so:
import { render } from 'your-test-renderer';
setProjectAnnotations({
renderToCanvas: ({ storyFn }) => {
const Story = storyFn();
// Svelte
render(Story.Component, Story.props);
// Vue
render(Story);
// or for React
render(<Story/>);
},
});`,
});
}
}
export class NoRenderFunctionError extends StorybookError {
constructor(public data: { id: string }) {
super({

View File

@ -379,16 +379,30 @@ export class MainFileESMOnlyImportError extends StorybookError {
}
export class MainFileMissingError extends StorybookError {
constructor(public data: { location: string }) {
constructor(public data: { location: string; source?: 'storybook' | 'vitest' }) {
const map = {
storybook: {
helperMessage:
'You can pass a --config-dir flag to tell Storybook, where your main.js file is located at.',
documentation: 'https://storybook.js.org/docs/configure',
},
vitest: {
helperMessage:
'You can pass a configDir plugin option to tell where your main.js file is located at.',
// TODO: add proper docs once available
documentation: 'https://storybook.js.org/docs/configure',
},
};
const { documentation, helperMessage } = map[data.source || 'storybook'];
super({
category: Category.CORE_SERVER,
code: 6,
documentation: 'https://storybook.js.org/docs/configure',
documentation,
message: dedent`
No configuration files have been found in your configDir: ${chalk.yellow(data.location)}.
Storybook needs a "main.js" file, please add it.
You can pass a --config-dir flag to tell Storybook, where your main.js file is located at).`,
${helperMessage}`,
});
}
}

View File

@ -9,7 +9,7 @@ const mocksDir = join(__dirname, '..', '__mocks__');
describe('getPortableStoriesFileCountUncached', () => {
it('should ignores node_modules, non-source files', async () => {
const usage = await getPortableStoriesFileCountUncached(mocksDir);
// you can verify with: `git grep -m1 -c composeStor | wc -l`
// you can verify with: `git grep -l composeStor | wc -l`
expect(usage).toMatchInlineSnapshot(`2`);
});
});

View File

@ -9,11 +9,12 @@ const cache = createFileSystemCache({
});
export const getPortableStoriesFileCountUncached = async (path?: string) => {
const command = `git grep -m1 -c composeStor` + (path ? ` -- ${path}` : '');
const command = `git grep -l composeStor` + (path ? ` -- ${path}` : '');
const { stdout } = await execaCommand(command, {
cwd: process.cwd(),
shell: true,
});
return stdout.split('\n').filter(Boolean).length;
};

View File

@ -431,6 +431,10 @@ export interface Addon_WrapperType {
}>
>;
}
/**
* @deprecated This doesn't do anything anymore and will be removed in Storybook 9.0.
*/
export interface Addon_SidebarBottomType {
type: Addon_TypesEnum.experimental_SIDEBAR_BOTTOM;
/**
@ -443,6 +447,9 @@ export interface Addon_SidebarBottomType {
render: FC;
}
/**
* @deprecated This will be removed in Storybook 9.0.
*/
export interface Addon_SidebarTopType {
type: Addon_TypesEnum.experimental_SIDEBAR_TOP;
/**
@ -523,12 +530,12 @@ export enum Addon_TypesEnum {
experimental_PAGE = 'page',
/**
* This adds items in the bottom of the sidebar.
* @unstable
* @deprecated This doesn't do anything anymore and will be removed in Storybook 9.0.
*/
experimental_SIDEBAR_BOTTOM = 'sidebar-bottom',
/**
* This adds items in the top of the sidebar.
* @unstable This will get replaced with a new API in 8.0, use at your own risk.
* @deprecated This will be removed in Storybook 9.0.
*/
experimental_SIDEBAR_TOP = 'sidebar-top',
}

View File

@ -125,6 +125,7 @@ export interface API_StatusObject {
title: string;
description: string;
data?: any;
onClick?: () => void;
}
export type API_StatusState = Record<StoryId, Record<string, API_StatusObject>>;

View File

@ -67,6 +67,7 @@ export const Targets = {
b: 'b',
});
},
tags: ['!vitest'],
};
export const Events = {
@ -85,4 +86,5 @@ export const Events = {
await new Promise((resolve) => channel.once(STORY_ARGS_UPDATED, resolve));
await within(canvasElement).findByText(/updated/);
},
tags: ['!vitest'],
};

View File

@ -67,4 +67,5 @@ export const Hooks = {
});
await new Promise((resolve) => channel.once(STORY_ARGS_UPDATED, resolve));
},
tags: ['!vitest'],
};

View File

@ -44,6 +44,7 @@ export const Events = {
await channel.emit('updateGlobals', { globals: { foo: 'fooValue' } });
await within(canvasElement).findByText('fooValue');
},
tags: ['!vitest'],
};
export const Overrides1 = {

View File

@ -14,6 +14,7 @@ export default {
args: {
label: 'Click me',
},
tags: ['!vitest'],
};
export const ForceRemount = {
@ -37,7 +38,7 @@ export const ForceRemount = {
// By forcing the component to remount, we reset the focus state
await channel.emit(FORCE_REMOUNT, { storyId: id });
},
tags: ['!test'],
tags: ['!test', '!vitest'],
};
export const ChangeArgs = {

View File

@ -16,7 +16,7 @@ export default {
};
export const Inheritance = {
tags: ['story-one'],
tags: ['story-one', '!vitest'],
play: async ({ canvasElement }: PlayFunctionContext<any>) => {
const canvas = within(canvasElement);
await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({

View File

@ -16,7 +16,7 @@ export default {
};
export const Inheritance = {
tags: ['story-one'],
tags: ['story-one', '!vitest'],
play: async ({ canvasElement }: PlayFunctionContext<any>) => {
const canvas = within(canvasElement);
await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({

View File

@ -16,7 +16,7 @@ export default {
};
export const Inheritance = {
tags: ['story-one'],
tags: ['story-one', '!vitest'],
play: async ({ canvasElement }: PlayFunctionContext<any>) => {
const canvas = within(canvasElement);
await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({

View File

@ -3,11 +3,13 @@ import {
composeStories as originalComposeStories,
composeStory as originalComposeStory,
setProjectAnnotations as originalSetProjectAnnotations,
setDefaultProjectAnnotations,
} from 'storybook/internal/preview-api';
import type {
Args,
ComposedStoryFn,
NamedOrDefaultProjectAnnotations,
NormalizedProjectAnnotations,
ProjectAnnotations,
Store_CSFExports,
StoriesWithPartialProps,
@ -16,7 +18,6 @@ import type {
import type { Meta, ReactRenderer } from '@storybook/react';
import * as rscAnnotations from '../../../renderers/react/src/entry-preview-rsc';
// ! ATTENTION: This needs to be a relative import so it gets prebundled. This is to avoid ESM issues in Nextjs + Jest setups
import { INTERNAL_DEFAULT_PROJECT_ANNOTATIONS as reactAnnotations } from '../../../renderers/react/src/portable-stories';
import * as nextJsAnnotations from './preview';
@ -28,7 +29,7 @@ import * as nextJsAnnotations from './preview';
* Example:
*```jsx
* // setup.js (for jest)
* import { setProjectAnnotations } from '@storybook/nextjs';
* import { setProjectAnnotations } from '@storybook/experimental-nextjs-vite';
* import projectAnnotations from './.storybook/preview';
*
* setProjectAnnotations(projectAnnotations);
@ -38,16 +39,18 @@ import * as nextJsAnnotations from './preview';
*/
export function setProjectAnnotations(
projectAnnotations:
| NamedOrDefaultProjectAnnotations<ReactRenderer>
| NamedOrDefaultProjectAnnotations<ReactRenderer>[]
): ProjectAnnotations<ReactRenderer> {
return originalSetProjectAnnotations<ReactRenderer>(projectAnnotations);
| NamedOrDefaultProjectAnnotations<any>
| NamedOrDefaultProjectAnnotations<any>[]
): NormalizedProjectAnnotations<ReactRenderer> {
setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS);
return originalSetProjectAnnotations(
projectAnnotations
) as NormalizedProjectAnnotations<ReactRenderer>;
}
// This will not be necessary once we have auto preset loading
const defaultProjectAnnotations: ProjectAnnotations<ReactRenderer> = composeConfigs([
const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<ReactRenderer> = composeConfigs([
reactAnnotations,
rscAnnotations,
nextJsAnnotations,
]);
@ -62,7 +65,7 @@ const defaultProjectAnnotations: ProjectAnnotations<ReactRenderer> = composeConf
* Example:
*```jsx
* import { render } from '@testing-library/react';
* import { composeStory } from '@storybook/nextjs';
* import { composeStory } from '@storybook/experimental-nextjs-vite';
* import Meta, { Primary as PrimaryStory } from './Button.stories';
*
* const Primary = composeStory(PrimaryStory, Meta);
@ -88,7 +91,7 @@ export function composeStory<TArgs extends Args = Args>(
story as StoryAnnotationsOrFn<ReactRenderer, Args>,
componentAnnotations,
projectAnnotations,
defaultProjectAnnotations,
INTERNAL_DEFAULT_PROJECT_ANNOTATIONS,
exportsName
);
}
@ -104,7 +107,7 @@ export function composeStory<TArgs extends Args = Args>(
* Example:
*```jsx
* import { render } from '@testing-library/react';
* import { composeStories } from '@storybook/nextjs';
* import { composeStories } from '@storybook/experimental-nextjs-vite';
* import * as stories from './Button.stories';
*
* const { Primary, Secondary } = composeStories(stories);

View File

@ -8,6 +8,7 @@ import NextHeader from './NextHeader';
export default {
component: NextHeader,
parameters: { react: { rsc: true } },
} as Meta<typeof NextHeader>;
type Story = StoryObj<typeof NextHeader>;

View File

@ -5,6 +5,11 @@ import { Nested, RSC } from './RSC';
export default {
component: RSC,
args: { label: 'label' },
parameters: {
react: {
rsc: true,
},
},
};
export const Default = {};
@ -18,7 +23,7 @@ export const DisableRSC = {
};
export const Error = {
tags: ['!test'],
tags: ['!test', '!vitest'],
parameters: {
chromatic: { disable: true },
},

View File

@ -46,7 +46,7 @@ export default {
},
},
},
tags: ['!test'],
tags: ['!test', '!vitest'],
} as Meta;
export const SingletonStateGetsInvalidatedAfterRedirecting: StoryObj = {

View File

@ -3,11 +3,13 @@ import {
composeStories as originalComposeStories,
composeStory as originalComposeStory,
setProjectAnnotations as originalSetProjectAnnotations,
setDefaultProjectAnnotations,
} from 'storybook/internal/preview-api';
import type {
Args,
ComposedStoryFn,
NamedOrDefaultProjectAnnotations,
NormalizedProjectAnnotations,
ProjectAnnotations,
Store_CSFExports,
StoriesWithPartialProps,
@ -16,7 +18,6 @@ import type {
import type { Meta, ReactRenderer } from '@storybook/react';
import * as rscAnnotations from '../../../renderers/react/src/entry-preview-rsc';
// ! ATTENTION: This needs to be a relative import so it gets prebundled. This is to avoid ESM issues in Nextjs + Jest setups
import { INTERNAL_DEFAULT_PROJECT_ANNOTATIONS as reactAnnotations } from '../../../renderers/react/src/portable-stories';
import * as nextJsAnnotations from './preview';
@ -38,16 +39,18 @@ import * as nextJsAnnotations from './preview';
*/
export function setProjectAnnotations(
projectAnnotations:
| NamedOrDefaultProjectAnnotations<ReactRenderer>
| NamedOrDefaultProjectAnnotations<ReactRenderer>[]
): ProjectAnnotations<ReactRenderer> {
return originalSetProjectAnnotations<ReactRenderer>(projectAnnotations);
| NamedOrDefaultProjectAnnotations<any>
| NamedOrDefaultProjectAnnotations<any>[]
): NormalizedProjectAnnotations<ReactRenderer> {
setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS);
return originalSetProjectAnnotations(
projectAnnotations
) as NormalizedProjectAnnotations<ReactRenderer>;
}
// This will not be necessary once we have auto preset loading
const defaultProjectAnnotations: ProjectAnnotations<ReactRenderer> = composeConfigs([
const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<ReactRenderer> = composeConfigs([
reactAnnotations,
rscAnnotations,
nextJsAnnotations,
]);
@ -88,7 +91,7 @@ export function composeStory<TArgs extends Args = Args>(
story as StoryAnnotationsOrFn<ReactRenderer, Args>,
componentAnnotations,
projectAnnotations,
defaultProjectAnnotations,
INTERNAL_DEFAULT_PROJECT_ANNOTATIONS,
exportsName
);
}

View File

@ -5,6 +5,11 @@ import { Nested, RSC } from './RSC';
export default {
component: RSC,
args: { label: 'label' },
parameters: {
react: {
rsc: true,
},
},
};
export const Default = {};

View File

@ -7,6 +7,11 @@ import NextHeader from './NextHeader';
export default {
component: NextHeader,
parameters: {
react: {
rsc: true,
},
},
} as Meta<typeof NextHeader>;
type Story = StoryObj<typeof NextHeader>;

View File

@ -2,6 +2,6 @@
import { enhance } from '$app/forms';
</script>
<form use:enhance>
<form use:enhance method="post">
<button>enhance</button>
</form>

Some files were not shown because too many files have changed in this diff Show More