Skip to content
3 changes: 3 additions & 0 deletions code/core/src/common/js-package-manager/JsPackageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,9 @@ export abstract class JsPackageManager {
} catch (e: any) {
logger.error('\nAn error occurred while adding dependencies to your package.json:');
logger.log(String(e));
if (e?.fromStorybook) {
Comment thread
ndelangen marked this conversation as resolved.
throw e;
}
throw new HandledError(e);
}
}
Expand Down
180 changes: 180 additions & 0 deletions code/core/src/common/utils/categorize-execa-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { StorybookError } from '../../storybook-error.ts';
import {
AutomigrateAddonA11yTestError,
ExecaCommandFailedError,
NuxtModuleAddFailedError,
PackageInstallDependencyConflictError,
PackageInstallFailedError,
PackageInstallMissingManifestError,
PackageManagerBinaryNotFoundError,
PlaywrightInstallFailedError,
PnpmIgnoredBuildsError,
PnpmNoTtyModulesDirError,
type ExecaCommandErrorData,
} from '../../server-errors.ts';
import { getErrorLogs } from '../js-package-manager/util.ts';

export type ExecaErrorContext = {
command: string;
args?: string[];
};
Comment thread
ndelangen marked this conversation as resolved.

type ExecaErrorLike = {
exitCode?: number;
code?: number | string;
signal?: string;
shortMessage?: string;
command?: string;
};

const PACKAGE_MANAGER_COMMANDS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
const PACKAGE_INSTALL_ARGS = new Set(['install', 'add', 'ci', 'i']);

export function extractExecaCommandErrorData(
error: unknown,
context: ExecaErrorContext
): ExecaCommandErrorData {
const structured = error as ExecaErrorLike;
const args = context.args ?? [];
let logs = getErrorLogs(error);

if (logs === '[object Object]') {
logs = '';
}

return {
command: context.command,
args,
exitCode: structured.exitCode ?? structured.code,
signal: structured.signal,
logs,
packageManagerErrorCode: extractPackageManagerErrorCode(logs),
};
}

export function extractPackageManagerErrorCode(logs: string): string | undefined {
const npmMatch = logs.match(/npm\s+(?:ERR!|error)\s+code\s+([A-Z0-9_]+)/i);
if (npmMatch) {
return npmMatch[1];
}

const pnpmMatch = logs.match(/(ERR_PNPM_[A-Z0-9_]+)/);
if (pnpmMatch) {
return pnpmMatch[1];
}

return undefined;
}

function isAutomigrateAddonA11yTest(args: string[]) {
const joined = args.join(' ');
return joined.includes('automigrate') && joined.includes('addon-a11y-addon-test');
}

function isNuxtModuleAdd(args: string[]) {
const joined = args.join(' ');
return joined.includes('nuxi') && joined.includes('module add') && joined.includes('storybook');
}

function isPlaywrightInstall(args: string[]) {
const joined = args.join(' ');
return joined.includes('playwright') && joined.includes('install');
}

function isPackageManagerCommand(command: string) {
const normalizedCommand = command.replace(/\.(cmd|exe|ps1)$/i, '');

return PACKAGE_MANAGER_COMMANDS.has(normalizedCommand);
}

function isPackageInstall(command: string, args: string[]) {
if (!isPackageManagerCommand(command)) {
return false;
}

return args.some((arg) => PACKAGE_INSTALL_ARGS.has(arg));
}

function isBinaryNotFound(data: ExecaCommandErrorData) {
const logs = data.logs.toLowerCase();

return (
data.exitCode === 127 ||
(logs.includes('enoent') && logs.includes('spawn')) ||
logs.includes('command not found') ||
logs.includes("couldn't find the binary") ||
logs.includes('is not recognized as an internal or external command') ||
logs.includes('err_pnpm_recursive_exec_first_fail')
);
}

function isMissingPackageManifest(data: ExecaCommandErrorData) {
const logs = data.logs;

return (
(data.packageManagerErrorCode === 'ENOENT' && logs.includes('package.json')) ||
logs.includes('ERR_PNPM_NO_PKG_MANIFEST')
);
}

function isDependencyConflict(data: ExecaCommandErrorData) {
return data.packageManagerErrorCode === 'ERESOLVE';
}

function isPnpmIgnoredBuilds(data: ExecaCommandErrorData) {
return (
data.packageManagerErrorCode === 'ERR_PNPM_IGNORED_BUILDS' ||
data.logs.includes('Ignored build scripts')
);
}

function isPnpmNoTtyModulesDir(data: ExecaCommandErrorData) {
return data.packageManagerErrorCode === 'ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY';
}

export function categorizeExecaError(error: unknown, context: ExecaErrorContext): StorybookError {
if (error instanceof StorybookError) {
return error;
}

const data = extractExecaCommandErrorData(error, context);
const { command, args } = data;

if (isAutomigrateAddonA11yTest(args)) {
return new AutomigrateAddonA11yTestError({ ...data, cause: error });
}

if (isNuxtModuleAdd(args)) {
return new NuxtModuleAddFailedError({ ...data, cause: error });
}

if (isPlaywrightInstall(args)) {
return new PlaywrightInstallFailedError({ ...data, cause: error });
}

if (isBinaryNotFound(data) && isPackageManagerCommand(command)) {
return new PackageManagerBinaryNotFoundError({ ...data, cause: error });
}

if (isPackageInstall(command, args)) {
if (isDependencyConflict(data)) {
return new PackageInstallDependencyConflictError({ ...data, cause: error });
}

if (isMissingPackageManifest(data)) {
return new PackageInstallMissingManifestError({ ...data, cause: error });
}

if (isPnpmIgnoredBuilds(data)) {
return new PnpmIgnoredBuildsError({ ...data, cause: error });
}

if (isPnpmNoTtyModulesDir(data)) {
return new PnpmNoTtyModulesDirError({ ...data, cause: error });
}

return new PackageInstallFailedError({ ...data, cause: error });
}

return new ExecaCommandFailedError({ ...data, cause: error });
}
38 changes: 29 additions & 9 deletions code/core/src/common/utils/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { execa, execaCommandSync } from 'execa';

import { executeCommand, executeCommandSync } from './command.ts';
import { ExecaCommandFailedError } from '../../server-errors.ts';

vi.mock('storybook/internal/node-logger', () => ({
logger: {
Expand Down Expand Up @@ -158,31 +159,47 @@ describe('command', () => {
expect(mockedExeca).toHaveBeenCalledWith('pnpm.cmd', ['--version'], expect.anything());
});

it('should propagate errors from the resolved command', async () => {
it('should propagate categorized errors from the resolved command', async () => {
mockedExistsSync.mockReturnValue(false);
mockedExeca.mockRejectedValueOnce({
stderr: 'Some other error',
message: 'Command failed with different error',
exitCode: 1,
});

await expect(
executeCommand({
command: 'pnpm',
args: ['--version'],
})
).rejects.toEqual({
stderr: 'Some other error',
message: 'Command failed with different error',
});
).rejects.toBeInstanceOf(ExecaCommandFailedError);

expect(mockedExeca).toHaveBeenCalledTimes(1);
});

it('should propagate errors when resolved command is not found', async () => {
it('should preserve subprocess methods like on after attaching categorized errors', async () => {
const on = vi.fn();
mockedExeca.mockReturnValueOnce(
Object.assign(Promise.resolve({ stdout: 'success', stderr: '' }), { on }) as any
);

const process = executeCommand({
command: 'pnpm',
args: ['--version'],
});

process.on('exit', on);
await process;

expect(on).toHaveBeenCalledWith('exit', expect.any(Function));
});

it('should propagate categorized errors when resolved command is not found', async () => {
mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.cmd'));
const error = {
stderr: "'pnpm.cmd' is not recognized as an internal or external command",
message: 'Command failed',
exitCode: 1,
};
mockedExeca.mockRejectedValueOnce(error);

Expand All @@ -191,7 +208,9 @@ describe('command', () => {
command: 'pnpm',
args: ['--version'],
})
).rejects.toEqual(error);
).rejects.toMatchObject({
name: expect.stringContaining('PackageManagerBinaryNotFoundError'),
});

expect(mockedExeca).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -365,11 +384,12 @@ describe('command', () => {
);
});

it('should throw error immediately if first call fails with non-"not recognized" error', () => {
it('should throw categorized error immediately if first call fails with non-"not recognized" error', () => {
mockedExecaCommandSync.mockImplementationOnce(() => {
throw {
stderr: 'Some other error',
message: 'Command failed with different error',
exitCode: 1,
};
});

Expand All @@ -378,7 +398,7 @@ describe('command', () => {
command: 'pnpm',
args: ['--version'],
})
).toThrow();
).toThrow(ExecaCommandFailedError);

expect(mockedExecaCommandSync).toHaveBeenCalledTimes(1);
});
Expand Down
30 changes: 29 additions & 1 deletion code/core/src/common/utils/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
execaNode,
} from 'execa';

import { categorizeExecaError } from './categorize-execa-error.ts';

const COMMON_ENV_VARS = {
COREPACK_ENABLE_STRICT: '0',
COREPACK_ENABLE_AUTO_PIN: '0',
Expand All @@ -27,6 +29,30 @@ export type ExecuteCommandOptions = Omit<Options, 'cancelSignal'> & {
signal?: AbortSignal; // Alias for cancelSignal (execa v9 uses cancelSignal)
};

function attachCategorizedExecaFailureHandler(
execaProcess: ResultPromise,
context: { command: string; args: string[] }
) {
const categorize = (error: unknown) => categorizeExecaError(error, context);
const originalThen = execaProcess.then.bind(execaProcess);
const originalCatch = execaProcess.catch.bind(execaProcess);

execaProcess.then = ((onFulfilled, onRejected) =>
originalThen(
onFulfilled,
typeof onRejected === 'function'
? (error) => onRejected(categorize(error))
: (error) => {
throw categorize(error);
}
)) as ResultPromise['then'];
Comment thread
ndelangen marked this conversation as resolved.

execaProcess.catch = ((onRejected) =>
originalCatch((error) => {
throw categorize(error);
}).catch(typeof onRejected === 'function' ? onRejected : undefined)) as ResultPromise['catch'];
}

function getExecaOptions({
stdio,
cwd,
Expand Down Expand Up @@ -59,6 +85,8 @@ export function executeCommand(options: ExecuteCommandOptions): ResultPromise {
execaProcess.catch(() => {
// Silently ignore errors when ignoreError is true
});
} else {
attachCategorizedExecaFailureHandler(execaProcess, { command, args });
}

return execaProcess;
Expand All @@ -75,7 +103,7 @@ export function executeCommandSync(options: ExecuteCommandOptions): string {
);
} catch (err) {
if (!ignoreError) {
throw err;
throw categorizeExecaError(err, { command, args });
}
return '';
}
Expand Down
Loading