diff --git a/packages/angular/cli/src/package-managers/manifest-dependencies.ts b/packages/angular/cli/src/package-managers/manifest-dependencies.ts new file mode 100644 index 000000000000..260de3f3fee2 --- /dev/null +++ b/packages/angular/cli/src/package-managers/manifest-dependencies.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { dirname, join } from 'node:path'; +import type { Host } from './host'; +import type { InstalledPackage } from './package-tree'; + +/** + * Maximum number of concurrent file system read operations to prevent OS file descriptor exhaustion. + */ +const MAX_CONCURRENT_READS = 16; + +/** + * Maps each item to a result using an asynchronous function, running at most `limit` operations + * concurrently. Results are returned in the same order as the input items. + */ +async function mapConcurrent( + items: readonly T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let index = 0; + + const worker = async () => { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]); + } + }; + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + + return results; +} + +/** + * Checks whether a path exists and matches the expected type. + */ +async function isPathOfType( + host: Host, + path: string, + type: 'file' | 'directory' | 'any', +): Promise { + try { + const stats = await host.stat(path); + + switch (type) { + case 'file': + return stats.isFile(); + case 'directory': + return stats.isDirectory(); + case 'any': + return true; + } + } catch { + return false; + } +} + +/** + * Finds existing `node_modules` directories starting from `startDir` and walking up + * ancestor directories until reaching a directory with one of `lockfiles` or the git repository root. + */ +async function findNodeModulesDirectories( + host: Host, + startDir: string, + lockfiles: readonly string[], +): Promise { + const directories: string[] = []; + let currentDir = startDir; + + while (true) { + const nodeModulesPath = join(currentDir, 'node_modules'); + const [hasNodeModules, isGitRoot, ...lockfileChecks] = await Promise.all([ + isPathOfType(host, nodeModulesPath, 'directory'), + // `.git` is a file (not a directory) in git worktrees and submodules. + isPathOfType(host, join(currentDir, '.git'), 'any'), + ...lockfiles.map((lockfile) => isPathOfType(host, join(currentDir, lockfile), 'file')), + ]); + + if (hasNodeModules) { + directories.push(nodeModulesPath); + } + + // Stop walking up once we reach a directory containing the package manager's lockfile + // or the git repository root. + if (isGitRoot || lockfileChecks.some(Boolean)) { + break; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return directories; +} + +/** + * Resolves the installed manifest of a package by searching the given `node_modules` + * directories in order. + */ +async function resolveInstalledPackage( + host: Host, + packageName: string, + nodeModulesDirs: readonly string[], +): Promise { + for (const nodeModulesDir of nodeModulesDirs) { + const packagePath = join(nodeModulesDir, packageName); + try { + const pkgContent = await host.readFile(join(packagePath, 'package.json')); + const { version } = JSON.parse(pkgContent) as { version?: unknown }; + if (typeof version === 'string') { + return { name: packageName, version, path: packagePath }; + } + } catch { + // Continue searching ancestor node_modules directories. + } + } + + return undefined; +} + +/** + * Resolves installed versions of dependencies declared in the given directory's `package.json` + * by searching ancestor `node_modules` directories starting from `cwd`. + * + * The returned map preserves the declaration order of the manifest. + */ +export async function resolveManifestDependencies( + host: Host, + cwd: string, + lockfiles: readonly string[], +): Promise> { + const resolvedDependencies = new Map(); + + let manifest: { + dependencies?: Record; + devDependencies?: Record; + }; + try { + manifest = JSON.parse(await host.readFile(join(cwd, 'package.json'))); + } catch { + return resolvedDependencies; + } + + if (!manifest || typeof manifest !== 'object') { + return resolvedDependencies; + } + + const declaredPackages = new Set(); + for (const depMap of [manifest.dependencies, manifest.devDependencies]) { + if (!depMap || typeof depMap !== 'object') { + continue; + } + for (const [name, specifier] of Object.entries(depMap)) { + if (typeof specifier === 'string' && !specifier.startsWith('workspace:')) { + declaredPackages.add(name); + } + } + } + + if (declaredPackages.size === 0) { + return resolvedDependencies; + } + + const nodeModulesDirs = await findNodeModulesDirectories(host, cwd, lockfiles); + if (nodeModulesDirs.length === 0) { + return resolvedDependencies; + } + + const installedPackages = await mapConcurrent( + [...declaredPackages], + MAX_CONCURRENT_READS, + (packageName) => resolveInstalledPackage(host, packageName, nodeModulesDirs), + ); + + for (const installedPackage of installedPackages) { + if (installedPackage) { + resolvedDependencies.set(installedPackage.name, installedPackage); + } + } + + return resolvedDependencies; +} diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index cad01202462d..4aed574cfb19 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -18,6 +18,7 @@ import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; import { parseBunDependencies, + parseBunError, parseNpmBeforeDate, parseNpmLikeDependencies, parseNpmLikeError, @@ -90,6 +91,17 @@ export interface PackageManagerDescriptor { /** The command to list all installed dependencies. */ readonly listDependenciesCommand: readonly string[]; + /** + * Whether declared dependencies in the current directory's `package.json` should be + * resolved from `node_modules` to supplement `listDependenciesCommand`. + * + * When enabled, this lookup runs for every project using the package manager (not only + * workspace members). Resolved entries take precedence over the command output and include + * the installed package path. This is required when `listDependenciesCommand` does not + * report a workspace member's own dependencies (e.g., `bun pm ls`). + */ + readonly requiresManifestDependencyLookup?: boolean; + /** The command to get the release age configuration value. */ readonly getReleaseAgeConfigCommand?: readonly string[]; @@ -297,6 +309,10 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), versionCommand: ['--version'], listDependenciesCommand: ['pm', 'ls'], + // `bun pm ls` only outputs root workspace dependencies when run in a workspace member. + // See: https://github.com/oven-sh/bun/issues/10008 + requiresManifestDependencyLookup: true, + getPackageNameCommand: ['pm', 'pkg', 'get', 'name'], getManifestCommand: ['pm', 'view', '--json'], getRegistryMetadata: async (packageName, fetchAndParse) => { const [distTags, versions] = await Promise.all([ @@ -334,7 +350,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { listDependencies: parseBunDependencies, getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, - getError: parseNpmLikeError, + getError: parseBunError, }, isNotFound: isKnownNotFound, }, diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 272454c21cbd..8e68279a5c20 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -18,6 +18,7 @@ import { maxSatisfying, valid } from 'semver'; import { PackageManagerError } from './error'; import { Host } from './host'; import { Logger } from './logger'; +import { resolveManifestDependencies } from './manifest-dependencies'; import { PackageManagerDescriptor } from './package-manager-descriptor'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; @@ -150,11 +151,26 @@ export class PackageManager { const args = this.descriptor.listDependenciesCommand; const workspacePackageName = await this.getCurrentPackageName(); - const dependencies = await this.#fetchAndParse(args, (stdout, logger) => - this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }), - ); + const dependencies = + (await this.#fetchAndParse(args, (stdout, logger) => + this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }), + )) ?? new Map(); + + if (this.descriptor.requiresManifestDependencyLookup) { + const manifestDependencies = await resolveManifestDependencies( + this.host, + this.cwd, + this.descriptor.lockfiles, + ); + // Entries resolved from disk intentionally override the command output. In a workspace + // member, they reflect the member's actual installed version (which may be nested and + // differ from the hoisted root version) and also provide the package path. + for (const [name, pkg] of manifestDependencies) { + dependencies.set(name, pkg); + } + } - return (this.#dependencyCache = dependencies ?? new Map()); + return (this.#dependencyCache = dependencies); } /** 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 37e0d8aae113..5a32562989ee 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { PackageManagerError } from './error'; import { MANIFEST_FIELDS, PackageManager } from './package-manager'; import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor'; import { MockHost } from './testing/mock-host'; @@ -54,6 +55,37 @@ describe('PackageManager', () => { jasmine.anything(), ); }); + + it('should return null when bun pm view fails with No matching version found', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']); + runCommandSpy.and.rejectWith( + new PackageManagerError( + 'Process exited with code 1.', + JSON.stringify({ error: 'No matching version found', version: 'foo@99.99.99' }), + '', + 1, + ), + ); + + expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull(); + }); + + it('should throw when bun pm view fails with an unrecognized JSON error', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']); + runCommandSpy.and.rejectWith( + new PackageManagerError( + 'Process exited with code 1.', + JSON.stringify({ error: 'Authentication token not found' }), + '', + 1, + ), + ); + + await expectAsync(pm.getRegistryManifest('foo', '1.0.0')).toBeRejectedWithError( + PackageManagerError, + 'Authentication token not found', + ); + }); }); describe('getVersion', () => { @@ -376,6 +408,20 @@ describe('PackageManager', () => { jasmine.anything(), ); }); + + it('should return null when bun pm view fails with 404 Not Found', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']); + runCommandSpy.and.rejectWith( + new PackageManagerError( + 'Process exited with code 1.', + '', + "404 Not Found: https://registry.npmjs.org/non-existent\n\n - 'non-existent@latest' does not exist in this registry", + 1, + ), + ); + + expect(await pm.getRegistryMetadata('non-existent')).toBeNull(); + }); }); describe('getCurrentPackageName', () => { @@ -420,6 +466,19 @@ describe('PackageManager', () => { expect(await pm.getCurrentPackageName()).toBeUndefined(); }); + + it('should fall back to bun pm pkg get name when package.json cannot be read', async () => { + const pm = new PackageManager(host, '/repo/apps/app/src', SUPPORTED_PACKAGE_MANAGERS['bun']); + spyOn(host, 'readFile').and.rejectWith(new Error('ENOENT')); + runCommandSpy.and.resolveTo({ stdout: '"my-bun-app"', stderr: '' }); + + expect(await pm.getCurrentPackageName()).toBe('my-bun-app'); + expect(runCommandSpy).toHaveBeenCalledWith( + 'bun', + ['pm', 'pkg', 'get', 'name'], + jasmine.objectContaining({ cwd: '/repo/apps/app/src' }), + ); + }); }); describe('getProjectDependencies', () => { @@ -445,6 +504,144 @@ describe('PackageManager', () => { expect([...dependencies.keys()]).toEqual(['@angular/core']); expect(dependencies.get('@angular/core')?.version).toBe('22.0.0'); }); + + it('should resolve dependencies of a bun workspace member from local and hoisted node_modules', async () => { + const testHost = new MockHost({ + '/outside/node_modules': true, + '/outside/repo/bun.lock': [], + '/outside/repo/node_modules': true, + '/outside/repo/apps/app/node_modules': true, + }); + const pm = new PackageManager( + testHost, + '/outside/repo/apps/app', + SUPPORTED_PACKAGE_MANAGERS['bun'], + ); + spyOn(testHost, 'runCommand').and.resolveTo({ + stdout: [ + '/outside/repo node_modules (350)', + '├── app@workspace:apps/app', + '├── prettier@3.5.0', + '└── typescript@5.9.3', + ].join('\n'), + stderr: '', + }); + + const files: Record = { + '/outside/repo/apps/app/package.json': JSON.stringify({ + name: 'app', + dependencies: { + '@angular/core': '^21.2.0', + '@repo/shared': 'workspace:*', + 'uninstalled-pkg': '^1.0.0', + }, + devDependencies: { + '@angular/cli': '^21.2.0', + typescript: '~5.8.0', + }, + }), + '/outside/repo/node_modules/@angular/core/package.json': JSON.stringify({ + name: '@angular/core', + version: '21.2.4', + }), + '/outside/repo/node_modules/@angular/cli/package.json': JSON.stringify({ + name: '@angular/cli', + version: '21.2.5', + }), + '/outside/repo/apps/app/node_modules/typescript/package.json': JSON.stringify({ + name: 'typescript', + version: '5.8.3', + }), + '/outside/repo/node_modules/typescript/package.json': JSON.stringify({ + name: 'typescript', + version: '5.9.3', + }), + '/outside/node_modules/uninstalled-pkg/package.json': JSON.stringify({ + name: 'uninstalled-pkg', + version: '1.0.0', + }), + }; + + const readFileSpy = spyOn(testHost, 'readFile').and.callFake(async (filePath: string) => { + const normalized = filePath.replace(/\\/g, '/'); + if (normalized in files) { + return files[normalized]; + } + throw new Error(`ENOENT: ${filePath}`); + }); + + const dependencies = await pm.getProjectDependencies(); + + expect(dependencies.has('app')).toBeFalse(); + expect(dependencies.has('@repo/shared')).toBeFalse(); + // Should stop searching at the workspace root (bun.lock) and not resolve from /outside/node_modules + expect(dependencies.has('uninstalled-pkg')).toBeFalse(); + expect(readFileSpy).not.toHaveBeenCalledWith( + jasmine.stringMatching(/\/outside\/node_modules\//), + ); + expect(dependencies.get('prettier')).toEqual({ name: 'prettier', version: '3.5.0' }); + expect(dependencies.get('@angular/core')).toEqual({ + name: '@angular/core', + version: '21.2.4', + path: '/outside/repo/node_modules/@angular/core', + }); + expect(dependencies.get('@angular/cli')).toEqual({ + name: '@angular/cli', + version: '21.2.5', + path: '/outside/repo/node_modules/@angular/cli', + }); + expect(dependencies.get('typescript')).toEqual({ + name: 'typescript', + version: '5.8.3', + path: '/outside/repo/apps/app/node_modules/typescript', + }); + }); + + it('should stop searching for node_modules at a git root where .git is a file', async () => { + const testHost = new MockHost({ + '/outside/node_modules': true, + // A `.git` file is used by git worktrees and submodules. + '/outside/repo/.git': [], + '/outside/repo/apps/app/node_modules': true, + }); + const pm = new PackageManager( + testHost, + '/outside/repo/apps/app', + SUPPORTED_PACKAGE_MANAGERS['bun'], + ); + spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' }); + + const files: Record = { + '/outside/repo/apps/app/package.json': JSON.stringify({ + name: 'app', + dependencies: { + 'local-pkg': '^1.0.0', + 'outside-pkg': '^1.0.0', + }, + }), + '/outside/repo/apps/app/node_modules/local-pkg/package.json': JSON.stringify({ + name: 'local-pkg', + version: '1.2.3', + }), + '/outside/node_modules/outside-pkg/package.json': JSON.stringify({ + name: 'outside-pkg', + version: '1.0.0', + }), + }; + + spyOn(testHost, 'readFile').and.callFake(async (filePath: string) => { + const normalized = filePath.replace(/\\/g, '/'); + if (normalized in files) { + return files[normalized]; + } + throw new Error(`ENOENT: ${filePath}`); + }); + + const dependencies = await pm.getProjectDependencies(); + + expect(dependencies.get('local-pkg')?.version).toBe('1.2.3'); + expect(dependencies.has('outside-pkg')).toBeFalse(); + }); }); describe('initializationError', () => { diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 9fa915ed9ee9..eedfb7ce8471 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -513,7 +513,7 @@ export function parseNpmLikeError(output: string, logger?: Logger): ErrorInfo | return null; } - // Attempt to parse as JSON first (common for pnpm, modern yarn, bun, npm 12) + // Attempt to parse as JSON first (common for pnpm, modern yarn, npm 12) try { let jsonError = JSON.parse(output); if (Array.isArray(jsonError)) { @@ -636,6 +636,92 @@ export function parseYarnClassicError(output: string, logger?: Logger): ErrorInf return null; } +/** + * Known Bun JSON error messages that indicate a requested version or dist-tag does not exist. + */ +const BUN_NOT_FOUND_ERROR_PATTERN = /^(?:no matching version found|dist-tag not found)\b/i; + +/** + * Parses the `stdout` or `stderr` output of `bun` commands to extract structured error information. + * + * Handles flat JSON error payloads emitted on `stdout` by `bun pm view` (e.g., when a requested + * version or dist-tag does not exist) as well as plain-text HTTP status errors emitted on `stderr` + * (e.g., 404 Not Found). Other JSON error shapes are delegated to `parseNpmLikeError`. + * + * Example JSON error output: + * ```json + * { "error": "No matching version found", "version": "@angular/core@99.99.99" } + * ``` + * + * Example plain-text error output: + * ``` + * 404 Not Found: https://registry.npmjs.org/@angular%2fnon-existent + * ``` + * + * @param output The standard output or standard error of the command. + * @param logger An optional logger instance. + * @returns An `ErrorInfo` object if parsing is successful, otherwise `null`. + */ +export function parseBunError(output: string, logger?: Logger): ErrorInfo | null { + logger?.debug('Parsing Bun error output...'); + logStdout(output, logger); + + if (!output) { + logger?.debug(' output is empty. No error found.'); + + return null; + } + + // 1. JSON output. Valid JSON is never a plain-text status line, so text matching is skipped. + let parsed: unknown; + let isJson = false; + try { + parsed = JSON.parse(output); + isJson = true; + } catch { + // Not JSON. Fall back to plain-text error matching. + } + + if (isJson) { + // Only accept Bun's exact flat error shape (`error` with an optional `version`) to avoid + // misinterpreting successful output, such as a dist-tags object containing an `error` tag. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const { error, version, ...rest } = parsed as Record; + if ( + typeof error === 'string' && + (version === undefined || typeof version === 'string') && + Object.keys(rest).length === 0 + ) { + const code = BUN_NOT_FOUND_ERROR_PATTERN.test(error) ? 'E404' : 'UNKNOWN_ERROR'; + const summary = version ? `${error}: ${version}` : error; + logger?.debug(` Successfully parsed Bun JSON error with code '${code}'.`); + + return { code, summary }; + } + } + + // Fall back to npm-compatible JSON error objects (e.g., `{ "error": { "code": "E404", ... } }`). + return parseNpmLikeError(output, logger); + } + + // 2. Plain-text HTTP error status lines from `bun pm view` + // (e.g., `404 Not Found: https://registry.npmjs.org/...`). + const httpStatusMatch = output.match(/^([45]\d{2})\s+[^\r\n:]+:.+$/m); + if (httpStatusMatch) { + const code = `E${httpStatusMatch[1]}`; + logger?.debug(` Detected HTTP error status code '${httpStatusMatch[1]}' in Bun output.`); + + return { + code, + summary: httpStatusMatch[0].trim(), + }; + } + + logger?.debug(' Failed to parse Bun error. No structured error found.'); + + return null; +} + /** * Parses the output of `bun pm ls`. * @@ -681,6 +767,9 @@ export function parseBunDependencies( if (match) { const name = match[1]; const version = match[2]; + if (version.startsWith('workspace:')) { + continue; + } dependencies.set(name, { name, version }); } } diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 049aeaec9129..93c3fd66a9f1 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -8,6 +8,7 @@ import { parseBunDependencies, + parseBunError, parseNpmBeforeDate, parseNpmLikeDependencies, parseNpmLikeError, @@ -540,6 +541,104 @@ project node_modules `.trim(); expect(parseBunDependencies(stdout).size).toBe(0); }); + + it('should skip workspace: protocol entries', () => { + const stdout = ` +/workspace node_modules (350) +├── @scope/pkg@workspace:packages/pkg +├── app@workspace:app +└── typescript@5.9.3 +`.trim(); + const deps = parseBunDependencies(stdout); + expect(deps.size).toBe(1); + expect(deps.get('typescript')).toEqual({ name: 'typescript', version: '5.9.3' }); + }); + }); + + describe('parseBunError', () => { + it('should parse JSON missing version error as E404', () => { + const stdout = JSON.stringify({ + error: 'No matching version found', + version: '@angular/core@99.99.99', + }); + expect(parseBunError(stdout)).toEqual({ + code: 'E404', + summary: 'No matching version found: @angular/core@99.99.99', + }); + }); + + it('should parse JSON missing dist-tag error as E404', () => { + const stdout = JSON.stringify({ + error: 'Dist-tag not found', + version: '@angular/core@nonexistent-tag', + }); + expect(parseBunError(stdout)).toEqual({ + code: 'E404', + summary: 'Dist-tag not found: @angular/core@nonexistent-tag', + }); + }); + + it('should parse plain-text 404 Not Found output from bun pm view as E404', () => { + const stderr = ` +404 Not Found: https://registry.npmjs.org/@angular%2fnon-existent + + - '@angular/non-existent@latest' does not exist in this registry +`.trim(); + expect(parseBunError(stderr)).toEqual({ + code: 'E404', + summary: '404 Not Found: https://registry.npmjs.org/@angular%2fnon-existent', + }); + }); + + it('should parse plain-text 401 Unauthorized output from bun pm view as E401', () => { + const stderr = '401 Unauthorized: https://registry.npmjs.org/@private%2fpkg'; + expect(parseBunError(stderr)).toEqual({ + code: 'E401', + summary: '401 Unauthorized: https://registry.npmjs.org/@private%2fpkg', + }); + }); + + it('should parse unrecognized JSON error messages as UNKNOWN_ERROR', () => { + const stdout = JSON.stringify({ error: 'Authentication token not found' }); + expect(parseBunError(stdout)).toEqual({ + code: 'UNKNOWN_ERROR', + summary: 'Authentication token not found', + }); + }); + + it('should fall back to npm-compatible JSON error objects', () => { + const stdout = JSON.stringify({ error: { code: 'E403', summary: 'Forbidden' } }); + expect(parseBunError(stdout)).toEqual({ + code: 'E403', + summary: 'Forbidden', + detail: undefined, + }); + }); + + it('should not treat successful bun pm view JSON output as an error', () => { + // A dist-tags object that happens to contain an `error` tag. + expect(parseBunError(JSON.stringify({ latest: '1.0.0', error: '0.0.1' }))).toBeNull(); + expect(parseBunError(JSON.stringify(['1.0.0', '1.1.0']))).toBeNull(); + expect(parseBunError(JSON.stringify({ name: 'foo', version: '1.0.0' }))).toBeNull(); + }); + + it('should not treat successful bun pm ls output as an error', () => { + const stdout = ` +/workspace node_modules (350) +├── @angular/core@21.2.4 +└── typescript@5.9.3 +`.trim(); + expect(parseBunError(stdout)).toBeNull(); + }); + + it('should ignore non-error HTTP status lines', () => { + expect(parseBunError('304 Not Modified: https://registry.npmjs.org/foo')).toBeNull(); + }); + + it('should return null for empty or unrecognized output', () => { + expect(parseBunError('')).toBeNull(); + expect(parseBunError('some unrelated output')).toBeNull(); + }); }); describe('parseYarnModernDependencies', () => {