Skip to content

Commit ed38464

Browse files
authored
fix(@angular/cli): strip terminal escape sequences in modern yarn error parser
Modern Yarn formats error detail fields such as `Response Code` with ANSI color sequences and OSC 8 hyperlinks before passing them to `StreamReport`, which serializes the formatted strings verbatim into its JSON line output. When colors are enabled, such as by default on GitHub Actions even over non-TTY pipes or via `FORCE_COLOR` and `.yarnrc.yml` settings, escape codes appear around both the `Response Code` label and status number. As a result, `parseYarnModernError` failed to match the HTTP status code, fell back to the generic `YN0035` code instead of `E404` for missing packages, and leaked raw control sequences into the parsed summary and detail text.
1 parent 70afe1b commit ed38464

3 files changed

Lines changed: 160 additions & 27 deletions

File tree

‎packages/angular/cli/src/package-managers/package-manager_spec.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,38 @@ describe('PackageManager', () => {
525525

526526
expect(await pm.getRegistryMetadata('non-existent')).toBeNull();
527527
});
528+
529+
it('should throw a PackageManagerError with the parsed summary on non-404 modern yarn errors', async () => {
530+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
531+
runCommandSpy.and.rejectWith(
532+
new PackageManagerError(
533+
'Process exited with code 1.',
534+
[
535+
JSON.stringify({
536+
type: 'error',
537+
name: 35,
538+
displayName: 'YN0035',
539+
indent: '',
540+
data: 'The remote server failed to provide the requested resource',
541+
}),
542+
JSON.stringify({
543+
type: 'error',
544+
name: 35,
545+
displayName: 'YN0035',
546+
indent: '',
547+
data: ' Response Code: 403 (Forbidden)',
548+
}),
549+
].join('\n'),
550+
'',
551+
1,
552+
),
553+
);
554+
555+
await expectAsync(pm.getRegistryMetadata('@private/pkg')).toBeRejectedWithError(
556+
PackageManagerError,
557+
'The remote server failed to provide the requested resource',
558+
);
559+
});
528560
});
529561

530562
describe('getCurrentPackageName', () => {

‎packages/angular/cli/src/package-managers/parsers.ts‎

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* into their own file improves modularity and allows for focused testing.
1313
*/
1414

15+
import { stripVTControlCharacters } from 'node:util';
1516
import { compare, valid } from 'semver';
1617
import { ErrorInfo } from './error';
1718
import { Logger } from './logger';
@@ -477,10 +478,11 @@ export function parseYarnClassicMetadata(stdout: string, logger?: Logger): Packa
477478
}
478479

479480
/**
480-
* Parses the `stdout` or `stderr` output of npm, pnpm, modern yarn, or bun to extract structured error information.
481+
* Parses the `stdout` or `stderr` output of npm or pnpm to extract structured error information.
482+
* It is also used by `parseBunError` as a fallback for npm-compatible JSON error objects.
481483
*
482484
* This parser uses a multi-stage approach. It first attempts to parse the entire `output` as a
483-
* single JSON object, which is the standard for modern tools like pnpm, yarn, and bun. If JSON
485+
* single JSON object, which is the standard for modern tools like pnpm and npm. If JSON
484486
* parsing fails, it falls back to a line-by-line regex-based approach to handle the plain
485487
* text output from older versions of npm.
486488
*
@@ -513,7 +515,7 @@ export function parseNpmLikeError(output: string, logger?: Logger): ErrorInfo |
513515
return null;
514516
}
515517

516-
// Attempt to parse as JSON first (common for pnpm, modern yarn, npm 12)
518+
// Attempt to parse as JSON first (common for pnpm, npm 12, and the Bun JSON fallback)
517519
try {
518520
let jsonError = JSON.parse(output);
519521
if (Array.isArray(jsonError)) {
@@ -639,9 +641,9 @@ export function parseYarnClassicError(output: string, logger?: Logger): ErrorInf
639641
/**
640642
* Parses the `stdout` or `stderr` output of modern yarn (v2+) to extract structured error information.
641643
*
642-
* Modern yarn reports errors as JSON lines, one per message line. The first line holds the summary,
643-
* and registry errors include the HTTP status, which is returned as a standardized error code
644-
* (`E${statusCode}`). Other output is delegated to `parseNpmLikeError`.
644+
* Modern yarn reports errors as JSON lines, one per message line, which may include ANSI color codes
645+
* or OSC 8 hyperlinks when colors are enabled (e.g., in CI). The first line holds the summary, and
646+
* registry errors include the HTTP status, which is returned as a standardized error code (`E${statusCode}`).
645647
*
646648
* Example output (`yarn npm info <missing-package> --json`):
647649
* ```json
@@ -666,29 +668,48 @@ export function parseYarnModernError(output: string, logger?: Logger): ErrorInfo
666668
const messages: { code: string; data: string }[] = [];
667669
for (const json of parseJsonLines(output, logger)) {
668670
if (json?.type === 'error' && typeof json.data === 'string') {
669-
messages.push({ code: String(json.displayName ?? 'UNKNOWN_ERROR'), data: json.data });
671+
const displayName =
672+
typeof json.displayName === 'string'
673+
? stripVTControlCharacters(json.displayName).trim()
674+
: '';
675+
messages.push({
676+
code: displayName || 'UNKNOWN_ERROR',
677+
data: stripVTControlCharacters(json.data),
678+
});
670679
}
671680
}
672681

673682
if (messages.length === 0) {
674-
return parseNpmLikeError(output, logger);
683+
logger?.debug(' Failed to parse modern yarn error. No structured error found.');
684+
685+
return null;
686+
}
687+
688+
let statusCode: string | undefined;
689+
for (const { data } of messages) {
690+
const match = data.match(/^\s*Response Code:\s*(\d{3})\b/);
691+
if (match) {
692+
const numericCode = Number(match[1]);
693+
if (numericCode < 200 || numericCode >= 300) {
694+
statusCode = match[1];
695+
break;
696+
}
697+
}
675698
}
676699

677-
const [first, ...rest] = messages;
678-
const statusCodeMatch = messages
679-
.map(({ data }) => data.match(/Response Code: (\d{3})/))
680-
.find((match) => match !== null);
681-
const code = statusCodeMatch ? `E${statusCodeMatch[1]}` : first.code;
700+
const [first] = messages;
701+
const code = statusCode ? `E${statusCode}` : first.code;
682702
logger?.debug(` Successfully parsed modern yarn error with code '${code}'.`);
683703

704+
const [summary = `Package manager error: ${code}`, ...detailLines] = messages
705+
.flatMap(({ data }) => data.split('\n'))
706+
.map((line) => line.trim())
707+
.filter(Boolean);
708+
684709
return {
685710
code,
686-
summary: first.data.trim(),
687-
detail:
688-
rest
689-
.map(({ data }) => data.trim())
690-
.filter(Boolean)
691-
.join('\n') || undefined,
711+
summary,
712+
detail: detailLines.join('\n') || undefined,
692713
};
693714
}
694715

‎packages/angular/cli/src/package-managers/parsers_spec.ts‎

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -643,28 +643,108 @@ project node_modules
643643
});
644644

645645
describe('parseYarnModernError', () => {
646-
it('should parse a missing package from yarn npm info as E404', () => {
646+
it('should parse a missing package from yarn npm info as E404 with detail', () => {
647647
const stdout = [
648648
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Package not found"}',
649649
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Response Code: 404 (Not Found)"}',
650650
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Request Method: GET"}',
651651
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Request URL: https://registry.yarnpkg.com/@angular%2fnon-existent"}',
652652
].join('\n');
653-
expect(parseYarnModernError(stdout)).toEqual(
654-
jasmine.objectContaining({ code: 'E404', summary: 'Package not found' }),
655-
);
653+
expect(parseYarnModernError(stdout)).toEqual({
654+
code: 'E404',
655+
summary: 'Package not found',
656+
detail: [
657+
'Response Code: 404 (Not Found)',
658+
'Request Method: GET',
659+
'Request URL: https://registry.yarnpkg.com/@angular%2fnon-existent',
660+
].join('\n'),
661+
});
662+
});
663+
664+
it('should strip ANSI color codes and OSC 8 hyperlinks from error lines', () => {
665+
const stdout = [
666+
JSON.stringify({
667+
type: 'error',
668+
name: 35,
669+
displayName: 'YN0035',
670+
indent: '',
671+
data: '\u001b[31mPackage not found\u001b[39m',
672+
}),
673+
JSON.stringify({
674+
type: 'error',
675+
name: 35,
676+
displayName: 'YN0035',
677+
indent: '',
678+
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',
679+
}),
680+
].join('\n');
681+
expect(parseYarnModernError(stdout)).toEqual({
682+
code: 'E404',
683+
summary: 'Package not found',
684+
detail: 'Response Code: 404 (Not Found)',
685+
});
656686
});
657687

658688
it('should use the yarn error code when there is no HTTP status', () => {
659689
const stdout =
660690
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Request timed out"}';
661-
expect(parseYarnModernError(stdout)).toEqual(
662-
jasmine.objectContaining({ code: 'YN0035', summary: 'Request timed out' }),
663-
);
691+
expect(parseYarnModernError(stdout)).toEqual({
692+
code: 'YN0035',
693+
summary: 'Request timed out',
694+
detail: undefined,
695+
});
696+
});
697+
698+
it('should omit detail when trailing error lines are empty or whitespace-only', () => {
699+
const stdout = [
700+
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Request timed out"}',
701+
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" "}',
702+
].join('\n');
703+
expect(parseYarnModernError(stdout)).toEqual({
704+
code: 'YN0035',
705+
summary: 'Request timed out',
706+
detail: undefined,
707+
});
708+
});
709+
710+
it('should split multi-line exception stack traces into summary and detail', () => {
711+
const stdout = JSON.stringify({
712+
type: 'error',
713+
name: 1,
714+
displayName: 'YN0001',
715+
indent: '',
716+
data: 'RequestError: getaddrinfo ENOTFOUND registry.yarnpkg.com\n at GetAddrInfoReqWrap.onlookupall (node:dns:120:26)',
717+
});
718+
expect(parseYarnModernError(stdout)).toEqual({
719+
code: 'YN0001',
720+
summary: 'RequestError: getaddrinfo ENOTFOUND registry.yarnpkg.com',
721+
detail: 'at GetAddrInfoReqWrap.onlookupall (node:dns:120:26)',
722+
});
723+
});
724+
725+
it('should fall back to UNKNOWN_ERROR when displayName is empty or missing', () => {
726+
const stdout =
727+
'{"type":"error","name":35,"displayName":"","indent":"","data":"Network disabled"}';
728+
expect(parseYarnModernError(stdout)).toEqual({
729+
code: 'UNKNOWN_ERROR',
730+
summary: 'Network disabled',
731+
detail: undefined,
732+
});
733+
});
734+
735+
it('should provide a fallback summary when error data is empty or whitespace-only', () => {
736+
const stdout = '{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" "}';
737+
expect(parseYarnModernError(stdout)).toEqual({
738+
code: 'YN0035',
739+
summary: 'Package manager error: YN0035',
740+
detail: undefined,
741+
});
664742
});
665743

666-
it('should return null for successful or empty output', () => {
744+
it('should return null for successful, empty, or unrecognized output', () => {
667745
expect(parseYarnModernError('{"name":"@angular/core","version":"22.2.4"}')).toBeNull();
746+
expect(parseYarnModernError('"@angular/core@npm:22.2.4"\n"rxjs@npm:7.8.2"')).toBeNull();
747+
expect(parseYarnModernError('some unrelated plain-text output')).toBeNull();
668748
expect(parseYarnModernError('')).toBeNull();
669749
});
670750
});

0 commit comments

Comments
 (0)