import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { execFileSyncMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), })); vi.mock('child_process', () => ({ execFileSync: execFileSyncMock, })); import { buildWorkspaceCommandEnvironment, buildWorkspaceScriptCommand, detectWorkspacePackageManager, ensureWorkspaceDependenciesInstalled, hasInstalledNodeModules, resolveWorkspaceInstallCommand, } from './workspace-package-manager.js'; let tempRoot: string; beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-')); execFileSyncMock.mockReset(); }); afterEach(() => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); describe('workspace package manager detection', () => { it('detects package managers from packageManager field and lockfiles', () => { const pnpmRepo = path.join(tempRoot, 'pnpm'); fs.mkdirSync(pnpmRepo, { recursive: true }); fs.writeFileSync( path.join(pnpmRepo, 'package.json'), JSON.stringify({ packageManager: 'pnpm@10.11.0' }), ); const bunRepo = path.join(tempRoot, 'bun'); fs.mkdirSync(bunRepo, { recursive: true }); fs.writeFileSync(path.join(bunRepo, 'package.json'), JSON.stringify({})); fs.writeFileSync(path.join(bunRepo, 'bun.lock'), ''); const yarnRepo = path.join(tempRoot, 'yarn'); fs.mkdirSync(yarnRepo, { recursive: true }); fs.writeFileSync(path.join(yarnRepo, 'package.json'), JSON.stringify({})); fs.writeFileSync(path.join(yarnRepo, 'yarn.lock'), ''); const mixedRepo = path.join(tempRoot, 'mixed'); fs.mkdirSync(mixedRepo, { recursive: true }); fs.writeFileSync( path.join(mixedRepo, 'package.json'), JSON.stringify({ packageManager: 'bun@1.3.11' }), ); fs.writeFileSync(path.join(mixedRepo, 'bun.lock'), ''); fs.writeFileSync( path.join(mixedRepo, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n', ); expect(detectWorkspacePackageManager(pnpmRepo)).toBe('pnpm'); expect(detectWorkspacePackageManager(bunRepo)).toBe('bun'); expect(detectWorkspacePackageManager(yarnRepo)).toBe('yarn'); expect(detectWorkspacePackageManager(mixedRepo)).toBe('bun'); }); it('fails fast when multiple lockfiles exist without a packageManager field', () => { const repoDir = path.join(tempRoot, 'ambiguous'); fs.mkdirSync(repoDir, { recursive: true }); fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({})); fs.writeFileSync(path.join(repoDir, 'bun.lock'), ''); fs.writeFileSync(path.join(repoDir, 'package-lock.json'), '{}\n'); expect(() => detectWorkspacePackageManager(repoDir)).toThrow( /Ambiguous package manager/i, ); }); it('builds script and install commands for non-npm workspaces', () => { const pnpmRepo = path.join(tempRoot, 'pnpm'); fs.mkdirSync(pnpmRepo, { recursive: true }); fs.writeFileSync( path.join(pnpmRepo, 'package.json'), JSON.stringify({ packageManager: 'pnpm@10.11.0' }), ); const bunRepo = path.join(tempRoot, 'bun'); fs.mkdirSync(bunRepo, { recursive: true }); fs.writeFileSync(path.join(bunRepo, 'package.json'), JSON.stringify({})); fs.writeFileSync(path.join(bunRepo, 'bun.lock'), ''); expect(buildWorkspaceScriptCommand(pnpmRepo, 'typecheck')).toMatchObject({ file: 'corepack', args: ['pnpm', 'run', 'typecheck'], commandText: 'corepack pnpm run typecheck', }); expect(resolveWorkspaceInstallCommand(pnpmRepo)).toMatchObject({ file: 'corepack', args: ['pnpm', 'install', '--frozen-lockfile'], commandText: 'corepack pnpm install --frozen-lockfile', }); expect(buildWorkspaceScriptCommand(bunRepo, 'build')).toMatchObject({ file: 'bun', args: ['run', 'build'], commandText: 'bun run build', }); }); }); describe('workspace dependency installs', () => { it('installs dependencies once and tracks install fingerprint', () => { const repoDir = path.join(tempRoot, 'repo'); fs.mkdirSync(repoDir, { recursive: true }); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'repo', packageManager: 'pnpm@10.11.0', scripts: { typecheck: 'tsc --noEmit' }, }), ); fs.writeFileSync( path.join(repoDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n', ); execFileSyncMock.mockImplementation((_file, _args, options) => { const cwd = (options as { cwd: string }).cwd; fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true }); fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'tsc'), ''); return ''; }); const first = ensureWorkspaceDependenciesInstalled(repoDir); expect(first).toMatchObject({ installed: true, packageManager: 'pnpm', commandText: 'corepack pnpm install --frozen-lockfile', }); expect(execFileSyncMock).toHaveBeenCalledWith( 'corepack', ['pnpm', 'install', '--frozen-lockfile'], expect.objectContaining({ cwd: repoDir, }), ); expect(hasInstalledNodeModules(repoDir)).toBe(true); execFileSyncMock.mockClear(); const second = ensureWorkspaceDependenciesInstalled(repoDir); expect(second).toMatchObject({ installed: false, packageManager: 'pnpm', }); expect(execFileSyncMock).not.toHaveBeenCalled(); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'repo', packageManager: 'pnpm@10.11.0', scripts: { typecheck: 'tsc --noEmit', test: 'vitest run' }, }), ); expect(hasInstalledNodeModules(repoDir)).toBe(false); }); it('rejects no-op installs that only leave the sentinel file behind', () => { const repoDir = path.join(tempRoot, 'noop'); fs.mkdirSync(repoDir, { recursive: true }); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'noop', packageManager: 'npm@10.0.0', scripts: { typecheck: 'tsc --noEmit' }, }), ); fs.writeFileSync(path.join(repoDir, 'package-lock.json'), '{}\n'); execFileSyncMock.mockImplementation(() => ''); expect(() => ensureWorkspaceDependenciesInstalled(repoDir)).toThrow( /did not produce a usable node_modules tree/i, ); expect(hasInstalledNodeModules(repoDir)).toBe(false); }); it('backfills install state for a legacy runnable node_modules tree', () => { const repoDir = path.join(tempRoot, 'legacy'); fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), { recursive: true, }); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'legacy', packageManager: 'bun@1.3.11', scripts: { test: 'vitest run' }, }), ); fs.writeFileSync(path.join(repoDir, 'bun.lock'), ''); fs.writeFileSync(path.join(repoDir, 'node_modules', '.bin', 'vitest'), ''); expect(hasInstalledNodeModules(repoDir)).toBe(false); const result = ensureWorkspaceDependenciesInstalled(repoDir); expect(result).toMatchObject({ installed: false, packageManager: 'bun', }); expect(hasInstalledNodeModules(repoDir)).toBe(true); expect(execFileSyncMock).not.toHaveBeenCalled(); }); it('repairs a stale pnpm self-referential node_modules tree before install', () => { const repoDir = path.join(tempRoot, 'stale-pnpm'); fs.mkdirSync(path.join(repoDir, 'node_modules', '.pnpm'), { recursive: true, }); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'stale-pnpm', packageManager: 'pnpm@10.11.0', scripts: { test: 'vitest run' }, }), ); fs.writeFileSync( path.join(repoDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n', ); const selfLoopPath = path.join( repoDir, 'node_modules', '.pnpm', 'node_modules', ); fs.symlinkSync(selfLoopPath, selfLoopPath); execFileSyncMock.mockImplementation((_file, _args, options) => { const cwd = (options as { cwd: string }).cwd; expect(fs.existsSync(selfLoopPath)).toBe(false); fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true }); fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'vitest'), ''); return ''; }); const result = ensureWorkspaceDependenciesInstalled(repoDir); expect(result).toMatchObject({ installed: true, packageManager: 'pnpm', commandText: 'corepack pnpm install --frozen-lockfile', }); expect(execFileSyncMock).toHaveBeenCalledTimes(1); expect(hasInstalledNodeModules(repoDir)).toBe(true); }); it('retries once after pnpm reports an ELOOP install failure', () => { const repoDir = path.join(tempRoot, 'eloop-retry'); fs.mkdirSync(repoDir, { recursive: true }); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'eloop-retry', packageManager: 'pnpm@10.11.0', scripts: { test: 'vitest run' }, }), ); fs.writeFileSync( path.join(repoDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n', ); execFileSyncMock.mockImplementationOnce((_file, _args, options) => { const cwd = (options as { cwd: string }).cwd; fs.mkdirSync(path.join(cwd, 'node_modules', '.pnpm'), { recursive: true, }); const selfLoopPath = path.join( cwd, 'node_modules', '.pnpm', 'node_modules', ); fs.symlinkSync(selfLoopPath, selfLoopPath); throw { stderr: 'ELOOP: too many symbolic links encountered' }; }); execFileSyncMock.mockImplementationOnce((_file, _args, options) => { const cwd = (options as { cwd: string }).cwd; expect( fs.existsSync(path.join(cwd, 'node_modules', '.pnpm', 'node_modules')), ).toBe(false); fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true }); fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'vitest'), ''); return ''; }); const result = ensureWorkspaceDependenciesInstalled(repoDir); expect(result).toMatchObject({ installed: true, packageManager: 'pnpm', commandText: 'corepack pnpm install --frozen-lockfile', }); expect(execFileSyncMock).toHaveBeenCalledTimes(2); expect(hasInstalledNodeModules(repoDir)).toBe(true); }); }); describe('workspace package manager environment', () => { it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => { const parentDir = path.join(tempRoot, 'parent'); fs.mkdirSync(parentDir, { recursive: true }); fs.writeFileSync( path.join(parentDir, 'package.json'), JSON.stringify({ packageManager: 'bun@1.3.11' }), ); const repoDir = path.join(parentDir, 'child'); fs.mkdirSync(repoDir, { recursive: true }); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'child', scripts: { typecheck: 'tsc --noEmit' } }), ); fs.writeFileSync( path.join(repoDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n', ); expect( buildWorkspaceCommandEnvironment(repoDir, 'pnpm', { PATH: '/tmp/bin' }), ).toMatchObject({ PATH: '/tmp/bin', COREPACK_ENABLE_PROJECT_SPEC: '0', }); }); it('keeps an explicitly pinned pnpm workspace packageManager under a bun ancestor', () => { const parentDir = path.join(tempRoot, 'parent'); fs.mkdirSync(parentDir, { recursive: true }); fs.writeFileSync( path.join(parentDir, 'package.json'), JSON.stringify({ packageManager: 'bun@1.3.11' }), ); const repoDir = path.join(parentDir, 'child'); fs.mkdirSync(repoDir, { recursive: true }); fs.writeFileSync( path.join(repoDir, 'package.json'), JSON.stringify({ name: 'child', packageManager: 'pnpm@10.11.0', scripts: { typecheck: 'tsc --noEmit' }, }), ); fs.writeFileSync( path.join(repoDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n', ); expect( buildWorkspaceCommandEnvironment(repoDir, 'pnpm', { PATH: '/tmp/bin' }), ).toEqual({ PATH: '/tmp/bin' }); }); });