UNPKG

@napi-rs/cli

Version:

Cli tools for napi-rs

1,021 lines (913 loc) 33.6 kB
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, } from 'node:fs' import { exec } from 'node:child_process' import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, dirname, resolve } from 'node:path' import { join as posixJoin, sep as posixSep } from 'node:path/posix' import { sep as win32Sep } from 'node:path/win32' import { fileURLToPath } from 'node:url' import ava, { type ExecutionContext, type TestFn } from 'ava' import { buildProject, generateTypeDef, napiCrossToolchainEnvs, validateCrossCompileFlags, validateNapiCrossSupport, writeJsBinding, } from '../build.js' import { getSystemDefaultTarget } from '../../utils/index.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(__dirname, '../../../..') const test = ava as TestFn<{ tmpDir: string projectDir: string typeDefDir: string }> test.beforeEach(async (t) => { const timestamp = Date.now() const random = Math.random().toString(36).substring(7) const tmpDir = posixJoin( tmpdir(), 'napi-rs-test', `build-spec-${timestamp}-${random}`, ) const projectDir = posixJoin(tmpDir, 'project') const typeDefDir = posixJoin(projectDir, 'target', 'type-def') await mkdir(typeDefDir, { recursive: true }) t.context = { tmpDir, projectDir, typeDefDir } }) test.afterEach.always(async (t) => { if (existsSync(t.context.tmpDir)) { await rm(t.context.tmpDir, { recursive: true, force: true }) } }) test('build pipeline generates bindings and artifacts', async (t) => { const { projectDir, typeDefDir } = t.context const crateName = 'build_integration' const binaryName = 'build-integration' const packageName = 'build-integration' const version = '0.1.0' const target = getSystemDefaultTarget() const napiPath = posixJoin(repoRoot, 'crates', 'napi').replaceAll( win32Sep, posixSep, ) const napiDerivePath = posixJoin(repoRoot, 'crates', 'macro').replaceAll( win32Sep, posixSep, ) const napiBuildPath = posixJoin(repoRoot, 'crates', 'build').replaceAll( win32Sep, posixSep, ) await mkdir(join(projectDir, 'src'), { recursive: true }) const cargoToml = `[package] name = "${crateName}" version = "${version}" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] napi = { path = "${napiPath}" } napi-derive = { path = "${napiDerivePath}" } [build-dependencies] napi-build = { path = "${napiBuildPath}" } ` await writeFile(join(projectDir, 'Cargo.toml'), cargoToml) await writeFile( join(projectDir, 'package.json'), `${JSON.stringify( { name: packageName, version, napi: { binaryName, targets: [target.triple], }, }, null, 2, )}\n`, ) await writeFile( join(projectDir, 'build.rs'), 'fn main() {\n napi_build::setup();\n}\n', ) await writeFile( join(projectDir, 'src', 'lib.rs'), 'use napi_derive::napi;\n\n#[napi]\npub fn sum(a: i32, b: i32) -> i32 {\n a + b\n}\n', ) const buildCmd = `cargo build --target ${target.triple}` await new Promise<void>((resolve, reject) => { const child = exec(buildCmd, { cwd: projectDir, env: { ...process.env, NAPI_TYPE_DEF_TMP_FOLDER: typeDefDir }, }) child.stderr?.on('data', (data) => { console.error(data.toString()) }) child.on('exit', (code) => { if (code === 0) { resolve() } else { reject(new Error(`cargo build exited with code ${code ?? 'null'}`)) } }) child.on('error', reject) }) const files = await readdir(typeDefDir) t.true(files.length > 0, 'type definition files should be generated') const { exports, dts } = await generateTypeDef({ typeDefDir, cwd: projectDir, }) t.true(exports.includes('sum'), 'generateTypeDef should expose napi exports') const jsBinding = await writeJsBinding({ platform: true, idents: exports, binaryName, packageName, version, outputDir: projectDir, }) t.truthy(jsBinding) t.is(jsBinding?.path, join(projectDir, 'index.js')) const libName = crateName.replace(/-/g, '_') const srcName = target.platform === 'darwin' ? `lib${libName}.dylib` : target.platform === 'win32' ? `${libName}.dll` : `lib${libName}.so` const profile = 'debug' const srcPath = join(projectDir, 'target', target.triple, profile, srcName) t.true(existsSync(srcPath), 'compiled artifact should exist') const destName = `${binaryName}.${target.platformArchABI}.${srcName.endsWith('.wasm') ? 'wasm' : 'node'}` const destPath = join(projectDir, destName) await copyFile(srcPath, destPath) t.true(existsSync(destPath), 'artifact should be copied to output directory') const nodeStat = await stat(destPath) t.true(nodeStat.size > 0) t.regex(dts, /export declare function sum\(a: number, b: number\): number/) const jsPath = join(projectDir, 'index.js') t.true(existsSync(jsPath)) const jsContent = await readFile(jsPath, 'utf-8') t.regex(jsContent, /module\.exports\.sum = nativeBinding\.sum/) }) test('generateTypeDef preserves deterministic file order', async (t) => { const { projectDir, typeDefDir } = t.context await mkdir(join(typeDefDir, 'nested'), { recursive: true }) await writeFile( join(typeDefDir, 'b.type'), '{"kind":"fn","name":"zeta","def":"function zeta(): void"}\n', ) await writeFile( join(typeDefDir, 'a.type'), '{"kind":"fn","name":"alpha","def":"function alpha(): void"}\n', ) const { exports, dts } = await generateTypeDef({ typeDefDir, cwd: projectDir, }) t.deepEqual(exports, ['alpha', 'zeta']) t.true( dts.indexOf('function alpha(): void') < dts.indexOf('function zeta(): void'), ) }) test('should throw on emnapi version mismatch in wasm build', async (t) => { const { projectDir } = t.context const crateName = 'wasm_version_check' const binaryName = 'wasm-version-check' const packageName = 'wasm-version-check' const version = '0.1.0' const napiPath = posixJoin(repoRoot, 'crates', 'napi').replaceAll( win32Sep, posixSep, ) const napiDerivePath = posixJoin(repoRoot, 'crates', 'macro').replaceAll( win32Sep, posixSep, ) const napiBuildPath = posixJoin(repoRoot, 'crates', 'build').replaceAll( win32Sep, posixSep, ) await mkdir(join(projectDir, 'src'), { recursive: true }) const cargoToml = `[package] name = "${crateName}" version = "${version}" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] napi = { path = "${napiPath}", features = ["noop"] } napi-derive = { path = "${napiDerivePath}", features = ["noop"] } [build-dependencies] napi-build = { path = "${napiBuildPath}" } ` await writeFile(join(projectDir, 'Cargo.toml'), cargoToml) await writeFile( join(projectDir, 'package.json'), `${JSON.stringify( { name: packageName, version, napi: { binaryName, targets: ['wasm32-wasi-preview1-threads'], }, }, null, 2, )}\n`, ) await writeFile( join(projectDir, 'build.rs'), 'fn main() {\n napi_build::setup();\n}\n', ) await writeFile( join(projectDir, 'src', 'lib.rs'), 'use napi_derive::napi;\n\n#[napi]\npub fn sum(a: i32, b: i32) -> i32 {\n a + b\n}\n', ) // Create fake @emnapi/core and @emnapi/runtime with mismatched versions const fakeVersion = '0.0.0-fake' for (const pkg of ['@emnapi/core', '@emnapi/runtime']) { const pkgDir = join(projectDir, 'node_modules', pkg) await mkdir(pkgDir, { recursive: true }) await writeFile( join(pkgDir, 'package.json'), JSON.stringify({ name: pkg, version: fakeVersion, main: 'index.js' }), ) await writeFile( join(pkgDir, 'index.js'), `module.exports = { version: "${fakeVersion}" }`, ) } const error = await t.throwsAsync(() => buildProject({ target: 'wasm32-wasi-preview1-threads', cwd: projectDir, }), ) t.truthy(error) t.regex(error!.message, /emnapi version mismatch/) }) test('validateCrossCompileFlags rejects combining two cross-compilation mechanisms', (t) => { t.throws( () => validateCrossCompileFlags({ useCross: true, crossCompile: true }), { message: /`--use-cross`.+`--cross-compile`.+cannot be used together/ }, ) t.throws( () => validateCrossCompileFlags({ useCross: true, useNapiCross: true }), { message: /`--use-cross`.+`--use-napi-cross`.+cannot be used together/ }, ) t.throws( () => validateCrossCompileFlags({ useNapiCross: true, crossCompile: true }), { message: /`--use-napi-cross`.+`--cross-compile`.+cannot be used together/, }, ) t.throws( () => validateCrossCompileFlags({ useCross: true, useNapiCross: true, crossCompile: true, }), { message: /`--use-cross`.+`--use-napi-cross`.+`--cross-compile`.+cannot be used together/, }, ) }) test('validateCrossCompileFlags allows a single cross-compilation mechanism', (t) => { t.notThrows(() => validateCrossCompileFlags({})) t.notThrows(() => validateCrossCompileFlags({ useCross: true })) t.notThrows(() => validateCrossCompileFlags({ crossCompile: true })) t.notThrows(() => validateCrossCompileFlags({ useNapiCross: true })) }) test('validateCrossCompileFlags rejects windows-gnu targets with `--cross-compile`', (t) => { const windowsGnuError = /`--cross-compile` \(`-x`\) does not support the target x86_64-pc-windows-gnu/ // `cargo-xwin` is only used on non-Windows hosts, where it silently // no-ops for `windows-gnu` targets, so the combination must be rejected. const gnuError = t.throws( () => validateCrossCompileFlags( { crossCompile: true, target: 'x86_64-pc-windows-gnu' }, 'linux', ), { message: windowsGnuError }, ) // `windows-gnu` links with a mingw-w64 GCC toolchain. t.regex(gnuError!.message, /x86_64-w64-mingw32-gcc/) t.regex(gnuError!.message, /LIBNODE_PATH/) t.regex(gnuError!.message, /x86_64-pc-windows-msvc/) t.throws( () => validateCrossCompileFlags( { crossCompile: true, target: 'x86_64-pc-windows-gnu' }, 'darwin', ), { message: windowsGnuError }, ) // `gnullvm`-flavored triples take the same broken `cargo-xwin` route, but // link with an LLVM toolchain (llvm-mingw), not the mingw-w64 GCC one. const gnullvmError = t.throws( () => validateCrossCompileFlags( { crossCompile: true, target: 'x86_64-pc-windows-gnullvm' }, 'linux', ), { message: /`--cross-compile` \(`-x`\) does not support the target x86_64-pc-windows-gnullvm/, }, ) t.regex(gnullvmError!.message, /llvm-mingw/) t.notRegex(gnullvmError!.message, /mingw32-gcc/) t.regex(gnullvmError!.message, /LIBNODE_PATH/) t.regex(gnullvmError!.message, /x86_64-pc-windows-msvc/) // The target can also come from `CARGO_BUILD_TARGET`; the check is fully // synchronous, so the environment is mutated and restored without any // interleaving point another concurrently running test could observe. const originalCargoBuildTarget = process.env.CARGO_BUILD_TARGET try { process.env.CARGO_BUILD_TARGET = 'x86_64-pc-windows-gnu' t.throws(() => validateCrossCompileFlags({ crossCompile: true }, 'linux'), { message: windowsGnuError, }) } finally { if (originalCargoBuildTarget === undefined) { delete process.env.CARGO_BUILD_TARGET } else { process.env.CARGO_BUILD_TARGET = originalCargoBuildTarget } } }) test('validateCrossCompileFlags allows `--cross-compile` for non windows-gnu targets', (t) => { // MSVC targets are exactly what `cargo-xwin` supports. for (const target of [ 'x86_64-pc-windows-msvc', 'aarch64-pc-windows-msvc', 'i686-pc-windows-msvc', 'x86_64-unknown-linux-gnu', 'aarch64-apple-darwin', ]) { t.notThrows(() => validateCrossCompileFlags({ crossCompile: true, target }, 'linux'), ) } // On a Windows host `--cross-compile` never routes through `cargo-xwin` // (it falls back to a plain `cargo build`), so windows-gnu stays allowed. t.notThrows(() => validateCrossCompileFlags( { crossCompile: true, target: 'x86_64-pc-windows-gnu' }, 'win32', ), ) // Without `--cross-compile` the target is none of this check's business. t.notThrows(() => validateCrossCompileFlags({ target: 'x86_64-pc-windows-gnu' }, 'linux'), ) }) test('validateCrossCompileFlags rejects watch mode combined with cross builds', (t) => { t.throws(() => validateCrossCompileFlags({ watch: true, useCross: true }), { message: /`--watch` cannot be used with `--use-cross`/, }) t.throws( () => validateCrossCompileFlags({ watch: true, crossCompile: true }), { message: /`--watch` cannot be used with `--cross-compile`/ }, ) t.notThrows(() => validateCrossCompileFlags({ watch: true })) t.notThrows(() => validateCrossCompileFlags({ watch: true, useNapiCross: true }), ) }) test('validateNapiCrossSupport rejects unsupported hosts', (t) => { t.throws( () => validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'darwin', 'arm64'), { message: /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/ }, ) t.throws( () => validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'win32', 'x64'), { message: /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/ }, ) t.throws( () => validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'linux', 'ia32'), { message: /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/ }, ) t.notThrows(() => validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'linux', 'x64'), ) t.notThrows(() => validateNapiCrossSupport('x86_64-unknown-linux-gnu', 'linux', 'arm64'), ) }) test('validateNapiCrossSupport rejects unsupported target triples', (t) => { t.throws( () => validateNapiCrossSupport('x86_64-unknown-linux-musl', 'linux', 'x64'), { message: /`--use-napi-cross` does not support the target x86_64-unknown-linux-musl/, }, ) t.throws( () => validateNapiCrossSupport('riscv64gc-unknown-linux-gnu', 'linux', 'arm64'), { message: /`--use-napi-cross` does not support the target riscv64gc-unknown-linux-gnu/, }, ) for (const triple of [ 'x86_64-unknown-linux-gnu', 'aarch64-unknown-linux-gnu', 'armv7-unknown-linux-gnueabihf', 's390x-unknown-linux-gnu', 'powerpc64le-unknown-linux-gnu', ]) { t.notThrows(() => validateNapiCrossSupport(triple, 'linux', 'x64')) t.notThrows(() => validateNapiCrossSupport(triple, 'linux', 'arm64')) } }) const napiCrossToolchainPath = posixJoin( '/home/user/.napi-rs/cross-toolchain/1.0.0', 'aarch64-unknown-linux-gnu', ) const napiCrossDownloadedSysroot = join( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', 'sysroot', ) test('napiCrossToolchainEnvs points the build at the downloaded toolchain', (t) => { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin' }, ) t.is( envs.CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER, join(napiCrossToolchainPath, 'bin', 'aarch64-unknown-linux-gnu-gcc'), ) t.is(envs.TARGET_SYSROOT, napiCrossDownloadedSysroot) t.is(envs.BINDGEN_EXTRA_CLANG_ARGS, `--sysroot=${napiCrossDownloadedSysroot}`) t.is(envs.PATH, `${napiCrossToolchainPath}/bin:/usr/bin`) // gcc is the default compiler, so no clang-specific flags are set. t.is(envs.TARGET_CFLAGS, undefined) t.is(envs.TARGET_CXXFLAGS, undefined) }) test('napiCrossToolchainEnvs respects a user-provided TARGET_SYSROOT', (t) => { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_SYSROOT: '/opt/custom-sysroot' }, ) // The user's value wins: it is not overridden... t.is(envs.TARGET_SYSROOT, undefined) // ...and the derived flags use it. t.is(envs.BINDGEN_EXTRA_CLANG_ARGS, '--sysroot=/opt/custom-sysroot') }) test('napiCrossToolchainEnvs treats an empty TARGET_SYSROOT as unset', (t) => { // `setEnvIfNotExists` uses falsy semantics (`!process.env[env]`), so a // present-but-empty `TARGET_SYSROOT` still gets the downloaded sysroot // written to the build environment — the flags derived from the effective // sysroot must follow the same rule instead of producing `--sysroot=`. const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_SYSROOT: '' }, ) t.is(envs.TARGET_SYSROOT, napiCrossDownloadedSysroot) t.is(envs.BINDGEN_EXTRA_CLANG_ARGS, `--sysroot=${napiCrossDownloadedSysroot}`) }) test('napiCrossToolchainEnvs derives clang flags from the effective sysroot', (t) => { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', // Present-but-empty values must fall back to the downloaded sysroot. TARGET_SYSROOT: '', TARGET_CC: 'clang', TARGET_CXX: 'clang++', TARGET_CFLAGS: '-O2', }, ) t.is( envs.TARGET_CFLAGS, `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} -O2`, ) t.is( envs.TARGET_CXXFLAGS, `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} `, ) }) test('napiCrossToolchainEnvs sets a bare toolchain PATH when the env has none', (t) => { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', {}, ) // No `:undefined` tail when the provided env has no PATH at all. t.is(envs.PATH, `${napiCrossToolchainPath}/bin`) }) test('napiCrossToolchainEnvs recognizes path-qualified, prefixed and versioned clang', (t) => { const clangFlags = `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} ` for (const [cc, cxx] of [ ['/usr/bin/clang', '/opt/llvm/bin/clang++'], ['aarch64-linux-gnu-clang', 'aarch64-linux-gnu-clang++'], ['clang-18', 'clang++-18'], ]) { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx }, ) t.is(envs.TARGET_CFLAGS, clangFlags, `TARGET_CC=${cc}`) t.is(envs.TARGET_CXXFLAGS, clangFlags, `TARGET_CXX=${cxx}`) } }) test('napiCrossToolchainEnvs ignores CC/CXX when the toolchain compiler is effective', (t) => { // With TARGET_CC/TARGET_CXX unset, the function itself exports the // toolchain gcc/g++ as TARGET_CC/TARGET_CXX — and cc-rs prefers TARGET_CC // over CC for cross builds, so a clang in CC/CXX never actually runs. // Injecting the clang-only `--gcc-toolchain=` flag here would hard-error // the gcc that does run. const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', CC: '/usr/bin/clang', CXX: '/opt/llvm/bin/clang++' }, ) t.true(envs.TARGET_CC.endsWith('-gcc')) t.true(envs.TARGET_CXX.endsWith('-g++')) t.is(envs.TARGET_CFLAGS, undefined) t.is(envs.TARGET_CXXFLAGS, undefined) }) test('napiCrossToolchainEnvs treats an empty TARGET_CC as unset for clang detection', (t) => { // Falsy semantics: a present-but-empty TARGET_CC still gets the toolchain // gcc written to the build environment, so the CC fallback must not // resurrect clang detection for a compiler that will not run. const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: '', CC: '/usr/bin/clang' }, ) t.is( envs.TARGET_CC, join(napiCrossToolchainPath, 'bin', 'aarch64-unknown-linux-gnu-gcc'), ) t.is(envs.TARGET_CFLAGS, undefined) }) test('napiCrossToolchainEnvs lets a user TARGET_CC=clang win over CC=gcc', (t) => { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: 'clang', CC: 'gcc' }, ) t.is( envs.TARGET_CFLAGS, `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} `, ) // The CXX side is untouched, so it stays on the toolchain g++ without // clang flags — each language is detected independently. t.is(envs.TARGET_CXXFLAGS, undefined) }) test('napiCrossToolchainEnvs injects no flags on either side when only CC=clang is set', (t) => { // Both languages default to the toolchain gcc/g++; neither effective // compiler is clang, so neither flag set is injected. const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', CC: 'clang' }, ) t.true(envs.TARGET_CC.endsWith('-gcc')) t.is(envs.TARGET_CFLAGS, undefined) t.is(envs.TARGET_CXXFLAGS, undefined) }) test('napiCrossToolchainEnvs does not mistake non-clang tools for clang', (t) => { for (const [cc, cxx] of [ ['gcc', 'g++'], ['x86_64-unknown-linux-gnu-gcc', 'x86_64-unknown-linux-gnu-g++'], // `clang-format` is a clang-family tool but not a compiler. ['clang-format', 'clang-format'], ['someclangthing', 'someclangthing'], ]) { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx }, ) t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`) t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`) } }) test('napiCrossToolchainEnvs detects clang behind cc-rs wrapper and argument forms', (t) => { const clangFlags = `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} ` // cc-rs parses the env value before executing it (`env_tool`): the value // is split on whitespace, a known wrapper prefix (`sccache clang`) runs // the second token, and an argument form (`clang -target …`) runs the // first token with the rest as arguments — clang runs in both cases. for (const [cc, cxx] of [ ['sccache clang', 'sccache clang++'], ['ccache clang-18', 'ccache clang++-18'], ['distcc /usr/bin/clang', 'distcc /usr/bin/clang++'], [ 'clang -target aarch64-unknown-linux-gnu', 'clang++ -target aarch64-unknown-linux-gnu', ], ['/usr/bin/clang --sysroot=/x', '/usr/bin/clang++ --sysroot=/x'], ]) { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx }, ) t.is(envs.TARGET_CFLAGS, clangFlags, `TARGET_CC=${cc}`) t.is(envs.TARGET_CXXFLAGS, clangFlags, `TARGET_CXX=${cxx}`) } }) // Writes empty files at `relativePaths` under a fresh temp directory whose // subdirectories contain spaces, mirroring installs like `/opt/LLVM 18`. // Returns the temp directory root; removal is registered on `t.teardown`. const makeCompilerFixture = ( t: ExecutionContext, relativePaths: Array<string>, ): string => { const fixtureRoot = mkdtempSync(join(tmpdir(), 'napi-clang-detect-')) t.teardown(() => rmSync(fixtureRoot, { recursive: true, force: true })) for (const relativePath of relativePaths) { const absolutePath = join(fixtureRoot, relativePath) mkdirSync(dirname(absolutePath), { recursive: true }) writeFileSync(absolutePath, '') } return fixtureRoot } test('napiCrossToolchainEnvs matches clang in space-containing paths that exist on disk', (t) => { const clangFlags = `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} ` // cc-rs's `env_tool` treats the WHOLE env value as the compiler when it // exists on the filesystem (`check_exe`) before any whitespace splitting, // so `TARGET_CC="<tmp>/LLVM 18/bin/clang"` runs clang — splitting it into // `<tmp>/LLVM` + `18/bin/clang` would miss the clang detection entirely. const fixtureRoot = makeCompilerFixture(t, [ 'LLVM 18/bin/clang', 'LLVM 18/bin/clang++-17', ]) const cc = join(fixtureRoot, 'LLVM 18', 'bin', 'clang') const cxx = join(fixtureRoot, 'LLVM 18', 'bin', 'clang++-17') const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx }, ) t.is(envs.TARGET_CFLAGS, clangFlags, `TARGET_CC=${cc}`) t.is(envs.TARGET_CXXFLAGS, clangFlags, `TARGET_CXX=${cxx}`) }) test('napiCrossToolchainEnvs does not mistake existing space-containing non-clang paths for clang', (t) => { const fixtureRoot = makeCompilerFixture(t, [ 'app dir/bin/gcc', 'app dir/bin/g++', // A clang-family tool that is not a compiler, in a space-containing path. 'LLVM 18/bin/clang-format', ]) for (const [cc, cxx] of [ [ join(fixtureRoot, 'app dir', 'bin', 'gcc'), join(fixtureRoot, 'app dir', 'bin', 'g++'), ], [ join(fixtureRoot, 'LLVM 18', 'bin', 'clang-format'), join(fixtureRoot, 'LLVM 18', 'bin', 'clang-format'), ], ]) { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx }, ) t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`) t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`) } }) test('napiCrossToolchainEnvs splits space-containing paths that do not exist on disk', (t) => { // cc-rs only takes the whole value as a compiler path when it exists on // the filesystem; otherwise it splits on whitespace, so this value runs // `/nonexistent` with `dir/bin/clang` as an argument — never clang. const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: '/nonexistent dir/bin/clang', TARGET_CXX: '/nonexistent dir/bin/clang++', }, ) t.is(envs.TARGET_CFLAGS, undefined) t.is(envs.TARGET_CXXFLAGS, undefined) }) test('napiCrossToolchainEnvs does not mistake gcc with clang-ending arguments for clang', (t) => { // The basename of each WHOLE value below is `clang`, but none of them // exists as a file, so cc-rs splits on whitespace and runs gcc — clang // flags injected here would hard-error the gcc compile. for (const [cc, cxx] of [ ['gcc --sysroot=/opt/clang', 'g++ --sysroot=/opt/clang'], ['gcc -B/opt/LLVM 18/bin/clang', 'g++ -B/opt/LLVM 18/bin/clang'], ['sccache gcc --sysroot=/opt/clang', 'sccache g++ --sysroot=/opt/clang'], ]) { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx }, ) t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`) t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`) } }) test('napiCrossToolchainEnvs does not mistake wrapped or argument-form non-clang for clang', (t) => { for (const [cc, cxx] of [ ['sccache gcc', 'sccache g++'], ['ccache gcc', 'ccache g++'], ['gcc -B/foo', 'g++ -B/foo'], // The compiler token behind the wrapper is still not a compiler. ['sccache clang-format', 'sccache clang-format'], ]) { const envs = napiCrossToolchainEnvs( napiCrossToolchainPath, 'aarch64-unknown-linux-gnu', { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx }, ) t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`) t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`) } }) test('buildProject rejects invalid cross flag combinations upfront', async (t) => { const { projectDir } = t.context await mkdir(join(projectDir, 'src'), { recursive: true }) await writeFile( join(projectDir, 'Cargo.toml'), `[package] name = "cross_flags_check" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] `, ) await writeFile(join(projectDir, 'src', 'lib.rs'), '') await writeFile( join(projectDir, 'package.json'), `${JSON.stringify( { name: 'cross-flags-check', version: '0.1.0', napi: { binaryName: 'cross-flags-check' }, }, null, 2, )}\n`, ) const comboError = await t.throwsAsync(() => buildProject({ cwd: projectDir, useCross: true, crossCompile: true }), ) t.regex(comboError!.message, /cannot be used together/) const watchError = await t.throwsAsync(() => buildProject({ cwd: projectDir, watch: true, crossCompile: true }), ) t.regex( watchError!.message, /`--watch` cannot be used with `--cross-compile`/, ) const napiCrossError = await t.throwsAsync(() => buildProject({ cwd: projectDir, useNapiCross: true, target: 'riscv64gc-unknown-linux-gnu', }), ) t.regex(napiCrossError!.message, /`--use-napi-cross`/) }) test('buildProject validates cross flags before resolving the crate manifest', async (t) => { // `projectDir` deliberately contains no `Cargo.toml`: if `buildProject` // resolved the manifest (and spawned `cargo metadata`) before validating // the cross-compilation flags, these calls would fail with // "No crate found in manifest" instead of the validation errors below. const { projectDir } = t.context const comboError = await t.throwsAsync(() => buildProject({ cwd: projectDir, useCross: true, crossCompile: true }), ) t.regex( comboError!.message, /`--use-cross`.+`--cross-compile`.+cannot be used together/, ) const watchError = await t.throwsAsync(() => buildProject({ cwd: projectDir, watch: true, useCross: true }), ) t.regex(watchError!.message, /`--watch` cannot be used with `--use-cross`/) const watchCrossCompileError = await t.throwsAsync(() => buildProject({ cwd: projectDir, watch: true, crossCompile: true }), ) t.regex( watchCrossCompileError!.message, /`--watch` cannot be used with `--cross-compile`/, ) // Rejected either for the unsupported host (non Linux x64/arm64) or for // the unsupported target triple (on Linux x64/arm64 hosts) — both are // `--use-napi-cross` validation errors, keeping this assertion // host-platform independent. const napiCrossError = await t.throwsAsync(() => buildProject({ cwd: projectDir, useNapiCross: true, target: 'riscv64gc-unknown-linux-gnu', }), ) t.regex(napiCrossError!.message, /`--use-napi-cross`/) }) // On a Windows host `--cross-compile` never routes through `cargo-xwin`, // so the windows-gnu rejection only exists on non-Windows hosts. ;(process.platform === 'win32' ? test.skip : test)( 'buildProject rejects `--cross-compile` with a windows-gnu target before any side effect', async (t) => { // `projectDir` deliberately contains no `Cargo.toml`: if `buildProject` // resolved the manifest (and spawned `cargo metadata`) before validating // the target, this call would fail with a manifest error instead of the // windows-gnu validation error below. const { projectDir } = t.context const error = await t.throwsAsync(() => buildProject({ cwd: projectDir, crossCompile: true, target: 'x86_64-pc-windows-gnu', }), ) t.regex( error!.message, /`--cross-compile` \(`-x`\) does not support the target x86_64-pc-windows-gnu/, ) t.regex(error!.message, /cargo-xwin/) }, ) // The scenario below only exists on hosts `--use-napi-cross` does not // support (anything but Linux x64 / Linux arm64), so skip it elsewhere. const isNapiCrossUnsupportedHost = process.platform !== 'linux' || (process.arch !== 'x64' && process.arch !== 'arm64') ;(isNapiCrossUnsupportedHost ? test : test.skip)( 'buildProject reports the `--use-napi-cross` host error before resolving the target', async (t) => { const { projectDir } = t.context // Without an explicit `--target` (or `CARGO_BUILD_TARGET`), resolving // the target spawns `rustc -vV`. Point `PATH` at an empty directory so // that spawn is guaranteed to fail — whether or not Rust is installed on // this machine: if `buildProject` resolved the target before validating // the host, the error below would be the `rustc` spawn failure instead // of the host validation error. const emptyPathDir = join(projectDir, 'empty-path') await mkdir(emptyPathDir, { recursive: true }) const originalPath = process.env.PATH const originalCargoBuildTarget = process.env.CARGO_BUILD_TARGET process.env.PATH = emptyPathDir delete process.env.CARGO_BUILD_TARGET // The cross-flag validation runs synchronously at the top of // `buildProject`, so the promise below is already settled (rejected) // when the environment is restored right after — no other concurrently // running test can observe the modified `PATH`. let buildPromise: Promise<unknown> try { buildPromise = buildProject({ cwd: projectDir, useNapiCross: true }) } finally { if (originalPath === undefined) { delete process.env.PATH } else { process.env.PATH = originalPath } if (originalCargoBuildTarget === undefined) { delete process.env.CARGO_BUILD_TARGET } else { process.env.CARGO_BUILD_TARGET = originalCargoBuildTarget } } const error = await t.throwsAsync(() => buildPromise) t.regex( error!.message, /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/, ) }, )