diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index fa7446af1a96..65d3f76db115 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -11,9 +11,14 @@ import { getStorybookInfo, } from 'storybook/internal/common'; import { CLI_COLORS } from 'storybook/internal/node-logger'; +import type { StorybookError } from 'storybook/internal/server-errors'; import { + AddonVitestPostinstallConfigUpdateError, AddonVitestPostinstallError, + AddonVitestPostinstallExistingSetupFileError, + AddonVitestPostinstallFailedAddonA11yError, AddonVitestPostinstallPrerequisiteCheckError, + AddonVitestPostinstallWorkspaceUpdateError, } from 'storybook/internal/server-errors'; import { SupportedFramework } from 'storybook/internal/types'; @@ -32,7 +37,7 @@ const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.cts', '.mts', '.cjs', '.mjs' const addonA11yName = '@storybook/addon-a11y'; export default async function postInstall(options: PostinstallOptions) { - const errors: string[] = []; + const errors: InstanceType[] = []; const { logger, prompt } = options; const packageManager = JsPackageManagerFactory.getPackageManager({ @@ -138,10 +143,9 @@ export default async function postInstall(options: PostinstallOptions) { // Install Playwright browser binaries using AddonVitestService if (!options.skipDependencyManagement) { if (!options.skipInstall) { - const { errors: playwrightErrors } = await addonVitestService.installPlaywright({ + await addonVitestService.installPlaywright({ yes: options.yes, }); - errors.push(...playwrightErrors); } else { logger.warn(dedent` Playwright browser binaries installation skipped. Please run the following command manually later: @@ -164,7 +168,7 @@ export default async function postInstall(options: PostinstallOptions) { `; logger.line(); logger.error(`${errorMessage}\n`); - errors.push('Found existing Vitest setup file'); + errors.push(new AddonVitestPostinstallExistingSetupFileError({ filePath: vitestSetupFile })); } else { logger.step(`Creating a Vitest setup file for Storybook:`); logger.log(`${vitestSetupFile}\n`); @@ -255,7 +259,9 @@ export default async function postInstall(options: PostinstallOptions) { https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup ` ); - errors.push('Unable to update existing Vitest workspace file'); + errors.push( + new AddonVitestPostinstallWorkspaceUpdateError({ filePath: vitestWorkspaceFile }) + ); } } // If there's an existing Vite/Vitest config with workspaces, we update it to include the Storybook Addon Vitest plugin. @@ -300,7 +306,7 @@ export default async function postInstall(options: PostinstallOptions) { Please refer to the documentation to complete the setup manually: https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup `); - errors.push('Unable to update existing Vitest config file'); + errors.push(new AddonVitestPostinstallConfigUpdateError({ filePath: rootConfig })); } } // If there's no existing Vitest/Vite config, we create a new Vitest config file. @@ -361,10 +367,7 @@ export default async function postInstall(options: PostinstallOptions) { Please refer to the documentation to complete the setup manually: https://storybook.js.org/docs/writing-tests/accessibility-testing#test-addon-integration `); - errors.push( - "The @storybook/addon-a11y couldn't be set up for the Vitest addon" + - (e instanceof Error ? e.stack : String(e)) - ); + errors.push(new AddonVitestPostinstallFailedAddonA11yError({ error: e })); } } diff --git a/code/core/src/core-server/withTelemetry.ts b/code/core/src/core-server/withTelemetry.ts index c0d6deb37815..d15cee2560d8 100644 --- a/code/core/src/core-server/withTelemetry.ts +++ b/code/core/src/core-server/withTelemetry.ts @@ -90,7 +90,8 @@ export async function sendTelemetryError( _error: unknown, eventType: EventType, options: TelemetryOptions, - blocking = true + blocking = true, + parent?: StorybookError ) { try { let errorLevel = 'error'; @@ -125,6 +126,8 @@ export async function sendTelemetryError( errorHash, // if we ever end up sending a non-error instance, we'd like to know isErrorInstance: error instanceof Error, + // Include parent error information if this is a sub-error + ...(parent ? { parent: parent.fullErrorCode } : {}), }, { immediate: true, @@ -132,6 +135,15 @@ export async function sendTelemetryError( enableCrashReports: errorLevel === 'full', } ); + + // If this is a StorybookError with sub-errors, send telemetry for each sub-error separately + if (error instanceof StorybookError && error.subErrors.length > 0) { + for (const subError of error.subErrors) { + if (subError instanceof StorybookError) { + await sendTelemetryError(subError, eventType, options, blocking, error); + } + } + } } } catch (err) { // if this throws an error, we just move on diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index a258eec27461..0e2edce795db 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -5,6 +5,8 @@ import type { Status } from './shared/status-store'; import type { StatusTypeId } from './shared/status-store'; import { StorybookError } from './storybook-error'; +export { StorybookError } from './storybook-error'; + /** * If you can't find a suitable category for your error, create one based on the package name/file * path of which the error is thrown. For instance: If it's from `@storybook/node-logger`, then @@ -468,14 +470,75 @@ export class AddonVitestPostinstallPrerequisiteCheckError extends StorybookError } } +export class AddonVitestPostinstallFailedAddonA11yError extends StorybookError { + constructor(public data: { error: unknown | Error }) { + super({ + name: 'AddonVitestPostinstallFailedAddonA11yError', + message: "The @storybook/addon-a11y couldn't be set up for the Vitest addon", + category: Category.CLI_INIT, + isHandledError: true, + code: 6, + }); + } +} + +export class AddonVitestPostinstallExistingSetupFileError extends StorybookError { + constructor(public data: { filePath: string }) { + super({ + name: 'AddonVitestPostinstallExistingSetupFileError', + category: Category.CLI_INIT, + isHandledError: true, + code: 7, + documentation: `https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup`, + message: dedent` + Found an existing Vitest setup file: ${data.filePath} + Please refer to the documentation to complete the setup manually. + `, + }); + } +} + +export class AddonVitestPostinstallWorkspaceUpdateError extends StorybookError { + constructor(public data: { filePath: string }) { + super({ + name: 'AddonVitestPostinstallWorkspaceUpdateError', + category: Category.CLI_INIT, + isHandledError: true, + code: 8, + documentation: `https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup`, + message: dedent` + Could not update existing Vitest workspace file: ${data.filePath} + Please refer to the documentation to complete the setup manually. + `, + }); + } +} + +export class AddonVitestPostinstallConfigUpdateError extends StorybookError { + constructor(public data: { filePath: string }) { + super({ + name: 'AddonVitestPostinstallConfigUpdateError', + category: Category.CLI_INIT, + isHandledError: true, + code: 9, + documentation: `https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup`, + message: dedent` + Unable to update existing Vitest config file: ${data.filePath} + Please refer to the documentation to complete the setup manually. + `, + }); + } +} + export class AddonVitestPostinstallError extends StorybookError { - constructor(public data: { errors: string[] }) { + constructor(public data: { errors: StorybookError[] }) { super({ name: 'AddonVitestPostinstallError', category: Category.CLI_INIT, isHandledError: true, code: 5, message: 'The Vitest addon setup failed.', + subErrors: data.errors, }); } } diff --git a/code/core/src/storybook-error.ts b/code/core/src/storybook-error.ts index ffb703bd2574..bcee6a34a09e 100644 --- a/code/core/src/storybook-error.ts +++ b/code/core/src/storybook-error.ts @@ -71,6 +71,26 @@ export abstract class StorybookError extends Error { this._name = name; } + /** + * A collection of sub errors which relate to a parent error. + * + * Sub-errors are used to represent multiple related errors that occurred together. When a + * StorybookError with sub-errors is sent to telemetry, both the parent error and each sub-error + * are sent as separate telemetry events. This allows for better error tracking and debugging. + * + * @example + * + * ```ts + * const error1 = new SomeError(); + * const error2 = new AnotherError(); + * const parentError = new ParentError({ + * // ... other props + * subErrors: [error1, error2], + * }); + * ``` + */ + subErrors: StorybookError[] = []; + constructor(props: { category: string; code: number; @@ -78,6 +98,11 @@ export abstract class StorybookError extends Error { documentation?: boolean | string | string[]; isHandledError?: boolean; name: string; + /** + * Optional array of sub-errors that are related to this error. When this error is sent to + * telemetry, each sub-error will be sent as a separate event. + */ + subErrors?: StorybookError[]; }) { super(StorybookError.getFullMessage(props)); this.category = props.category; @@ -85,6 +110,7 @@ export abstract class StorybookError extends Error { this.code = props.code; this.isHandledError = props.isHandledError ?? false; this.name = props.name; + this.subErrors = props.subErrors ?? []; } /** Generates the error message along with additional documentation link (if applicable). */ diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 61be6ce2028d..25780e25a4f5 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -117,7 +117,7 @@ export async function doInitiate(options: CommandOptions): Promise< !!options.dev && !options.skipInstall && shouldRunDev !== false && - ErrorCollector.getErrors().length === 0, + dependencyInstallationResult.status === 'success', shouldOnboard: newUser, projectType, packageManager,