diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index e6b8f95c0c4c..3fe46ea1ec08 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -439,6 +439,38 @@ describe('PackageManager', () => { expect(await pm.getRegistryMetadata('non-existent')).toBeNull(); }); + + it('should throw a PackageManagerError with the parsed summary on non-404 modern yarn errors', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']); + runCommandSpy.and.rejectWith( + new PackageManagerError( + 'Process exited with code 1.', + [ + JSON.stringify({ + type: 'error', + name: 35, + displayName: 'YN0035', + indent: '', + data: 'The remote server failed to provide the requested resource', + }), + JSON.stringify({ + type: 'error', + name: 35, + displayName: 'YN0035', + indent: '', + data: ' Response Code: 403 (Forbidden)', + }), + ].join('\n'), + '', + 1, + ), + ); + + await expectAsync(pm.getRegistryMetadata('@private/pkg')).toBeRejectedWithError( + PackageManagerError, + 'The remote server failed to provide the requested resource', + ); + }); }); describe('getCurrentPackageName', () => { diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index b6a9a3f5cbb7..68be3c71ceb4 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -12,6 +12,7 @@ * into their own file improves modularity and allows for focused testing. */ +import { stripVTControlCharacters } from 'node:util'; import { compare, valid } from 'semver'; import { ErrorInfo } from './error'; import { Logger } from './logger'; @@ -477,10 +478,11 @@ export function parseYarnClassicMetadata(stdout: string, logger?: Logger): Packa } /** - * Parses the `stdout` or `stderr` output of npm, pnpm, modern yarn, or bun to extract structured error information. + * Parses the `stdout` or `stderr` output of npm or pnpm to extract structured error information. + * It is also used by `parseBunError` as a fallback for npm-compatible JSON error objects. * * This parser uses a multi-stage approach. It first attempts to parse the entire `output` as a - * single JSON object, which is the standard for modern tools like pnpm, yarn, and bun. If JSON + * single JSON object, which is the standard for modern tools like pnpm and npm. If JSON * parsing fails, it falls back to a line-by-line regex-based approach to handle the plain * text output from older versions of npm. * @@ -513,7 +515,7 @@ export function parseNpmLikeError(output: string, logger?: Logger): ErrorInfo | return null; } - // Attempt to parse as JSON first (common for pnpm, modern yarn, npm 12) + // Attempt to parse as JSON first (common for pnpm, npm 12, and the Bun JSON fallback) try { let jsonError = JSON.parse(output); if (Array.isArray(jsonError)) { @@ -639,9 +641,9 @@ export function parseYarnClassicError(output: string, logger?: Logger): ErrorInf /** * Parses the `stdout` or `stderr` output of modern yarn (v2+) to extract structured error information. * - * Modern yarn reports errors as JSON lines, one per message line. The first line holds the summary, - * and registry errors include the HTTP status, which is returned as a standardized error code - * (`E${statusCode}`). Other output is delegated to `parseNpmLikeError`. + * Modern yarn reports errors as JSON lines, one per message line, which may include ANSI color codes + * or OSC 8 hyperlinks when colors are enabled (e.g., in CI). The first line holds the summary, and + * registry errors include the HTTP status, which is returned as a standardized error code (`E${statusCode}`). * * Example output (`yarn npm info --json`): * ```json @@ -666,29 +668,48 @@ export function parseYarnModernError(output: string, logger?: Logger): ErrorInfo const messages: { code: string; data: string }[] = []; for (const json of parseJsonLines(output, logger)) { if (json?.type === 'error' && typeof json.data === 'string') { - messages.push({ code: String(json.displayName ?? 'UNKNOWN_ERROR'), data: json.data }); + const displayName = + typeof json.displayName === 'string' + ? stripVTControlCharacters(json.displayName).trim() + : ''; + messages.push({ + code: displayName || 'UNKNOWN_ERROR', + data: stripVTControlCharacters(json.data), + }); } } if (messages.length === 0) { - return parseNpmLikeError(output, logger); + logger?.debug(' Failed to parse modern yarn error. No structured error found.'); + + return null; + } + + let statusCode: string | undefined; + for (const { data } of messages) { + const match = data.match(/^\s*Response Code:\s*(\d{3})\b/); + if (match) { + const numericCode = Number(match[1]); + if (numericCode < 200 || numericCode >= 300) { + statusCode = match[1]; + break; + } + } } - const [first, ...rest] = messages; - const statusCodeMatch = messages - .map(({ data }) => data.match(/Response Code: (\d{3})/)) - .find((match) => match !== null); - const code = statusCodeMatch ? `E${statusCodeMatch[1]}` : first.code; + const [first] = messages; + const code = statusCode ? `E${statusCode}` : first.code; logger?.debug(` Successfully parsed modern yarn error with code '${code}'.`); + const [summary = `Package manager error: ${code}`, ...detailLines] = messages + .flatMap(({ data }) => data.split('\n')) + .map((line) => line.trim()) + .filter(Boolean); + return { code, - summary: first.data.trim(), - detail: - rest - .map(({ data }) => data.trim()) - .filter(Boolean) - .join('\n') || undefined, + summary, + detail: detailLines.join('\n') || undefined, }; } diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 930e3c49e6a9..93a2975845b6 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -643,28 +643,108 @@ project node_modules }); describe('parseYarnModernError', () => { - it('should parse a missing package from yarn npm info as E404', () => { + it('should parse a missing package from yarn npm info as E404 with detail', () => { const stdout = [ '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Package not found"}', '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Response Code: 404 (Not Found)"}', '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Request Method: GET"}', '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Request URL: https://registry.yarnpkg.com/@angular%2fnon-existent"}', ].join('\n'); - expect(parseYarnModernError(stdout)).toEqual( - jasmine.objectContaining({ code: 'E404', summary: 'Package not found' }), - ); + expect(parseYarnModernError(stdout)).toEqual({ + code: 'E404', + summary: 'Package not found', + detail: [ + 'Response Code: 404 (Not Found)', + 'Request Method: GET', + 'Request URL: https://registry.yarnpkg.com/@angular%2fnon-existent', + ].join('\n'), + }); + }); + + it('should strip ANSI color codes and OSC 8 hyperlinks from error lines', () => { + const stdout = [ + JSON.stringify({ + type: 'error', + name: 35, + displayName: 'YN0035', + indent: '', + data: '\u001b[31mPackage not found\u001b[39m', + }), + JSON.stringify({ + type: 'error', + name: 35, + displayName: 'YN0035', + indent: '', + data: ' \u001b[38;5;111mResponse Code\u001b[39m: \u001b]8;;https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404\u0007\u001b[38;5;220m404\u001b[39m (Not Found)\u001b]8;;\u0007', + }), + ].join('\n'); + expect(parseYarnModernError(stdout)).toEqual({ + code: 'E404', + summary: 'Package not found', + detail: 'Response Code: 404 (Not Found)', + }); }); it('should use the yarn error code when there is no HTTP status', () => { const stdout = '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Request timed out"}'; - expect(parseYarnModernError(stdout)).toEqual( - jasmine.objectContaining({ code: 'YN0035', summary: 'Request timed out' }), - ); + expect(parseYarnModernError(stdout)).toEqual({ + code: 'YN0035', + summary: 'Request timed out', + detail: undefined, + }); + }); + + it('should omit detail when trailing error lines are empty or whitespace-only', () => { + const stdout = [ + '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Request timed out"}', + '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" "}', + ].join('\n'); + expect(parseYarnModernError(stdout)).toEqual({ + code: 'YN0035', + summary: 'Request timed out', + detail: undefined, + }); + }); + + it('should split multi-line exception stack traces into summary and detail', () => { + const stdout = JSON.stringify({ + type: 'error', + name: 1, + displayName: 'YN0001', + indent: '', + data: 'RequestError: getaddrinfo ENOTFOUND registry.yarnpkg.com\n at GetAddrInfoReqWrap.onlookupall (node:dns:120:26)', + }); + expect(parseYarnModernError(stdout)).toEqual({ + code: 'YN0001', + summary: 'RequestError: getaddrinfo ENOTFOUND registry.yarnpkg.com', + detail: 'at GetAddrInfoReqWrap.onlookupall (node:dns:120:26)', + }); + }); + + it('should fall back to UNKNOWN_ERROR when displayName is empty or missing', () => { + const stdout = + '{"type":"error","name":35,"displayName":"","indent":"","data":"Network disabled"}'; + expect(parseYarnModernError(stdout)).toEqual({ + code: 'UNKNOWN_ERROR', + summary: 'Network disabled', + detail: undefined, + }); + }); + + it('should provide a fallback summary when error data is empty or whitespace-only', () => { + const stdout = '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" "}'; + expect(parseYarnModernError(stdout)).toEqual({ + code: 'YN0035', + summary: 'Package manager error: YN0035', + detail: undefined, + }); }); - it('should return null for successful or empty output', () => { + it('should return null for successful, empty, or unrecognized output', () => { expect(parseYarnModernError('{"name":"@angular/core","version":"22.2.4"}')).toBeNull(); + expect(parseYarnModernError('"@angular/core@npm:22.2.4"\n"rxjs@npm:7.8.2"')).toBeNull(); + expect(parseYarnModernError('some unrelated plain-text output')).toBeNull(); expect(parseYarnModernError('')).toBeNull(); }); });