diff --git a/.gitignore b/.gitignore index 43107a4f3e07..3460788946c0 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,5 @@ CLAUDE.local.md .cursor/mcp.json .vscode/mcp.json .mcp.json -.nx/polygraph \ No newline at end of file +.nx/polygraph +.omc \ No newline at end of file diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 9f5654bdc2e8..dd381efdbce5 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,10 +1,12 @@ import { detectAgent } from 'std-env'; -const fmtCmd = detectAgent().name ? 'oxfmt' : 'oxfmt --check'; +const autofix = process.env.FIX_ON_COMMIT || detectAgent().name; +const fmtCmd = autofix ? 'oxfmt' : 'oxfmt --check'; +const lintSuffix = autofix ? ' --fix' : ''; export default { - 'code/**/*.{js,jsx,mjs,ts,tsx,html,json}': [fmtCmd, 'yarn --cwd code lint:js:cmd'], - 'scripts/**/*.{html,js,json,jsx,mjs,ts,tsx}': ['yarn --cwd scripts lint:js:cmd'], + 'code/**/*.{js,jsx,mjs,ts,tsx,html,json}': [fmtCmd, `yarn --cwd code lint:js:cmd${lintSuffix}`], + 'scripts/**/*.{html,js,json,jsx,mjs,ts,tsx}': [`yarn --cwd scripts lint:js:cmd${lintSuffix}`], 'docs/_snippets/**/*.{js,jsx,mjs,ts,tsx,html,json}': [fmtCmd], '**/*.ejs': ['yarn --cwd scripts exec ejslint'], '**/package.json': ['yarn --cwd scripts lint:package'], diff --git a/AGENTS.md b/AGENTS.md index b76d10d6248b..ad985033a0db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ For routine agent work, prefer the faster non-production commands first. Add `-c yarn yarn task compile yarn nx run-many -t compile -yarn nx compile +yarn nx compile ``` ### Lint and typecheck @@ -122,7 +122,7 @@ yarn storybook:vitest | Scenario | Command | | ------------------------------- | ------------------------------------------------------------------------------ | | Compile everything quickly | `yarn nx run-many -t compile` | -| Compile one package | `yarn nx compile ` | +| Compile one project | `yarn nx compile ` | | Check TypeScript errors quickly | `yarn nx run-many -t check` | | Start the internal Storybook UI | `cd code && yarn storybook:ui` | | Build the internal Storybook UI | `cd code && yarn storybook:ui:build` | @@ -160,6 +160,8 @@ Key points: - `react-vite/default-ts` is the default sandbox template - `--no-link` is opt-in, not the default - NX handles task dependencies via `nx.json` +- NX target commands use Nx project names (from `project.json` / Nx graph), not `package.json` names +- Example: `yarn nx compile core` (project `core` is published as package `storybook`) ## Sandbox Notes @@ -266,6 +268,7 @@ Avoid `console.log`, `console.warn`, and `console.error` unless the file is isol | `STORYBOOK_DISABLE_TELEMETRY` | Disable telemetry | | `STORYBOOK_TELEMETRY_DEBUG` | Log telemetry events | | `DEBUG` | Enable debug logging | +| `FIX_ON_COMMIT` | Force autofix for fmt & lint in pre-commit hook | ## Commands To Avoid diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index a55815862616..0204b03c9f27 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,8 @@ +## 10.4.0-alpha.10 + +- Sidebar: Fix clear status button to only clear test statuses - [#34478](https://github.com/storybookjs/storybook/pull/34478), thanks @valentinpalkovic! +- Telemetry: Centralize disable logic with module-level flag - [#34485](https://github.com/storybookjs/storybook/pull/34485), thanks @valentinpalkovic! + ## 10.4.0-alpha.9 - A11y: Improve boolean control contrast in forced colors mode - [#34204](https://github.com/storybookjs/storybook/pull/34204), thanks @anchmelev! diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 707aae862d53..7b2d6644a7f9 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -143,10 +143,12 @@ const config = defineMain({ }, core: { disableTelemetry: true, + changeDetection: true, }, features: { developmentModeForBuild: true, experimentalTestSyntax: true, + changeDetection: true, }, staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }], viteFinal: async (viteConfig, { configType }) => { @@ -154,8 +156,8 @@ const config = defineMain({ return mergeConfig(viteConfig, { resolve: { - alias: { - ...(configType === 'DEVELOPMENT' + alias: + configType === 'DEVELOPMENT' ? { 'storybook/internal/components': componentsPath, 'storybook/manager-api': managerApiPath, @@ -165,8 +167,7 @@ const config = defineMain({ } : { 'storybook/manager-api': managerApiPath, - }), - }, + }, }, plugins: [react()], build: { diff --git a/code/addons/onboarding/src/preset.ts b/code/addons/onboarding/src/preset.ts index 77f7bd91ad14..eb64f9238901 100644 --- a/code/addons/onboarding/src/preset.ts +++ b/code/addons/onboarding/src/preset.ts @@ -12,12 +12,6 @@ type Event = { }; export const experimental_serverChannel = async (channel: Channel, options: Options) => { - const { disableTelemetry } = await options.presets.apply('core', {}); - - if (disableTelemetry) { - return channel; - } - channel.on(ADDON_ONBOARDING_CHANNEL, ({ type, ...event }: Event) => { if (type === 'telemetry') { telemetry('addon-onboarding', { ...event, addonVersion }); diff --git a/code/addons/vitest/src/manager.tsx b/code/addons/vitest/src/manager.tsx index d0d34f51c58a..80544ab9f4a1 100644 --- a/code/addons/vitest/src/manager.tsx +++ b/code/addons/vitest/src/manager.tsx @@ -48,6 +48,10 @@ addons.register(ADDON_ID, (api) => { addons.add(TEST_PROVIDER_ID, { type: Addon_TypesEnum.experimental_TEST_PROVIDER, + clear: () => { + componentTestStatusStore.unset(); + a11yStatusStore.unset(); + }, render: () => { const [isModalOpen, setModalOpen] = useState(false); const { diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index c617621b8af9..c10349d63f00 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -257,49 +257,47 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti }); }); - if (!core.disableTelemetry) { - const enableCrashReports = core.enableCrashReports || options.enableCrashReports; - - channel.on(STORYBOOK_ADDON_TEST_CHANNEL, (event: Event) => { - if (event.type !== 'test-run-completed') { - telemetry('addon-test', { - ...event, - payload: { - ...event.payload, - storyId: oneWayHash(event.payload.storyId), - }, - }); - } - }); + const enableCrashReports = core?.enableCrashReports || options.enableCrashReports; + + channel.on(STORYBOOK_ADDON_TEST_CHANNEL, (event: Event) => { + if (event.type !== 'test-run-completed') { + telemetry('addon-test', () => ({ + ...event, + payload: { + ...event.payload, + storyId: oneWayHash(event.payload.storyId), + }, + })); + } + }); - store.subscribe('TOGGLE_WATCHING', async (event) => { - await telemetry('addon-test', { - watchMode: event.payload.to, - }); - }); - store.subscribe('TEST_RUN_COMPLETED', async (event) => { - const { unhandledErrors, startedAt, finishedAt, ...currentRun } = event.payload; - await telemetry('addon-test', { - ...currentRun, - duration: (finishedAt ?? 0) - (startedAt ?? 0), - unhandledErrorCount: unhandledErrors.length, - ...(enableCrashReports && - unhandledErrors.length > 0 && { - unhandledErrors: unhandledErrors.map((error) => { - const { stacks, ...errorWithoutStacks } = error; - return sanitizeError(errorWithoutStacks); - }), + store.subscribe('TOGGLE_WATCHING', async (event) => { + await telemetry('addon-test', () => ({ + watchMode: event.payload.to, + })); + }); + store.subscribe('TEST_RUN_COMPLETED', async (event) => { + const { unhandledErrors, startedAt, finishedAt, ...currentRun } = event.payload; + await telemetry('addon-test', () => ({ + ...currentRun, + duration: (finishedAt ?? 0) - (startedAt ?? 0), + unhandledErrorCount: unhandledErrors.length, + ...(enableCrashReports && + unhandledErrors.length > 0 && { + unhandledErrors: unhandledErrors.map((error) => { + const { stacks, ...errorWithoutStacks } = error; + return sanitizeError(errorWithoutStacks); }), - }); - }); + }), + })); + }); - if (enableCrashReports) { - store.subscribe('FATAL_ERROR', async (event) => { - await telemetry('addon-test', { - fatalError: cleanPaths(event.payload.error.message), - }); - }); - } + if (enableCrashReports) { + store.subscribe('FATAL_ERROR', async (event) => { + await telemetry('addon-test', () => ({ + fatalError: cleanPaths(event.payload.error.message), + })); + }); } return channel; diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 17a42dd20882..9f1bd5c7bbde 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -7,7 +7,6 @@ import type { ViteUserConfig } from 'vitest/config'; import { DEFAULT_FILES_PATTERN, getInterpretedFile, - loadPreviewOrConfigFile, normalizeStories, optionalEnvToBoolean, resolvePathInStorybookCache, @@ -19,14 +18,9 @@ import { experimental_loadStorybook, mapStaticDir, } from 'storybook/internal/core-server'; -import { - componentTransform, - isCsfFactoryPreview, - readConfig, - vitestTransform, -} from 'storybook/internal/csf-tools'; +import { componentTransform, readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; -import { telemetry } from 'storybook/internal/telemetry'; +import { setTelemetryEnabled, telemetry } from 'storybook/internal/telemetry'; import { oneWayHash } from 'storybook/internal/telemetry'; import type { Presets } from 'storybook/internal/types'; @@ -224,6 +218,8 @@ export const storybookTest = async (options?: UserOptions): Promise => presets.apply('features', {}), ]); + await setTelemetryEnabled(!core?.disableTelemetry); + const pluginsToIgnore = [ 'storybook:react-docgen-plugin', 'vite:react-docgen-typescript', // aka @joshwooding/vite-plugin-react-docgen-typescript @@ -448,22 +444,17 @@ export const storybookTest = async (options?: UserOptions): Promise => configureVitest(context) { context.vitest.config.coverage.exclude.push('storybook-static'); - 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( - 'test-run', - { - runner: 'vitest', - watch: context.vitest.config.watch, - coverage: !!context.vitest.config.coverage?.enabled, - }, - { configDir: finalOptions.configDir } - ); - } + // 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( + 'test-run', + { + runner: 'vitest', + watch: context.vitest.config.watch, + coverage: !!context.vitest.config.coverage?.enabled, + }, + { configDir: finalOptions.configDir } + ); }, async configureServer(server) { if (staticDirs) { diff --git a/code/core/package.json b/code/core/package.json index af930dd9085d..a882cef65c3d 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -373,7 +373,6 @@ "typescript": "^5.8.3", "unique-string": "^3.0.0", "use-resize-observer": "^9.1.0", - "vite-plus": "^0.1.16", "watchpack": "^2.5.0", "wrap-ansi": "^9.0.2", "zod": "^3.25.76" diff --git a/code/core/src/common/js-package-manager/vite-plus-versions.ts b/code/core/src/common/js-package-manager/vite-plus-versions.ts index e603958b0926..6322d1722307 100644 --- a/code/core/src/common/js-package-manager/vite-plus-versions.ts +++ b/code/core/src/common/js-package-manager/vite-plus-versions.ts @@ -18,6 +18,7 @@ export async function getVitePlusVersions(): Promise | nu } try { + // @ts-expect-error - This is a dynamic import of a potentially non-existent package. Vite-plus is currently a peer dependency. const mod = await import('vite-plus/versions'); const versions = mod.versions ?? mod; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 3d7902abdc09..29e58d421a07 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -15,7 +15,7 @@ import { } from 'storybook/internal/common'; import { CLI_COLORS, deprecate, logger, prompt } from 'storybook/internal/node-logger'; import { MissingBuilderError, NoStatsForViteDevError } from 'storybook/internal/server-errors'; -import { oneWayHash, telemetry } from 'storybook/internal/telemetry'; +import { oneWayHash, setTelemetryEnabled, telemetry } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -181,6 +181,8 @@ export async function buildDevStandalone( const { allowedHosts, renderer, builder, disableTelemetry } = await presets.apply('core', {}); + await setTelemetryEnabled(!disableTelemetry); + // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments. // By default we allow requests from all hosts in this case, but the user should be made aware of the risk. if ( @@ -198,10 +200,8 @@ export async function buildDevStandalone( throw new MissingBuilderError(); } - if (!options.disableTelemetry && !disableTelemetry) { - if (versionCheck.success && !versionCheck.cached) { - telemetry('version-update'); - } + if (versionCheck.success && !versionCheck.cached) { + telemetry('version-update'); } const resolvedPreviewBuilder = typeof builder === 'string' ? builder : builder.name; diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 607f2cb7aa47..6e4e5cc7dcce 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -9,7 +9,7 @@ import { resolveAddonName, } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; -import { getPrecedingUpgrade, telemetry } from 'storybook/internal/telemetry'; +import { getPrecedingUpgrade, setTelemetryEnabled, telemetry } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -111,8 +111,10 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption presets.apply('staticDirs'), ]); + await setTelemetryEnabled(!core?.disableTelemetry); + const invokedBy = process.env.STORYBOOK_INVOKED_BY; - if (!core?.disableTelemetry && invokedBy) { + if (invokedBy) { // NOTE: we don't await this event to avoid slowing things down. // This could result in telemetry events being lost. telemetry('test-run', { runner: invokedBy, watch: false }, { configDir: options.configDir }); @@ -208,7 +210,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption // Now the code has successfully built, we can count this as a 'build' event. // NOTE: we don't send the 'build' event for test runs as we want to be as fast as possible. - if (!core?.disableTelemetry && !options.test) { + if (!options.test) { try { const generator = await storyIndexGeneratorPromise; const storyIndex = await generator?.getIndex(); diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 90a242deda54..cb18922df3bf 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -8,7 +8,7 @@ import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; import polka from 'polka'; -import { telemetry } from '../telemetry/index.ts'; +import { isTelemetryModuleEnabled, telemetry } from '../telemetry/index.ts'; import { ChangeDetectionService } from './change-detection/index.ts'; import { setChangeDetectionReadiness } from './change-detection/readiness.ts'; import { getStatusStoreByTypeId } from './stores/status.ts'; @@ -203,6 +203,10 @@ export async function storybookDevServer( doTelemetry(app, core, storyIndexGeneratorPromise, options); async function cancelTelemetry() { + if (!isTelemetryModuleEnabled()) { + return; + } + const payload = { eventType: 'dev' }; try { const generator = await storyIndexGeneratorPromise; @@ -219,10 +223,8 @@ export async function storybookDevServer( process.exit(0); } - if (!core?.disableTelemetry) { - process.on('SIGINT', cancelTelemetry); - process.on('SIGTERM', cancelTelemetry); - } + process.on('SIGINT', cancelTelemetry); + process.on('SIGTERM', cancelTelemetry); return { previewResult, managerResult }; } diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index bcdcd8941ed3..2c6a804326c9 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -15,7 +15,7 @@ import { import { StoryIndexGenerator } from 'storybook/internal/core-server'; import { loadCsf } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; -import { telemetry } from 'storybook/internal/telemetry'; +import { setTelemetryEnabled, telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, Indexer, @@ -176,12 +176,10 @@ export const experimental_serverAPI = (extension: Record, opti const packageManager = JsPackageManagerFactory.getPackageManager({ configDir: options.configDir, }); - if (!options.disableTelemetry) { - removeAddon = async (id: string, opts: RemoveAddonOptions) => { - await telemetry('remove', { addon: id, source: 'api' }); - return removeAddonBase(id, { ...opts, packageManager }); - }; - } + removeAddon = async (id: string, opts: RemoveAddonOptions) => { + await telemetry('remove', { addon: id, source: 'api' }); + return removeAddonBase(id, { ...opts, packageManager }); + }; return { ...extension, removeAddon }; }; @@ -197,7 +195,8 @@ export const core = async (existing: CoreConfig, options: Options): Promise { const coreOptions = await options.presets.apply('core'); + await setTelemetryEnabled(!coreOptions?.disableTelemetry); + initializeChecklist(); - initializeWhatsNew(channel, options, coreOptions); - initializeSaveStory(channel, options, coreOptions); + initializeWhatsNew(channel, options); + initializeSaveStory(channel, options); - initFileSearchChannel(channel, options, coreOptions); - initCreateNewStoryChannel(channel, options, coreOptions); - initGhostStoriesChannel(channel, options, coreOptions); - initOpenInEditorChannel(channel, options, coreOptions); + initFileSearchChannel(channel, options); + initCreateNewStoryChannel(channel, options); + initGhostStoriesChannel(channel, options); + initOpenInEditorChannel(channel); initTelemetryChannel(channel, options); return channel; diff --git a/code/core/src/core-server/server-channel/create-new-story-channel.test.ts b/code/core/src/core-server/server-channel/create-new-story-channel.test.ts index 0e1d39fdbb17..033722db3d2a 100644 --- a/code/core/src/core-server/server-channel/create-new-story-channel.test.ts +++ b/code/core/src/core-server/server-channel/create-new-story-channel.test.ts @@ -12,6 +12,14 @@ import { import { initCreateNewStoryChannel } from './create-new-story-channel.ts'; +vi.mock('storybook/internal/telemetry', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + telemetry: vi.fn(), + }; +}); + vi.mock('storybook/internal/common', async (importOriginal) => { const actual = await importOriginal(); return { @@ -56,23 +64,19 @@ describe('createNewStoryChannel', () => { mockChannel.addListener(CREATE_NEW_STORYFILE_RESPONSE, createNewStoryFileEventListener); const cwd = process.cwd(); - initCreateNewStoryChannel( - mockChannel, - { - configDir: join(cwd, '.storybook'), - presets: { - apply: (val: string) => { - if (val === 'framework') { - return Promise.resolve('@storybook/nextjs'); - } - if (val === 'stories') { - return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']); - } - }, + initCreateNewStoryChannel(mockChannel, { + configDir: join(cwd, '.storybook'), + presets: { + apply: (val: string) => { + if (val === 'framework') { + return Promise.resolve('@storybook/nextjs'); + } + if (val === 'stories') { + return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']); + } }, - } as any, - { disableTelemetry: true } - ); + }, + } as any); mockChannel.emit(CREATE_NEW_STORYFILE_REQUEST, { id: 'components-page--default', @@ -107,23 +111,19 @@ describe('createNewStoryChannel', () => { throw new Error('Failed to write file'); }); - initCreateNewStoryChannel( - mockChannel, - { - configDir: join(cwd, '.storybook'), - presets: { - apply: (val: string) => { - if (val === 'framework') { - return Promise.resolve('@storybook/nextjs'); - } - if (val === 'stories') { - return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']); - } - }, + initCreateNewStoryChannel(mockChannel, { + configDir: join(cwd, '.storybook'), + presets: { + apply: (val: string) => { + if (val === 'framework') { + return Promise.resolve('@storybook/nextjs'); + } + if (val === 'stories') { + return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']); + } }, - } as any, - { disableTelemetry: true } - ); + }, + } as any); mockChannel.emit(CREATE_NEW_STORYFILE_REQUEST, { id: 'components-page--default', diff --git a/code/core/src/core-server/server-channel/create-new-story-channel.ts b/code/core/src/core-server/server-channel/create-new-story-channel.ts index 2cdc8c6223f6..46faf3475358 100644 --- a/code/core/src/core-server/server-channel/create-new-story-channel.ts +++ b/code/core/src/core-server/server-channel/create-new-story-channel.ts @@ -11,15 +11,11 @@ import { CREATE_NEW_STORYFILE_RESPONSE, } from 'storybook/internal/core-events'; import { telemetry } from 'storybook/internal/telemetry'; -import type { CoreConfig, Options } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; import { generateStoryFile } from '../utils/generate-story.ts'; -export function initCreateNewStoryChannel( - channel: Channel, - options: Options, - coreOptions: CoreConfig -) { +export function initCreateNewStoryChannel(channel: Channel, options: Options) { /** Listens for events to create a new storyfile */ channel.on( CREATE_NEW_STORYFILE_REQUEST, @@ -38,11 +34,9 @@ export function initCreateNewStoryChannel( error: null, } satisfies ResponseData); - if (!coreOptions.disableTelemetry) { - telemetry('create-new-story-file', { - success: true, - }); - } + telemetry('create-new-story-file', { + success: true, + }); } else { channel.emit(CREATE_NEW_STORYFILE_RESPONSE, { success: false, @@ -57,12 +51,10 @@ export function initCreateNewStoryChannel( error: result.error || 'Unknown error occurred', } satisfies ResponseData); - if (!coreOptions.disableTelemetry) { - await telemetry('create-new-story-file', { - success: false, - error: result.errorType || result.error, - }); - } + await telemetry('create-new-story-file', { + success: false, + error: result.errorType || result.error, + }); } } ); diff --git a/code/core/src/core-server/server-channel/file-search-channel.test.ts b/code/core/src/core-server/server-channel/file-search-channel.test.ts index 453bc2484f8d..47a4ca3b78b3 100644 --- a/code/core/src/core-server/server-channel/file-search-channel.test.ts +++ b/code/core/src/core-server/server-channel/file-search-channel.test.ts @@ -42,7 +42,7 @@ describe('file-search-channel', () => { const mockOptions = {}; const data = { searchQuery: 'es-module' }; - await initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true }); + await initFileSearchChannel(mockChannel, mockOptions as any); mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener); mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, { @@ -102,7 +102,7 @@ describe('file-search-channel', () => { const mockOptions = {}; const data = { searchQuery: 'no-file-for-search-query' }; - await initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true }); + await initFileSearchChannel(mockChannel, mockOptions as any); mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener); mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, { @@ -133,7 +133,7 @@ describe('file-search-channel', () => { it('should emit an error message if an error occurs while searching for components in the project', async () => { const mockOptions = {} as any; const data = { searchQuery: 'commonjs' }; - await initFileSearchChannel(mockChannel, mockOptions, { disableTelemetry: true }); + await initFileSearchChannel(mockChannel, mockOptions); mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener); diff --git a/code/core/src/core-server/server-channel/file-search-channel.ts b/code/core/src/core-server/server-channel/file-search-channel.ts index a00289662afa..c4c3340bc880 100644 --- a/code/core/src/core-server/server-channel/file-search-channel.ts +++ b/code/core/src/core-server/server-channel/file-search-channel.ts @@ -14,17 +14,13 @@ import { FILE_COMPONENT_SEARCH_RESPONSE, } from 'storybook/internal/core-events'; import { telemetry } from 'storybook/internal/telemetry'; -import type { CoreConfig, Options, SupportedRenderer } from 'storybook/internal/types'; +import type { Options, SupportedRenderer } from 'storybook/internal/types'; import { doesStoryFileExist, getStoryMetadata } from '../utils/get-new-story-file.ts'; import { getParser } from '../utils/parser/index.ts'; import { searchFiles } from '../utils/search-files.ts'; -export async function initFileSearchChannel( - channel: Channel, - options: Options, - coreOptions: CoreConfig -) { +export async function initFileSearchChannel(channel: Channel, options: Options) { /** Listens for a search query event and searches for files in the project */ channel.on( FILE_COMPONENT_SEARCH_REQUEST, @@ -62,12 +58,10 @@ export async function initFileSearchChannel( storyFileExists, }; } catch (e) { - if (!coreOptions.disableTelemetry) { - telemetry('create-new-story-file-search', { - success: false, - error: `Could not parse file: ${e}`, - }); - } + telemetry('create-new-story-file-search', { + success: false, + error: `Could not parse file: ${e}`, + }); return { filepath: file, @@ -77,14 +71,12 @@ export async function initFileSearchChannel( } }); - if (!coreOptions.disableTelemetry) { - telemetry('create-new-story-file-search', { - success: true, - payload: { - fileCount: entries.length, - }, - }); - } + telemetry('create-new-story-file-search', { + success: true, + payload: { + fileCount: entries.length, + }, + }); channel.emit(FILE_COMPONENT_SEARCH_RESPONSE, { success: true, @@ -102,12 +94,10 @@ export async function initFileSearchChannel( error: `An error occurred while searching for components in the project.\n${e?.message}`, } satisfies ResponseData); - if (!coreOptions.disableTelemetry) { - telemetry('create-new-story-file-search', { - success: false, - error: `An error occurred while searching for components: ${e}`, - }); - } + telemetry('create-new-story-file-search', { + success: false, + error: `An error occurred while searching for components: ${e}`, + }); } } ); diff --git a/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts b/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts index 3420908012a9..fd42626dea38 100644 --- a/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts +++ b/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts @@ -27,6 +27,7 @@ vi.mock('storybook/internal/telemetry', async (importOriginal) => { getLastEvents: vi.fn(), getSessionId: vi.fn(), getStorybookMetadata: vi.fn(), + setTelemetryEnabled: vi.fn(), telemetry: vi.fn(), }; }); @@ -68,6 +69,17 @@ const mockCommon = await import('storybook/internal/common'); const mockTelemetry = await import('storybook/internal/telemetry'); const mockStoryGeneration = await import('../utils/ghost-stories/get-candidates.ts'); +const expectGhostStoriesTelemetryPayload = async (expectedPayload: unknown) => { + const telemetryMock = vi.mocked(mockTelemetry.telemetry); + const lastCallIndex = telemetryMock.mock.calls.length - 1; + + expect(telemetryMock.mock.calls[lastCallIndex]?.[0]).toBe('ghost-stories'); + + const payload = await telemetryMock.mock.results[lastCallIndex]?.value; + + expect(payload).toEqual(expectedPayload); +}; + describe('ghostStoriesChannel', () => { const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransport; const mockChannel = new Channel({ transport }); @@ -94,7 +106,15 @@ describe('ghostStoriesChannel', () => { vi.mocked(mockTelemetry.getLastEvents).mockReset(); vi.mocked(mockTelemetry.getSessionId).mockReset(); vi.mocked(mockTelemetry.getStorybookMetadata).mockReset(); - vi.mocked(mockTelemetry.telemetry).mockReset(); + vi.mocked(mockTelemetry.setTelemetryEnabled).mockReset(); + vi.mocked(mockTelemetry.telemetry) + .mockReset() + .mockImplementation(async (_eventType, payloadOrFactory) => { + if (typeof payloadOrFactory === 'function') { + return payloadOrFactory(); + } + return payloadOrFactory; + }); vi.mocked(mockStoryGeneration.getComponentCandidates).mockReset(); mockFs.existsSync.mockReset(); mockFs.mkdir.mockReset(); @@ -157,7 +177,7 @@ describe('ghostStoriesChannel', () => { }) ); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -184,7 +204,7 @@ describe('ghostStoriesChannel', () => { } as any); // Telemetry is called with the correct data - expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { + await expectGhostStoriesTelemetryPayload({ stats: { globMatchCount: 10, candidateAnalysisDuration: expect.any(Number), @@ -256,7 +276,7 @@ describe('ghostStoriesChannel', () => { }) ); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -283,8 +303,7 @@ describe('ghostStoriesChannel', () => { } as any); // Telemetry is called with the correct data - expect(mockTelemetry.telemetry).toHaveBeenCalledWith( - 'ghost-stories', + await expectGhostStoriesTelemetryPayload( expect.objectContaining({ stats: { globMatchCount: 10, @@ -312,15 +331,16 @@ describe('ghostStoriesChannel', () => { it('should skip discovery run when telemetry is disabled', async () => { mockChannel.addListener(GHOST_STORIES_RESPONSE, ghostStoriesEventListener); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: true }); + vi.mocked(mockTelemetry.telemetry).mockImplementation(async () => undefined); + + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); - // When telemetry is disabled, no listener is set up, so wait a bit and check nothing happened + // When telemetry is disabled, handler returns early after emitting response await new Promise((resolve) => setTimeout(resolve, 10)); - expect(ghostStoriesEventListener).not.toHaveBeenCalled(); - expect(mockCommon.cache.get).not.toHaveBeenCalled(); + expect(ghostStoriesEventListener).toHaveBeenCalled(); expect(mockTelemetry.getStorybookMetadata).not.toHaveBeenCalled(); expect(mockStoryGeneration.getComponentCandidates).not.toHaveBeenCalled(); }); @@ -334,7 +354,7 @@ describe('ghostStoriesChannel', () => { } as any); vi.mocked(mockTelemetry.getSessionId).mockResolvedValue('test-session'); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -360,7 +380,7 @@ describe('ghostStoriesChannel', () => { addons: { '@storybook/addon-vitest': {} }, } as any); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -386,7 +406,7 @@ describe('ghostStoriesChannel', () => { addons: {}, } as any); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -419,7 +439,7 @@ describe('ghostStoriesChannel', () => { globMatchCount: 0, }); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -428,7 +448,7 @@ describe('ghostStoriesChannel', () => { }); expect(mockStoryGeneration.getComponentCandidates).toHaveBeenCalled(); - expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { + await expectGhostStoriesTelemetryPayload({ runError: 'Failed to analyze components', stats: { globMatchCount: 0, @@ -459,7 +479,7 @@ describe('ghostStoriesChannel', () => { avgComplexity: 1.5, }); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -468,7 +488,7 @@ describe('ghostStoriesChannel', () => { }); expect(mockStoryGeneration.getComponentCandidates).toHaveBeenCalled(); - expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { + await expectGhostStoriesTelemetryPayload({ runError: 'No candidates found', stats: { globMatchCount: 5, @@ -504,7 +524,7 @@ describe('ghostStoriesChannel', () => { vi.mocked(mockCommon.executeCommand).mockRejectedValue(new Error('Test execution failed')); mockFs.existsSync.mockReturnValue(false); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -512,7 +532,7 @@ describe('ghostStoriesChannel', () => { expect(ghostStoriesEventListener).toHaveBeenCalled(); }); - expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { + await expectGhostStoriesTelemetryPayload({ runError: 'JSON report not found', stats: { globMatchCount: 5, @@ -557,7 +577,7 @@ describe('ghostStoriesChannel', () => { }) ); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -565,7 +585,7 @@ describe('ghostStoriesChannel', () => { expect(ghostStoriesEventListener).toHaveBeenCalled(); }); - expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { + await expectGhostStoriesTelemetryPayload({ runError: 'Startup Error', stats: { globMatchCount: 5, @@ -583,7 +603,7 @@ describe('ghostStoriesChannel', () => { mockChannel.addListener(GHOST_STORIES_RESPONSE, ghostStoriesEventListener); vi.mocked(mockTelemetry.getLastEvents).mockRejectedValue(new Error('Cache error') as any); - initGhostStoriesChannel(mockChannel, {} as Options, { disableTelemetry: false }); + initGhostStoriesChannel(mockChannel, {} as Options); mockChannel.emit(GHOST_STORIES_REQUEST); @@ -591,7 +611,7 @@ describe('ghostStoriesChannel', () => { expect(ghostStoriesEventListener).toHaveBeenCalled(); }); - expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { + await expectGhostStoriesTelemetryPayload({ runError: 'Unknown error during ghost run', stats: {}, }); diff --git a/code/core/src/core-server/server-channel/ghost-stories-channel.ts b/code/core/src/core-server/server-channel/ghost-stories-channel.ts index 86a9b5a47e03..870f61997b1e 100644 --- a/code/core/src/core-server/server-channel/ghost-stories-channel.ts +++ b/code/core/src/core-server/server-channel/ghost-stories-channel.ts @@ -6,20 +6,14 @@ import { getStorybookMetadata, telemetry, } from 'storybook/internal/telemetry'; -import type { CoreConfig, Options } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; import { getComponentCandidates } from '../utils/ghost-stories/get-candidates.ts'; import { runStoryTests } from '../utils/ghost-stories/run-story-tests.ts'; -export function initGhostStoriesChannel( - channel: Channel, - options: Options, - coreOptions: CoreConfig -) { - if (coreOptions.disableTelemetry) { - return channel; - } +class SkipGhostStoriesTelemetry extends Error {} +export function initGhostStoriesChannel(channel: Channel, options: Options) { /** Listens for events to discover and test stories */ channel.on(GHOST_STORIES_REQUEST, async () => { const stats: { @@ -33,84 +27,93 @@ export function initGhostStoriesChannel( } = {}; try { - const ghostRunStart = Date.now(); - const lastEvents = await getLastEvents(); - const lastInit = lastEvents?.init; - if (!lastEvents || !lastInit) { - return; - } + await telemetry('ghost-stories', async () => { + try { + const ghostRunStart = Date.now(); + const lastEvents = await getLastEvents(); + const lastInit = lastEvents?.init; + if (!lastEvents || !lastInit) { + throw new SkipGhostStoriesTelemetry(); + } - const sessionId = await getSessionId(); - const lastGhostStoriesRun = lastEvents['ghost-stories']; - if ( - lastGhostStoriesRun || - (lastInit.body?.sessionId && lastInit.body.sessionId !== sessionId) - ) { - return; - } + const sessionId = await getSessionId(); + const lastGhostStoriesRun = lastEvents['ghost-stories']; + if ( + lastGhostStoriesRun || + (lastInit.body?.sessionId && lastInit.body.sessionId !== sessionId) + ) { + throw new SkipGhostStoriesTelemetry(); + } - const metadata = await getStorybookMetadata(options.configDir); - const isReactStorybook = metadata?.renderer?.includes('@storybook/react'); - const hasVitestAddon = - !!metadata?.addons && - Object.keys(metadata.addons).some((addonKey) => - addonKey.includes('@storybook/addon-vitest') - ); + const metadata = await getStorybookMetadata(options.configDir); + const isReactStorybook = metadata?.renderer?.includes('@storybook/react'); + const hasVitestAddon = + !!metadata?.addons && + Object.keys(metadata.addons).some((addonKey) => + addonKey.includes('@storybook/addon-vitest') + ); - // For now this is gated by React + Vitest - if (!isReactStorybook || !hasVitestAddon) { - return; - } + // For now this is gated by React + Vitest + if (!isReactStorybook || !hasVitestAddon) { + throw new SkipGhostStoriesTelemetry(); + } - // Phase 1: find candidates from components - const candidateAnalysisStart = Date.now(); - const candidatesResult = await getComponentCandidates(); - stats.candidateAnalysisDuration = Date.now() - candidateAnalysisStart; - stats.globMatchCount = candidatesResult.globMatchCount; - stats.analyzedCount = candidatesResult.analyzedCount ?? 0; - stats.avgComplexity = candidatesResult.avgComplexity ?? 0; - stats.candidateCount = candidatesResult.candidates.length; + // Phase 1: find candidates from components + const candidateAnalysisStart = Date.now(); + const candidatesResult = await getComponentCandidates(); + stats.candidateAnalysisDuration = Date.now() - candidateAnalysisStart; + stats.globMatchCount = candidatesResult.globMatchCount; + stats.analyzedCount = candidatesResult.analyzedCount ?? 0; + stats.avgComplexity = candidatesResult.avgComplexity ?? 0; + stats.candidateCount = candidatesResult.candidates.length; - if (candidatesResult.error) { - stats.totalRunDuration = Date.now() - ghostRunStart; - telemetry('ghost-stories', { - stats, - runError: candidatesResult.error, - }); - return; - } + if (candidatesResult.error) { + stats.totalRunDuration = Date.now() - ghostRunStart; + return { + stats, + runError: candidatesResult.error, + }; + } - if (candidatesResult.candidates.length === 0) { - stats.totalRunDuration = Date.now() - ghostRunStart; - telemetry('ghost-stories', { - stats, - runError: 'No candidates found', - }); - return; - } + if (candidatesResult.candidates.length === 0) { + stats.totalRunDuration = Date.now() - ghostRunStart; + return { + stats, + runError: 'No candidates found', + }; + } - // Phase 2: Run tests on those candidates Vitest. The components will be transformed directly to tests - // If they pass, it means that creating a story file for them would succeed. - const testRunResult = await runStoryTests(candidatesResult.candidates); - stats.totalRunDuration = Date.now() - ghostRunStart; - stats.testRunDuration = testRunResult.duration; - if (testRunResult.runError) { - telemetry('ghost-stories', { - stats, - runError: testRunResult.runError, - }); - return; - } + // Phase 2: Run tests on those candidates Vitest. The components will be transformed directly to tests + // If they pass, it means that creating a story file for them would succeed. + const testRunResult = await runStoryTests(candidatesResult.candidates); + stats.totalRunDuration = Date.now() - ghostRunStart; + stats.testRunDuration = testRunResult.duration; + if (testRunResult.runError) { + return { + stats, + runError: testRunResult.runError, + }; + } - telemetry('ghost-stories', { - stats, - results: testRunResult.summary, - }); - } catch { - telemetry('ghost-stories', { - stats, - runError: 'Unknown error during ghost run', + return { + stats, + results: testRunResult.summary, + }; + } catch (error) { + if (error instanceof SkipGhostStoriesTelemetry) { + throw error; + } + + return { + stats, + runError: 'Unknown error during ghost run', + }; + } }); + } catch (error) { + if (!(error instanceof SkipGhostStoriesTelemetry)) { + throw error; + } } finally { // we don't currently do anything with this, but will be useful in the future channel.emit(GHOST_STORIES_RESPONSE); diff --git a/code/core/src/core-server/server-channel/open-in-editor-channel.ts b/code/core/src/core-server/server-channel/open-in-editor-channel.ts index 3f90477c5bad..80869ef8675c 100644 --- a/code/core/src/core-server/server-channel/open-in-editor-channel.ts +++ b/code/core/src/core-server/server-channel/open-in-editor-channel.ts @@ -5,20 +5,13 @@ import type { } from 'storybook/internal/core-events'; import { OPEN_IN_EDITOR_REQUEST, OPEN_IN_EDITOR_RESPONSE } from 'storybook/internal/core-events'; import { telemetry } from 'storybook/internal/telemetry'; -import type { CoreConfig, Options, StoryIndex } from 'storybook/internal/types'; import launch from 'launch-editor'; -export async function initOpenInEditorChannel( - channel: Channel, - _options: Options, - coreOptions: CoreConfig -) { +export async function initOpenInEditorChannel(channel: Channel) { channel.on(OPEN_IN_EDITOR_REQUEST, async (payload: OpenInEditorRequestPayload) => { const sendTelemetry = (data: { success: boolean; error?: string }) => { - if (!coreOptions.disableTelemetry) { - telemetry('open-in-editor', data); - } + telemetry('open-in-editor', data); }; try { const { file: targetFile, line, column } = payload; diff --git a/code/core/src/core-server/server-channel/telemetry-channel.ts b/code/core/src/core-server/server-channel/telemetry-channel.ts index e4b2820611ea..1430dea2425b 100644 --- a/code/core/src/core-server/server-channel/telemetry-channel.ts +++ b/code/core/src/core-server/server-channel/telemetry-channel.ts @@ -30,27 +30,25 @@ export const makePayload = ( }; export function initTelemetryChannel(channel: Channel, options: Options) { - if (!options.disableTelemetry) { - channel.on(PREVIEW_INITIALIZED, async ({ userAgent }) => { - try { - const sessionId = await getSessionId(); - const lastEvents = await getLastEvents(); - const lastInit = lastEvents.init; - const lastPreviewFirstLoad = lastEvents['preview-first-load']; - if (!lastPreviewFirstLoad) { - const payload = makePayload(userAgent, lastInit, sessionId); - telemetry('preview-first-load', payload); - } - } catch {} - }); - channel.on(SHARE_POPOVER_OPENED, async () => { - telemetry('share', { action: 'popover-opened' }); - }); - channel.on(SHARE_STORY_LINK, async () => { - telemetry('share', { action: 'story-link-copied' }); - }); - channel.on(SHARE_ISOLATE_MODE, async () => { - telemetry('share', { action: 'isolate-mode-opened' }); - }); - } + channel.on(PREVIEW_INITIALIZED, async ({ userAgent }) => { + try { + const sessionId = await getSessionId(); + const lastEvents = await getLastEvents(); + const lastInit = lastEvents.init; + const lastPreviewFirstLoad = lastEvents['preview-first-load']; + if (!lastPreviewFirstLoad) { + const payload = makePayload(userAgent, lastInit, sessionId); + telemetry('preview-first-load', payload); + } + } catch {} + }); + channel.on(SHARE_POPOVER_OPENED, async () => { + telemetry('share', { action: 'popover-opened' }); + }); + channel.on(SHARE_STORY_LINK, async () => { + telemetry('share', { action: 'story-link-copied' }); + }); + channel.on(SHARE_ISOLATE_MODE, async () => { + telemetry('share', { action: 'isolate-mode-opened' }); + }); } diff --git a/code/core/src/core-server/utils/doTelemetry.ts b/code/core/src/core-server/utils/doTelemetry.ts index 6553f6a1d1a7..4ac52f9cf57a 100644 --- a/code/core/src/core-server/utils/doTelemetry.ts +++ b/code/core/src/core-server/utils/doTelemetry.ts @@ -4,7 +4,6 @@ import type { CoreConfig, Options } from 'storybook/internal/types'; import type { Polka } from 'polka'; import invariant from 'tiny-invariant'; -import { sendTelemetryError } from '../withTelemetry.ts'; import type { StoryIndexGenerator } from './StoryIndexGenerator.ts'; import { summarizeIndex } from './summarizeIndex.ts'; import { versionStatus } from './versionStatus.ts'; @@ -15,42 +14,38 @@ export async function doTelemetry( storyIndexGeneratorPromise: Promise, options: Options ) { - if (!core?.disableTelemetry) { - const generator = await storyIndexGeneratorPromise; - let indexAndStats; - try { - indexAndStats = await generator?.getIndexAndStats(); - } catch (err) { - // If we fail to get the index, treat it as a recoverable error, but send it up to telemetry - // as if we crashed. In the future we will revisit this to send a distinct error - if (!(err instanceof Error)) { - throw new Error('encountered a non-recoverable error'); + const { versionCheck, versionUpdates } = options; + invariant( + !versionUpdates || (versionUpdates && versionCheck), + 'versionCheck should be defined when versionUpdates is true' + ); + telemetry( + 'dev', + async () => { + const generator = await storyIndexGeneratorPromise; + let indexAndStats; + try { + indexAndStats = await generator?.getIndexAndStats(); + } catch (err) { + // If we fail to get the index, treat it as a recoverable error, but send it up to telemetry + // as if we crashed. Returning { error } triggers automatic error telemetry in place of + // the normal event. + const error = err instanceof Error ? err : new Error('encountered a non-recoverable error'); + return { error }; } - sendTelemetryError(err, 'dev', { - cliOptions: options, - presetOptions: { - ...options, - corePresets: [], - overridePresets: [], - }, - }); - return; - } - const { versionCheck, versionUpdates } = options; - invariant( - !versionUpdates || (versionUpdates && versionCheck), - 'versionCheck should be defined when versionUpdates is true' - ); - const payload = { - precedingUpgrade: await getPrecedingUpgrade(), - }; - if (indexAndStats) { - Object.assign(payload, { - versionStatus: versionUpdates && versionCheck ? versionStatus(versionCheck) : 'disabled', - storyIndex: summarizeIndex(indexAndStats.storyIndex), - storyStats: indexAndStats.stats, - }); - } - telemetry('dev', payload, { configDir: options.configDir }); - } + + const payload = { + precedingUpgrade: await getPrecedingUpgrade(), + }; + if (indexAndStats) { + Object.assign(payload, { + versionStatus: versionUpdates && versionCheck ? versionStatus(versionCheck) : 'disabled', + storyIndex: summarizeIndex(indexAndStats.storyIndex), + storyStats: indexAndStats.stats, + }); + } + return payload; + }, + { configDir: options.configDir } + ); } diff --git a/code/core/src/core-server/utils/save-story/save-story.ts b/code/core/src/core-server/utils/save-story/save-story.ts index 3766315e36f6..aebd45dff98f 100644 --- a/code/core/src/core-server/utils/save-story/save-story.ts +++ b/code/core/src/core-server/utils/save-story/save-story.ts @@ -18,7 +18,7 @@ import { storyNameFromExport, toId } from 'storybook/internal/csf'; import { printCsf, readCsf } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import { isExampleStoryId, telemetry } from 'storybook/internal/telemetry'; -import type { CoreConfig, Options } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; import { duplicateStoryWithNewName } from './duplicate-story-with-new-name.ts'; import { updateArgsInCsfFile } from './update-args-in-csf-file.ts'; @@ -49,7 +49,7 @@ const removeExtraNewlines = (code: string, name: string) => { : code; }; -export function initializeSaveStory(channel: Channel, options: Options, coreConfig: CoreConfig) { +export function initializeSaveStory(channel: Channel, options: Options) { channel.on(SAVE_STORY_REQUEST, async ({ id, payload }: RequestData) => { const { csfId, importPath, args, name } = payload; @@ -122,7 +122,7 @@ export function initializeSaveStory(channel: Channel, options: Options, coreConf // don't take credit for save-from-controls actions against CLI example stories const isCLIExample = isExampleStoryId(newStoryId ?? csfId); - if (!coreConfig.disableTelemetry && !isCLIExample) { + if (!isCLIExample) { await telemetry('save-story', { action: name ? 'createStory' : 'updateStory', success: true, @@ -139,7 +139,7 @@ export function initializeSaveStory(channel: Channel, options: Options, coreConf `Error writing to ${sourceFilePath}:\n${error.stack || error.message || error.toString()}` ); - if (!coreConfig.disableTelemetry && !(error instanceof SaveStoryError)) { + if (!(error instanceof SaveStoryError)) { await telemetry('save-story', { action: name ? 'createStory' : 'updateStory', success: false, diff --git a/code/core/src/core-server/utils/whats-new.ts b/code/core/src/core-server/utils/whats-new.ts index 60ce5142c15a..d247f8326d12 100644 --- a/code/core/src/core-server/utils/whats-new.ts +++ b/code/core/src/core-server/utils/whats-new.ts @@ -33,11 +33,7 @@ export type WhatsNewResponse = { const WHATS_NEW_CACHE = 'whats-new-cache'; const WHATS_NEW_URL = 'https://storybook.js.org/whats-new/v1'; -export function initializeWhatsNew( - channel: Channel, - options: OptionsWithRequiredCache, - coreOptions: CoreConfig -) { +export function initializeWhatsNew(channel: Channel, options: OptionsWithRequiredCache) { channel.on(SET_WHATS_NEW_CACHE, async (data: WhatsNewCache) => { const cache: WhatsNewCache = await options.cache.get(WHATS_NEW_CACHE).catch((e) => { logger.verbose(e); @@ -80,7 +76,6 @@ export function initializeWhatsNew( channel.on( TOGGLE_WHATS_NEW_NOTIFICATIONS, async ({ disableWhatsNewNotifications }: { disableWhatsNewNotifications: boolean }) => { - const isTelemetryEnabled = coreOptions.disableTelemetry !== true; try { const mainPath = findConfigFile('main', options.configDir); invariant(mainPath, `unable to find Storybook main file in ${options.configDir}`); @@ -93,39 +88,31 @@ export function initializeWhatsNew( } main.setFieldValue(['core', 'disableWhatsNewNotifications'], disableWhatsNewNotifications); await writeFile(mainPath, printConfig(main).code); - if (isTelemetryEnabled) { - await telemetry('core-config', { disableWhatsNewNotifications }); - } + await telemetry('core-config', { disableWhatsNewNotifications }); } catch (error) { invariant(error instanceof Error); - if (isTelemetryEnabled) { - await sendTelemetryError(error, 'core-config', { - cliOptions: options, - presetOptions: { - ...options, - corePresets: [], - overridePresets: [], - }, - skipPrompt: true, - }); - } + await sendTelemetryError(error, 'core-config', { + cliOptions: options, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + }, + skipPrompt: true, + }); } } ); channel.on(TELEMETRY_ERROR, async (error) => { - const isTelemetryEnabled = coreOptions.disableTelemetry !== true; - - if (isTelemetryEnabled) { - await sendTelemetryError(error, 'browser', { - cliOptions: options, - presetOptions: { - ...options, - corePresets: [], - overridePresets: [], - }, - skipPrompt: true, - }); - } + await sendTelemetryError(error, 'browser', { + cliOptions: options, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + }, + skipPrompt: true, + }); }); } diff --git a/code/core/src/core-server/withTelemetry.test.ts b/code/core/src/core-server/withTelemetry.test.ts index beef361f9c24..7cfcf982bbc9 100644 --- a/code/core/src/core-server/withTelemetry.test.ts +++ b/code/core/src/core-server/withTelemetry.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cache, isCI, loadAllPresets } from 'storybook/internal/common'; import { prompt } from 'storybook/internal/node-logger'; -import { ErrorCollector, oneWayHash, telemetry } from 'storybook/internal/telemetry'; +import { + ErrorCollector, + isTelemetryStateResolved, + oneWayHash, + setTelemetryEnabled, + telemetry, +} from 'storybook/internal/telemetry'; import { getErrorLevel, sendTelemetryError, withTelemetry } from './withTelemetry.ts'; @@ -44,7 +50,8 @@ describe('withTelemetry', () => { await withTelemetry('dev', { cliOptions: { disableTelemetry: true } }, run); - expect(telemetry).toHaveBeenCalledTimes(0); + expect(setTelemetryEnabled).toHaveBeenCalledWith(false); + expect(telemetry).toHaveBeenCalled(); }); describe('when command fails', () => { @@ -66,7 +73,8 @@ describe('withTelemetry', () => { withTelemetry('dev', { cliOptions: { disableTelemetry: true }, printError: vi.fn() }, run) ).rejects.toThrow(error); - expect(telemetry).toHaveBeenCalledTimes(0); + expect(setTelemetryEnabled).toHaveBeenCalledWith(false); + expect(telemetry).toHaveBeenCalled(); }); it('sends error message when no options are passed', async () => { @@ -137,7 +145,8 @@ describe('withTelemetry', () => { withTelemetry('dev', { cliOptions: { disableTelemetry: true }, printError: vi.fn() }, run) ).rejects.toThrow(error); - expect(telemetry).toHaveBeenCalledTimes(0); + expect(setTelemetryEnabled).toHaveBeenCalledWith(false); + expect(telemetry).toHaveBeenCalled(); expect(telemetry).not.toHaveBeenCalledWith( 'error', expect.objectContaining({}), @@ -232,7 +241,7 @@ describe('withTelemetry', () => { error: expect.objectContaining({ message: 'An Error!', name: 'Error' }), isErrorInstance: true, }), - expect.objectContaining({ enableCrashReports: true }) + expect.objectContaining({ enableCrashReports: true, force: true }) ); }); @@ -355,6 +364,20 @@ describe('withTelemetry', () => { ); }); }); + + it('resolves telemetry state to disabled when run() throws and state is still uninitialized', async () => { + vi.mocked(isTelemetryStateResolved).mockReturnValue(false); + + const run = vi.fn(async () => { + throw new Error('preset loading failed'); + }); + + await expect(async () => + withTelemetry('dev', { cliOptions: {}, printError: vi.fn() }, run) + ).rejects.toThrow('preset loading failed'); + + expect(setTelemetryEnabled).toHaveBeenCalledWith(false); + }); }); describe('sendTelemetryError', () => { diff --git a/code/core/src/core-server/withTelemetry.ts b/code/core/src/core-server/withTelemetry.ts index 044ce7bd7afb..c0eeb7278ded 100644 --- a/code/core/src/core-server/withTelemetry.ts +++ b/code/core/src/core-server/withTelemetry.ts @@ -1,15 +1,25 @@ -import { HandledError, cache, isCI, loadAllPresets } from 'storybook/internal/common'; +import { + HandledError, + cache, + getStorybookInfo, + isCI, + loadAllPresets, +} from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; import { ErrorCollector, getPrecedingUpgrade, + isTelemetryStateResolved, oneWayHash, + onPayloadError, + setTelemetryEnabled, telemetry, } from 'storybook/internal/telemetry'; import type { EventType } from 'storybook/internal/telemetry'; import type { CLIOptions } from 'storybook/internal/types'; import { StorybookError } from '../storybook-error.ts'; +import { dirname } from 'path'; type TelemetryOptions = { cliOptions: CLIOptions; @@ -139,6 +149,7 @@ export async function sendTelemetryError( immediate: true, configDir: options.cliOptions.configDir || options.presetOptions?.configDir, enableCrashReports: errorLevel === 'full', + force: true, } ); @@ -155,7 +166,34 @@ export async function sendTelemetryError( } export function isTelemetryEnabled(options: TelemetryOptions) { - return !(options.cliOptions.disableTelemetry || options.cliOptions.test === true); + return !options.cliOptions.disableTelemetry; +} + +/** + * Resolve telemetry state by loading presets from configDir to check core.disableTelemetry. + * Used when run() completes without resolving telemetry state (e.g. CLI commands like + * add/remove/doctor/upgrade/migrate that don't load presets themselves). + */ +async function tryResolveTelemetryStateFromConfig(options: TelemetryOptions) { + const configDir = options.cliOptions.configDir || options.presetOptions?.configDir; + + try { + const { mainConfig } = await getStorybookInfo( + configDir, + configDir ? dirname(configDir) : undefined + ); + + if (!mainConfig) { + // No config dir available — default to enabled + await setTelemetryEnabled(true); + return; + } + + await setTelemetryEnabled(!mainConfig.core?.disableTelemetry); + } catch { + // If presets fail to load, conservatively disable + await setTelemetryEnabled(false); + } } export async function withTelemetry( @@ -163,15 +201,15 @@ export async function withTelemetry( options: TelemetryOptions, run: () => Promise ): Promise { - const enableTelemetry = isTelemetryEnabled(options); + if (!isTelemetryEnabled(options)) { + await setTelemetryEnabled(false); + } let canceled = false; async function cancelTelemetry() { canceled = true; - if (enableTelemetry) { - await telemetry('canceled', { eventType }, { stripMetadata: true, immediate: true }); - } + await telemetry('canceled', { eventType }, { stripMetadata: true, immediate: true }); process.exit(0); } @@ -181,13 +219,29 @@ export async function withTelemetry( process.on('SIGINT', cancelTelemetry); } - if (enableTelemetry) { - telemetry('boot', { eventType }, { stripMetadata: true }); - } + // Register error handler so that payload factories returning { error } or throwing + // automatically trigger sendTelemetryError with full context (presets, cache, error levels). + onPayloadError(async (error, evtType) => { + await sendTelemetryError(error, evtType, options); + }); + + telemetry('boot', { eventType }, { stripMetadata: true }); try { - return await run(); + const result = await run(); + + // If run() completed but telemetry state was never resolved (e.g. CLI commands like + // add/remove/doctor that don't load presets themselves), load the config to resolve it. + if (!isTelemetryStateResolved()) { + await tryResolveTelemetryStateFromConfig(options); + } + + return result; } catch (error: any) { + if (!isTelemetryStateResolved()) { + await tryResolveTelemetryStateFromConfig(options); + } + if (canceled) { return undefined; } @@ -200,18 +254,15 @@ export async function withTelemetry( printError(error); } - if (enableTelemetry) { - await sendTelemetryError(error, eventType, options); - } + await sendTelemetryError(error, eventType, options); throw error; } finally { - if (enableTelemetry) { - const errors = ErrorCollector.getErrors(); - for (const error of errors) { - await sendTelemetryError(error, eventType, options, false); - } - process.off('SIGINT', cancelTelemetry); + const errors = ErrorCollector.getErrors(); + for (const error of errors) { + await sendTelemetryError(error, eventType, options, false); } + process.off('SIGINT', cancelTelemetry); + onPayloadError(undefined); } } diff --git a/code/core/src/manager-api/modules/addons.ts b/code/core/src/manager-api/modules/addons.ts index 04a4b00582ca..e8ccf47384c0 100644 --- a/code/core/src/manager-api/modules/addons.ts +++ b/code/core/src/manager-api/modules/addons.ts @@ -1,3 +1,4 @@ +import { logger } from 'storybook/internal/client-logger'; import { Addon_TypesEnum } from 'storybook/internal/types'; import type { API_StateMerger, @@ -33,6 +34,10 @@ export interface SubAPI { >( type: T ) => Addon_Collection; + /** + * Clears statuses for all registered test providers by calling each provider's `clear` function. + */ + clearStatuses: () => void; /** * Returns the id of the currently selected panel. * @@ -74,11 +79,11 @@ export interface SubAPI { } export function ensurePanel( - panels: Addon_Collection, + panels: Addon_Collection | null | undefined, selectedPanel?: string, currentPanel?: string ) { - const keys = Object.keys(panels); + const keys = Object.keys(panels ?? {}); if (keys.indexOf(selectedPanel!) >= 0) { return selectedPanel; @@ -93,6 +98,20 @@ export function ensurePanel( export const init: ModuleFn = ({ provider, store, fullAPI }): any => { const api: SubAPI = { getElements: (type) => provider.getElements(type), + clearStatuses: () => { + const testProviders = api.getElements(Addon_TypesEnum.experimental_TEST_PROVIDER); + Object.values(testProviders).forEach((testProvider) => { + try { + testProvider.clear?.(); + } catch (e) { + try { + logger.warn(`Failed to clear test provider "${testProvider.id}":`, e); + } catch { + // noop + } + } + }); + }, getSelectedPanel: (): any => { const { selectedPanel } = store.getState(); return ensurePanel(api.getElements(Addon_TypesEnum.PANEL), selectedPanel, selectedPanel); diff --git a/code/core/src/manager-api/tests/addons.test.js b/code/core/src/manager-api/tests/addons.test.js index 8f79b953e7c3..8576b44ab3e9 100644 --- a/code/core/src/manager-api/tests/addons.test.js +++ b/code/core/src/manager-api/tests/addons.test.js @@ -114,4 +114,72 @@ describe('Addons API', () => { expect(setState).toHaveBeenCalledWith({ selectedPanel: 'knobs' }, { persistence: 'session' }); }); }); + + describe('#clearStatuses', () => { + it('calls clear() on every test provider that has it', () => { + const clearA = vi.fn(); + const clearB = vi.fn(); + const providerWithClear = { + getElements(type) { + if (type === types.experimental_TEST_PROVIDER) { + return { + 'provider-a': { id: 'provider-a', clear: clearA }, + 'provider-b': { id: 'provider-b', clear: clearB }, + }; + } + return null; + }, + }; + const { api } = initAddons({ provider: providerWithClear, store }); + + api.clearStatuses(); + + expect(clearA).toHaveBeenCalledOnce(); + expect(clearB).toHaveBeenCalledOnce(); + }); + + it('skips providers without a clear() function', () => { + const clearA = vi.fn(); + const providerMixed = { + getElements(type) { + if (type === types.experimental_TEST_PROVIDER) { + return { + 'provider-with-clear': { id: 'provider-with-clear', clear: clearA }, + // simulates change-detection provider — no clear() + 'storybook/change-detection': { id: 'storybook/change-detection' }, + }; + } + return null; + }, + }; + const { api } = initAddons({ provider: providerMixed, store }); + + expect(() => api.clearStatuses()).not.toThrow(); + expect(clearA).toHaveBeenCalledOnce(); + }); + + it('continues clearing remaining providers when one throws', () => { + const clearOk = vi.fn(); + const providerWithError = { + getElements(type) { + if (type === types.experimental_TEST_PROVIDER) { + return { + 'provider-throws': { + id: 'provider-throws', + clear: () => { + throw new Error('boom'); + }, + }, + 'provider-ok': { id: 'provider-ok', clear: clearOk }, + }; + } + return null; + }, + }; + const { api } = initAddons({ provider: providerWithError, store }); + + expect(() => api.clearStatuses()).not.toThrow(); + expect(clearOk).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.tsx index 34e74d5c20c9..a08e6e730e61 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.tsx @@ -10,7 +10,6 @@ import { import { experimental_useStatusStore, experimental_useTestProviderStore, - internal_fullStatusStore, internal_fullTestProviderStore, } from '#manager-stores'; import { type API, type State, useStorybookApi, useStorybookState } from 'storybook/manager-api'; @@ -153,8 +152,7 @@ export const SidebarBottomBase = ({ }, hasStatuses, clearStatuses: () => { - internal_fullStatusStore.unset(); - internal_fullTestProviderStore.clearAll(); + api.clearStatuses(); setErrorsActive(false); setWarningsActive(false); }, diff --git a/code/core/src/mocking-utils/extract.ts b/code/core/src/mocking-utils/extract.ts index e80d8729fb74..3b42781d1c83 100644 --- a/code/core/src/mocking-utils/extract.ts +++ b/code/core/src/mocking-utils/extract.ts @@ -183,17 +183,15 @@ export function extractMockCalls( }, }); - if (!options.coreOptions?.disableTelemetry) { - telemetry( - 'mocking', - { - modulesMocked: mocks.length, - modulesSpied: mocks.map((mock) => mock.spy).filter(Boolean).length, - modulesManuallyMocked: mocks.map((mock) => !!mock.redirectPath).filter(Boolean).length, - }, - { configDir: options.configDir } - ); - } + telemetry( + 'mocking', + { + modulesMocked: mocks.length, + modulesSpied: mocks.map((mock) => mock.spy).filter(Boolean).length, + modulesManuallyMocked: mocks.map((mock) => !!mock.redirectPath).filter(Boolean).length, + }, + { configDir: options.configDir } + ); return mocks; } catch (error) { logger.debug('Error extracting mock calls: ' + String(error)); diff --git a/code/core/src/telemetry/index.test.ts b/code/core/src/telemetry/index.test.ts new file mode 100644 index 000000000000..126dffe5033b --- /dev/null +++ b/code/core/src/telemetry/index.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// We need to reset module state between tests +let telemetryModule: typeof import('./index.ts'); + +// Mock the dependencies that telemetry() calls internally +vi.mock('storybook/internal/node-logger', () => ({ + logger: { info: vi.fn() }, +})); + +vi.mock('./notify.ts', () => ({ + notify: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./storybook-metadata.ts', () => ({ + getStorybookMetadata: vi.fn().mockResolvedValue({}), +})); + +vi.mock('./telemetry.ts', () => ({ + sendTelemetry: vi.fn().mockResolvedValue(undefined), + addToGlobalContext: vi.fn(), +})); + +beforeEach(async () => { + vi.resetModules(); + telemetryModule = await import('./index.ts'); +}, 30_000); + +describe('telemetry state machine', () => { + it('starts in uninitialized state and queues events', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.telemetry('boot', { eventType: 'dev' }, { stripMetadata: true }); + + // Event should be queued, not sent + expect(sendTelemetry).not.toHaveBeenCalled(); + }); + + it('flushes queued events when setTelemetryEnabled(true) is called', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.telemetry('boot', { eventType: 'dev' }, { stripMetadata: true }); + expect(sendTelemetry).not.toHaveBeenCalled(); + + await telemetryModule.setTelemetryEnabled(true); + + expect(sendTelemetry).toHaveBeenCalledTimes(1); + expect(sendTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'boot', payload: { eventType: 'dev' } }), + expect.objectContaining({ stripMetadata: true, timestamp: expect.any(Number) }) + ); + }); + + it('clears queue when setTelemetryEnabled(false) is called', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.telemetry('boot', { eventType: 'dev' }, { stripMetadata: true }); + expect(sendTelemetry).not.toHaveBeenCalled(); + + await telemetryModule.setTelemetryEnabled(false); + + // Queue cleared, nothing sent + expect(sendTelemetry).not.toHaveBeenCalled(); + }); + + it('sends events immediately after state is resolved to enabled', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.setTelemetryEnabled(true); + expect(telemetryModule.isTelemetryModuleEnabled()).toBe(true); + + await telemetryModule.telemetry('dev', { foo: 'bar' }); + + // Sent immediately, not queued + expect(sendTelemetry).toHaveBeenCalledTimes(1); + }); + + it('drops events after state is resolved to disabled', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.setTelemetryEnabled(false); + expect(telemetryModule.isTelemetryModuleEnabled()).toBe(false); + + await telemetryModule.telemetry('dev', { foo: 'bar' }); + + expect(sendTelemetry).not.toHaveBeenCalled(); + }); + + it('sends events with force:true even when disabled', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.setTelemetryEnabled(false); + await telemetryModule.telemetry('error', { eventType: 'dev' }, { force: true }); + + expect(sendTelemetry).toHaveBeenCalledTimes(1); + }); + + it('does not evaluate payload factory when disabled', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + const payloadFactory = vi.fn().mockReturnValue({ eventType: 'dev' }); + + await telemetryModule.setTelemetryEnabled(false); + await telemetryModule.telemetry('dev', payloadFactory); + + expect(payloadFactory).not.toHaveBeenCalled(); + expect(sendTelemetry).not.toHaveBeenCalled(); + }); + + it('evaluates payload factory when queued event is flushed', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + const payloadFactory = vi.fn().mockReturnValue({ eventType: 'dev' }); + + await telemetryModule.telemetry('boot', payloadFactory, { stripMetadata: true }); + + expect(payloadFactory).not.toHaveBeenCalled(); + expect(sendTelemetry).not.toHaveBeenCalled(); + + await telemetryModule.setTelemetryEnabled(true); + + expect(payloadFactory).toHaveBeenCalledTimes(1); + expect(sendTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'boot', payload: { eventType: 'dev' } }), + expect.objectContaining({ stripMetadata: true, timestamp: expect.any(Number) }) + ); + }); + + it('preserves timestamps when flushing queued events', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + const before = Date.now(); + await telemetryModule.telemetry('boot', { eventType: 'dev' }, { stripMetadata: true }); + const after = Date.now(); + + await telemetryModule.setTelemetryEnabled(true); + + const call = vi.mocked(sendTelemetry).mock.calls[0]; + const timestamp = (call[1] as any).timestamp; + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); + }); + + it('double setTelemetryEnabled(true) is idempotent', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.telemetry('boot', { eventType: 'dev' }, { stripMetadata: true }); + + await telemetryModule.setTelemetryEnabled(true); + await telemetryModule.setTelemetryEnabled(true); + + // Only flushed once + expect(sendTelemetry).toHaveBeenCalledTimes(1); + }); + + it('double setTelemetryEnabled(false) is idempotent', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.telemetry('boot', { eventType: 'dev' }, { stripMetadata: true }); + + await telemetryModule.setTelemetryEnabled(false); + await telemetryModule.setTelemetryEnabled(false); + + expect(sendTelemetry).not.toHaveBeenCalled(); + }); + + it('isTelemetryModuleEnabled returns correct state', async () => { + expect(telemetryModule.isTelemetryModuleEnabled()).toBe(false); + + await telemetryModule.setTelemetryEnabled(false); + expect(telemetryModule.isTelemetryModuleEnabled()).toBe(false); + + await telemetryModule.setTelemetryEnabled(true); + expect(telemetryModule.isTelemetryModuleEnabled()).toBe(true); + }); +}); + +describe('payload error handler (onPayloadError)', () => { + it('calls registered handler when payload factory returns { error }', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + const errorHandler = vi.fn().mockResolvedValue(undefined); + + await telemetryModule.setTelemetryEnabled(true); + telemetryModule.onPayloadError(errorHandler); + + const testError = new Error('index generation failed'); + await telemetryModule.telemetry('dev', async () => ({ error: testError })); + + // Handler should be called with the error and original event type + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler).toHaveBeenCalledWith(testError, 'dev'); + // Normal telemetry should NOT be sent + expect(sendTelemetry).not.toHaveBeenCalled(); + + telemetryModule.onPayloadError(undefined); + }); + + it('calls registered handler when payload factory throws', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + const errorHandler = vi.fn().mockResolvedValue(undefined); + + await telemetryModule.setTelemetryEnabled(true); + telemetryModule.onPayloadError(errorHandler); + + const testError = new Error('something broke'); + await telemetryModule.telemetry('dev', async () => { + throw testError; + }); + + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler).toHaveBeenCalledWith(testError, 'dev'); + expect(sendTelemetry).not.toHaveBeenCalled(); + + telemetryModule.onPayloadError(undefined); + }); + + it('does not call handler for error event type (prevents recursion)', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + const errorHandler = vi.fn().mockResolvedValue(undefined); + + await telemetryModule.setTelemetryEnabled(true); + telemetryModule.onPayloadError(errorHandler); + + // An 'error' event with error in payload should be sent normally, not intercepted + await telemetryModule.telemetry( + 'error', + { eventType: 'dev', error: new Error('test') }, + { enableCrashReports: true, force: true } + ); + + expect(errorHandler).not.toHaveBeenCalled(); + expect(sendTelemetry).toHaveBeenCalledTimes(1); + expect(sendTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'error' }), + expect.anything() + ); + + telemetryModule.onPayloadError(undefined); + }); + + it('sends normal telemetry when no handler is registered and factory returns { error }', async () => { + const { sendTelemetry } = await import('./telemetry.ts'); + + await telemetryModule.setTelemetryEnabled(true); + // No handler registered — falls through to existing behavior + + const testError = new Error('unhandled error'); + await telemetryModule.telemetry('dev', async () => ({ error: testError })); + + // Without a handler, the existing finally-block logic applies + // (error gets sanitized, event suppressed unless enableCrashReports) + expect(sendTelemetry).not.toHaveBeenCalled(); + }); + + it('clears handler when undefined is passed', async () => { + const errorHandler = vi.fn().mockResolvedValue(undefined); + + await telemetryModule.setTelemetryEnabled(true); + telemetryModule.onPayloadError(errorHandler); + telemetryModule.onPayloadError(undefined); + + await telemetryModule.telemetry('dev', async () => ({ error: new Error('test') })); + + expect(errorHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/code/core/src/telemetry/index.ts b/code/core/src/telemetry/index.ts index 3b0dee4d935d..bca390ba971b 100644 --- a/code/core/src/telemetry/index.ts +++ b/code/core/src/telemetry/index.ts @@ -4,7 +4,14 @@ import { notify } from './notify.ts'; import { sanitizeError } from './sanitize.ts'; import { getStorybookMetadata } from './storybook-metadata.ts'; import { sendTelemetry } from './telemetry.ts'; -import type { EventType, Options, Payload, TelemetryData } from './types.ts'; +import type { + EventType, + Options, + Payload, + PayloadFactory, + PayloadInput, + TelemetryData, +} from './types.ts'; export { oneWayHash } from './one-way-hash.ts'; @@ -28,20 +35,127 @@ export const isExampleStoryId = (storyId: string) => storyId.startsWith('example-header--') || storyId.startsWith('example-page--'); -export const telemetry = async ( +// --- State machine --- + +type TelemetryState = undefined | 'enabled' | 'disabled'; + +globalThis.SB_TELEMETRY_STATE = undefined as TelemetryState; // Start in uninitialized state until we know whether telemetry is enabled or disabled based on presets and CLI options. In the meantime, events are queued. + +type QueuedEvent = { + eventType: EventType; + payload: PayloadInput; + options: Partial; + timestamp: number; +}; + +let _queue: QueuedEvent[] = []; + +const isPayloadFactory = (payload: PayloadInput): payload is PayloadFactory => + typeof payload === 'function'; + +const resolvePayload = async (payload: PayloadInput): Promise => + isPayloadFactory(payload) ? await payload() : payload; + +/** + * Resolve telemetry state. When enabled, flushes the queue. When disabled, clears it. + * This should be called once presets have been evaluated and the disableTelemetry config is known. + */ +export async function setTelemetryEnabled(enabled: boolean) { + const previousState = globalThis.SB_TELEMETRY_STATE; + globalThis.SB_TELEMETRY_STATE = enabled ? 'enabled' : 'disabled'; + + if (enabled && previousState === undefined) { + // Flush the queue + const pending = _queue; + _queue = []; + for (const event of pending) { + try { + await _processAndSend(event.eventType, event.payload, { + ...event.options, + timestamp: event.timestamp, + }); + } catch (error) { + logger.warn('Failed to flush queued telemetry event'); + logger.debug(error); + } + } + } else { + // Clear the queue (disabled, or already resolved) + _queue = []; + } +} + +/** Check whether telemetry is currently enabled. */ +export function isTelemetryModuleEnabled() { + return globalThis.SB_TELEMETRY_STATE === 'enabled'; +} + +/** Check whether the telemetry state has been resolved (is no longer uninitialized). */ +export function isTelemetryStateResolved() { + return globalThis.SB_TELEMETRY_STATE !== undefined; +} + +// --- Payload error handler --- + +/** + * Callback invoked when a payload factory throws or returns { error }. + * Registered by withTelemetry() to delegate to sendTelemetryError with full context + * (presets, cache, error levels, sub-errors). + */ +type PayloadErrorHandler = (error: Error, eventType: EventType) => Promise; +globalThis.PAYLOAD_ERROR_HANDLER = undefined as PayloadErrorHandler | undefined; + +/** + * Register a handler for payload factory errors. When a telemetry payload factory + * throws or returns { error }, this handler is called instead of sending the normal event. + * Pass undefined to clear the handler. + * + * This is used by withTelemetry() to wire up sendTelemetryError with full context + * (cliOptions, presetOptions, error levels, sub-errors) so all commands benefit + * from automatic error telemetry. + */ +export function onPayloadError(handler: PayloadErrorHandler | undefined) { + globalThis.PAYLOAD_ERROR_HANDLER = handler; +} + +// --- Internal send logic --- + +async function _processAndSend( eventType: EventType, - payload: Payload = {}, + payloadInput: PayloadInput, options: Partial = {} -) => { +) { + let payload: Payload; + + try { + payload = await resolvePayload(payloadInput); + } catch (err) { + // If the payload factory throws, delegate to the registered error handler + if (eventType !== 'error' && globalThis.PAYLOAD_ERROR_HANDLER) { + const error = err instanceof Error ? err : new Error(String(err)); + await globalThis.PAYLOAD_ERROR_HANDLER(error, eventType); + } + return; + } + + // When a payload factory returns { error }, delegate to the registered error handler + if (payload.error && eventType !== 'error' && globalThis.PAYLOAD_ERROR_HANDLER) { + const error = payload.error instanceof Error ? payload.error : new Error(String(payload.error)); + await globalThis.PAYLOAD_ERROR_HANDLER(error, eventType); + return; + } + // Don't notify on boot since it can lead to double notification in `sb init`. // The notification will happen when the actual command runs. if (eventType !== 'boot' && options.notify !== false) { await notify(); } + const telemetryData: TelemetryData = { eventType, payload, }; + try { if (!options?.stripMetadata) { telemetryData.metadata = await getStorybookMetadata(options?.configDir); @@ -54,7 +168,6 @@ export const telemetry = async ( } } finally { const { error } = payload; - // make sure to anonymise possible paths from error messages // make sure to anonymise possible paths from error messages if (error) { @@ -69,4 +182,24 @@ export const telemetry = async ( await sendTelemetry(telemetryData, options); } } +} + +// --- Public API --- + +export const telemetry = async ( + eventType: EventType, + payload: PayloadInput = {}, + options: Partial = {} +) => { + // force:true bypasses the disabled state (used for error telemetry with enableCrashReports) + if (globalThis.SB_TELEMETRY_STATE === 'disabled' && !options.force) { + return; + } + + if (globalThis.SB_TELEMETRY_STATE === undefined && !options.force) { + _queue.push({ eventType, payload, options, timestamp: Date.now() }); + return; + } + + await _processAndSend(eventType, payload, options); }; diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 8a81ca4ee010..209f9b31ce4f 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -103,6 +103,10 @@ export interface Payload { [key: string]: any; } +export type PayloadFactory = () => Payload | Promise; + +export type PayloadInput = Payload | PayloadFactory; + export interface Context { [key: string]: any; } @@ -114,6 +118,10 @@ export interface Options { enableCrashReports?: boolean; stripMetadata?: boolean; notify?: boolean; + /** Override the event timestamp. Used when flushing queued events to preserve original timing. */ + timestamp?: number; + /** When true, bypass the disabled state. Used for error telemetry with enableCrashReports. */ + force?: boolean; } export interface TelemetryData { diff --git a/code/core/src/types/modules/addons.ts b/code/core/src/types/modules/addons.ts index a62a6ae1bcf8..d1e4bedf012d 100644 --- a/code/core/src/types/modules/addons.ts +++ b/code/core/src/types/modules/addons.ts @@ -447,6 +447,8 @@ export interface Addon_TestProviderType { id: string; render: () => ReactNode; sidebarContextMenu?: (options: { context: API_HashEntry }) => ReactNode; + /** Called when the user clears all statuses. The provider should clear its own status stores. */ + clear?: () => void; } type Addon_TypeBaseNames = Exclude< diff --git a/code/core/src/typings.d.ts b/code/core/src/typings.d.ts index 512933868d83..1ecd785ebef0 100644 --- a/code/core/src/typings.d.ts +++ b/code/core/src/typings.d.ts @@ -16,6 +16,8 @@ declare var STORYBOOK_FRAMEWORK: declare var STORYBOOK_RENDERER: import('./types/modules/renderers').SupportedRenderer | undefined; declare var STORYBOOK_HOOKS_CONTEXT: any; declare var STORYBOOK_CURRENT_TASK_LOG: undefined | null | Array; +declare var SB_TELEMETRY_STATE: 'enabled' | 'disabled' | undefined; +declare var PAYLOAD_ERROR_HANDLER: PayloadErrorHandler | undefined; declare var STORYBOOK_NETWORK_ADDRESS: string | undefined; declare var PREVIEW_URL: string | undefined; diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts index cac2a71d4fbe..81e3b7ad2eee 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -3,7 +3,8 @@ import type { Type } from '@angular/core'; import { Component, - ComponentFactoryResolver, + // Removed in Angular 22 + // ComponentFactoryResolver, Directive, EventEmitter, HostBinding, @@ -46,6 +47,9 @@ describe('getComponentInputsOutputs', () => { }); }); + /* Commented out until we figure out how to handle the removal of ComponentFactoryResolver in Angular 22 + See https://github.com/angular/angular/releases/tag/v22.0.0-next.7 + it('should return I/O', () => { @Component({ template: '', @@ -205,6 +209,7 @@ describe('getComponentInputsOutputs', () => { ); expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); }); + */ }); describe('isDeclarable', () => { @@ -370,7 +375,7 @@ function sortByPropName( ) { return array.sort((a, b) => a.propName.localeCompare(b.propName)); } - +/* function resolveComponentFactory>(component: T) { TestBed.configureTestingModule({ declarations: [component], @@ -379,3 +384,4 @@ function resolveComponentFactory>(component: T) { return componentFactoryResolver.resolveComponentFactory(component); } +*/ diff --git a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts b/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts deleted file mode 100644 index 78fd72adcecb..000000000000 --- a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. -// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; - -@Component({ - standalone: false, - selector: 'storybook-attribute-selector[foo=bar]', - template: `

Attribute selector

-Selector: {{ selectors }}
-Generated template: {{ generatedTemplate }}`, -}) -export class AttributeSelectorComponent { - generatedTemplate!: string; - - selectors!: string; - - constructor( - public el: ElementRef, - private resolver: ComponentFactoryResolver - ) { - const factory = this.resolver.resolveComponentFactory(AttributeSelectorComponent); - this.selectors = factory.selector; - this.generatedTemplate = el.nativeElement.outerHTML; - } -} diff --git a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts b/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts deleted file mode 100644 index 2d9e13dd4d78..000000000000 --- a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; - -import { AttributeSelectorComponent } from './attribute-selector.component'; - -const meta: Meta = { - // title: 'Basics / Component / With Complex Selectors', - component: AttributeSelectorComponent, -}; - -export default meta; - -type Story = StoryObj; - -export const AttributeSelectors: Story = {}; diff --git a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts b/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts deleted file mode 100644 index c8da5e41e2c4..000000000000 --- a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ClassSelectorComponent } from './class-selector.component'; - -export default { - // title: 'Basics / Component / With Complex Selectors', - component: ClassSelectorComponent, -}; - -export const ClassSelectors = {}; diff --git a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/class-selector.component.ts b/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/class-selector.component.ts deleted file mode 100644 index d6cbf90d2f4a..000000000000 --- a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/class-selector.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. -// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; - -@Component({ - standalone: false, - selector: 'storybook-class-selector.foo, storybook-class-selector.bar', - template: `

Class selector

-Selector: {{ selectors }}
-Generated template: {{ generatedTemplate }}`, -}) -export class ClassSelectorComponent { - generatedTemplate!: string; - - selectors!: string; - - constructor( - public el: ElementRef, - private resolver: ComponentFactoryResolver - ) { - const factory = this.resolver.resolveComponentFactory(ClassSelectorComponent); - this.selectors = factory.selector; - this.generatedTemplate = el.nativeElement.outerHTML; - } -} diff --git a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts b/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts deleted file mode 100644 index 0ed46ecfdbcd..000000000000 --- a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MultipleClassSelectorComponent } from './multiple-selector.component'; - -export default { - // title: 'Basics / Component / With Complex Selectors', - component: MultipleClassSelectorComponent, -}; - -export const MultipleClassSelectors = {}; diff --git a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts b/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts deleted file mode 100644 index 3dac394c440a..000000000000 --- a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MultipleSelectorComponent } from './multiple-selector.component'; - -export default { - // title: 'Basics / Component / With Complex Selectors', - component: MultipleSelectorComponent, -}; - -export const MultipleSelectors = {}; diff --git a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts b/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts deleted file mode 100644 index 14a70bc230de..000000000000 --- a/code/frameworks/angular/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. -// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; - -@Component({ - standalone: false, - selector: 'storybook-multiple-selector, storybook-multiple-selector2', - template: `

Multiple selector

-Selector: {{ selectors }}
-Generated template: {{ generatedTemplate }}`, -}) -export class MultipleSelectorComponent { - generatedTemplate!: string; - - selectors!: string; - - constructor( - public el: ElementRef, - private resolver: ComponentFactoryResolver - ) { - const factory = this.resolver.resolveComponentFactory(MultipleClassSelectorComponent); - this.selectors = factory.selector; - this.generatedTemplate = el.nativeElement.outerHTML; - } -} - -@Component({ - standalone: false, - selector: 'storybook-button, button[foo], .button[foo], button[baz]', - template: `

Multiple selector

-Selector: {{ selectors }}
-Generated template: {{ generatedTemplate }}`, -}) -export class MultipleClassSelectorComponent { - generatedTemplate!: string; - - selectors!: string; - - constructor( - public el: ElementRef, - private resolver: ComponentFactoryResolver - ) { - const factory = this.resolver.resolveComponentFactory(MultipleClassSelectorComponent); - this.selectors = factory.selector; - this.generatedTemplate = el.nativeElement.outerHTML; - } -} diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index fa07baf2b8d8..36ebb1a28a7b 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -133,9 +133,7 @@ command('add ') await add(addonName, options); - if (!options.disableTelemetry) { - await telemetry('add', { addon: addonName, source: 'cli' }); - } + await telemetry('add', { addon: addonName, source: 'cli' }); logger.outro('Done!'); }).catch(handleCommandFailure); }); @@ -161,9 +159,7 @@ command('remove ') packageManager, skipInstall: options.skipInstall, }); - if (!options.disableTelemetry) { - await telemetry('remove', { addon: addonName, source: 'cli' }); - } + await telemetry('remove', { addon: addonName, source: 'cli' }); logger.outro('Done!'); }).catch(handleCommandFailure(options.logfile)) ); diff --git a/code/lib/cli-storybook/src/upgrade.ts b/code/lib/cli-storybook/src/upgrade.ts index 0f03cc857793..6a2a7b79ec40 100644 --- a/code/lib/cli-storybook/src/upgrade.ts +++ b/code/lib/cli-storybook/src/upgrade.ts @@ -480,55 +480,53 @@ export async function upgrade(options: UpgradeOptions): Promise { logUpgradeResults(automigrationResults, detectedAutomigrations, doctorResults); // TELEMETRY - if (!options.disableTelemetry) { - for (const project of storybookProjects) { - const resultData = automigrationResults[project.configDir] || { - automigrationStatuses: {}, - automigrationErrors: {}, - }; - let doctorFailureCount = 0; - let doctorErrorCount = 0; - Object.values(doctorResults[project.configDir]?.diagnostics || {}).forEach((status) => { - if (status === 'has_issues') { - doctorFailureCount++; - } - - if (status === 'check_error') { - doctorErrorCount++; - } - }); - const automigrationFailureCount = Object.keys(resultData.automigrationErrors).length; - const automigrationPreCheckFailure = - project.autoblockerCheckResults && project.autoblockerCheckResults.length > 0 - ? project.autoblockerCheckResults - ?.map((result) => { - if (result.result !== null) { - return result.blocker.id; - } - return null; - }) - .filter(Boolean) - : null; - await telemetry('upgrade', { - beforeVersion: project.beforeVersion, - afterVersion: project.currentCLIVersion, - automigrationResults: resultData.automigrationStatuses, - automigrationErrors: resultData.automigrationErrors, - automigrationFailureCount, - automigrationPreCheckFailure, - doctorResults: doctorResults[project.configDir]?.diagnostics || {}, - doctorFailureCount, - doctorErrorCount, - }); - } + for (const project of storybookProjects) { + const resultData = automigrationResults[project.configDir] || { + automigrationStatuses: {}, + automigrationErrors: {}, + }; + let doctorFailureCount = 0; + let doctorErrorCount = 0; + Object.values(doctorResults[project.configDir]?.diagnostics || {}).forEach((status) => { + if (status === 'has_issues') { + doctorFailureCount++; + } - await sendMultiUpgradeTelemetry({ - allProjects, - selectedProjects: storybookProjects, - projectResults: automigrationResults, - doctorResults, + if (status === 'check_error') { + doctorErrorCount++; + } + }); + const automigrationFailureCount = Object.keys(resultData.automigrationErrors).length; + const automigrationPreCheckFailure = + project.autoblockerCheckResults && project.autoblockerCheckResults.length > 0 + ? project.autoblockerCheckResults + ?.map((result) => { + if (result.result !== null) { + return result.blocker.id; + } + return null; + }) + .filter(Boolean) + : null; + await telemetry('upgrade', { + beforeVersion: project.beforeVersion, + afterVersion: project.currentCLIVersion, + automigrationResults: resultData.automigrationStatuses, + automigrationErrors: resultData.automigrationErrors, + automigrationFailureCount, + automigrationPreCheckFailure, + doctorResults: doctorResults[project.configDir]?.diagnostics || {}, + doctorFailureCount, + doctorErrorCount, }); } + + await sendMultiUpgradeTelemetry({ + allProjects, + selectedProjects: storybookProjects, + projectResults: automigrationResults, + doctorResults, + }); } finally { // Clean up signal handlers process.removeListener('SIGINT', handleInterruption); diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts index a5ab28f52730..7ae20d06a39d 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -38,7 +38,7 @@ export class AddonConfigurationCommand { readonly packageManager: JsPackageManager, private readonly commandOptions: CommandOptions, private readonly addonVitestService = new AddonVitestService(packageManager), - private readonly telemetryService = new TelemetryService(commandOptions.disableTelemetry) + private readonly telemetryService = new TelemetryService() ) {} /** Execute addon configuration */ diff --git a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts index eeb0a2a62e88..c445383801cb 100644 --- a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts +++ b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts @@ -92,13 +92,11 @@ We assume that Storybook is already instantiated for your project. Do you still if (force || options.yes) { options.force = true; } else { - if (!options.disableTelemetry) { - await telemetry( - 'exit', - { eventType: 'init', reason: 'existing-installation' }, - { stripMetadata: true, immediate: true } - ); - } + await telemetry( + 'exit', + { eventType: 'init', reason: 'existing-installation' }, + { stripMetadata: true, immediate: true } + ); process.exit(0); } } diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts index 492b3fb7bc08..f135e8a01f1e 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts @@ -47,7 +47,7 @@ export class UserPreferencesCommand { private readonly commandOptions: CommandOptions, packageManager: JsPackageManager, private readonly featureService = new FeatureCompatibilityService(packageManager), - private readonly telemetryService = new TelemetryService(commandOptions.disableTelemetry) + private readonly telemetryService = new TelemetryService() ) {} /** Execute user preferences gathering */ diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 2d8fcb7c3983..10c5a9725244 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -40,7 +40,7 @@ export async function doInitiate(options: CommandOptions): Promise< | { shouldRunDev: false } > { // Initialize services - const telemetryService = new TelemetryService(options.disableTelemetry); + const telemetryService = new TelemetryService(); // Register all framework generators registerAllGenerators(); diff --git a/code/lib/create-storybook/src/scaffold-new-project.ts b/code/lib/create-storybook/src/scaffold-new-project.ts index 453f2be88404..eaa2e3c38bb8 100644 --- a/code/lib/create-storybook/src/scaffold-new-project.ts +++ b/code/lib/create-storybook/src/scaffold-new-project.ts @@ -135,13 +135,11 @@ export const scaffoldNewProject = async ( } if (projectStrategy === 'other') { - if (!disableTelemetry) { - await telemetry( - 'exit', - { eventType: 'init', reason: 'scaffold-other' }, - { stripMetadata: true, immediate: true } - ); - } + await telemetry( + 'exit', + { eventType: 'init', reason: 'scaffold-other' }, + { stripMetadata: true, immediate: true } + ); logger.warn( 'To install Storybook on another framework, first generate a project with that framework and then rerun this command.' ); @@ -198,12 +196,10 @@ export const scaffoldNewProject = async ( spinner.stop(`${projectDisplayName} project with ${packageManagerName} created successfully!`); - if (!disableTelemetry) { - await telemetry('scaffolded-empty', { - packageManager: packageManagerName, - projectType: projectStrategy, - }); - } + await telemetry('scaffolded-empty', { + packageManager: packageManagerName, + projectType: projectStrategy, + }); }; const FILES_TO_IGNORE = [ diff --git a/code/lib/create-storybook/src/services/TelemetryService.test.ts b/code/lib/create-storybook/src/services/TelemetryService.test.ts index c5f864228490..d87735ed1087 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.test.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.test.ts @@ -21,7 +21,7 @@ describe('TelemetryService', () => { let telemetryService: TelemetryService; beforeEach(() => { - telemetryService = new TelemetryService(false); + telemetryService = new TelemetryService(); }); it('should track new user check', async () => { @@ -73,53 +73,9 @@ describe('TelemetryService', () => { }); }); - describe('when telemetry is disabled', () => { - let telemetryService: TelemetryService; - - beforeEach(() => { - telemetryService = new TelemetryService(true); - }); - - it('should not track new user check', async () => { - await telemetryService.trackNewUserCheck(true); - - expect(telemetry).not.toHaveBeenCalled(); - }); - - it('should not track install type', async () => { - await telemetryService.trackInstallType('light'); - - expect(telemetry).not.toHaveBeenCalled(); - }); - - it('should not track init event', async () => { - await telemetryService.trackInit({ - projectType: ProjectType.VUE3, - features: { - dev: true, - docs: false, - test: false, - onboarding: false, - }, - newUser: false, - }); - - expect(telemetry).not.toHaveBeenCalled(); - }); - - it('should not track scaffolded event', async () => { - await telemetryService.trackScaffolded({ - packageManager: 'yarn', - projectType: 'vue-vite-ts', - }); - - expect(telemetry).not.toHaveBeenCalled(); - }); - }); - describe('trackInitWithContext', () => { it('should track init with version and CLI integration from ancestry', async () => { - const telemetryService = new TelemetryService(false); + const telemetryService = new TelemetryService(); const selectedFeatures = new Set([Feature.DOCS, Feature.TEST]); vi.mocked(getProcessAncestry).mockReturnValue([ @@ -144,7 +100,7 @@ describe('TelemetryService', () => { }); it('should handle ancestry errors gracefully', async () => { - const telemetryService = new TelemetryService(false); + const telemetryService = new TelemetryService(); const selectedFeatures = new Set([]); vi.mocked(getProcessAncestry).mockImplementation(() => { @@ -167,18 +123,8 @@ describe('TelemetryService', () => { }); }); - it('should not track when telemetry is disabled', async () => { - const telemetryService = new TelemetryService(true); - const selectedFeatures = new Set([Feature.DOCS]); - - await telemetryService.trackInitWithContext(ProjectType.ANGULAR, selectedFeatures, true); - - expect(getProcessAncestry).not.toHaveBeenCalled(); - expect(telemetry).not.toHaveBeenCalled(); - }); - it('should detect CLI integration from ancestry', async () => { - const telemetryService = new TelemetryService(false); + const telemetryService = new TelemetryService(); const selectedFeatures = new Set([]); vi.mocked(getProcessAncestry).mockReturnValue([{ command: 'sv create my-app' }] as any); diff --git a/code/lib/create-storybook/src/services/TelemetryService.ts b/code/lib/create-storybook/src/services/TelemetryService.ts index 8ab7668fcdfd..897c4bce1111 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.ts @@ -8,17 +8,15 @@ import { VersionService } from './VersionService.ts'; /** Service for tracking telemetry events during Storybook initialization */ export class TelemetryService { - private disableTelemetry: boolean; private versionService: VersionService; - constructor(disableTelemetry: boolean = false) { - this.disableTelemetry = disableTelemetry; + constructor() { this.versionService = new VersionService(); } /** Track a new user check step */ async trackNewUserCheck(newUser: boolean): Promise { - await this.runTelemetryIfEnabled('init-step', { + await telemetry('init-step', { step: 'new-user-check', newUser, }); @@ -26,7 +24,7 @@ export class TelemetryService { /** Track install type selection */ async trackInstallType(installType: 'recommended' | 'light'): Promise { - await this.runTelemetryIfEnabled('init-step', { + await telemetry('init-step', { step: 'install-type', installType, }); @@ -36,7 +34,7 @@ export class TelemetryService { async trackPlaywrightPromptDecision( result: 'installed' | 'skipped' | 'aborted' | 'failed' ): Promise { - await this.runTelemetryIfEnabled('init-step', { + await telemetry('init-step', { step: 'playwright-install', result, }); @@ -55,12 +53,12 @@ export class TelemetryService { versionSpecifier?: string; cliIntegration?: string; }): Promise { - await this.runTelemetryIfEnabled('init', data); + await telemetry('init', data); } /** Track empty directory scaffolding event */ async trackScaffolded(data: { packageManager: string; projectType: string }): Promise { - await this.runTelemetryIfEnabled('scaffolded-empty', data); + await telemetry('scaffolded-empty', data); } /** @@ -72,10 +70,6 @@ export class TelemetryService { selectedFeatures: Set, newUser: boolean ): Promise { - if (this.disableTelemetry) { - return; - } - // Get telemetry info from process ancestry let versionSpecifier: string | undefined; let cliIntegration: string | undefined; @@ -104,12 +98,4 @@ export class TelemetryService { cliIntegration, }); } - - private runTelemetryIfEnabled(...args: Parameters): Promise { - if (this.disableTelemetry) { - return Promise.resolve(); - } - - return telemetry(...args); - } } diff --git a/code/package.json b/code/package.json index 14d1b36fed40..fdb02e40d2ee 100644 --- a/code/package.json +++ b/code/package.json @@ -48,7 +48,7 @@ "node >= 20" ], "dependencies": { - "@chromatic-com/storybook": "^5.0.0", + "@chromatic-com/storybook": "^5.1.2", "@playwright/test": "1.58.2", "@storybook/addon-a11y": "workspace:*", "@storybook/addon-designs": "^11.0.3", @@ -195,5 +195,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.4.0-alpha.10" } diff --git a/docs/versions/next.json b/docs/versions/next.json index 0c39a3284d94..d688928de14e 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.4.0-alpha.9","info":{"plain":"- A11y: Improve boolean control contrast in forced colors mode - [#34204](https://github.com/storybookjs/storybook/pull/34204), thanks @anchmelev!\n- Addon-Docs: Avoid rerendering static Source blocks - [#34206](https://github.com/storybookjs/storybook/pull/34206), thanks @anchmelev!\n- CLI: Streamline Node.js version detection code - [#34440](https://github.com/storybookjs/storybook/pull/34440), thanks @Sidnioulz!\n- Core: Improve startup performance by deferring change detection initialization - [#34498](https://github.com/storybookjs/storybook/pull/34498), thanks @ghengeveld!\n- Core: Normalize file paths in ChangeDetectionService and trace-changed for Windows support - [#34445](https://github.com/storybookjs/storybook/pull/34445), thanks @ghengeveld!\n- Fix: Add vite-plus vendored libraries version detection - [#34509](https://github.com/storybookjs/storybook/pull/34509), thanks @huang-julien!\n- Nextjs: Handle node builtin webpack imports - [#34494](https://github.com/storybookjs/storybook/pull/34494), thanks @JReinhold!\n- React: Add subcomponents to component manifests - [#34428](https://github.com/storybookjs/storybook/pull/34428), thanks @kasperpeulen!\n- Vue3: Clear stale args/globals when nextArgs is empty in updateArgs - [#34409](https://github.com/storybookjs/storybook/pull/34409), thanks @whdjh!"}} \ No newline at end of file +{"version":"10.4.0-alpha.10","info":{"plain":"- Sidebar: Fix clear status button to only clear test statuses - [#34478](https://github.com/storybookjs/storybook/pull/34478), thanks @valentinpalkovic!\n- Telemetry: Centralize disable logic with module-level flag - [#34485](https://github.com/storybookjs/storybook/pull/34485), thanks @valentinpalkovic!"}} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 5d91f8cc3dee..05874291102d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2111,9 +2111,9 @@ __metadata: languageName: node linkType: hard -"@chromatic-com/storybook@npm:^5.0.0": - version: 5.0.0 - resolution: "@chromatic-com/storybook@npm:5.0.0" +"@chromatic-com/storybook@npm:^5.1.2": + version: 5.1.2 + resolution: "@chromatic-com/storybook@npm:5.1.2" dependencies: "@neoconfetti/react": "npm:^1.0.0" chromatic: "npm:^13.3.4" @@ -2121,8 +2121,8 @@ __metadata: jsonfile: "npm:^6.1.0" strip-ansi: "npm:^7.1.0" peerDependencies: - storybook: ^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 - checksum: 10c0/8e4dcba6c70b2943a5b68fd3d690084328844d757fffccef26adebf1eadfc3b0d2fee80280e57c40d6897dee3ab9c6c202de46eb1de39991d316df5e36b4fe89 + storybook: ^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 + checksum: 10c0/0d21aab34eef8dea756d7a510fd78227379751e23d9cedac127161442eb96622f302e0b8f71a78f1254b31a26ee5350e328e4c994909d28553dbe74174d606e1 languageName: node linkType: hard @@ -4672,13 +4672,6 @@ __metadata: languageName: node linkType: hard -"@oxc-project/runtime@npm:=0.123.0": - version: 0.123.0 - resolution: "@oxc-project/runtime@npm:0.123.0" - checksum: 10c0/ed057ca3c95a2570914c3c99c842b6274b9f4b7e4005d4f7775617bfc31b80adc65272449316032ee4d6933ba0a0eb6e8ee5d0ca4633c845c12be00dcbfb26bf - languageName: node - linkType: hard - "@oxc-project/types@npm:=0.115.0": version: 0.115.0 resolution: "@oxc-project/types@npm:0.115.0" @@ -4686,13 +4679,6 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.123.0": - version: 0.123.0 - resolution: "@oxc-project/types@npm:0.123.0" - checksum: 10c0/7f71f9fa38796e6e5431390c213ec9626a3972feec07b513c513828bbfba5f6d908b04e8c679ae2b30b49cc1dee2dc0b2f1012f38ed1cb9e54bfeba09119f36d - languageName: node - linkType: hard - "@oxc-resolver/binding-android-arm-eabi@npm:11.14.0": version: 11.14.0 resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.14.0" @@ -4835,13 +4821,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-android-arm-eabi@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-android-arm-eabi@npm:0.43.0" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@oxfmt/binding-android-arm64@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-android-arm64@npm:0.41.0" @@ -4849,13 +4828,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-android-arm64@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-android-arm64@npm:0.43.0" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@oxfmt/binding-darwin-arm64@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-darwin-arm64@npm:0.41.0" @@ -4863,13 +4835,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-darwin-arm64@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-darwin-arm64@npm:0.43.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@oxfmt/binding-darwin-x64@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-darwin-x64@npm:0.41.0" @@ -4877,13 +4842,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-darwin-x64@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-darwin-x64@npm:0.43.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@oxfmt/binding-freebsd-x64@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-freebsd-x64@npm:0.41.0" @@ -4891,13 +4849,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-freebsd-x64@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-freebsd-x64@npm:0.43.0" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@oxfmt/binding-linux-arm-gnueabihf@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.41.0" @@ -4905,13 +4856,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-arm-gnueabihf@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.43.0" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@oxfmt/binding-linux-arm-musleabihf@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.41.0" @@ -4919,13 +4863,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-arm-musleabihf@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.43.0" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@oxfmt/binding-linux-arm64-gnu@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.41.0" @@ -4933,13 +4870,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-arm64-gnu@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.43.0" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@oxfmt/binding-linux-arm64-musl@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.41.0" @@ -4947,13 +4877,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-arm64-musl@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.43.0" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@oxfmt/binding-linux-ppc64-gnu@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.41.0" @@ -4961,13 +4884,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-ppc64-gnu@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.43.0" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - "@oxfmt/binding-linux-riscv64-gnu@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.41.0" @@ -4975,13 +4891,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-riscv64-gnu@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.43.0" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - "@oxfmt/binding-linux-riscv64-musl@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.41.0" @@ -4989,13 +4898,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-riscv64-musl@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.43.0" - conditions: os=linux & cpu=riscv64 & libc=musl - languageName: node - linkType: hard - "@oxfmt/binding-linux-s390x-gnu@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.41.0" @@ -5003,13 +4905,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-s390x-gnu@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.43.0" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - "@oxfmt/binding-linux-x64-gnu@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.41.0" @@ -5017,13 +4912,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-x64-gnu@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.43.0" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@oxfmt/binding-linux-x64-musl@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-linux-x64-musl@npm:0.41.0" @@ -5031,13 +4919,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-linux-x64-musl@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-linux-x64-musl@npm:0.43.0" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@oxfmt/binding-openharmony-arm64@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-openharmony-arm64@npm:0.41.0" @@ -5045,13 +4926,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-openharmony-arm64@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-openharmony-arm64@npm:0.43.0" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - "@oxfmt/binding-win32-arm64-msvc@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.41.0" @@ -5059,13 +4933,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-win32-arm64-msvc@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.43.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@oxfmt/binding-win32-ia32-msvc@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.41.0" @@ -5073,13 +4940,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-win32-ia32-msvc@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.43.0" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@oxfmt/binding-win32-x64-msvc@npm:0.41.0": version: 0.41.0 resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.41.0" @@ -5087,188 +4947,6 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-win32-x64-msvc@npm:0.43.0": - version: 0.43.0 - resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.43.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/darwin-arm64@npm:0.20.0": - version: 0.20.0 - resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.20.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/darwin-x64@npm:0.20.0": - version: 0.20.0 - resolution: "@oxlint-tsgolint/darwin-x64@npm:0.20.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/linux-arm64@npm:0.20.0": - version: 0.20.0 - resolution: "@oxlint-tsgolint/linux-arm64@npm:0.20.0" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/linux-x64@npm:0.20.0": - version: 0.20.0 - resolution: "@oxlint-tsgolint/linux-x64@npm:0.20.0" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/win32-arm64@npm:0.20.0": - version: 0.20.0 - resolution: "@oxlint-tsgolint/win32-arm64@npm:0.20.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/win32-x64@npm:0.20.0": - version: 0.20.0 - resolution: "@oxlint-tsgolint/win32-x64@npm:0.20.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@oxlint/binding-android-arm-eabi@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-android-arm-eabi@npm:1.58.0" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - -"@oxlint/binding-android-arm64@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-android-arm64@npm:1.58.0" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint/binding-darwin-arm64@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-darwin-arm64@npm:1.58.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint/binding-darwin-x64@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-darwin-x64@npm:1.58.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@oxlint/binding-freebsd-x64@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-freebsd-x64@npm:1.58.0" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@oxlint/binding-linux-arm-gnueabihf@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.58.0" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@oxlint/binding-linux-arm-musleabihf@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.58.0" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@oxlint/binding-linux-arm64-gnu@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.58.0" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@oxlint/binding-linux-arm64-musl@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-arm64-musl@npm:1.58.0" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@oxlint/binding-linux-ppc64-gnu@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.58.0" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - -"@oxlint/binding-linux-riscv64-gnu@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.58.0" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - -"@oxlint/binding-linux-riscv64-musl@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.58.0" - conditions: os=linux & cpu=riscv64 & libc=musl - languageName: node - linkType: hard - -"@oxlint/binding-linux-s390x-gnu@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.58.0" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - -"@oxlint/binding-linux-x64-gnu@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-x64-gnu@npm:1.58.0" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@oxlint/binding-linux-x64-musl@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-linux-x64-musl@npm:1.58.0" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@oxlint/binding-openharmony-arm64@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-openharmony-arm64@npm:1.58.0" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint/binding-win32-arm64-msvc@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.58.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint/binding-win32-ia32-msvc@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.58.0" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"@oxlint/binding-win32-x64-msvc@npm:1.58.0": - version: 1.58.0 - resolution: "@oxlint/binding-win32-x64-msvc@npm:1.58.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@parcel/watcher-android-arm64@npm:2.5.1": version: 2.5.1 resolution: "@parcel/watcher-android-arm64@npm:2.5.1" @@ -8478,7 +8156,7 @@ __metadata: version: 0.0.0-use.local resolution: "@storybook/code@workspace:code" dependencies: - "@chromatic-com/storybook": "npm:^5.0.0" + "@chromatic-com/storybook": "npm:^5.1.2" "@playwright/test": "npm:1.58.2" "@storybook/addon-a11y": "workspace:*" "@storybook/addon-designs": "npm:^11.0.3" @@ -11438,178 +11116,6 @@ __metadata: languageName: node linkType: hard -"@voidzero-dev/vite-plus-core@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-core@npm:0.1.16" - dependencies: - "@oxc-project/runtime": "npm:=0.123.0" - "@oxc-project/types": "npm:=0.123.0" - fsevents: "npm:~2.3.3" - lightningcss: "npm:^1.30.2" - postcss: "npm:^8.5.6" - peerDependencies: - "@arethetypeswrong/core": ^0.18.1 - "@tsdown/css": 0.21.7 - "@tsdown/exe": 0.21.7 - "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.1.0 - esbuild: ^0.28.0 - jiti: ">=1.21.0" - less: ^4.0.0 - publint: ^0.3.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - typescript: ^5.0.0 || ^6.0.0 - unplugin-unused: ^0.5.0 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@arethetypeswrong/core": - optional: true - "@tsdown/css": - optional: true - "@tsdown/exe": - optional: true - "@types/node": - optional: true - "@vitejs/devtools": - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - publint: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - typescript: - optional: true - unplugin-unused: - optional: true - yaml: - optional: true - checksum: 10c0/7b779e7aa6e6a8a1bc95f68859dea1095dd9a1b5b1155fc1a7590500e1befa097a72583ba284aaf295c1f89efef7d02d8f573326ab79c7c194e561cf9409e24d - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-darwin-arm64@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-darwin-arm64@npm:0.1.16" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-darwin-x64@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-darwin-x64@npm:0.1.16" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-linux-arm64-gnu@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-linux-arm64-gnu@npm:0.1.16" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-linux-arm64-musl@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-linux-arm64-musl@npm:0.1.16" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-linux-x64-gnu@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-linux-x64-gnu@npm:0.1.16" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-linux-x64-musl@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-linux-x64-musl@npm:0.1.16" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-test@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-test@npm:0.1.16" - dependencies: - "@standard-schema/spec": "npm:^1.1.0" - "@types/chai": "npm:^5.2.2" - "@voidzero-dev/vite-plus-core": "npm:0.1.16" - es-module-lexer: "npm:^1.7.0" - obug: "npm:^2.1.1" - pixelmatch: "npm:^7.1.0" - pngjs: "npm:^7.0.0" - sirv: "npm:^3.0.2" - std-env: "npm:^4.0.0" - tinybench: "npm:^2.9.0" - tinyexec: "npm:^1.0.2" - tinyglobby: "npm:^0.2.15" - ws: "npm:^8.18.3" - peerDependencies: - "@edge-runtime/vm": "*" - "@opentelemetry/api": ^1.9.0 - "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/ui": 4.1.2 - happy-dom: "*" - jsdom: "*" - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@opentelemetry/api": - optional: true - "@types/node": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vite: - optional: false - checksum: 10c0/f9ba8ddd3b05e2baa5c57143449e47a4dbc68ca8a7aee99fb1ea55aad22789215c8b25a0093f38e55ecb8a8618ec3188d476535938ab6e2c0aae624fcfd2afcc - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-win32-arm64-msvc@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-win32-arm64-msvc@npm:0.1.16" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-win32-x64-msvc@npm:0.1.16": - version: 0.1.16 - resolution: "@voidzero-dev/vite-plus-win32-x64-msvc@npm:0.1.16" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@volar/language-core@npm:2.4.15": version: 2.4.15 resolution: "@volar/language-core@npm:2.4.15" @@ -16559,7 +16065,7 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.2.1, es-module-lexer@npm:^1.5.0, es-module-lexer@npm:^1.5.4, es-module-lexer@npm:^1.7.0": +"es-module-lexer@npm:^1.2.1, es-module-lexer@npm:^1.5.0, es-module-lexer@npm:^1.5.4": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b @@ -21785,7 +21291,7 @@ __metadata: languageName: node linkType: hard -"lightningcss@npm:^1.30.2, lightningcss@npm:^1.32.0": +"lightningcss@npm:^1.32.0": version: 1.32.0 resolution: "lightningcss@npm:1.32.0" dependencies: @@ -24589,75 +24095,6 @@ __metadata: languageName: node linkType: hard -"oxfmt@npm:=0.43.0": - version: 0.43.0 - resolution: "oxfmt@npm:0.43.0" - dependencies: - "@oxfmt/binding-android-arm-eabi": "npm:0.43.0" - "@oxfmt/binding-android-arm64": "npm:0.43.0" - "@oxfmt/binding-darwin-arm64": "npm:0.43.0" - "@oxfmt/binding-darwin-x64": "npm:0.43.0" - "@oxfmt/binding-freebsd-x64": "npm:0.43.0" - "@oxfmt/binding-linux-arm-gnueabihf": "npm:0.43.0" - "@oxfmt/binding-linux-arm-musleabihf": "npm:0.43.0" - "@oxfmt/binding-linux-arm64-gnu": "npm:0.43.0" - "@oxfmt/binding-linux-arm64-musl": "npm:0.43.0" - "@oxfmt/binding-linux-ppc64-gnu": "npm:0.43.0" - "@oxfmt/binding-linux-riscv64-gnu": "npm:0.43.0" - "@oxfmt/binding-linux-riscv64-musl": "npm:0.43.0" - "@oxfmt/binding-linux-s390x-gnu": "npm:0.43.0" - "@oxfmt/binding-linux-x64-gnu": "npm:0.43.0" - "@oxfmt/binding-linux-x64-musl": "npm:0.43.0" - "@oxfmt/binding-openharmony-arm64": "npm:0.43.0" - "@oxfmt/binding-win32-arm64-msvc": "npm:0.43.0" - "@oxfmt/binding-win32-ia32-msvc": "npm:0.43.0" - "@oxfmt/binding-win32-x64-msvc": "npm:0.43.0" - tinypool: "npm:2.1.0" - dependenciesMeta: - "@oxfmt/binding-android-arm-eabi": - optional: true - "@oxfmt/binding-android-arm64": - optional: true - "@oxfmt/binding-darwin-arm64": - optional: true - "@oxfmt/binding-darwin-x64": - optional: true - "@oxfmt/binding-freebsd-x64": - optional: true - "@oxfmt/binding-linux-arm-gnueabihf": - optional: true - "@oxfmt/binding-linux-arm-musleabihf": - optional: true - "@oxfmt/binding-linux-arm64-gnu": - optional: true - "@oxfmt/binding-linux-arm64-musl": - optional: true - "@oxfmt/binding-linux-ppc64-gnu": - optional: true - "@oxfmt/binding-linux-riscv64-gnu": - optional: true - "@oxfmt/binding-linux-riscv64-musl": - optional: true - "@oxfmt/binding-linux-s390x-gnu": - optional: true - "@oxfmt/binding-linux-x64-gnu": - optional: true - "@oxfmt/binding-linux-x64-musl": - optional: true - "@oxfmt/binding-openharmony-arm64": - optional: true - "@oxfmt/binding-win32-arm64-msvc": - optional: true - "@oxfmt/binding-win32-ia32-msvc": - optional: true - "@oxfmt/binding-win32-x64-msvc": - optional: true - bin: - oxfmt: bin/oxfmt - checksum: 10c0/f78c05c2f834fb6ea1d5a421c964d2211bc519dc260b80ed846b078cd404b7f6ba71d0f34be83064bd4bacfbd2e451a974f11d31713bbead39495d9d8234bae5 - languageName: node - linkType: hard - "oxfmt@npm:^0.41.0": version: 0.41.0 resolution: "oxfmt@npm:0.41.0" @@ -24727,108 +24164,6 @@ __metadata: languageName: node linkType: hard -"oxlint-tsgolint@npm:=0.20.0": - version: 0.20.0 - resolution: "oxlint-tsgolint@npm:0.20.0" - dependencies: - "@oxlint-tsgolint/darwin-arm64": "npm:0.20.0" - "@oxlint-tsgolint/darwin-x64": "npm:0.20.0" - "@oxlint-tsgolint/linux-arm64": "npm:0.20.0" - "@oxlint-tsgolint/linux-x64": "npm:0.20.0" - "@oxlint-tsgolint/win32-arm64": "npm:0.20.0" - "@oxlint-tsgolint/win32-x64": "npm:0.20.0" - dependenciesMeta: - "@oxlint-tsgolint/darwin-arm64": - optional: true - "@oxlint-tsgolint/darwin-x64": - optional: true - "@oxlint-tsgolint/linux-arm64": - optional: true - "@oxlint-tsgolint/linux-x64": - optional: true - "@oxlint-tsgolint/win32-arm64": - optional: true - "@oxlint-tsgolint/win32-x64": - optional: true - bin: - tsgolint: bin/tsgolint.js - checksum: 10c0/9521a8e6aaea637592cda093bfb9220eb8a728bfccc980cc82de0011ed84733f1a42c629dfff8574a023e40e48c2dcdaf1675f0cf11aa92d164d5ccca1305c52 - languageName: node - linkType: hard - -"oxlint@npm:=1.58.0": - version: 1.58.0 - resolution: "oxlint@npm:1.58.0" - dependencies: - "@oxlint/binding-android-arm-eabi": "npm:1.58.0" - "@oxlint/binding-android-arm64": "npm:1.58.0" - "@oxlint/binding-darwin-arm64": "npm:1.58.0" - "@oxlint/binding-darwin-x64": "npm:1.58.0" - "@oxlint/binding-freebsd-x64": "npm:1.58.0" - "@oxlint/binding-linux-arm-gnueabihf": "npm:1.58.0" - "@oxlint/binding-linux-arm-musleabihf": "npm:1.58.0" - "@oxlint/binding-linux-arm64-gnu": "npm:1.58.0" - "@oxlint/binding-linux-arm64-musl": "npm:1.58.0" - "@oxlint/binding-linux-ppc64-gnu": "npm:1.58.0" - "@oxlint/binding-linux-riscv64-gnu": "npm:1.58.0" - "@oxlint/binding-linux-riscv64-musl": "npm:1.58.0" - "@oxlint/binding-linux-s390x-gnu": "npm:1.58.0" - "@oxlint/binding-linux-x64-gnu": "npm:1.58.0" - "@oxlint/binding-linux-x64-musl": "npm:1.58.0" - "@oxlint/binding-openharmony-arm64": "npm:1.58.0" - "@oxlint/binding-win32-arm64-msvc": "npm:1.58.0" - "@oxlint/binding-win32-ia32-msvc": "npm:1.58.0" - "@oxlint/binding-win32-x64-msvc": "npm:1.58.0" - peerDependencies: - oxlint-tsgolint: ">=0.18.0" - dependenciesMeta: - "@oxlint/binding-android-arm-eabi": - optional: true - "@oxlint/binding-android-arm64": - optional: true - "@oxlint/binding-darwin-arm64": - optional: true - "@oxlint/binding-darwin-x64": - optional: true - "@oxlint/binding-freebsd-x64": - optional: true - "@oxlint/binding-linux-arm-gnueabihf": - optional: true - "@oxlint/binding-linux-arm-musleabihf": - optional: true - "@oxlint/binding-linux-arm64-gnu": - optional: true - "@oxlint/binding-linux-arm64-musl": - optional: true - "@oxlint/binding-linux-ppc64-gnu": - optional: true - "@oxlint/binding-linux-riscv64-gnu": - optional: true - "@oxlint/binding-linux-riscv64-musl": - optional: true - "@oxlint/binding-linux-s390x-gnu": - optional: true - "@oxlint/binding-linux-x64-gnu": - optional: true - "@oxlint/binding-linux-x64-musl": - optional: true - "@oxlint/binding-openharmony-arm64": - optional: true - "@oxlint/binding-win32-arm64-msvc": - optional: true - "@oxlint/binding-win32-ia32-msvc": - optional: true - "@oxlint/binding-win32-x64-msvc": - optional: true - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - bin: - oxlint: bin/oxlint - checksum: 10c0/b766362cf700b842077f7a4873b971c8f31dd05f103c28e0c0a07141a24fca2e03a79b43570006010629c20b96d214fadfb81bb0297307e72250af3504a7e59f - languageName: node - linkType: hard - "p-finally@npm:^1.0.0": version: 1.0.0 resolution: "p-finally@npm:1.0.0" @@ -25569,17 +24904,6 @@ __metadata: languageName: node linkType: hard -"pixelmatch@npm:^7.1.0": - version: 7.1.0 - resolution: "pixelmatch@npm:7.1.0" - dependencies: - pngjs: "npm:^7.0.0" - bin: - pixelmatch: bin/pixelmatch - checksum: 10c0/ff069f92edaa841ac9b58b0ab74e1afa1f3b5e770eea0218c96bac1da4e752f5f6b79a0f9c4ba6b02afb955d39b8c78bcc3cc884f8122b67a1f2efbbccbe1a73 - languageName: node - linkType: hard - "pkg-dir@npm:^3.0.0": version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" @@ -29378,7 +28702,6 @@ __metadata: unique-string: "npm:^3.0.0" use-resize-observer: "npm:^9.1.0" use-sync-external-store: "npm:^1.5.0" - vite-plus: "npm:^0.1.16" watchpack: "npm:^2.5.0" wrap-ansi: "npm:^9.0.2" ws: "npm:^8.18.0" @@ -31593,49 +30916,6 @@ __metadata: languageName: node linkType: hard -"vite-plus@npm:^0.1.16": - version: 0.1.16 - resolution: "vite-plus@npm:0.1.16" - dependencies: - "@oxc-project/types": "npm:=0.123.0" - "@voidzero-dev/vite-plus-core": "npm:0.1.16" - "@voidzero-dev/vite-plus-darwin-arm64": "npm:0.1.16" - "@voidzero-dev/vite-plus-darwin-x64": "npm:0.1.16" - "@voidzero-dev/vite-plus-linux-arm64-gnu": "npm:0.1.16" - "@voidzero-dev/vite-plus-linux-arm64-musl": "npm:0.1.16" - "@voidzero-dev/vite-plus-linux-x64-gnu": "npm:0.1.16" - "@voidzero-dev/vite-plus-linux-x64-musl": "npm:0.1.16" - "@voidzero-dev/vite-plus-test": "npm:0.1.16" - "@voidzero-dev/vite-plus-win32-arm64-msvc": "npm:0.1.16" - "@voidzero-dev/vite-plus-win32-x64-msvc": "npm:0.1.16" - oxfmt: "npm:=0.43.0" - oxlint: "npm:=1.58.0" - oxlint-tsgolint: "npm:=0.20.0" - dependenciesMeta: - "@voidzero-dev/vite-plus-darwin-arm64": - optional: true - "@voidzero-dev/vite-plus-darwin-x64": - optional: true - "@voidzero-dev/vite-plus-linux-arm64-gnu": - optional: true - "@voidzero-dev/vite-plus-linux-arm64-musl": - optional: true - "@voidzero-dev/vite-plus-linux-x64-gnu": - optional: true - "@voidzero-dev/vite-plus-linux-x64-musl": - optional: true - "@voidzero-dev/vite-plus-win32-arm64-msvc": - optional: true - "@voidzero-dev/vite-plus-win32-x64-msvc": - optional: true - bin: - oxfmt: bin/oxfmt - oxlint: bin/oxlint - vp: bin/vp - checksum: 10c0/480651acc9f57e5a8bb1e1506aeb916b452ce17da9f2f670868452602652782c3ae73eccf0b8f5be47ce6b5616fb0c055d042a3e818312fc5a1c88db66b9f37d - languageName: node - linkType: hard - "vite-tsconfig-paths@npm:^5.1.4": version: 5.1.4 resolution: "vite-tsconfig-paths@npm:5.1.4" @@ -32757,7 +32037,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.0, ws@npm:^8.18.3, ws@npm:^8.19.0": +"ws@npm:^8.18.0, ws@npm:^8.19.0": version: 8.20.0 resolution: "ws@npm:8.20.0" peerDependencies: