From 0c1805de44e1c65c5786a53940442f453ac8a769 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Thu, 28 May 2026 15:47:54 +0200 Subject: [PATCH 1/3] fix: resolve hoisted tsc binaries --- .../src/targets/typescript.test.ts | 61 +++++++++++++++++++ .../src/targets/typescript.ts | 32 +++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-builder-bob/src/targets/typescript.test.ts diff --git a/packages/react-native-builder-bob/src/targets/typescript.test.ts b/packages/react-native-builder-bob/src/targets/typescript.test.ts new file mode 100644 index 000000000..0df477dda --- /dev/null +++ b/packages/react-native-builder-bob/src/targets/typescript.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test } from 'vitest'; +import { findBinInAncestorNodeModules } from './typescript.ts'; + +const tempDirs: string[] = []; + +async function createTempDir() { + const dir = await mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); + tempDirs.push(dir); + return dir; +} + +async function touch(file: string) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, ''); +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + + await Promise.all( + dirs.map(async (dir) => rm(dir, { force: true, recursive: true })) + ); +}); + +test('finds a binary in ancestor node_modules', async () => { + const workspace = await createTempDir(); + const packageRoot = path.join(workspace, 'packages', 'library'); + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); + + await mkdir(packageRoot, { recursive: true }); + await touch(tsc); + + await expect(findBinInAncestorNodeModules(packageRoot, 'tsc')).resolves.toBe( + tsc + ); +}); + +test('prefers the nearest node_modules binary', async () => { + const workspace = await createTempDir(); + const packageRoot = path.join(workspace, 'packages', 'library'); + const hoistedTsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); + const localTsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); + + await touch(hoistedTsc); + await touch(localTsc); + + await expect(findBinInAncestorNodeModules(packageRoot, 'tsc')).resolves.toBe( + localTsc + ); +}); + +test('returns undefined when a binary cannot be found', async () => { + const packageRoot = await createTempDir(); + + await expect( + findBinInAncestorNodeModules(packageRoot, 'tsc') + ).resolves.toBeUndefined(); +}); diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 84718f80e..f3cf7bc9e 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -25,6 +25,29 @@ type Field = { message: string | undefined; }; +export async function findBinInAncestorNodeModules( + root: string, + binary: string +) { + let current = root; + + while (true) { + const candidate = path.resolve(current, 'node_modules', '.bin', binary); + + if (await fs.pathExists(candidate)) { + return candidate; + } + + const parent = path.dirname(current); + + if (parent === current) { + return undefined; + } + + current = parent; + } +} + export default async function build({ source, root, @@ -89,7 +112,7 @@ export default async function build({ ); } - let tsc; + let tsc: string | undefined; if (options?.tsc) { tsc = path.resolve(root, options.tsc); @@ -117,7 +140,12 @@ export default async function build({ tsc = result.trim(); } else { - tsc = path.resolve(root, 'node_modules', '.bin', 'tsc'); + tsc = await findBinInAncestorNodeModules( + root, + platform() === 'win32' ? 'tsc.cmd' : 'tsc' + ); + + tsc ??= path.resolve(root, 'node_modules', '.bin', 'tsc'); } if (platform() === 'win32' && !tsc.endsWith('.cmd')) { From e8b756f61745ba8c31f30edd47baf96b697341a1 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Sun, 7 Jun 2026 20:47:01 +0200 Subject: [PATCH 2/3] fix: limit tsc search to workspace root --- .../src/__tests__/typescript.test.ts | 382 ++++++++++++++++++ .../src/targets/typescript.test.ts | 61 --- .../src/targets/typescript.ts | 94 +++-- 3 files changed, 443 insertions(+), 94 deletions(-) create mode 100644 packages/react-native-builder-bob/src/__tests__/typescript.test.ts delete mode 100644 packages/react-native-builder-bob/src/targets/typescript.test.ts diff --git a/packages/react-native-builder-bob/src/__tests__/typescript.test.ts b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts new file mode 100644 index 000000000..99316793d --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts @@ -0,0 +1,382 @@ +import path from 'node:path'; +import mockFs from 'mock-fs'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import build, { findBinInAncestorNodeModules } from '../targets/typescript.ts'; +import type { Report } from '../types.ts'; +import { spawn } from '../utils/spawn.ts'; + +const whichMock = vi.hoisted(() => + vi.fn<(cmd: string, options: { nothrow: true }) => Promise>() +); + +vi.mock('which', () => ({ + default: whichMock, +})); + +vi.mock('../utils/spawn.ts', () => ({ + spawn: vi.fn(), +})); + +const workspace = path.resolve('/workspace'); +const packageRoot = path.join(workspace, 'packages', 'library'); +const source = path.join(packageRoot, 'src'); +const output = path.join(packageRoot, 'lib'); +const spawnMock = vi.mocked(spawn); + +function createReport(): Report { + return { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }; +} + +function mockProject(files = {}) { + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: { + 'package.json': JSON.stringify({ name: 'library' }), + 'tsconfig.json': '{}', + src: {}, + ...files, + }, + }, + }, + }); +} + +async function buildTypescript(options?: { tsc?: string }) { + const report = createReport(); + + await build({ + root: packageRoot, + source, + output, + report, + options, + variants: { commonjs: true }, + esm: false, + }); + + return report; +} + +beforeEach(() => { + whichMock.mockResolvedValue(null); + spawnMock.mockResolvedValue(''); +}); + +afterEach(() => { + mockFs.restore(); + vi.clearAllMocks(); +}); + +test('finds a binary in ancestor node_modules', async () => { + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: {}, + }, + node_modules: { + '.bin': { + tsc: '', + }, + }, + }, + }); + + await expect( + findBinInAncestorNodeModules(packageRoot, 'tsc', workspace) + ).resolves.toBe(tsc); +}); + +test('prefers the nearest node_modules binary', async () => { + const localTsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: { + node_modules: { + '.bin': { + tsc: '', + }, + }, + }, + }, + node_modules: { + '.bin': { + tsc: '', + }, + }, + }, + }); + + await expect( + findBinInAncestorNodeModules(packageRoot, 'tsc', workspace) + ).resolves.toBe(localTsc); +}); + +test('stops looking for binaries at the workspace root', async () => { + const home = path.resolve('/home/user'); + const workspace = path.join(home, 'workspace'); + const packageRoot = path.join(workspace, 'packages', 'library'); + + mockFs({ + [home]: { + node_modules: { + '.bin': { + tsc: '', + }, + }, + workspace: { + 'yarn.lock': '', + packages: { + library: {}, + }, + }, + }, + }); + + await expect( + findBinInAncestorNodeModules(packageRoot, 'tsc', workspace) + ).resolves.toBeUndefined(); +}); + +test.each([ + 'bun.lock', + 'bun.lockb', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +])('uses %s to find the workspace root', async (lockfile) => { + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); + + mockFs({ + [workspace]: { + [lockfile]: '', + packages: { + library: {}, + }, + node_modules: { + '.bin': { + tsc: '', + }, + }, + }, + }); + + await expect( + findBinInAncestorNodeModules(packageRoot, 'tsc', workspace) + ).resolves.toBe(tsc); +}); + +test('prefers package node_modules tsc over PATH', async () => { + const localTsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); + const pathTsc = path.join(packageRoot, 'bin', 'tsc'); + + mockProject({ + bin: { + tsc: '', + }, + node_modules: { + '.bin': { + tsc: '', + }, + }, + }); + + whichMock.mockResolvedValue(pathTsc); + + await buildTypescript(); + + expect(whichMock).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledWith( + localTsc, + expect.any(Array), + expect.any(Object) + ); +}); + +test('does not warn when tsc from PATH is inside the workspace root', async () => { + const pathTsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + node_modules: { + '.bin': { + tsc: '', + }, + }, + packages: { + library: { + 'package.json': JSON.stringify({ name: 'library' }), + 'tsconfig.json': '{}', + src: {}, + }, + }, + }, + }); + + whichMock.mockResolvedValue(pathTsc); + + const report = await buildTypescript(); + + expect(report.warn).not.toHaveBeenCalledWith( + expect.stringContaining('outside the workspace root') + ); + expect(spawnMock).toHaveBeenCalledWith( + pathTsc, + expect.any(Array), + expect.any(Object) + ); +}); + +test('warns when tsc from PATH is outside the workspace root', async () => { + const pathTsc = path.resolve('/home/user/node_modules/.bin/tsc'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: { + 'package.json': JSON.stringify({ name: 'library' }), + 'tsconfig.json': '{}', + src: {}, + }, + }, + }, + '/home/user/node_modules/.bin': { + tsc: '', + }, + }); + + whichMock.mockResolvedValue(pathTsc); + + const report = await buildTypescript(); + + expect(report.warn).toHaveBeenCalledWith( + expect.stringContaining('outside the workspace root') + ); + expect(spawnMock).toHaveBeenCalledWith( + pathTsc, + expect.any(Array), + expect.any(Object) + ); +}); + +test('falls back to ancestor node_modules when tsc is missing from PATH', async () => { + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + node_modules: { + '.bin': { + tsc: '', + }, + }, + packages: { + library: { + 'package.json': JSON.stringify({ name: 'library' }), + 'tsconfig.json': '{}', + src: {}, + }, + }, + }, + }); + + await buildTypescript(); + + expect(spawnMock).toHaveBeenCalledWith( + tsc, + expect.any(Array), + expect.any(Object) + ); +}); + +test('fails when tsc cannot be found in PATH or workspace node_modules', async () => { + mockProject(); + + await expect(buildTypescript()).rejects.toThrow( + 'Failed to build definition files.' + ); + + expect(spawnMock).not.toHaveBeenCalled(); +}); + +test('does not traverse outside package root when no lockfile is found', async () => { + mockFs({ + [workspace]: { + node_modules: { + '.bin': { + tsc: '', + }, + }, + packages: { + library: { + 'package.json': JSON.stringify({ name: 'library' }), + 'tsconfig.json': '{}', + src: {}, + }, + }, + }, + }); + + await expect(buildTypescript()).rejects.toThrow( + 'Failed to build definition files.' + ); + + expect(spawnMock).not.toHaveBeenCalled(); +}); + +test('uses explicit tsc option without automatic lookup', async () => { + const tsc = path.join(packageRoot, 'scripts', 'tsc'); + + mockProject({ + scripts: { + tsc: '', + }, + }); + + whichMock.mockResolvedValue(path.join(workspace, 'bin', 'tsc')); + + await buildTypescript({ tsc: 'scripts/tsc' }); + + expect(whichMock).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledWith( + tsc, + expect.any(Array), + expect.any(Object) + ); +}); + +test('finds Windows command binaries in ancestor node_modules', async () => { + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc.cmd'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: {}, + }, + node_modules: { + '.bin': { + 'tsc.cmd': '', + }, + }, + }, + }); + + await expect( + findBinInAncestorNodeModules(packageRoot, 'tsc.cmd', workspace) + ).resolves.toBe(tsc); +}); diff --git a/packages/react-native-builder-bob/src/targets/typescript.test.ts b/packages/react-native-builder-bob/src/targets/typescript.test.ts deleted file mode 100644 index 0df477dda..000000000 --- a/packages/react-native-builder-bob/src/targets/typescript.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, expect, test } from 'vitest'; -import { findBinInAncestorNodeModules } from './typescript.ts'; - -const tempDirs: string[] = []; - -async function createTempDir() { - const dir = await mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); - tempDirs.push(dir); - return dir; -} - -async function touch(file: string) { - await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, ''); -} - -afterEach(async () => { - const dirs = tempDirs.splice(0); - - await Promise.all( - dirs.map(async (dir) => rm(dir, { force: true, recursive: true })) - ); -}); - -test('finds a binary in ancestor node_modules', async () => { - const workspace = await createTempDir(); - const packageRoot = path.join(workspace, 'packages', 'library'); - const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); - - await mkdir(packageRoot, { recursive: true }); - await touch(tsc); - - await expect(findBinInAncestorNodeModules(packageRoot, 'tsc')).resolves.toBe( - tsc - ); -}); - -test('prefers the nearest node_modules binary', async () => { - const workspace = await createTempDir(); - const packageRoot = path.join(workspace, 'packages', 'library'); - const hoistedTsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); - const localTsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); - - await touch(hoistedTsc); - await touch(localTsc); - - await expect(findBinInAncestorNodeModules(packageRoot, 'tsc')).resolves.toBe( - localTsc - ); -}); - -test('returns undefined when a binary cannot be found', async () => { - const packageRoot = await createTempDir(); - - await expect( - findBinInAncestorNodeModules(packageRoot, 'tsc') - ).resolves.toBeUndefined(); -}); diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index f3cf7bc9e..9840eb1d4 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -25,9 +25,45 @@ type Field = { message: string | undefined; }; +const LOCKFILES = [ + 'bun.lock', + 'bun.lockb', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +]; + +function isPathInside(root: string, file: string) { + const relative = path.relative(root, file); + const isOutside = relative === '..' || relative.startsWith(`..${path.sep}`); + + return relative === '' || (!isOutside && !path.isAbsolute(relative)); +} + +async function findWorkspaceRoot(root: string) { + let current = root; + + while (true) { + for (const lockfile of LOCKFILES) { + if (await fs.pathExists(path.join(current, lockfile))) { + return current; + } + } + + const parent = path.dirname(current); + + if (parent === current) { + return root; + } + + current = parent; + } +} + export async function findBinInAncestorNodeModules( root: string, - binary: string + binary: string, + limit: string ) { let current = root; @@ -38,6 +74,10 @@ export async function findBinInAncestorNodeModules( return candidate; } + if (current === limit) { + return undefined; + } + const parent = path.dirname(current); if (parent === current) { @@ -112,7 +152,7 @@ export default async function build({ ); } - let tsc: string | undefined; + let tsc: string | null | undefined; if (options?.tsc) { tsc = path.resolve(root, options.tsc); @@ -127,43 +167,28 @@ export default async function build({ ); } } else { - const execpath = process.env.npm_execpath; - const cli = execpath?.split(path.sep).pop()?.includes('yarn') - ? 'yarn' - : 'npm'; - - if (cli === 'yarn') { - const result = await spawn('yarn', ['bin', 'tsc'], { - cwd: root, - env: { ...process.env, FORCE_COLOR: '0' }, - }); + const binary = platform() === 'win32' ? 'tsc.cmd' : 'tsc'; - tsc = result.trim(); - } else { - tsc = await findBinInAncestorNodeModules( - root, - platform() === 'win32' ? 'tsc.cmd' : 'tsc' - ); + tsc = path.resolve(root, 'node_modules', '.bin', binary); - tsc ??= path.resolve(root, 'node_modules', '.bin', 'tsc'); + if (!(await fs.pathExists(tsc))) { + tsc = await which(binary, { nothrow: true }); } - if (platform() === 'win32' && !tsc.endsWith('.cmd')) { - tsc += '.cmd'; - } - } + let workspaceRoot: string | undefined; - if (!(await fs.pathExists(tsc))) { - try { - tsc = await which('tsc'); + if (tsc != null && !isPathInside(root, tsc)) { + workspaceRoot = await findWorkspaceRoot(root); - if (await fs.pathExists(tsc)) { + if (!isPathInside(workspaceRoot, tsc)) { report.warn( - `Failed to locate ${kleur.blue( - 'tsc' - )} in the workspace. Falling back to the binary found in ${kleur.blue( + `Found ${kleur.blue('tsc')} in ${kleur.blue( 'PATH' - )} at ${kleur.blue(tsc)}. Consider adding ${kleur.blue( + )} at ${kleur.blue( + tsc + )}, but it is outside the workspace root at ${kleur.blue( + workspaceRoot + )}. Consider adding ${kleur.blue( 'typescript' )} to your ${kleur.blue( 'devDependencies' @@ -172,8 +197,11 @@ export default async function build({ )} option for the typescript target.` ); } - } catch (e) { - // Ignore + } + + if (tsc == null) { + workspaceRoot ??= await findWorkspaceRoot(root); + tsc = await findBinInAncestorNodeModules(root, binary, workspaceRoot); } } From f2729c9260d13e94390e6758bd53d500fd610e36 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Sun, 7 Jun 2026 20:54:10 +0200 Subject: [PATCH 3/3] chore: tweak tests --- .../src/__tests__/typescript.test.ts | 215 +++++++----------- 1 file changed, 83 insertions(+), 132 deletions(-) diff --git a/packages/react-native-builder-bob/src/__tests__/typescript.test.ts b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts index 99316793d..34bbb1331 100644 --- a/packages/react-native-builder-bob/src/__tests__/typescript.test.ts +++ b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts @@ -32,20 +32,13 @@ function createReport(): Report { }; } -function mockProject(files = {}) { - mockFs({ - [workspace]: { - 'yarn.lock': '', - packages: { - library: { - 'package.json': JSON.stringify({ name: 'library' }), - 'tsconfig.json': '{}', - src: {}, - ...files, - }, - }, - }, - }); +function library(files = {}) { + return { + 'package.json': JSON.stringify({ name: 'library' }), + 'tsconfig.json': '{}', + src: {}, + ...files, + }; } async function buildTypescript(options?: { tsc?: string }) { @@ -74,34 +67,11 @@ afterEach(() => { vi.clearAllMocks(); }); -test('finds a binary in ancestor node_modules', async () => { - const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); - - mockFs({ - [workspace]: { - 'yarn.lock': '', - packages: { - library: {}, - }, - node_modules: { - '.bin': { - tsc: '', - }, - }, - }, - }); - - await expect( - findBinInAncestorNodeModules(packageRoot, 'tsc', workspace) - ).resolves.toBe(tsc); -}); - -test('prefers the nearest node_modules binary', async () => { +test('finds the nearest binary before the lookup limit', async () => { const localTsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); mockFs({ [workspace]: { - 'yarn.lock': '', packages: { library: { node_modules: { @@ -124,7 +94,7 @@ test('prefers the nearest node_modules binary', async () => { ).resolves.toBe(localTsc); }); -test('stops looking for binaries at the workspace root', async () => { +test('stops looking for binaries at the lookup limit', async () => { const home = path.resolve('/home/user'); const workspace = path.join(home, 'workspace'); const packageRoot = path.join(workspace, 'packages', 'library'); @@ -137,7 +107,6 @@ test('stops looking for binaries at the workspace root', async () => { }, }, workspace: { - 'yarn.lock': '', packages: { library: {}, }, @@ -150,50 +119,78 @@ test('stops looking for binaries at the workspace root', async () => { ).resolves.toBeUndefined(); }); -test.each([ - 'bun.lock', - 'bun.lockb', - 'package-lock.json', - 'pnpm-lock.yaml', - 'yarn.lock', -])('uses %s to find the workspace root', async (lockfile) => { - const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); +test('finds Windows command binaries before the lookup limit', async () => { + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc.cmd'); mockFs({ [workspace]: { - [lockfile]: '', packages: { library: {}, }, node_modules: { '.bin': { - tsc: '', + 'tsc.cmd': '', }, }, }, }); await expect( - findBinInAncestorNodeModules(packageRoot, 'tsc', workspace) + findBinInAncestorNodeModules(packageRoot, 'tsc.cmd', workspace) ).resolves.toBe(tsc); }); +test('uses explicit tsc option without automatic lookup', async () => { + const tsc = path.join(packageRoot, 'scripts', 'tsc'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: library({ + scripts: { + tsc: '', + }, + }), + }, + }, + }); + + whichMock.mockResolvedValue( + path.join(workspace, 'node_modules', '.bin', 'tsc') + ); + + await buildTypescript({ tsc: 'scripts/tsc' }); + + expect(whichMock).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledWith( + tsc, + expect.any(Array), + expect.any(Object) + ); +}); + test('prefers package node_modules tsc over PATH', async () => { const localTsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); - const pathTsc = path.join(packageRoot, 'bin', 'tsc'); - mockProject({ - bin: { - tsc: '', - }, - node_modules: { - '.bin': { - tsc: '', + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: library({ + node_modules: { + '.bin': { + tsc: '', + }, + }, + }), }, }, }); - whichMock.mockResolvedValue(pathTsc); + whichMock.mockResolvedValue( + path.join(workspace, 'node_modules', '.bin', 'tsc') + ); await buildTypescript(); @@ -205,8 +202,8 @@ test('prefers package node_modules tsc over PATH', async () => { ); }); -test('does not warn when tsc from PATH is inside the workspace root', async () => { - const pathTsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); +test('uses tsc from PATH when package node_modules does not contain it', async () => { + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); mockFs({ [workspace]: { @@ -217,16 +214,12 @@ test('does not warn when tsc from PATH is inside the workspace root', async () = }, }, packages: { - library: { - 'package.json': JSON.stringify({ name: 'library' }), - 'tsconfig.json': '{}', - src: {}, - }, + library: library(), }, }, }); - whichMock.mockResolvedValue(pathTsc); + whichMock.mockResolvedValue(tsc); const report = await buildTypescript(); @@ -234,24 +227,20 @@ test('does not warn when tsc from PATH is inside the workspace root', async () = expect.stringContaining('outside the workspace root') ); expect(spawnMock).toHaveBeenCalledWith( - pathTsc, + tsc, expect.any(Array), expect.any(Object) ); }); test('warns when tsc from PATH is outside the workspace root', async () => { - const pathTsc = path.resolve('/home/user/node_modules/.bin/tsc'); + const tsc = path.resolve('/home/user/node_modules/.bin/tsc'); mockFs({ [workspace]: { 'yarn.lock': '', packages: { - library: { - 'package.json': JSON.stringify({ name: 'library' }), - 'tsconfig.json': '{}', - src: {}, - }, + library: library(), }, }, '/home/user/node_modules/.bin': { @@ -259,7 +248,7 @@ test('warns when tsc from PATH is outside the workspace root', async () => { }, }); - whichMock.mockResolvedValue(pathTsc); + whichMock.mockResolvedValue(tsc); const report = await buildTypescript(); @@ -267,29 +256,31 @@ test('warns when tsc from PATH is outside the workspace root', async () => { expect.stringContaining('outside the workspace root') ); expect(spawnMock).toHaveBeenCalledWith( - pathTsc, + tsc, expect.any(Array), expect.any(Object) ); }); -test('falls back to ancestor node_modules when tsc is missing from PATH', async () => { +test.each([ + 'bun.lock', + 'bun.lockb', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +])('uses %s to bound ancestor node_modules lookup', async (lockfile) => { const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc'); mockFs({ [workspace]: { - 'yarn.lock': '', + [lockfile]: '', node_modules: { '.bin': { tsc: '', }, }, packages: { - library: { - 'package.json': JSON.stringify({ name: 'library' }), - 'tsconfig.json': '{}', - src: {}, - }, + library: library(), }, }, }); @@ -303,16 +294,6 @@ test('falls back to ancestor node_modules when tsc is missing from PATH', async ); }); -test('fails when tsc cannot be found in PATH or workspace node_modules', async () => { - mockProject(); - - await expect(buildTypescript()).rejects.toThrow( - 'Failed to build definition files.' - ); - - expect(spawnMock).not.toHaveBeenCalled(); -}); - test('does not traverse outside package root when no lockfile is found', async () => { mockFs({ [workspace]: { @@ -322,11 +303,7 @@ test('does not traverse outside package root when no lockfile is found', async ( }, }, packages: { - library: { - 'package.json': JSON.stringify({ name: 'library' }), - 'tsconfig.json': '{}', - src: {}, - }, + library: library(), }, }, }); @@ -338,45 +315,19 @@ test('does not traverse outside package root when no lockfile is found', async ( expect(spawnMock).not.toHaveBeenCalled(); }); -test('uses explicit tsc option without automatic lookup', async () => { - const tsc = path.join(packageRoot, 'scripts', 'tsc'); - - mockProject({ - scripts: { - tsc: '', - }, - }); - - whichMock.mockResolvedValue(path.join(workspace, 'bin', 'tsc')); - - await buildTypescript({ tsc: 'scripts/tsc' }); - - expect(whichMock).not.toHaveBeenCalled(); - expect(spawnMock).toHaveBeenCalledWith( - tsc, - expect.any(Array), - expect.any(Object) - ); -}); - -test('finds Windows command binaries in ancestor node_modules', async () => { - const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc.cmd'); - +test('fails when tsc cannot be found in PATH or workspace node_modules', async () => { mockFs({ [workspace]: { 'yarn.lock': '', packages: { - library: {}, - }, - node_modules: { - '.bin': { - 'tsc.cmd': '', - }, + library: library(), }, }, }); - await expect( - findBinInAncestorNodeModules(packageRoot, 'tsc.cmd', workspace) - ).resolves.toBe(tsc); + await expect(buildTypescript()).rejects.toThrow( + 'Failed to build definition files.' + ); + + expect(spawnMock).not.toHaveBeenCalled(); });