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..34bbb1331 --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts @@ -0,0 +1,333 @@ +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 library(files = {}) { + return { + '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 the nearest binary before the lookup limit', async () => { + const localTsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); + + mockFs({ + [workspace]: { + 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 lookup limit', 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: { + packages: { + library: {}, + }, + }, + }, + }); + + await expect( + findBinInAncestorNodeModules(packageRoot, 'tsc', workspace) + ).resolves.toBeUndefined(); +}); + +test('finds Windows command binaries before the lookup limit', async () => { + const tsc = path.join(workspace, 'node_modules', '.bin', 'tsc.cmd'); + + mockFs({ + [workspace]: { + packages: { + library: {}, + }, + node_modules: { + '.bin': { + 'tsc.cmd': '', + }, + }, + }, + }); + + await expect( + 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'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: library({ + node_modules: { + '.bin': { + tsc: '', + }, + }, + }), + }, + }, + }); + + whichMock.mockResolvedValue( + path.join(workspace, 'node_modules', '.bin', 'tsc') + ); + + await buildTypescript(); + + expect(whichMock).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledWith( + localTsc, + expect.any(Array), + expect.any(Object) + ); +}); + +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]: { + 'yarn.lock': '', + node_modules: { + '.bin': { + tsc: '', + }, + }, + packages: { + library: library(), + }, + }, + }); + + whichMock.mockResolvedValue(tsc); + + const report = await buildTypescript(); + + expect(report.warn).not.toHaveBeenCalledWith( + expect.stringContaining('outside the workspace root') + ); + expect(spawnMock).toHaveBeenCalledWith( + tsc, + expect.any(Array), + expect.any(Object) + ); +}); + +test('warns when tsc from PATH is outside the workspace root', async () => { + const tsc = path.resolve('/home/user/node_modules/.bin/tsc'); + + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: library(), + }, + }, + '/home/user/node_modules/.bin': { + tsc: '', + }, + }); + + whichMock.mockResolvedValue(tsc); + + const report = await buildTypescript(); + + expect(report.warn).toHaveBeenCalledWith( + expect.stringContaining('outside the workspace root') + ); + expect(spawnMock).toHaveBeenCalledWith( + tsc, + expect.any(Array), + expect.any(Object) + ); +}); + +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]: { + [lockfile]: '', + node_modules: { + '.bin': { + tsc: '', + }, + }, + packages: { + library: library(), + }, + }, + }); + + await buildTypescript(); + + expect(spawnMock).toHaveBeenCalledWith( + tsc, + expect.any(Array), + expect.any(Object) + ); +}); + +test('does not traverse outside package root when no lockfile is found', async () => { + mockFs({ + [workspace]: { + node_modules: { + '.bin': { + tsc: '', + }, + }, + packages: { + library: library(), + }, + }, + }); + + await expect(buildTypescript()).rejects.toThrow( + 'Failed to build definition files.' + ); + + expect(spawnMock).not.toHaveBeenCalled(); +}); + +test('fails when tsc cannot be found in PATH or workspace node_modules', async () => { + mockFs({ + [workspace]: { + 'yarn.lock': '', + packages: { + library: library(), + }, + }, + }); + + await expect(buildTypescript()).rejects.toThrow( + 'Failed to build definition files.' + ); + + expect(spawnMock).not.toHaveBeenCalled(); +}); diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 84718f80e..9840eb1d4 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -25,6 +25,69 @@ 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, + limit: string +) { + let current = root; + + while (true) { + const candidate = path.resolve(current, 'node_modules', '.bin', binary); + + if (await fs.pathExists(candidate)) { + return candidate; + } + + if (current === limit) { + return undefined; + } + + const parent = path.dirname(current); + + if (parent === current) { + return undefined; + } + + current = parent; + } +} + export default async function build({ source, root, @@ -89,7 +152,7 @@ export default async function build({ ); } - let tsc; + let tsc: string | null | undefined; if (options?.tsc) { tsc = path.resolve(root, options.tsc); @@ -104,38 +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 = path.resolve(root, 'node_modules', '.bin', 'tsc'); - } + tsc = path.resolve(root, 'node_modules', '.bin', binary); - if (platform() === 'win32' && !tsc.endsWith('.cmd')) { - tsc += '.cmd'; + if (!(await fs.pathExists(tsc))) { + tsc = await which(binary, { nothrow: true }); } - } - if (!(await fs.pathExists(tsc))) { - try { - tsc = await which('tsc'); + let workspaceRoot: string | undefined; + + 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' @@ -144,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); } }