@napi-rs/cli
Version:
Cli tools for napi-rs
528 lines (451 loc) • 15.7 kB
text/typescript
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parse as parseToml } from '@std/toml'
import ava, { type TestFn } from 'ava'
import { load as yamlLoad } from 'js-yaml'
import { newProject } from '../new.js'
const test = ava as TestFn<{
tmpDir: string
}>
test.beforeEach(async (t) => {
// Create a unique temp directory for tests
const timestamp = Date.now()
const random = Math.random().toString(36).substring(7)
const tmpDir = join(
tmpdir(),
'napi-rs-test',
`new-project-${timestamp}-${random}`,
)
t.context = { tmpDir }
})
test.afterEach.always(async (t) => {
// Clean up any created directories
if (existsSync(t.context.tmpDir)) {
await rm(t.context.tmpDir, { recursive: true, force: true })
}
})
test('create a new project with default options', async (t) => {
const projectPath = join(t.context.tmpDir, 'default-project')
await newProject({
path: projectPath,
enableDefaultTargets: true,
})
t.true(existsSync(projectPath))
t.true(existsSync(join(projectPath, 'package.json')))
t.true(existsSync(join(projectPath, 'Cargo.toml')))
t.true(existsSync(join(projectPath, 'src')))
t.true(existsSync(join(projectPath, '.github', 'workflows', 'CI.yml')))
// Check package.json
const pkgJson = JSON.parse(
await readFile(join(projectPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, 'default-project')
t.is(pkgJson.napi.binaryName, 'default-project')
t.is(pkgJson.license, 'MIT')
t.truthy(pkgJson.engines.node)
t.false('browser' in pkgJson)
t.false(pkgJson.files.includes('browser.js'))
t.falsy(existsSync(join(projectPath, 'browser.js')))
t.falsy(existsSync(join(projectPath, 'default-project.wasi-browser.js')))
t.falsy(existsSync(join(projectPath, 'wasi-worker-browser.mjs')))
t.falsy(existsSync(join(projectPath, 'wasi-worker.mjs')))
const gitAttributes = await readFile(
join(projectPath, '.gitattributes'),
'utf-8',
)
t.truthy(gitAttributes.includes('default-project.wasi-browser.js'))
t.truthy(gitAttributes.includes('default-project.wasi.cjs'))
t.truthy(gitAttributes.includes('wasi-worker-browser.mjs'))
t.truthy(gitAttributes.includes('wasi-worker.mjs'))
const ciYaml = await readFile(
join(projectPath, '.github', 'workflows', 'CI.yml'),
'utf-8',
)
const yamlObject = yamlLoad(ciYaml) as any
t.is(yamlObject.env.APP_NAME, 'default-project')
t.falsy(yamlObject.jobs.publish.needs.includes('wasm32-wasip1-threads'))
t.falsy(
yamlObject.jobs['test-linux-binding'].strategy.matrix.target.includes(
'aarch64-unknown-linux-musl',
),
)
})
test('create a new project with custom name', async (t) => {
const projectPath = join(t.context.tmpDir, 'custom-name-dir')
await newProject({
path: projectPath,
name: '@my-scope/custom-package',
enableDefaultTargets: true,
})
t.true(existsSync(projectPath))
const pkgJson = JSON.parse(
await readFile(join(projectPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, '@my-scope/custom-package')
t.is(pkgJson.napi.binaryName, 'custom-package')
const cargoToml = await readFile(join(projectPath, 'Cargo.toml'), 'utf-8')
// Verify that the package name was properly renamed to follow Rust naming conventions
t.true(cargoToml.includes('name = "custom_package"'))
})
test('create a new project with custom path', async (t) => {
const customPath = join(t.context.tmpDir, 'nested', 'folders', 'my-project')
await newProject({
path: customPath,
enableDefaultTargets: true,
})
t.true(existsSync(customPath))
t.true(existsSync(join(customPath, 'package.json')))
const pkgJson = JSON.parse(
await readFile(join(customPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, 'my-project')
})
test('create a new project with custom path and name', async (t) => {
const projectPath = join(t.context.tmpDir, 'custom-dir')
await newProject({
path: projectPath,
name: 'custom-project-name',
enableDefaultTargets: true,
})
t.true(existsSync(projectPath))
const pkgJson = JSON.parse(
await readFile(join(projectPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, 'custom-project-name')
t.is(pkgJson.napi.binaryName, 'custom-project-name')
})
test('create a new project with custom path, name, and targets', async (t) => {
const projectPath = join(t.context.tmpDir, 'full-custom')
const customTargets = [
'x86_64-unknown-linux-gnu',
'x86_64-apple-darwin',
'aarch64-apple-darwin',
'wasm32-wasip1-threads',
'x86_64-unknown-freebsd',
]
await newProject({
path: projectPath,
name: '@custom/full-project',
targets: customTargets,
enableDefaultTargets: false,
license: 'Apache-2.0',
minNodeApiVersion: 6,
})
t.true(existsSync(projectPath))
const pkgJson = JSON.parse(
await readFile(join(projectPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, '@custom/full-project')
t.is(pkgJson.napi.binaryName, 'full-project')
t.is(pkgJson.license, 'Apache-2.0')
t.true(pkgJson.engines.node.includes('>= 14.0.0'))
t.is(pkgJson.browser, 'browser.js')
t.true(pkgJson.files.includes('browser.js'))
t.true(existsSync(join(projectPath, 'browser.js')))
// Check that CI workflow only includes the specified targets
const ciYaml = await readFile(
join(projectPath, '.github', 'workflows', 'CI.yml'),
'utf-8',
)
const yamlObject = yamlLoad(ciYaml) as any
t.true(
yamlObject.jobs.build.strategy.matrix.settings.some(
(setting: any) => setting.target === 'x86_64-unknown-linux-gnu',
),
)
t.true(
yamlObject.jobs.build.strategy.matrix.settings.some(
(setting: any) => setting.target === 'x86_64-apple-darwin',
),
)
t.true(
yamlObject.jobs.build.strategy.matrix.settings.some(
(setting: any) => setting.target === 'aarch64-apple-darwin',
),
)
t.false(
yamlObject.jobs.build.strategy.matrix.settings.some(
(setting: any) => setting.target === 'x86_64-pc-windows-msvc',
),
)
t.true(
yamlObject.jobs.build.strategy.matrix.settings.some(
(setting: any) => setting.target === 'wasm32-wasip1-threads',
),
)
t.truthy(yamlObject.jobs['build-freebsd'])
t.truthy(yamlObject.jobs['test-wasi'])
t.falsy(
yamlObject.jobs['test-macOS-windows-binding'].strategy.matrix.settings.some(
(setting: any) => setting.target === 'x86_64-pc-windows-msvc',
),
)
t.truthy(
yamlObject.jobs['test-macOS-windows-binding'].strategy.matrix.settings.some(
(setting: any) => setting.target === 'aarch64-apple-darwin',
),
)
})
test('non Windows and macOS targets should remove test-macOS-windows-binding job', async (t) => {
const projectPath = join(t.context.tmpDir, 'no-windows-macos')
const targets = [
'x86_64-unknown-linux-gnu',
'aarch64-unknown-linux-gnu',
'wasm32-wasip1-threads',
'x86_64-unknown-freebsd',
]
await newProject({
path: projectPath,
targets,
enableDefaultTargets: false,
})
t.true(existsSync(projectPath))
const ciYaml = await readFile(
join(projectPath, '.github', 'workflows', 'CI.yml'),
'utf-8',
)
const yamlObject = yamlLoad(ciYaml) as any
t.falsy(yamlObject.jobs['test-macOS-windows-binding'])
t.falsy(yamlObject.jobs.publish.needs.includes('test-macOS-windows-binding'))
})
test('aarch64-apple-darwin should keep test-macOS-windows-binding job', async (t) => {
const projectPath = join(t.context.tmpDir, 'apple-silicon-only')
const targets = ['aarch64-apple-darwin']
await newProject({
path: projectPath,
targets,
enableDefaultTargets: false,
})
t.true(existsSync(projectPath))
const ciYaml = await readFile(
join(projectPath, '.github', 'workflows', 'CI.yml'),
'utf-8',
)
const yamlObject = yamlLoad(ciYaml) as any
t.truthy(yamlObject.jobs['test-macOS-windows-binding'])
t.deepEqual(
yamlObject.jobs['test-macOS-windows-binding'].strategy.matrix.settings.map(
(setting: any) => setting.target,
),
['aarch64-apple-darwin'],
)
t.truthy(yamlObject.jobs.publish.needs.includes('test-macOS-windows-binding'))
})
test('should remove test-linux-binding job if no Linux targets are enabled', async (t) => {
const projectPath = join(t.context.tmpDir, 'no-linux')
const targets = ['x86_64-apple-darwin', 'aarch64-apple-darwin']
await newProject({
path: projectPath,
targets,
enableDefaultTargets: false,
})
t.true(existsSync(projectPath))
const ciYaml = await readFile(
join(projectPath, '.github', 'workflows', 'CI.yml'),
'utf-8',
)
const yamlObject = yamlLoad(ciYaml) as any
t.falsy(yamlObject.jobs['test-linux-binding'])
t.falsy(yamlObject.jobs.publish.needs.includes('test-linux-binding'))
})
test('should fail when no path is provided', async (t) => {
await t.throwsAsync(
async () => {
await newProject({
enableDefaultTargets: true,
})
},
{ message: /Please provide the path as the argument/ },
)
})
test('should fail when path already exists and is not empty', async (t) => {
const projectPath = join(t.context.tmpDir, 'existing-project')
// Create directory with a file
await rm(projectPath, { recursive: true, force: true }).catch(() => {})
const { mkdirSync, writeFileSync } = await import('node:fs')
mkdirSync(projectPath, { recursive: true })
writeFileSync(join(projectPath, 'existing-file.txt'), 'content')
await t.throwsAsync(
async () => {
await newProject({
path: projectPath,
enableDefaultTargets: true,
})
},
{ message: /already exists and it's not empty/ },
)
})
test('should fail when path is a file', async (t) => {
const filePath = join(t.context.tmpDir, 'file.txt')
// Create a file
const { writeFileSync } = await import('node:fs')
const { mkdirSync } = await import('node:fs')
mkdirSync(t.context.tmpDir, { recursive: true })
writeFileSync(filePath, 'content')
await t.throwsAsync(
async () => {
await newProject({
path: filePath,
enableDefaultTargets: true,
})
},
{ message: /already exists and it's not a directory/ },
)
})
test('should fail when git is unavailable', async (t) => {
const projectPath = join(t.context.tmpDir, 'missing-git')
const moduleUrl = pathToFileURL(join(process.cwd(), 'src/api/new.ts')).href
const result = await new Promise<{
code: number | null
stderr: string
}>((resolve) => {
execFile(
process.execPath,
[
'--import',
'@oxc-node/core/register',
'--input-type=module',
'-e',
`const { newProject } = await import(${JSON.stringify(moduleUrl)}); await newProject({ path: ${JSON.stringify(projectPath)}, enableDefaultTargets: true });`,
],
{
cwd: process.cwd(),
env: {
...process.env,
PATH: '',
},
},
(error, _stdout, stderr) => {
resolve({
code:
error && 'code' in error && typeof error.code === 'number'
? error.code
: 0,
stderr,
})
},
)
})
t.not(result.code, 0)
t.regex(
result.stderr,
/Git is not installed or not available in PATH\. Please install Git to continue\./,
)
})
test('dry run should not create any files', async (t) => {
const projectPath = join(t.context.tmpDir, 'dry-run-project')
await newProject({
path: projectPath,
name: 'dry-run-test',
enableDefaultTargets: true,
dryRun: true,
})
t.false(existsSync(projectPath))
})
test('create project without GitHub Actions', async (t) => {
const projectPath = join(t.context.tmpDir, 'no-github-actions')
await newProject({
path: projectPath,
enableDefaultTargets: true,
enableGithubActions: false,
})
t.true(existsSync(projectPath))
t.true(existsSync(join(projectPath, 'package.json')))
t.false(existsSync(join(projectPath, '.github')))
})
test('create project without type-def', async (t) => {
const projectPath = join(t.context.tmpDir, 'no-type-def')
await newProject({
path: projectPath,
enableDefaultTargets: true,
enableTypeDef: false,
})
const cargoToml = await readFile(join(projectPath, 'Cargo.toml'), 'utf-8')
const cargoTomlData = parseToml(cargoToml) as any
t.is(cargoTomlData.dependencies['napi-derive']['default-features'], false)
t.false(
cargoTomlData.dependencies['napi-derive'].features.includes('type-def'),
)
})
test('create a new project with pnpm package manager', async (t) => {
const projectPath = join(t.context.tmpDir, 'pnpm-project')
await newProject({
path: projectPath,
name: 'pnpm-test-project',
packageManager: 'pnpm',
enableDefaultTargets: true,
})
t.true(existsSync(projectPath))
t.true(existsSync(join(projectPath, 'package.json')))
t.true(existsSync(join(projectPath, 'Cargo.toml')))
// Check package.json
const pkgJson = JSON.parse(
await readFile(join(projectPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, 'pnpm-test-project')
t.is(pkgJson.napi.binaryName, 'pnpm-test-project')
// Verify that the Cargo.toml has the correct sanitized name
const cargoToml = await readFile(join(projectPath, 'Cargo.toml'), 'utf-8')
t.true(cargoToml.includes('name = "pnpm_test_project"'))
// Check for pnpm-specific files or configurations if any
// The template might have different structure for pnpm
})
test('create a new project with pnpm and custom name', async (t) => {
const projectPath = join(t.context.tmpDir, 'pnpm-custom-name')
await newProject({
path: projectPath,
name: '@my-org/custom-pnpm-package',
packageManager: 'pnpm',
enableDefaultTargets: true,
})
t.true(existsSync(projectPath))
t.true(existsSync(join(projectPath, 'package.json')))
const pkgJson = JSON.parse(
await readFile(join(projectPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, '@my-org/custom-pnpm-package')
t.is(pkgJson.napi.binaryName, 'custom-pnpm-package')
const cargoToml = await readFile(join(projectPath, 'Cargo.toml'), 'utf-8')
t.true(cargoToml.includes('name = "custom_pnpm_package"'))
})
test('create a new project with pnpm and custom path/name combination', async (t) => {
const projectPath = join(t.context.tmpDir, 'deep', 'nested', 'pnpm-dir')
await newProject({
path: projectPath,
name: '@scoped/pnpm-custom-name',
packageManager: 'pnpm',
enableDefaultTargets: true,
license: 'Apache-2.0',
})
t.true(existsSync(projectPath))
t.true(existsSync(join(projectPath, 'package.json')))
t.true(existsSync(join(projectPath, 'Cargo.toml')))
// Check package.json
const pkgJson = JSON.parse(
await readFile(join(projectPath, 'package.json'), 'utf-8'),
)
t.is(pkgJson.name, '@scoped/pnpm-custom-name')
t.is(pkgJson.napi.binaryName, 'pnpm-custom-name')
t.is(pkgJson.license, 'Apache-2.0')
// Check Cargo.toml has sanitized name
const cargoToml = await readFile(join(projectPath, 'Cargo.toml'), 'utf-8')
t.true(cargoToml.includes('name = "pnpm_custom_name"'))
})
test('should fail when no targets are enabled', async (t) => {
const projectPath = join(t.context.tmpDir, 'no-targets')
await t.throwsAsync(
async () => {
await newProject({
path: projectPath,
enableDefaultTargets: false,
enableAllTargets: false,
targets: [],
})
},
{ message: /At least one target must be enabled/ },
)
})