diff --git a/CHANGELOG.md b/CHANGELOG.md index 8979b190287d..7a3d4a5410ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 9.1.1 + +- CLI: Fix throwing in readonly environments - [#31785](https://github.com/storybookjs/storybook/pull/31785), thanks @JReinhold! +- Onboarding: Tweak referral wording in survey - [#32185](https://github.com/storybookjs/storybook/pull/32185), thanks @shilman! +- Telemetry: Send index stats on dev exit - [#32168](https://github.com/storybookjs/storybook/pull/32168), thanks @shilman! + ## 9.1.0 Storybook 9.1 is packed with new features and improvements to enhance accessibility, streamline testing, and make your development workflow even smoother! diff --git a/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx b/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx index b9b703831ca1..282fbec199ee 100644 --- a/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx +++ b/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx @@ -111,7 +111,7 @@ export const IntentSurvey = ({ }, }, referrer: { - label: 'How did you learn about Storybook?', + label: 'How did you discover Storybook?', type: 'select', required: true, options: shuffleObject({ diff --git a/code/addons/vitest/src/postinstall-logger.ts b/code/addons/vitest/src/postinstall-logger.ts index 71dbd5fe045a..03bdf56c8710 100644 --- a/code/addons/vitest/src/postinstall-logger.ts +++ b/code/addons/vitest/src/postinstall-logger.ts @@ -1,7 +1,7 @@ +import { isCI } from 'storybook/internal/common'; import { colors, logger } from 'storybook/internal/node-logger'; -const fancy = - process.platform !== 'win32' || process.env.CI || process.env.TERM === 'xterm-256color'; +const fancy = process.platform !== 'win32' || isCI() || process.env.TERM === 'xterm-256color'; export const step = colors.gray('›'); export const info = colors.blue(fancy ? 'ℹ' : 'i'); diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 525ba6960514..020ac845a31e 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -8,6 +8,7 @@ import { extractProperFrameworkName, formatFileContent, getProjectRoot, + isCI, loadAllPresets, loadMainConfig, scanAndTransformFiles, @@ -79,7 +80,7 @@ export default async function postInstall(options: PostinstallOptions) { const hasCustomWebpackConfig = !!config.getFieldNode(['webpackFinal']); - const isInteractive = process.stdout.isTTY && !process.env.CI; + const isInteractive = process.stdout.isTTY && !isCI(); if (info.frameworkPackageName === '@storybook/nextjs' && !hasCustomWebpackConfig) { const out = diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index e888f1d16234..0e8bbfc949ca 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -8,6 +8,7 @@ import { DEFAULT_FILES_PATTERN, getInterpretedFile, normalizeStories, + optionalEnvToBoolean, resolvePathInStorybookCache, validateConfigurationFiles, } from 'storybook/internal/common'; @@ -123,7 +124,7 @@ export const storybookTest = async (options?: UserOptions): Promise => }, } as InternalOptions; - if (process.env.DEBUG) { + if (optionalEnvToBoolean(process.env.DEBUG)) { finalOptions.debug = true; } @@ -131,6 +132,10 @@ export const storybookTest = async (options?: UserOptions): Promise => process.env.__STORYBOOK_URL__ = finalOptions.storybookUrl; process.env.__STORYBOOK_SCRIPT__ = finalOptions.storybookScript; + // We signal the test runner that we are not running it via Storybook + // We are overriding the environment variable to 'true' if vitest runs via @storybook/addon-vitest's backend + const isVitestStorybook = optionalEnvToBoolean(process.env.VITEST_STORYBOOK); + const directories = { configDir: finalOptions.configDir, workingDir: WORKING_DIR, @@ -212,10 +217,6 @@ export const storybookTest = async (options?: UserOptions): Promise => // plugin.name?.startsWith('vitest:browser') // ) - // We signal the test runner that we are not running it via Storybook - // We are overriding the environment variable to 'true' if vitest runs via @storybook/addon-vitest's backend - const vitestStorybook = process.env.VITEST_STORYBOOK ?? 'false'; - const testConfig = nonMutableInputConfig.test; finalOptions.vitestRoot = testConfig?.dir || testConfig?.root || nonMutableInputConfig.root || process.cwd(); @@ -260,7 +261,7 @@ export const storybookTest = async (options?: UserOptions): Promise => // To be accessed by the setup file __STORYBOOK_URL__: finalOptions.storybookUrl, - VITEST_STORYBOOK: vitestStorybook, + VITEST_STORYBOOK: isVitestStorybook ? 'true' : 'false', __VITEST_INCLUDE_TAGS__: finalOptions.tags.include.join(','), __VITEST_EXCLUDE_TAGS__: finalOptions.tags.exclude.join(','), __VITEST_SKIP_TAGS__: finalOptions.tags.skip.join(','), @@ -288,9 +289,7 @@ export const storybookTest = async (options?: UserOptions): Promise => getInitialGlobals: () => { const envConfig = JSON.parse(process.env.VITEST_STORYBOOK_CONFIG ?? '{}'); - const shouldRunA11yTests = process.env.VITEST_STORYBOOK - ? (envConfig.a11y ?? false) - : true; + const shouldRunA11yTests = isVitestStorybook ? (envConfig.a11y ?? false) : true; return { a11y: { @@ -373,10 +372,10 @@ export const storybookTest = async (options?: UserOptions): Promise => configureVitest(context) { context.vitest.config.coverage.exclude.push('storybook-static'); - const disableTelemetryVar = - process.env.STORYBOOK_DISABLE_TELEMETRY && - process.env.STORYBOOK_DISABLE_TELEMETRY !== 'false'; - if (!core?.disableTelemetry && !disableTelemetryVar) { + if ( + !core?.disableTelemetry && + !optionalEnvToBoolean(process.env.STORYBOOK_DISABLE_TELEMETRY) + ) { // NOTE: we start telemetry immediately but do not wait on it. Typically it should complete // before the tests do. If not we may miss the event, we are OK with that. telemetry( @@ -410,7 +409,7 @@ export const storybookTest = async (options?: UserOptions): Promise => } }, async transform(code, id) { - if (process.env.VITEST !== 'true') { + if (!optionalEnvToBoolean(process.env.VITEST)) { return code; } @@ -434,7 +433,7 @@ export const storybookTest = async (options?: UserOptions): Promise => // When running tests via the Storybook UI, we need // to find the right project to run, thus we override // with a unique identifier using the path to the config dir - if (process.env.VITEST_STORYBOOK) { + if (isVitestStorybook) { const projectName = `storybook:${normalize(finalOptions.configDir)}`; plugins.push({ name: 'storybook:workspace-name-override', diff --git a/code/core/src/cli/bin/index.ts b/code/core/src/cli/bin/index.ts index 533108bd8a83..6613e80ef6a3 100644 --- a/code/core/src/cli/bin/index.ts +++ b/code/core/src/cli/bin/index.ts @@ -1,4 +1,4 @@ -import { getEnvConfig, parseList } from 'storybook/internal/common'; +import { getEnvConfig, optionalEnvToBoolean, parseList } from 'storybook/internal/common'; import { logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext } from 'storybook/internal/telemetry'; @@ -22,8 +22,7 @@ const command = (name: string) => .option( '--disable-telemetry', 'Disable sending telemetry data', - // default value is false, but if the user sets STORYBOOK_DISABLE_TELEMETRY, it can be true - process.env.STORYBOOK_DISABLE_TELEMETRY && process.env.STORYBOOK_DISABLE_TELEMETRY !== 'false' + optionalEnvToBoolean(process.env.STORYBOOK_DISABLE_TELEMETRY) ) .option('--debug', 'Get more logs in debug mode', false) .option('--enable-crash-reports', 'Enable sending crash reports to telemetry data') @@ -151,7 +150,7 @@ command('build') await build({ ...options, packageJson: pkg, - test: !!options.test || process.env.SB_TESTBUILD === 'true', + test: !!options.test || optionalEnvToBoolean(process.env.SB_TESTBUILD), }).catch(() => process.exit(1)); }); diff --git a/code/core/src/cli/globalSettings.test.ts b/code/core/src/cli/globalSettings.test.ts index 32f746ac4594..95060338d0b4 100644 --- a/code/core/src/cli/globalSettings.test.ts +++ b/code/core/src/cli/globalSettings.test.ts @@ -85,16 +85,22 @@ describe('Settings', () => { ); }); - it('throws error if write fails', async () => { + it('logs warning if write fails', async () => { vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write error')); - await expect(settings.save()).rejects.toThrow('Unable to save global settings'); + await expect(settings.save()).resolves.toBeUndefined(); + expect(console.warn).toHaveBeenCalledWith( + 'Unable to save global settings file to /test/settings.json\nReason: Write error' + ); }); - it('throws error if directory creation fails', async () => { + it('logs warning if directory creation fails', async () => { vi.mocked(fs.mkdir).mockRejectedValue(new Error('Directory creation error')); - await expect(settings.save()).rejects.toThrow('Unable to save global settings'); + await expect(settings.save()).resolves.toBeUndefined(); + expect(console.warn).toHaveBeenCalledWith( + 'Unable to save global settings file to /test/settings.json\nReason: Directory creation error' + ); }); }); }); diff --git a/code/core/src/cli/globalSettings.ts b/code/core/src/cli/globalSettings.ts index 34eb6f6a990d..f72e31c6f1a7 100644 --- a/code/core/src/cli/globalSettings.ts +++ b/code/core/src/cli/globalSettings.ts @@ -2,10 +2,9 @@ import fs from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import { dedent } from 'ts-dedent'; import { z } from 'zod'; -import { SavingGlobalSettingsFileError } from '../server-errors'; - const DEFAULT_SETTINGS_PATH = join(homedir(), '.storybook', 'settings.json'); const VERSION = 1; @@ -71,10 +70,9 @@ export class Settings { await fs.mkdir(dirname(this.filePath), { recursive: true }); await fs.writeFile(this.filePath, JSON.stringify(this.value, null, 2)); } catch (err) { - throw new SavingGlobalSettingsFileError({ - filePath: this.filePath, - error: err, - }); + console.warn(dedent` + Unable to save global settings file to ${this.filePath} + ${err && `Reason: ${(err as Error).message ?? err}`}`); } } } diff --git a/code/core/src/common/utils/envs.ts b/code/core/src/common/utils/envs.ts index adf1297f686f..e825bd6af2ae 100644 --- a/code/core/src/common/utils/envs.ts +++ b/code/core/src/common/utils/envs.ts @@ -1,15 +1,14 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - Needed for Angular sandbox running without --no-link option. Do NOT convert to @ts-expect-error! -import { getEnvironment } from 'lazy-universal-dotenv'; - import { nodePathsToArray } from './paths'; // Load environment variables starts with STORYBOOK_ to the client side. -export function loadEnvs(options: { production?: boolean } = {}): { +export async function loadEnvs(options: { production?: boolean } = {}): Promise<{ stringified: Record; raw: Record; -} { +}> { + const { getEnvironment } = await import('lazy-universal-dotenv'); const defaultNodeEnv = options.production ? 'production' : 'development'; const env: Record = { @@ -67,3 +66,26 @@ export const stringifyProcessEnvs = (raw: Record): Record { + if (input === undefined) { + return undefined; + } + if (input.toUpperCase() === 'FALSE' || input === '0') { + return false; + } + if (input.toUpperCase() === 'TRUE' || input === '1') { + return true; + } + return Boolean(input); +}; + +/** + * Consistently determine if we are in a CI environment + * + * Doing Boolean(process.env.CI) or !process.env.CI is not enough, because users might set CI=false + * or CI=0, which would be truthy, and thus return true in those cases. + */ +export function isCI(): boolean | undefined { + return optionalEnvToBoolean(process.env.CI); +} diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index d6a06ca5155e..69675f08f93b 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -7,6 +7,7 @@ import compression from '@polka/compression'; import polka from 'polka'; import invariant from 'tiny-invariant'; +import { telemetry } from '../telemetry'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { doTelemetry } from './utils/doTelemetry'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; @@ -19,6 +20,7 @@ import { openInBrowser } from './utils/open-in-browser'; import { getServerAddresses } from './utils/server-address'; import { getServer } from './utils/server-init'; import { useStatics } from './utils/server-statics'; +import { summarizeIndex } from './utils/summarizeIndex'; export async function storybookDevServer(options: Options) { const [server, core] = await Promise.all([getServer(options), options.presets.apply('core')]); @@ -130,5 +132,26 @@ export async function storybookDevServer(options: Options) { // Now the preview has successfully started, we can count this as a 'dev' event. doTelemetry(app, core, initializedStoryIndexGenerator, options); + async function cancelTelemetry() { + const payload = { eventType: 'dev' }; + try { + const generator = await initializedStoryIndexGenerator; + const indexAndStats = await generator?.getIndexAndStats(); + // compute stats so we can get more accurate story counts + if (indexAndStats) { + Object.assign(payload, { + storyIndex: summarizeIndex(indexAndStats.storyIndex), + storyStats: indexAndStats.stats, + }); + } + } catch (err) {} + await telemetry('canceled', payload, { immediate: true }); + process.exit(0); + } + + if (!core?.disableTelemetry) { + process.on('SIGINT', cancelTelemetry); + } + return { previewResult, managerResult, address, networkAddress }; } diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 7fdc20ba92da..d3a402e3fc64 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, isAbsolute, join } from 'node:path'; import type { Channel } from 'storybook/internal/channels'; +import { optionalEnvToBoolean } from 'storybook/internal/common'; import { JsPackageManagerFactory, type RemoveAddonOptions, @@ -143,7 +144,8 @@ export const previewHead = async (base: any, { configDir, presets }: Options) => }; export const env = async () => { - return loadEnvs({ production: true }).raw; + const { raw } = await loadEnvs({ production: true }); + return raw; }; export const previewBody = async (base: any, { configDir, presets }: Options) => { @@ -164,22 +166,6 @@ export const typescript = () => ({ }, }); -const optionalEnvToBoolean = (input: string | undefined): boolean | undefined => { - if (input === undefined) { - return undefined; - } - if (input.toUpperCase() === 'FALSE') { - return false; - } - if (input.toUpperCase() === 'TRUE') { - return true; - } - if (typeof input === 'string') { - return true; - } - return undefined; -}; - /** This API is used by third-parties to access certain APIs in a Node environment */ export const experimental_serverAPI = (extension: Record, options: Options) => { let removeAddon = removeAddonBase; diff --git a/code/core/src/core-server/stores/status.ts b/code/core/src/core-server/stores/status.ts index 343e9c5f48da..24fc4310f6cd 100644 --- a/code/core/src/core-server/stores/status.ts +++ b/code/core/src/core-server/stores/status.ts @@ -1,3 +1,4 @@ +import { optionalEnvToBoolean } from '../../common/utils/envs'; import { createStatusStore } from '../../shared/status-store'; import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../../shared/status-store'; import { UniversalStore } from '../../shared/universal-store'; @@ -12,7 +13,7 @@ const statusStore = createStatusStore({ before it was ready. This will be fixed when we do the planned UniversalStore v0.2. */ - leader: process.env.VITEST_CHILD_PROCESS !== 'true', + leader: !optionalEnvToBoolean(process.env.VITEST_CHILD_PROCESS), }), environment: 'server', }); diff --git a/code/core/src/core-server/stores/test-provider.ts b/code/core/src/core-server/stores/test-provider.ts index ceb5b7bd443b..21c8a5112b05 100644 --- a/code/core/src/core-server/stores/test-provider.ts +++ b/code/core/src/core-server/stores/test-provider.ts @@ -1,3 +1,4 @@ +import { optionalEnvToBoolean } from '../../common/utils/envs'; import { createTestProviderStore } from '../../shared/test-provider-store'; import { UNIVERSAL_TEST_PROVIDER_STORE_OPTIONS } from '../../shared/test-provider-store'; import { UniversalStore } from '../../shared/universal-store'; @@ -12,7 +13,7 @@ const testProviderStore = createTestProviderStore({ before it was ready. This will be fixed when we do the planned UniversalStore v0.2. */ - leader: process.env.VITEST_CHILD_PROCESS !== 'true', + leader: !optionalEnvToBoolean(process.env.VITEST_CHILD_PROCESS), }), }); diff --git a/code/core/src/core-server/utils/server-address.test.ts b/code/core/src/core-server/utils/server-address.test.ts index a6b823bcac73..fe835c9257e5 100644 --- a/code/core/src/core-server/utils/server-address.test.ts +++ b/code/core/src/core-server/utils/server-address.test.ts @@ -6,6 +6,7 @@ import { getServerAddresses, getServerChannelUrl, getServerPort } from './server vi.mock('node:os', () => ({ default: { release: () => '' }, + platform: 'darwin', constants: { signals: {}, }, diff --git a/code/core/src/core-server/withTelemetry.ts b/code/core/src/core-server/withTelemetry.ts index aa1788c12a5b..914ec185fb7b 100644 --- a/code/core/src/core-server/withTelemetry.ts +++ b/code/core/src/core-server/withTelemetry.ts @@ -1,4 +1,4 @@ -import { HandledError, cache, loadAllPresets } from 'storybook/internal/common'; +import { HandledError, cache, isCI, loadAllPresets } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { getPrecedingUpgrade, oneWayHash, telemetry } from 'storybook/internal/telemetry'; import type { EventType } from 'storybook/internal/telemetry'; @@ -14,7 +14,7 @@ type TelemetryOptions = { }; const promptCrashReports = async () => { - if (process.env.CI || !process.stdout.isTTY) { + if (isCI() || !process.stdout.isTTY) { return undefined; } diff --git a/code/core/src/node-logger/logger/log-tracker.ts b/code/core/src/node-logger/logger/log-tracker.ts index 5394531aa3f5..c38f76c3ee93 100644 --- a/code/core/src/node-logger/logger/log-tracker.ts +++ b/code/core/src/node-logger/logger/log-tracker.ts @@ -1,6 +1,8 @@ import { promises as fs } from 'node:fs'; import path, { join } from 'node:path'; +import { isCI } from 'storybook/internal/common'; + import { cleanLog } from '../../../../lib/cli-storybook/src/automigrate/helpers/cleanLog'; import type { LogLevel } from './logger'; @@ -97,7 +99,7 @@ class LogTracker { await fs.writeFile(filePath, logContent, 'utf-8'); this.#logs = []; - return process.env.CI ? filePath : path.relative(process.cwd(), filePath); + return isCI() ? filePath : path.relative(process.cwd(), filePath); } } diff --git a/code/core/src/node-logger/prompts/prompt-config.ts b/code/core/src/node-logger/prompts/prompt-config.ts index e1cbc71099f6..c79cc8152974 100644 --- a/code/core/src/node-logger/prompts/prompt-config.ts +++ b/code/core/src/node-logger/prompts/prompt-config.ts @@ -1,3 +1,4 @@ +import { optionalEnvToBoolean } from '../../common/utils/envs'; import type { PromptProvider } from './prompt-provider-base'; import { ClackPromptProvider } from './prompt-provider-clack'; import { PromptsPromptProvider } from './prompt-provider-prompts'; @@ -9,7 +10,9 @@ const PROVIDERS = { prompts: new PromptsPromptProvider(), } as const; -let currentPromptLibrary: PromptLibrary = process.env.USE_CLACK === 'true' ? 'clack' : 'prompts'; +let currentPromptLibrary: PromptLibrary = optionalEnvToBoolean(process.env.USE_CLACK) + ? 'clack' + : 'prompts'; export const setPromptLibrary = (library: PromptLibrary): void => { currentPromptLibrary = library; diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index 216df6b68503..d27d7f815abb 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -566,15 +566,3 @@ export class IncompatiblePostCssConfigError extends StorybookError { }); } } - -export class SavingGlobalSettingsFileError extends StorybookError { - constructor(public data: { filePath: string; error: Error | unknown }) { - super({ - category: Category.CORE_SERVER, - code: 1, - message: dedent` - Unable to save global settings file to ${data.filePath} - ${data.error && `Reason: ${data.error}`}`, - }); - } -} diff --git a/code/core/src/telemetry/storybook-metadata.test.ts b/code/core/src/telemetry/storybook-metadata.test.ts index 549daea82c60..59232111d1c1 100644 --- a/code/core/src/telemetry/storybook-metadata.test.ts +++ b/code/core/src/telemetry/storybook-metadata.test.ts @@ -3,11 +3,12 @@ import path from 'node:path'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getStorybookInfo } from 'storybook/internal/common'; +import { getStorybookInfo, isCI } from 'storybook/internal/common'; import type { PackageJson, StorybookConfig } from 'storybook/internal/types'; import { detect } from 'package-manager-detector'; +import { type Settings, globalSettings } from '../cli/globalSettings'; import { getMonorepoType } from '../telemetry/get-monorepo-type'; import { getActualPackageJson, @@ -16,6 +17,7 @@ import { } from './package-json'; import { computeStorybookMetadata, metaFrameworks, sanitizeAddonName } from './storybook-metadata'; +vi.mock(import('../cli/globalSettings'), { spy: true }); vi.mock(import('./package-json'), { spy: true }); vi.mock(import('./get-monorepo-type'), { spy: true }); vi.mock(import('package-manager-detector'), { spy: true }); @@ -442,6 +444,29 @@ describe('storybook-metadata', () => { ); it('should detect userSince info', async () => { + vi.mocked(isCI).mockImplementation(() => false); + vi.mocked(globalSettings).mockResolvedValue({ + value: { + userSince: 1717334400000, + }, + } as Settings); + + const res = await computeStorybookMetadata({ + configDir: '.storybook', + packageJson: packageJsonMock, + packageJsonPath, + mainConfig: mainJsMock, + }); + + expect(globalSettings).toHaveBeenCalled(); + + expect(res.userSince).toEqual(1717334400000); + }); + + it('should not detect userSince info in CI', async () => { + vi.mocked(isCI).mockImplementation(() => true); + vi.mocked(globalSettings).mockResolvedValue({} as Settings); + const res = await computeStorybookMetadata({ configDir: '.storybook', packageJson: packageJsonMock, @@ -449,7 +474,8 @@ describe('storybook-metadata', () => { mainConfig: mainJsMock, }); - expect(res.userSince).toBeDefined(); + expect(globalSettings).not.toHaveBeenCalled(); + expect(res.userSince).not.toBeDefined(); }); }); }); diff --git a/code/core/src/telemetry/storybook-metadata.ts b/code/core/src/telemetry/storybook-metadata.ts index f443f24633d2..db336475dd43 100644 --- a/code/core/src/telemetry/storybook-metadata.ts +++ b/code/core/src/telemetry/storybook-metadata.ts @@ -3,6 +3,7 @@ import { dirname } from 'node:path'; import { getStorybookConfiguration, getStorybookInfo, + isCI, loadMainConfig, versions, } from 'storybook/internal/common'; @@ -56,10 +57,10 @@ export const computeStorybookMetadata = async ({ mainConfig?: StorybookConfig & Record; configDir: string; }): Promise => { - const settings = await globalSettings(); + const settings = isCI() ? undefined : await globalSettings(); const metadata: Partial = { generatedAt: new Date().getTime(), - userSince: settings.value.userSince, + userSince: settings?.value.userSince, hasCustomBabel: false, hasCustomWebpack: false, hasStaticDirs: false, diff --git a/code/core/src/telemetry/telemetry.ts b/code/core/src/telemetry/telemetry.ts index 4dfccfa6786b..9796a315db13 100644 --- a/code/core/src/telemetry/telemetry.ts +++ b/code/core/src/telemetry/telemetry.ts @@ -1,6 +1,8 @@ /// import * as os from 'node:os'; +import { isCI } from 'storybook/internal/common'; + import retry from 'fetch-retry'; import { nanoid } from 'nanoid'; @@ -45,7 +47,7 @@ const getOperatingSystem = (): 'Windows' | 'macOS' | 'Linux' | `Other: ${string} // by the app. currently: // - cliVersion const globalContext = { - inCI: Boolean(process.env.CI), + inCI: isCI(), isTTY: process.stdout.isTTY, platform: getOperatingSystem(), nodeVersion: process.versions.node, diff --git a/code/lib/cli-storybook/src/bin/index.ts b/code/lib/cli-storybook/src/bin/index.ts index 733c890574f5..04232bb00865 100644 --- a/code/lib/cli-storybook/src/bin/index.ts +++ b/code/lib/cli-storybook/src/bin/index.ts @@ -2,6 +2,8 @@ import { globalSettings } from 'storybook/internal/cli'; import { HandledError, JsPackageManagerFactory, + isCI, + optionalEnvToBoolean, removeAddon as remove, versions, } from 'storybook/internal/common'; @@ -43,8 +45,7 @@ const command = (name: string) => .option( '--disable-telemetry', 'Disable sending telemetry data', - // default value is false, but if the user sets STORYBOOK_DISABLE_TELEMETRY, it can be true - process.env.STORYBOOK_DISABLE_TELEMETRY && process.env.STORYBOOK_DISABLE_TELEMETRY !== 'false' + optionalEnvToBoolean(process.env.STORYBOOK_DISABLE_TELEMETRY) ) .option('--debug', 'Get more logs in debug mode', false) .option('--enable-crash-reports', 'Enable sending crash reports to telemetry data') @@ -88,7 +89,7 @@ command('init') .option( '--dev', 'Launch the development server after completing initialization. Enabled by default (default: true)', - process.env.CI !== 'true' && process.env.IN_STORYBOOK_SANDBOX !== 'true' + !isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX) ) .option( '--no-dev', diff --git a/code/lib/cli-storybook/src/codemod/csf-factories.ts b/code/lib/cli-storybook/src/codemod/csf-factories.ts index 1282d31a33b4..52c8c24aabd4 100644 --- a/code/lib/cli-storybook/src/codemod/csf-factories.ts +++ b/code/lib/cli-storybook/src/codemod/csf-factories.ts @@ -1,4 +1,8 @@ -import { type JsPackageManager, syncStorybookAddons } from 'storybook/internal/common'; +import { + type JsPackageManager, + optionalEnvToBoolean, + syncStorybookAddons, +} from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; import picocolors from 'picocolors'; @@ -19,7 +23,7 @@ async function runStoriesCodemod(options: { const { dryRun, packageManager, ...codemodOptions } = options; try { let globString = '{stories,src}/**/{Button,Header,Page}.stories.*'; - if (!process.env.IN_STORYBOOK_SANDBOX) { + if (!optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX)) { logger.log('Please enter the glob for your stories to migrate'); globString = await prompt.text({ message: 'glob', @@ -59,7 +63,7 @@ export const csfFactories: CommandFix = { promptType: 'command', async run({ dryRun, mainConfig, mainConfigPath, previewConfigPath, packageManager, configDir }) { let useSubPathImports = true; - if (!process.env.IN_STORYBOOK_SANDBOX) { + if (!optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX)) { // prompt whether the user wants to use imports map logger.logBox(dedent` The CSF factories format benefits from subpath imports (the imports property in your \`package.json\`), which is a node standard for module resolution. This makes it more convenient to import the preview config in your story files. diff --git a/code/lib/cli-storybook/src/sandbox.ts b/code/lib/cli-storybook/src/sandbox.ts index 9d531614d1ac..b28d9e54c4c2 100644 --- a/code/lib/cli-storybook/src/sandbox.ts +++ b/code/lib/cli-storybook/src/sandbox.ts @@ -3,7 +3,12 @@ import { mkdir, readdir, rm } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; import type { PackageManagerName } from 'storybook/internal/common'; -import { JsPackageManagerFactory, versions } from 'storybook/internal/common'; +import { + JsPackageManagerFactory, + isCI, + optionalEnvToBoolean, + versions, +} from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; import { downloadTemplate } from 'giget'; @@ -221,7 +226,7 @@ export const sandbox = async ({ // @ts-ignore-error (no types for this) const { initiate } = await import('create-storybook'); await initiate({ - dev: process.env.CI !== 'true' && process.env.IN_STORYBOOK_SANDBOX !== 'true', + dev: isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX), ...options, features: ['docs', 'test'], }); diff --git a/code/lib/create-storybook/src/bin/index.ts b/code/lib/create-storybook/src/bin/index.ts index d3176590c93e..eab35fee0863 100644 --- a/code/lib/create-storybook/src/bin/index.ts +++ b/code/lib/create-storybook/src/bin/index.ts @@ -1,13 +1,11 @@ import { program } from 'commander'; +import { isCI, optionalEnvToBoolean } from '../../../../core/src/common/utils/envs'; import { addToGlobalContext } from '../../../../core/src/telemetry'; import { version } from '../../package.json'; import type { CommandOptions } from '../generators/types'; import { initiate } from '../initiate'; -const IS_NON_CI = process.env.CI !== 'true'; -const IS_NON_STORYBOOK_SANDBOX = process.env.IN_STORYBOOK_SANDBOX !== 'true'; - addToGlobalContext('cliVersion', version); /** @@ -22,8 +20,7 @@ const createStorybookProgram = program .option( '--disable-telemetry', 'Disable sending telemetry data', - // default value is false, but if the user sets STORYBOOK_DISABLE_TELEMETRY, it can be true - process.env.STORYBOOK_DISABLE_TELEMETRY && process.env.STORYBOOK_DISABLE_TELEMETRY !== 'false' + optionalEnvToBoolean(process.env.STORYBOOK_DISABLE_TELEMETRY) ) .option('--features ', 'What features of storybook are you interested in?') .option('--debug', 'Get more logs in debug mode') @@ -53,8 +50,10 @@ const createStorybookProgram = program createStorybookProgram .action(async (options) => { + const isNeitherCiNorSandbox = + !isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX); options.debug = options.debug ?? false; - options.dev = options.dev ?? (IS_NON_CI && IS_NON_STORYBOOK_SANDBOX); + options.dev = options.dev ?? isNeitherCiNorSandbox; await initiate(options as CommandOptions).catch(() => process.exit(1)); }) diff --git a/code/lib/create-storybook/src/generators/baseGenerator.ts b/code/lib/create-storybook/src/generators/baseGenerator.ts index 742c2328a0d7..ee929c6a3734 100644 --- a/code/lib/create-storybook/src/generators/baseGenerator.ts +++ b/code/lib/create-storybook/src/generators/baseGenerator.ts @@ -18,7 +18,7 @@ import { SupportedLanguage, externalFrameworks, } from '../../../../core/src/cli/project_types'; -import { frameworkPackages } from '../../../../core/src/common'; +import { frameworkPackages, isCI, optionalEnvToBoolean } from '../../../../core/src/common'; import { type JsPackageManager, getPackageDetails, @@ -192,7 +192,7 @@ const hasFrameworkTemplates = (framework?: string) => { // As the Nuxt framework templates are not compatible with the stories we need for CI. // See: https://github.com/storybookjs/storybook/pull/28607#issuecomment-2467903327 if (framework === 'nuxt') { - return process.env.IN_STORYBOOK_SANDBOX !== 'true'; + return !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX); } const frameworksWithTemplates: SupportedFrameworks[] = [ @@ -354,7 +354,7 @@ export async function baseGenerator( }).start(); try { - if (process.env.CI !== 'true') { + if (!isCI()) { const { hasEslint, isStorybookPluginInstalled, isFlatConfig, eslintConfigFile } = // TODO: Investigate why packageManager type does not match on CI await extractEslintInfo(packageManager as any); diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index cfb9616e015c..326be9d0bac9 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -1,8 +1,6 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs/promises'; -import { logger } from 'storybook/internal/node-logger'; - import boxen from 'boxen'; import { findUp } from 'find-up'; import picocolors from 'picocolors'; @@ -24,10 +22,12 @@ import { ProjectType, installableProjectTypes } from '../../../core/src/cli/proj import type { JsPackageManager } from '../../../core/src/common/js-package-manager/JsPackageManager'; import { JsPackageManagerFactory } from '../../../core/src/common/js-package-manager/JsPackageManagerFactory'; import { HandledError } from '../../../core/src/common/utils/HandledError'; +import { isCI } from '../../../core/src/common/utils/envs'; import { commandLog, paddedLog } from '../../../core/src/common/utils/log'; import { getProjectRoot, invalidateProjectRootCache } from '../../../core/src/common/utils/paths'; import versions from '../../../core/src/common/versions'; import { withTelemetry } from '../../../core/src/core-server/withTelemetry'; +import { logger } from '../../../core/src/node-logger'; import { NxProjectDetectedError } from '../../../core/src/server-errors'; import { telemetry } from '../../../core/src/telemetry'; import angularGenerator from './generators/ANGULAR'; @@ -441,7 +441,7 @@ export async function doInitiate(options: CommandOptions): Promise< ) ); - const isInteractive = process.stdout.isTTY && !process.env.CI; + const isInteractive = process.stdout.isTTY && !isCI(); const settings = await globalSettings(); const promptOptions = { @@ -476,7 +476,8 @@ export async function doInitiate(options: CommandOptions): Promise< let selectedFeatures = new Set(options.features || []); if (installType === 'recommended') { selectedFeatures.add('docs'); - if (isInteractive) { + // Don't install in CI but install in non-TTY environments like agentic installs + if (!isCI()) { selectedFeatures.add('test'); } if (newUser) { diff --git a/code/package.json b/code/package.json index 6dad699baabd..04010d70614c 100644 --- a/code/package.json +++ b/code/package.json @@ -285,5 +285,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "9.1.1" } diff --git a/docs/_snippets/automock-register-full.md b/docs/_snippets/automock-register-full.md new file mode 100644 index 000000000000..5bcaaad2b28d --- /dev/null +++ b/docs/_snippets/automock-register-full.md @@ -0,0 +1,21 @@ +```ts filename=".storybook/preview.ts" renderer="common" language="ts" +import { sb } from 'storybook/test'; + +// 👇 Automatically replaces all exports from the `lib/session` local module with mock functions +sb.mock(import('../lib/session.ts')); +// 👇 Automatically replaces all exports from the `uuid` package in `node_modules` with mock functions +sb.mock(import('uuid')); + +// ...rest of the file +``` + +```js filename=".storybook/preview.js" renderer="common" language="js" +import { sb } from 'storybook/test'; + +// 👇 Automatically replaces all exports from the `lib/session` local module with mock functions +sb.mock('../lib/session.js'); +// 👇 Automatically replaces all exports from the `uuid` package in `node_modules` with mock functions +sb.mock('uuid'); + +// ...rest of the file +``` diff --git a/docs/_snippets/automock-register-mock-file.md b/docs/_snippets/automock-register-mock-file.md new file mode 100644 index 000000000000..a4febca91487 --- /dev/null +++ b/docs/_snippets/automock-register-mock-file.md @@ -0,0 +1,21 @@ +```ts filename=".storybook/preview.ts" renderer="common" language="ts" +import { sb } from 'storybook/test'; + +// 👇 Replaces imports of this module with imports to `../lib/__mocks__/session.ts` +sb.mock(import('../lib/session.ts')); +// 👇 Replaces imports of this module with imports to `../__mocks__/uuid.ts` +sb.mock(import('uuid')); + +// ...rest of the file +``` + +```js filename=".storybook/preview.js" renderer="common" language="js" +import { sb } from 'storybook/test'; + +// 👇 Replaces imports of this module with imports to `../lib/__mocks__/session.ts` +sb.mock('../lib/session.js'); +// 👇 Replaces imports of this module with imports to `../__mocks__/uuid.ts` +sb.mock('uuid'); + +// ...rest of the file +``` diff --git a/docs/_snippets/automock-register-spy.md b/docs/_snippets/automock-register-spy.md new file mode 100644 index 000000000000..15486afa63b0 --- /dev/null +++ b/docs/_snippets/automock-register-spy.md @@ -0,0 +1,21 @@ +```ts filename=".storybook/preview.ts" renderer="common" language="ts" +import { sb } from 'storybook/test'; + +// 👇 Automatically spies on all exports from the `lib/session` local module +sb.mock(import('../lib/session.ts'), { spy: true }); +// 👇 Automatically spies on all exports from the `uuid` package in `node_modules` +sb.mock(import('uuid'), { spy: true }); + +// ...rest of the file +``` + +```js filename=".storybook/preview.js" renderer="common" language="js" +import { sb } from 'storybook/test'; + +// 👇 Automatically spies on all exports from the `lib/session` local module +sb.mock('../lib/session.js', { spy: true }); +// 👇 Automatically spies on all exports from the `uuid` package in `node_modules` +sb.mock('uuid', { spy: true }); + +// ...rest of the file +``` diff --git a/docs/_snippets/automocked-modules-in-story.md b/docs/_snippets/automocked-modules-in-story.md new file mode 100644 index 000000000000..9aa23a41f93f --- /dev/null +++ b/docs/_snippets/automocked-modules-in-story.md @@ -0,0 +1,282 @@ +```ts filename="AuthButton.stories.ts" renderer="angular" language="ts" +import type { Meta, StoryObj } from '@storybook/angular'; +import { expect, mocked } from 'storybook/test'; + +import { AuthButton } from './AuthButton.component'; + +import { v4 as uuidv4 } from 'uuid'; +import { getUserFromSession } from '../lib/session'; + +const meta: Meta = { + component: AuthButton, + // 👇 This will run before each story is rendered + beforeEach: async () => { + // 👇 Force known, consistent behavior for mocked modules + mocked(uuidv4).mockReturnValue('1234-5678-90ab-cdef'); + mocked(getUserFromSession).mockReturnValue({ name: 'John Doe' }); + }, +}; +export default meta; + +type Story = StoryObj; + +export const LogIn: Story = { + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }, +}; +``` + +```ts filename="AuthButton.stories.ts" renderer="common" language="ts" +// Replace your-framework with the name of your framework (e.g. react-vite, vue3-vite, etc.) +import type { Meta, StoryObj } from '@storybook/your-framework'; +import { expect, mocked } from 'storybook/test'; + +import { AuthButton } from './AuthButton'; + +import { v4 as uuidv4 } from 'uuid'; +import { getUserFromSession } from '../lib/session'; + +const meta = { + component: AuthButton, + // 👇 This will run before each story is rendered + beforeEach: async () => { + // 👇 Force known, consistent behavior for mocked modules + mocked(uuidv4).mockReturnValue('1234-5678-90ab-cdef'); + mocked(getUserFromSession).mockReturnValue({ name: 'John Doe' }); + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const LogIn: Story = { + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }, +}; +``` + +```js filename="AuthButton.stories.js" renderer="common" language="js" +import { expect } from 'storybook/test'; + +import { AuthButton } from './AuthButton'; + +import { v4 as uuidv4 } from 'uuid'; +import { getUserFromSession } from '../lib/session'; + +export default { + component: AuthButton, + // 👇 This will run before each story is rendered + beforeEach: async () => { + // 👇 Force known, consistent behavior for mocked modules + uuidv4.mockReturnValue('1234-5678-90ab-cdef'); + getUserFromSession.mockReturnValue({ name: 'John Doe' }); + }, +}; + +export const LogIn = { + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }, +}; +``` + +```svelte filename="AuthButton.stories.svelte" renderer="svelte" language="ts" tabTitle="Svelte CSF" + + + { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }} +/> +``` + +```ts filename="AuthButton.stories.ts" renderer="svelte" language="ts" tabTitle="CSF" +// Replace your-framework with the framework you are using, e.g. sveltekit or svelte-vite +import type { Meta, StoryObj } from '@storybook/your-framework'; +import { expect, mocked } from 'storybook/test'; + +import { AuthButton } from './AuthButton.svelte'; + +import { v4 as uuidv4 } from 'uuid'; +import { getUserFromSession } from '../lib/session'; + +const meta = { + component: AuthButton, + // 👇 This will run before each story is rendered + beforeEach: async () => { + // 👇 Force known, consistent behavior for mocked modules + mocked(uuidv4).mockReturnValue('1234-5678-90ab-cdef'); + mocked(getUserFromSession).mockReturnValue({ name: 'John Doe' }); + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const LogIn: Story = { + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }, +}; +``` + +```svelte filename="AuthButton.stories.svelte" renderer="svelte" language="js" tabTitle="Svelte CSF" + + + { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }} +/> +``` + +```js filename="AuthButton.stories.js" renderer="svelte" language="js" tabTitle="CSF" +import { expect } from 'storybook/test'; + +import { AuthButton } from './AuthButton.svelte'; + +import { v4 as uuidv4 } from 'uuid'; +import { getUserFromSession } from '../lib/session'; + +export default { + component: AuthButton, + // 👇 This will run before each story is rendered + beforeEach: async () => { + // 👇 Force known, consistent behavior for mocked modules + uuidv4.mockReturnValue('1234-5678-90ab-cdef'); + getUserFromSession.mockReturnValue({ name: 'John Doe' }); + }, +}; + +export const LogIn = { + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }, +}; +``` + +```ts filename="AuthButton.stories.ts" renderer="web-components" language="ts" +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { expect, mocked } from 'storybook/test'; + +import { v4 as uuidv4 } from 'uuid'; +import { getUserFromSession } from '../lib/session'; + +const meta: Meta = { + component: 'demo-auth-button', + // 👇 This will run before each story is rendered + beforeEach: async () => { + // 👇 Force known, consistent behavior for mocked modules + mocked(uuidv4).mockReturnValue('1234-5678-90ab-cdef'); + mocked(getUserFromSession).mockReturnValue({ name: 'John Doe' }); + }, +}; +export default meta; + +type Story = StoryObj; + +export const LogIn: Story = { + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }, +}; +``` + +```js filename="AuthButton.stories.js" renderer="web-components" language="js" +import { expect } from 'storybook/test'; + +import { v4 as uuidv4 } from 'uuid'; +import { getUserFromSession } from '../lib/session'; + +export default { + component: 'demo-auth-button', + // 👇 This will run before each story is rendered + beforeEach: async () => { + // 👇 Force known, consistent behavior for mocked modules + uuidv4.mockReturnValue('1234-5678-90ab-cdef'); + getUserFromSession.mockReturnValue({ name: 'John Doe' }); + }, +}; + +export const LogIn = { + play: async ({ canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Sign in' }); + userEvent.click(button); + + // Assert that the getUserFromSession function was called + expect(getUserFromSession).toHaveBeenCalled(); + }, +}; +``` diff --git a/docs/versions/latest.json b/docs/versions/latest.json index 7ca1e16d5ec6..0fa594378e57 100644 --- a/docs/versions/latest.json +++ b/docs/versions/latest.json @@ -1 +1 @@ -{"version":"9.1.0","info":{"plain":"- Core: Disable dynamic favicon behavior when using custom favicon filename - [#32147](https://github.com/storybookjs/storybook/pull/32147), thanks @ghengeveld!\n- Core: Don't update interactions panel state until we reach the rendering phase - [#32163](https://github.com/storybookjs/storybook/pull/32163), thanks @ghengeveld!"}} +{"version":"9.1.1","info":{"plain":"- CLI: Fix throwing in readonly environments - [#31785](https://github.com/storybookjs/storybook/pull/31785), thanks @JReinhold!\n- Onboarding: Tweak referral wording in survey - [#32185](https://github.com/storybookjs/storybook/pull/32185), thanks @shilman!\n- Telemetry: Send index stats on dev exit - [#32168](https://github.com/storybookjs/storybook/pull/32168), thanks @shilman!"}} diff --git a/docs/versions/next.json b/docs/versions/next.json index 04c02ef62750..47e250f0d12b 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"9.1.0-beta.3","info":{"plain":"- Core: Prevent interactions panel from flickering and showing incorrect state - [#32150](https://github.com/storybookjs/storybook/pull/32150), thanks @ghengeveld!"}} +{"version":"9.2.0-alpha.1","info":{"plain":"- Addon Docs: Add `__STORYBOOK_UNSAFE_TOCBOT__` global - [#32176](https://github.com/storybookjs/storybook/pull/32176), thanks @yannbf!\n- CLI: Fix throwing in readonly environments - [#31785](https://github.com/storybookjs/storybook/pull/31785), thanks @JReinhold!\n- Telemetry: Send index stats on dev exit - [#32168](https://github.com/storybookjs/storybook/pull/32168), thanks @shilman!"}} diff --git a/docs/writing-stories/mocking-data-and-modules/mocking-modules.mdx b/docs/writing-stories/mocking-data-and-modules/mocking-modules.mdx index c80e13b104f1..bb9d592ad24f 100644 --- a/docs/writing-stories/mocking-data-and-modules/mocking-modules.mdx +++ b/docs/writing-stories/mocking-data-and-modules/mocking-modules.mdx @@ -26,6 +26,8 @@ export function AuthButton() { } ``` +The above example is written with React, but the same principles apply to other renderers like Vue, Svelte, or Web Components. The important part is the usage of the two module dependencies. + When writing stories or tests for this component, you may want to mock the `getUserFromSession` function to control the user data returned, or mock the `uuidv4` function to return a predictable ID. This allows you to test the component's behavior without relying on the actual implementations of these modules. For maximum flexibility, Storybook provides three ways to mock modules for your stories. Let's walk through each of them, starting with the most straightforward approach. @@ -57,17 +59,9 @@ For most cases, you should register a mocked module as spy-only, by setting the For example, if you want to spy on the `getUserFromSession` function and the `uuidv4` function from the `uuid` package, you can call the `sb.mock` utility function in your `.storybook/preview.js|ts` file: -{/* TODO: Snippetize */} -```ts title=".storybook/preview.ts" -import { sb } from 'storybook/test'; - -// 👇 Automatically spies on all exports from the `lib/session` local module -sb.mock('../lib/session.ts', { spy: true }); -// 👇 Automatically spies on all exports from the `uuid` package in `node_modules` -sb.mock('uuid', { spy: true }); + -// ...rest of the file -``` +If you need to mock an external module that has a deeper import path (e.g. `lodash-es/add`), register the mock with that path. You can then [control the behavior of these modules](#using-automocked-modules-in-stories) and make assertions about them in your stories, such as checking if a function was called or what arguments it was called with. @@ -75,17 +69,7 @@ You can then [control the behavior of these modules](#using-automocked-modules-i For cases where you need to prevent the original module's functionality from executing, set the `spy` option to `false` (or omit it, because that is the default value). This will automatically replace all exports from the module with [Vitest mock functions](https://vitest.dev/api/mock.html), allowing you to control their behavior and make assertions while being certain that the original functionality never runs. -{/* TODO: Snippetize */} -```ts title=".storybook/preview.ts" -import { sb } from 'storybook/test'; - -// 👇 Automatically replaces all exports from the `lib/session` local module with mock functions -sb.mock('../lib/session.ts'); -// 👇 Automatically replaces all exports from the `uuid` package in `node_modules` with mock functions -sb.mock('uuid'); - -// ...rest of the file -``` + @@ -107,7 +91,7 @@ export function getUserFromSession() { } ``` -For packages in your `node_modules`, create a `__mocks__` directory in the root of your project and create the mock file there. For example, to mock the `uuid` package, create a file named `uuid.js|ts` in the `__mocks__` directory: +For packages in your `node_modules`, create a `__mocks__` directory in the root of your project and create the mock file there. For example, to mock the `uuid` package, create a file named `uuid.js` in the `__mocks__` directory: ```js title="__mocks__/uuid.js" export function v4() { @@ -115,7 +99,17 @@ export function v4() { } ``` -The root of your project is typically the directory where `.git` is located. You can set the project root manually by providing the `STORYBOOK_PROJECT_ROOT` environment variable when running or building Storybook. +If you need to mock an external module that has a deeper import path (e.g. `lodash-es/add`), create a corresponding mock file (e.g. `__mocks__/lodash-es/add.js`) in the root of your project. + +The root of your project is determined differently depending on your builder: + +**Vite projects** + +The root `__mocks__` directory should be placed in the [`root` directory](https://vite.dev/config/shared-options.html#root), as defined in your project's Vite configuration (typically `process.cwd()`) If that is unavailable, it defaults to the directory containing your `.storybook` directory. + +**Webpack projects** + +The root `__mocks__` directory should be placed in the [`context` directory](https://webpack.js.org/configuration/entry-context/#context), as defined in your project's Webpack configuration (typically `process.cwd()`). If that is unavailable, it defaults to the root of your repository. @@ -127,17 +121,7 @@ They must export the same named exports as the original module. If you want to m You can then use the `sb.mock` utility to register these mock files in your `preview.js|ts` file: -{/* TODO: Snippetize */} -```ts title=".storybook/preview.ts" -import { sb } from 'storybook/test'; - -// 👇 Replaces imports of this module with imports to `../lib/__mocks__/session.ts` -sb.mock('../lib/session.ts'); -// 👇 Replaces imports of this module with imports to `../__mocks__/uuid.ts` -sb.mock('uuid'); - -// ...rest of the file -``` + Note that the API for registering automatically mocked modules and mock files is the same. The only difference is that `sb.mock` will first look for a mock file in the appropriate directory before automatically mocking the module. @@ -145,38 +129,7 @@ Note that the API for registering automatically mocked modules and mock files is All registered automocked modules are used the same way within your stories. You can control the behavior, such as defining what it returns, and make assertions about the modules. -{/* TODO: Snippetize */} -```ts title="AuthButton.stories.ts" -import { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, mocked } from 'storybook/test'; -import { AuthButton } from './AuthButton'; - -import { v4 as uuidv4 } from 'uuid'; -import { getUserFromSession } from '../lib/session'; - -const meta = { - component: AuthButton, - // 👇 This will run before each story is rendered - beforeEach: async () => { - // 👇 Force known, consistent behavior for mocked modules - mocked(uuidv4).mockReturnValue('1234-5678-90ab-cdef'); - mocked(getUserFromSession).mockReturnValue({ name: 'John Doe' }); - }, -} satisfies Meta; -export default meta; - -type Story = StoryObj; - -export const LogIn: Story = { - play: async ({ canvas, userEvent }) => { - const button = canvas.getByRole('button', { name: 'Sign in' }); - userEvent.click(button); - - // Assert that the getUserFromSession function was called - expect(getUserFromSession).toHaveBeenCalled(); - }, -}; -``` + Mocked functions created with the `sb.mock` utility are full [Vitest mock functions](https://vitest.dev/api/mock.html), which means you can use all the methods available on them. Some of the most useful methods include: @@ -217,14 +170,6 @@ While this feature uses Vitest's mocking engine, the implementation within Story - Runtime Mocking: While the module swap is static, you can still control the behavior of the mocked functions at runtime within a play function or `beforeEach` hook (e.g., `mocked(myFunction).mockReturnValue('new value')`). - No Factory Functions: The `sb.mock()` API does not accept a factory function as its second argument (e.g., `sb.mock('path', () => ({...}))`). This is because all mocking decisions are resolved at build time, whereas factories are executed at runtime. -### Troubleshooting - -#### Receiving an `exports is not defined` error - -Webpack projects may encounter an `exports is not defined` error when using automocking. This is usually caused by attempting to mock a module with CommonJS (CJS) entry points. Automocking with Webpack only works with modules that have ESModules (ESM) entry points exclusively, so you must use a [mock file](#mock-files) to mock CJS modules. - ---- - ## Alternative methods If [automocking](#automocking) is not suitable for your project, there are two alternative methods to mock modules in Storybook: [subpath imports](#subpath-imports) and [builder aliases](#builder-aliases). These methods require a bit more setup but provide similar functionality to automocking, allowing you to control the behavior of modules in your stories. @@ -378,3 +323,11 @@ Here's an example of using the [`mockdate`](https://github.com/boblauer/MockDate {/* prettier-ignore-end */} + +----- + +## Troubleshooting + +### Receiving an `exports is not defined` error + +Webpack projects may encounter an `exports is not defined` error when using [automocking](#automocking). This is usually caused by attempting to mock a module with CommonJS (CJS) entry points. Automocking with Webpack only works with modules that have ESModules (ESM) entry points exclusively, so you must use a [mock file](#mock-files) to mock CJS modules. diff --git a/scripts/event-log-checker.ts b/scripts/event-log-checker.ts index 228329f1b295..f98a4f5c6470 100644 --- a/scripts/event-log-checker.ts +++ b/scripts/event-log-checker.ts @@ -119,9 +119,11 @@ async function run() { assert.equal(mainEvent.context.anonymousId, oneWayHash(unhashedId)); }); - test(`main event should contain a userSince value`, () => { - assert.ok(typeof mainEvent.metadata.userSince === 'number'); - }); + // Not sure if it's worth testing this as we are not providing this value in CI. + // For now the code is commented out so we can discuss later. + // test(`main event should contain a userSince value`, () => { + // assert.ok(typeof mainEvent.metadata.userSince === 'number'); + // }); const { expected: { renderer, builder, framework },