Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/angular/cli/src/package-managers/package-manager_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
59 changes: 40 additions & 19 deletions packages/angular/cli/src/package-managers/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 <missing-package> --json`):
* ```json
Expand All @@ -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,
};
}

Expand Down
96 changes: 88 additions & 8 deletions packages/angular/cli/src/package-managers/parsers_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Expand Down
Loading