diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9c10d335507..d3ed5e55b9f 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -3,6 +3,10 @@ _Released 11/18/2025 (PENDING)_ +**Features:** + +- Added discriminated union types for CLI error handling to improve type safety and eliminate `any` types. Addressed in [#32909](https://github.com/cypress-io/cypress/pull/32909). + **Misc:** - The keyboard shortcuts modal now displays the keyboard shortcut for saving Studio changes - `⌘` + `s` for Mac or `Ctrl` + `s` for Windows/Linux. Addressed [#32862](https://github.com/cypress-io/cypress/issues/32862). Addressed in [#32864](https://github.com/cypress-io/cypress/pull/32864). diff --git a/cli/lib/error-types.ts b/cli/lib/error-types.ts new file mode 100644 index 00000000000..e07230c8f8a --- /dev/null +++ b/cli/lib/error-types.ts @@ -0,0 +1,303 @@ +/** + * Discriminated union types for Cypress CLI errors + * + * This file provides type safety for error handling in the CLI, + * ensuring consistent error structure and eliminating the need + * for any types in error-related code. + */ + +// Base interface that all CLI errors must implement +interface BaseCypressCliError { + description: string + solution: string | ((msg?: string, prevMessage?: string) => string) + code?: string + exitCode?: number + footer?: string +} + +// Binary and Installation Errors +export interface BinaryNotFoundError extends BaseCypressCliError { + type: 'BINARY_NOT_FOUND' + code: 'E_BINARY_MISSING' + binaryDir: string +} + +export interface BinaryNotExecutableError extends BaseCypressCliError { + type: 'BINARY_NOT_EXECUTABLE' + code: 'E_BINARY_PERMISSIONS' + executable: string +} + +export interface NotInstalledCIError extends BaseCypressCliError { + type: 'NOT_INSTALLED_CI' + code: 'E_CI_MISSING_BINARY' + executable: string +} + +// Verification Errors +export interface VerifyFailedError extends BaseCypressCliError { + type: 'VERIFY_FAILED' + code: 'E_VERIFY_FAILED' + smokeTestCommand?: string + timedOut?: boolean +} + +export interface SmokeTestDisplayError extends BaseCypressCliError { + type: 'SMOKE_TEST_DISPLAY_ERROR' + code: 'INVALID_SMOKE_TEST_DISPLAY_ERROR' + message?: string +} + +export interface VersionMismatchError extends BaseCypressCliError { + type: 'VERSION_MISMATCH' + code: 'E_VERSION_MISMATCH' +} + +// Configuration Errors +export interface ConfigParseError extends BaseCypressCliError { + type: 'CONFIG_PARSE' + code: 'E_CONFIG_PARSE' + file?: string +} + +export interface InvalidConfigFileError extends BaseCypressCliError { + type: 'INVALID_CONFIG_FILE' + code: 'E_INVALID_CONFIG_FILE' +} + +export interface InvalidTestingTypeError extends BaseCypressCliError { + type: 'INVALID_TESTING_TYPE' + code: 'E_INVALID_TESTING_TYPE' +} + +// Runtime Errors +export interface ChildProcessKilledError extends BaseCypressCliError { + type: 'CHILD_PROCESS_KILLED' + code: 'E_CHILD_KILLED' + eventName: string + signal: string +} + +export interface InvalidCypressEnvError extends BaseCypressCliError { + type: 'INVALID_CYPRESS_ENV' + code: 'E_INVALID_ENV' + exitCode: 11 +} + +// System Dependency Errors +export interface MissingXvfbError extends BaseCypressCliError { + type: 'MISSING_XVFB' + code: 'E_MISSING_XVFB' +} + +export interface MissingDependencyError extends BaseCypressCliError { + type: 'MISSING_DEPENDENCY' + code: 'E_MISSING_DEPENDENCY' +} + +export interface NonZeroExitCodeXvfbError extends BaseCypressCliError { + type: 'XVFB_EXIT_ERROR' + code: 'E_XVFB_EXIT' +} + +// Download and Installation Errors +export interface FailedDownloadError extends BaseCypressCliError { + type: 'FAILED_DOWNLOAD' + code: 'E_DOWNLOAD_FAILED' +} + +export interface FailedUnzipError extends BaseCypressCliError { + type: 'FAILED_UNZIP' + code: 'E_UNZIP_FAILED' +} + +export interface FailedUnzipWindowsMaxPathError extends BaseCypressCliError { + type: 'FAILED_UNZIP_MAX_PATH' + code: 'E_WIN_MAX_PATH' +} + +export interface InvalidOSError extends BaseCypressCliError { + type: 'INVALID_OS' + code: 'E_INVALID_OS' +} + +export interface InvalidCacheDirectoryError extends BaseCypressCliError { + type: 'INVALID_CACHE_DIRECTORY' + code: 'E_CACHE_PERMISSIONS' +} + +// CLI Argument Errors +export interface IncompatibleHeadlessFlagsError extends BaseCypressCliError { + type: 'INCOMPATIBLE_HEADLESS_FLAGS' + code: 'E_INCOMPATIBLE_FLAGS' +} + +export interface IncompatibleTestTypeFlagsError extends BaseCypressCliError { + type: 'INCOMPATIBLE_TEST_TYPE_FLAGS' + code: 'E_INCOMPATIBLE_TEST_FLAGS' +} + +export interface IncompatibleTestingTypeAndFlagError extends BaseCypressCliError { + type: 'INCOMPATIBLE_TESTING_TYPE_AND_FLAG' + code: 'E_INCOMPATIBLE_TYPE_FLAG' +} + +export interface InvalidRunProjectPathError extends BaseCypressCliError { + type: 'INVALID_RUN_PROJECT_PATH' + code: 'E_INVALID_PROJECT_PATH' +} + +export interface CypressRunBinaryError extends BaseCypressCliError { + type: 'CYPRESS_RUN_BINARY_ERROR' + code: 'E_RUN_BINARY_INVALID' + value: string +} + +// Generic/Unknown Errors +export interface UnknownError extends BaseCypressCliError { + type: 'UNKNOWN' + code: 'E_UNKNOWN' +} + +export interface UnexpectedError extends BaseCypressCliError { + type: 'UNEXPECTED' + code: 'E_UNEXPECTED' +} + +// Union type of all possible CLI errors +export type CypressCliError = + | BinaryNotFoundError + | BinaryNotExecutableError + | NotInstalledCIError + | VerifyFailedError + | SmokeTestDisplayError + | VersionMismatchError + | ConfigParseError + | InvalidConfigFileError + | InvalidTestingTypeError + | ChildProcessKilledError + | InvalidCypressEnvError + | MissingXvfbError + | MissingDependencyError + | NonZeroExitCodeXvfbError + | FailedDownloadError + | FailedUnzipError + | FailedUnzipWindowsMaxPathError + | InvalidOSError + | InvalidCacheDirectoryError + | IncompatibleHeadlessFlagsError + | IncompatibleTestTypeFlagsError + | IncompatibleTestingTypeAndFlagError + | InvalidRunProjectPathError + | CypressRunBinaryError + | UnknownError + | UnexpectedError + +// Type guards for error discrimination +export function isBinaryError(error: CypressCliError): error is BinaryNotFoundError | BinaryNotExecutableError | NotInstalledCIError { + return error.type === 'BINARY_NOT_FOUND' || error.type === 'BINARY_NOT_EXECUTABLE' || error.type === 'NOT_INSTALLED_CI' +} + +export function isVerificationError(error: CypressCliError): error is VerifyFailedError | SmokeTestDisplayError | VersionMismatchError { + return error.type === 'VERIFY_FAILED' || error.type === 'SMOKE_TEST_DISPLAY_ERROR' || error.type === 'VERSION_MISMATCH' +} + +export function isConfigurationError(error: CypressCliError): error is ConfigParseError | InvalidConfigFileError | InvalidTestingTypeError { + return error.type === 'CONFIG_PARSE' || error.type === 'INVALID_CONFIG_FILE' || error.type === 'INVALID_TESTING_TYPE' +} + +export function isSystemError(error: CypressCliError): error is MissingXvfbError | MissingDependencyError | NonZeroExitCodeXvfbError { + return error.type === 'MISSING_XVFB' || error.type === 'MISSING_DEPENDENCY' || error.type === 'XVFB_EXIT_ERROR' +} + +// Helper function to create typed errors +export function createTypedError(errorData: T): T { + return errorData +} + +// Factory functions for common error patterns +export const ErrorFactories = { + binaryNotFound: (binaryDir: string): BinaryNotFoundError => + createTypedError({ + type: 'BINARY_NOT_FOUND', + code: 'E_BINARY_MISSING', + binaryDir, + description: `No version of Cypress is installed in: ${binaryDir}`, + solution: 'Please reinstall Cypress by running: cypress install', + }), + + childProcessKilled: (eventName: string, signal: string): ChildProcessKilledError => + createTypedError({ + type: 'CHILD_PROCESS_KILLED', + code: 'E_CHILD_KILLED', + eventName, + signal, + description: `The Test Runner unexpectedly exited via a ${eventName} event with signal ${signal}`, + solution: 'Please search Cypress documentation for possible solutions or open a GitHub issue.', + }), + + smokeTestFailure: (smokeTestCommand: string, timedOut: boolean): VerifyFailedError => + createTypedError({ + type: 'VERIFY_FAILED', + code: 'E_VERIFY_FAILED', + smokeTestCommand, + timedOut, + description: `Cypress verification ${timedOut ? 'timed out' : 'failed'}.`, + solution: `This command failed with the following output:\n\n${smokeTestCommand}`, + }), +} + +/** + * Helper function to normalize unknown errors into typed CypressCliError + * Useful for catch blocks and error handling where the error type is unknown + */ +export function asCliError(error: unknown, fallbackMessage?: string): CypressCliError { + // If it's already a CLI error, return as-is + if (error && typeof error === 'object' && 'type' in error && 'code' in error) { + return error as CypressCliError + } + + // If it's an Error object, use its message + if (error instanceof Error) { + return createTypedError({ + type: 'UNEXPECTED', + code: 'E_UNEXPECTED', + description: error.message || fallbackMessage || 'An unexpected error occurred', + solution: 'Please search Cypress documentation for possible solutions or open a GitHub issue.', + }) + } + + // For any other type, stringify it + return createTypedError({ + type: 'UNKNOWN', + code: 'E_UNKNOWN', + description: fallbackMessage || (typeof error === 'string' ? error : 'An unknown error occurred'), + solution: 'Please search Cypress documentation for possible solutions or open a GitHub issue.', + }) +} + +/** + * Backward compatibility: Convert plain error objects to typed CLI errors + * Useful during the transition period from any types to discriminated unions + */ +export function normalizeError(error: any): CypressCliError { + // If it's already a proper CLI error with type and code, return as-is + if (error && typeof error === 'object' && error.type && error.code) { + return error as CypressCliError + } + + // If it's a plain object with description/solution, convert to UnexpectedError + if (error && typeof error === 'object' && error.description && error.solution) { + return createTypedError({ + type: 'UNEXPECTED', + code: 'E_UNEXPECTED', + description: error.description, + solution: error.solution, + exitCode: error.exitCode, + footer: error.footer, + }) + } + + // Fallback to asCliError for anything else + return asCliError(error) +} \ No newline at end of file diff --git a/cli/lib/errors.ts b/cli/lib/errors.ts index 61af1845fe5..b7272ca7e05 100644 --- a/cli/lib/errors.ts +++ b/cli/lib/errors.ts @@ -4,14 +4,24 @@ import _ from 'lodash' import assert from 'assert' import util from './util' import state from './tasks/state' +import type { + CypressCliError, + BinaryNotFoundError, + BinaryNotExecutableError, + NotInstalledCIError, + VerifyFailedError, + SmokeTestDisplayError, + VersionMismatchError, + ChildProcessKilledError, + InvalidCypressEnvError, + CypressRunBinaryError, + ErrorFactories, +} from './error-types' const docsUrl = 'https://on.cypress.io' const requiredDependenciesUrl = `${docsUrl}/required-dependencies` const runDocumentationUrl = `${docsUrl}/cypress-run` -// TODO it would be nice if all error objects could be enforced via types -// to only have description + solution properties - export const hr = '----------' const genericErrorSolution = stripIndent` @@ -64,8 +74,11 @@ const failedUnzipWindowsMaxPathLength = { Read here for solutions to this problem: https://on.cypress.io/win-max-path-length-error`, } -const missingApp = (binaryDir: string): any => { +const missingApp = (binaryDir: string): BinaryNotFoundError => { return { + type: 'BINARY_NOT_FOUND', + code: 'E_BINARY_MISSING', + binaryDir, description: `No version of Cypress is installed in: ${chalk.cyan( binaryDir, )}`, @@ -75,8 +88,11 @@ const missingApp = (binaryDir: string): any => { } } -const binaryNotExecutable = (executable: string): any => { +const binaryNotExecutable = (executable: string): BinaryNotExecutableError => { return { + type: 'BINARY_NOT_EXECUTABLE', + code: 'E_BINARY_PERMISSIONS', + executable, description: `Cypress cannot run because this binary file does not have executable permissions here:\n\n${executable}`, solution: stripIndent`\n Reasons this may happen: @@ -91,8 +107,11 @@ const binaryNotExecutable = (executable: string): any => { } } -const notInstalledCI = (executable: string): any => { +const notInstalledCI = (executable: string): NotInstalledCIError => { return { + type: 'NOT_INSTALLED_CI', + code: 'E_CI_MISSING_BINARY', + executable, description: 'The cypress npm package is installed, but the Cypress binary is missing.', solution: stripIndent`\n @@ -134,8 +153,12 @@ const missingXvfb = { `, } -const smokeTestFailure = (smokeTestCommand: string, timedOut: boolean): any => { +const smokeTestFailure = (smokeTestCommand: string, timedOut: boolean): VerifyFailedError => { return { + type: 'VERIFY_FAILED', + code: 'E_VERIFY_FAILED', + smokeTestCommand, + timedOut, description: `Cypress verification ${timedOut ? 'timed out' : 'failed'}.`, solution: stripIndent` This command failed with the following output: @@ -146,10 +169,11 @@ const smokeTestFailure = (smokeTestCommand: string, timedOut: boolean): any => { } } -const invalidSmokeTestDisplayError = { +const invalidSmokeTestDisplayError: SmokeTestDisplayError = { + type: 'SMOKE_TEST_DISPLAY_ERROR', code: 'INVALID_SMOKE_TEST_DISPLAY_ERROR', description: 'Cypress verification failed.', - solution (msg: string): string { + solution (msg?: string): string { return stripIndent` Cypress failed to start after spawning a new Xvfb server. @@ -157,7 +181,7 @@ const invalidSmokeTestDisplayError = { ${hr} - ${msg} + ${msg || 'No additional error details'} ${hr} @@ -214,7 +238,9 @@ const unexpected = { solution: solutionUnknown, } -const invalidCypressEnv = { +const invalidCypressEnv: InvalidCypressEnvError = { + type: 'INVALID_CYPRESS_ENV', + code: 'E_INVALID_ENV', description: chalk.red('The environment variable with the reserved name "CYPRESS_INTERNAL_ENV" is set.'), solution: chalk.red('Unset the "CYPRESS_INTERNAL_ENV" environment variable and run Cypress again.'), @@ -248,25 +274,32 @@ const invalidConfigFile = { * @param {'close'|'event'} eventName Child close event name * @param {string} signal Signal that closed the child process, like "SIGBUS" */ -const childProcessKilled = (eventName: string, signal: string): any => { +const childProcessKilled = (eventName: string, signal: string): ChildProcessKilledError => { return { + type: 'CHILD_PROCESS_KILLED', + code: 'E_CHILD_KILLED', + eventName, + signal, description: `The Test Runner unexpectedly exited via a ${chalk.cyan(eventName)} event with signal ${chalk.cyan(signal)}`, solution: solutionUnknown, } } const CYPRESS_RUN_BINARY = { - notValid: (value: string): any => { + notValid: (value: string): CypressRunBinaryError => { const properFormat = `**/${state.getPlatformExecutable()}` return { + type: 'CYPRESS_RUN_BINARY_ERROR', + code: 'E_RUN_BINARY_INVALID', + value, description: `Could not run binary set by environment variable: CYPRESS_RUN_BINARY=${value}`, solution: `Ensure the environment variable is a path to the Cypress binary, matching ${properFormat}`, } }, } -async function addPlatformInformation (info: any): Promise { +async function addPlatformInformation (info: CypressCliError): Promise { const platform = await util.getPlatformInfo() return { ...info, platform } @@ -275,7 +308,7 @@ async function addPlatformInformation (info: any): Promise { /** * Given an error object (see the errors above), forms error message text with details, * then resolves with Error instance you can throw or reject with. - * @param {object} errorObject + * @param {CypressCliError} errorObject * @returns {Promise} resolves with an Error * @example ```js @@ -284,7 +317,7 @@ async function addPlatformInformation (info: any): Promise { return getError(errorObject).then(reject) ``` */ -export async function getError (errorObject: any): Promise { +export async function getError (errorObject: CypressCliError): Promise { const errorMessage = await formErrorText(errorObject) const err: any = new Error(errorMessage) @@ -298,7 +331,7 @@ export async function getError (errorObject: any): Promise { * Forms nice error message with error and platform information, * and if possible a way to solve it. Resolves with a string. */ -export async function formErrorText (info: any, msg?: string, prevMessage?: string): Promise { +export async function formErrorText (info: CypressCliError, msg?: string, prevMessage?: string): Promise { const infoWithPlatform = await addPlatformInformation(info) const formatted: string[] = [] @@ -311,7 +344,7 @@ export async function formErrorText (info: any, msg?: string, prevMessage?: stri // assuming that if there the solution is a function it will handle // error message and (optional previous error message) - if (_.isFunction(infoWithPlatform.solution)) { + if (typeof infoWithPlatform.solution === 'function') { const text = infoWithPlatform.solution(msg, prevMessage) assert.ok(_.isString(text) && !_.isEmpty(text), 'expected solution to be text.') @@ -360,7 +393,7 @@ export async function formErrorText (info: any, msg?: string, prevMessage?: stri return formatted.join('\n\n') } -export const raise = (info: any) => { +export const raise = (info: CypressCliError) => { return (text: string) => { const err: any = new Error(text) @@ -373,7 +406,7 @@ export const raise = (info: any) => { } } -export const throwFormErrorText = (info: any) => { +export const throwFormErrorText = (info: CypressCliError) => { return async (msg?: string, prevMessage?: string) => { const errorText = await formErrorText(info, msg, prevMessage) @@ -384,10 +417,10 @@ export const throwFormErrorText = (info: any) => { /** * Forms full error message with error and OS details, prints to the error output * and then exits the process. - * @param {ErrorInformation} info Error information {description, solution} + * @param {CypressCliError} info Error information {description, solution} * @example return exitWithError(errors.invalidCypressEnv)('foo') */ -export const exitWithError = (info: any) => { +export const exitWithError = (info: CypressCliError) => { return async (msg?: string) => { const text: string = await formErrorText(info, msg) @@ -397,6 +430,36 @@ export const exitWithError = (info: any) => { } } +/** + * Utility function to normalize unknown errors into typed CLI errors + * @param e - Unknown error object or value + * @returns Typed CypressCliError with standardized structure + */ +export function asCliError(e: unknown): CypressCliError { + // If it's already a typed error, return as-is + if (typeof e === 'object' && e !== null && 'type' in e) { + return e as CypressCliError + } + + // If it's an Error object, wrap it + if (e instanceof Error) { + return { + type: 'UNEXPECTED', + code: 'E_UNEXPECTED', + description: e.message || 'An unexpected error occurred while verifying the Cypress executable.', + solution: solutionUnknown, + } + } + + // Fallback for unknown types + return { + type: 'UNKNOWN', + code: 'E_UNKNOWN', + description: 'Unknown Cypress CLI error', + solution: genericErrorSolution, + } +} + export const errors = { unknownError, nonZeroExitCodeXvfb, diff --git a/cli/types/error-types.ts b/cli/types/error-types.ts new file mode 100644 index 00000000000..4eb155ded9e --- /dev/null +++ b/cli/types/error-types.ts @@ -0,0 +1,248 @@ +/** + * Discriminated union types for Cypress CLI errors + * + * This file provides type safety for error handling in the CLI, + * ensuring consistent error structure and eliminating the need + * for any types in error-related code. + */ + +// Base interface that all CLI errors must implement +interface BaseCypressCliError { + description: string + solution: string | ((msg?: string, prevMessage?: string) => string) + code?: string + exitCode?: number + footer?: string +} + +// Binary and Installation Errors +export interface BinaryNotFoundError extends BaseCypressCliError { + type: 'BINARY_NOT_FOUND' + code: 'E_BINARY_MISSING' + binaryDir: string +} + +export interface BinaryNotExecutableError extends BaseCypressCliError { + type: 'BINARY_NOT_EXECUTABLE' + code: 'E_BINARY_PERMISSIONS' + executable: string +} + +export interface NotInstalledCIError extends BaseCypressCliError { + type: 'NOT_INSTALLED_CI' + code: 'E_CI_MISSING_BINARY' + executable: string +} + +// Verification Errors +export interface VerifyFailedError extends BaseCypressCliError { + type: 'VERIFY_FAILED' + code: 'E_VERIFY_FAILED' + smokeTestCommand?: string + timedOut?: boolean +} + +export interface SmokeTestDisplayError extends BaseCypressCliError { + type: 'SMOKE_TEST_DISPLAY_ERROR' + code: 'INVALID_SMOKE_TEST_DISPLAY_ERROR' + message?: string +} + +export interface VersionMismatchError extends BaseCypressCliError { + type: 'VERSION_MISMATCH' + code: 'E_VERSION_MISMATCH' +} + +// Configuration Errors +export interface ConfigParseError extends BaseCypressCliError { + type: 'CONFIG_PARSE' + code: 'E_CONFIG_PARSE' + file?: string +} + +export interface InvalidConfigFileError extends BaseCypressCliError { + type: 'INVALID_CONFIG_FILE' + code: 'E_INVALID_CONFIG_FILE' +} + +export interface InvalidTestingTypeError extends BaseCypressCliError { + type: 'INVALID_TESTING_TYPE' + code: 'E_INVALID_TESTING_TYPE' +} + +// Runtime Errors +export interface ChildProcessKilledError extends BaseCypressCliError { + type: 'CHILD_PROCESS_KILLED' + code: 'E_CHILD_KILLED' + eventName: string + signal: string +} + +export interface InvalidCypressEnvError extends BaseCypressCliError { + type: 'INVALID_CYPRESS_ENV' + code: 'E_INVALID_ENV' + exitCode: 11 +} + +// System Dependency Errors +export interface MissingXvfbError extends BaseCypressCliError { + type: 'MISSING_XVFB' + code: 'E_MISSING_XVFB' +} + +export interface MissingDependencyError extends BaseCypressCliError { + type: 'MISSING_DEPENDENCY' + code: 'E_MISSING_DEPENDENCY' +} + +export interface NonZeroExitCodeXvfbError extends BaseCypressCliError { + type: 'XVFB_EXIT_ERROR' + code: 'E_XVFB_EXIT' +} + +// Download and Installation Errors +export interface FailedDownloadError extends BaseCypressCliError { + type: 'FAILED_DOWNLOAD' + code: 'E_DOWNLOAD_FAILED' +} + +export interface FailedUnzipError extends BaseCypressCliError { + type: 'FAILED_UNZIP' + code: 'E_UNZIP_FAILED' +} + +export interface FailedUnzipWindowsMaxPathError extends BaseCypressCliError { + type: 'FAILED_UNZIP_MAX_PATH' + code: 'E_WIN_MAX_PATH' +} + +export interface InvalidOSError extends BaseCypressCliError { + type: 'INVALID_OS' + code: 'E_INVALID_OS' +} + +export interface InvalidCacheDirectoryError extends BaseCypressCliError { + type: 'INVALID_CACHE_DIRECTORY' + code: 'E_CACHE_PERMISSIONS' +} + +// CLI Argument Errors +export interface IncompatibleHeadlessFlagsError extends BaseCypressCliError { + type: 'INCOMPATIBLE_HEADLESS_FLAGS' + code: 'E_INCOMPATIBLE_FLAGS' +} + +export interface IncompatibleTestTypeFlagsError extends BaseCypressCliError { + type: 'INCOMPATIBLE_TEST_TYPE_FLAGS' + code: 'E_INCOMPATIBLE_TEST_FLAGS' +} + +export interface IncompatibleTestingTypeAndFlagError extends BaseCypressCliError { + type: 'INCOMPATIBLE_TESTING_TYPE_AND_FLAG' + code: 'E_INCOMPATIBLE_TYPE_FLAG' +} + +export interface InvalidRunProjectPathError extends BaseCypressCliError { + type: 'INVALID_RUN_PROJECT_PATH' + code: 'E_INVALID_PROJECT_PATH' +} + +export interface CypressRunBinaryError extends BaseCypressCliError { + type: 'CYPRESS_RUN_BINARY_ERROR' + code: 'E_RUN_BINARY_INVALID' + value: string +} + +// Generic/Unknown Errors +export interface UnknownError extends BaseCypressCliError { + type: 'UNKNOWN' + code: 'E_UNKNOWN' +} + +export interface UnexpectedError extends BaseCypressCliError { + type: 'UNEXPECTED' + code: 'E_UNEXPECTED' +} + +// Union type of all possible CLI errors +export type CypressCliError = + | BinaryNotFoundError + | BinaryNotExecutableError + | NotInstalledCIError + | VerifyFailedError + | SmokeTestDisplayError + | VersionMismatchError + | ConfigParseError + | InvalidConfigFileError + | InvalidTestingTypeError + | ChildProcessKilledError + | InvalidCypressEnvError + | MissingXvfbError + | MissingDependencyError + | NonZeroExitCodeXvfbError + | FailedDownloadError + | FailedUnzipError + | FailedUnzipWindowsMaxPathError + | InvalidOSError + | InvalidCacheDirectoryError + | IncompatibleHeadlessFlagsError + | IncompatibleTestTypeFlagsError + | IncompatibleTestingTypeAndFlagError + | InvalidRunProjectPathError + | CypressRunBinaryError + | UnknownError + | UnexpectedError + +// Type guards for error discrimination +export function isBinaryError(error: CypressCliError): error is BinaryNotFoundError | BinaryNotExecutableError | NotInstalledCIError { + return error.type === 'BINARY_NOT_FOUND' || error.type === 'BINARY_NOT_EXECUTABLE' || error.type === 'NOT_INSTALLED_CI' +} + +export function isVerificationError(error: CypressCliError): error is VerifyFailedError | SmokeTestDisplayError | VersionMismatchError { + return error.type === 'VERIFY_FAILED' || error.type === 'SMOKE_TEST_DISPLAY_ERROR' || error.type === 'VERSION_MISMATCH' +} + +export function isConfigurationError(error: CypressCliError): error is ConfigParseError | InvalidConfigFileError | InvalidTestingTypeError { + return error.type === 'CONFIG_PARSE' || error.type === 'INVALID_CONFIG_FILE' || error.type === 'INVALID_TESTING_TYPE' +} + +export function isSystemError(error: CypressCliError): error is MissingXvfbError | MissingDependencyError | NonZeroExitCodeXvfbError { + return error.type === 'MISSING_XVFB' || error.type === 'MISSING_DEPENDENCY' || error.type === 'XVFB_EXIT_ERROR' +} + +// Helper function to create typed errors +export function createTypedError(errorData: T): T { + return errorData +} + +// Factory functions for common error patterns +export const ErrorFactories = { + binaryNotFound: (binaryDir: string): BinaryNotFoundError => + createTypedError({ + type: 'BINARY_NOT_FOUND', + code: 'E_BINARY_MISSING', + binaryDir, + description: `No version of Cypress is installed in: ${binaryDir}`, + solution: 'Please reinstall Cypress by running: cypress install', + }), + + childProcessKilled: (eventName: string, signal: string): ChildProcessKilledError => + createTypedError({ + type: 'CHILD_PROCESS_KILLED', + code: 'E_CHILD_KILLED', + eventName, + signal, + description: `The Test Runner unexpectedly exited via a ${eventName} event with signal ${signal}`, + solution: 'Please search Cypress documentation for possible solutions or open a GitHub issue.', + }), + + smokeTestFailure: (smokeTestCommand: string, timedOut: boolean): VerifyFailedError => + createTypedError({ + type: 'VERIFY_FAILED', + code: 'E_VERIFY_FAILED', + smokeTestCommand, + timedOut, + description: `Cypress verification ${timedOut ? 'timed out' : 'failed'}.`, + solution: `This command failed with the following output:\n\n${smokeTestCommand}`, + }), +} \ No newline at end of file