diff --git a/.gitignore b/.gitignore index 239ecff..0cc9f67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules yarn.lock +!/test/fixtures/**/node_modules diff --git a/source/context.js b/source/context.js index d185def..4be363b 100644 --- a/source/context.js +++ b/source/context.js @@ -1,19 +1,16 @@ import process from 'node:process'; import {stripVTControlCharacters} from 'node:util'; -export const getContext = (previous, rawFile, rawArguments) => { +export const getContext = (previous, raw) => { const start = previous.start ?? process.hrtime.bigint(); - const command = [previous.command, getCommand(rawFile, rawArguments)].filter(Boolean).join(' | '); + const command = [previous.command, getCommand(raw)].filter(Boolean).join(' | '); return {start, command, state: {stdout: '', stderr: '', output: ''}}; }; -const getCommand = (rawFile, rawArguments) => [rawFile, ...rawArguments] - .map(part => getCommandPart(part)) +const getCommand = raw => raw + .map(part => getCommandPart(stripVTControlCharacters(part))) .join(' '); -const getCommandPart = part => { - part = stripVTControlCharacters(part); - return /[^\w./-]/.test(part) - ? `'${part.replaceAll('\'', '\'\\\'\'')}'` - : part; -}; +const getCommandPart = part => /[^\w./-]/.test(part) + ? `'${part.replaceAll('\'', '\'\\\'\'')}'` + : part; diff --git a/source/index.js b/source/index.js index f009f16..0d64903 100644 --- a/source/index.js +++ b/source/index.js @@ -9,7 +9,7 @@ export default function nanoSpawn(first, second = [], third = {}) { const [rawFile, previous] = Array.isArray(first) ? first : [first, {}]; const [rawArguments, options] = Array.isArray(second) ? [second, third] : [[], second]; - const context = getContext(previous, rawFile, rawArguments); + const context = getContext(previous, [rawFile, ...rawArguments]); const spawnOptions = getOptions(options); const nodeChildProcess = spawnSubprocess(rawFile, rawArguments, spawnOptions, context); const resultPromise = getResult(nodeChildProcess, spawnOptions, context); diff --git a/source/options.js b/source/options.js index d860667..2bfd3f1 100644 --- a/source/options.js +++ b/source/options.js @@ -14,13 +14,11 @@ export const getOptions = ({ }) => { const cwd = cwdOption instanceof URL ? fileURLToPath(cwdOption) : path.resolve(cwdOption); const env = envOption === undefined ? undefined : {...process.env, ...envOption}; - const [stdioOption, input] = stdio[0]?.string === undefined - ? [stdio] - : [['pipe', ...stdio.slice(1)], stdio[0].string]; + const input = stdio[0]?.string; return { ...options, - stdio: stdioOption, input, + stdio: input === undefined ? stdio : ['pipe', ...stdio.slice(1)], env: preferLocal ? addLocalPath(env ?? process.env, cwd) : env, cwd, }; diff --git a/test/context.js b/test/context.js index 65c47f0..32123ef 100644 --- a/test/context.js +++ b/test/context.js @@ -3,7 +3,7 @@ import {red} from 'yoctocolors'; import nanoSpawn from '../source/index.js'; import {testString} from './helpers/arguments.js'; import {assertDurationMs} from './helpers/assert.js'; -import {nodePrint, nodePrintStdout} from './helpers/commands.js'; +import {nodePrint, nodePrintFail, nodePrintStdout} from './helpers/commands.js'; test('result.command does not quote normal arguments', async t => { const {command} = await nanoSpawn('node', ['--version']); @@ -27,6 +27,6 @@ test('result.durationMs is set', async t => { }); test('error.durationMs is set', async t => { - const {durationMs} = await t.throwsAsync(nanoSpawn('node', ['--unknown'])); + const {durationMs} = await t.throwsAsync(nanoSpawn(...nodePrintFail)); assertDurationMs(t, durationMs); }); diff --git a/test/fixtures/node_modules/.bin/git b/test/fixtures/node_modules/.bin/git new file mode 100755 index 0000000..467b9df --- /dev/null +++ b/test/fixtures/node_modules/.bin/git @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('test'); diff --git a/test/fixtures/subdir/node_modules/.bin/git b/test/fixtures/subdir/node_modules/.bin/git new file mode 100755 index 0000000..f5f95a2 --- /dev/null +++ b/test/fixtures/subdir/node_modules/.bin/git @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('secondTest'); diff --git a/test/helpers/assert.js b/test/helpers/assert.js index 5ec8672..084422f 100644 --- a/test/helpers/assert.js +++ b/test/helpers/assert.js @@ -2,7 +2,7 @@ import {nonExistentCommand, nodeHangingCommand, nodeEvalCommandStart} from './co export const assertDurationMs = (t, durationMs) => { t.true(Number.isFinite(durationMs)); - t.true(durationMs > 0); + t.true(durationMs >= 0); }; export const assertNonExistent = (t, {exitCode, signalName, command, message, stderr, cause, durationMs}, commandStart = nonExistentCommand, expectedCommand = commandStart) => { diff --git a/test/index.js b/test/index.js index 36e0914..99a32e0 100644 --- a/test/index.js +++ b/test/index.js @@ -1,7 +1,20 @@ import test from 'ava'; import nanoSpawn from '../source/index.js'; -import {assertSigterm} from './helpers/assert.js'; import {nodePrintStdout, nodeHanging} from './helpers/commands.js'; +import {assertSigterm} from './helpers/assert.js'; + +test('Can pass no arguments', async t => { + const error = await t.throwsAsync(nanoSpawn(...nodeHanging, {timeout: 1})); + assertSigterm(t, error); +}); + +test('Can pass no arguments nor options', async t => { + const promise = nanoSpawn(...nodeHanging); + const nodeChildProcess = await promise.nodeChildProcess; + nodeChildProcess.kill(); + const error = await t.throwsAsync(promise); + assertSigterm(t, error); +}); test('Returns a promise', async t => { const promise = nanoSpawn(...nodePrintStdout); @@ -12,10 +25,8 @@ test('Returns a promise', async t => { }); test('promise.nodeChildProcess is set', async t => { - const promise = nanoSpawn(...nodeHanging); + const promise = nanoSpawn(...nodePrintStdout); const nodeChildProcess = await promise.nodeChildProcess; - nodeChildProcess.kill(); - - const error = await t.throwsAsync(promise); - assertSigterm(t, error); + t.true(Number.isInteger(nodeChildProcess.pid)); + await promise; }); diff --git a/test/options.js b/test/options.js index 48136dd..53c945f 100644 --- a/test/options.js +++ b/test/options.js @@ -10,7 +10,7 @@ import { fixturesPath, nodeDirectory, } from './helpers/main.js'; -import {testString} from './helpers/arguments.js'; +import {testString, secondTestString} from './helpers/arguments.js'; import { assertNonExistent, assertWindowsNonExistent, @@ -45,6 +45,14 @@ const testArgv0 = async (t, shell) => { test('Can pass options.argv0', testArgv0, false); test('Can pass options.argv0, shell', testArgv0, true); +const testCwd = async (t, cwd) => { + const {stdout} = await nanoSpawn(...nodePrint('process.cwd()'), {cwd}); + t.is(stdout, fixturesPath.replace(/[\\/]$/, '')); +}; + +test('Can pass options.cwd string', testCwd, fixturesPath); +test('Can pass options.cwd URL', testCwd, FIXTURES_URL); + const testStdOption = async (t, optionName) => { const promise = nanoSpawn(...nodePrintStdout, {[optionName]: 'ignore'}); const subprocess = await promise.nodeChildProcess; @@ -56,13 +64,24 @@ test('Can pass options.stdin', testStdOption, 'stdin'); test('Can pass options.stdout', testStdOption, 'stdout'); test('Can pass options.stderr', testStdOption, 'stderr'); +const testStdOptionDefault = async (t, optionName) => { + const promise = nanoSpawn(...nodePrintStdout); + const subprocess = await promise.nodeChildProcess; + t.not(subprocess[optionName], null); + await promise; +}; + +test('options.stdin defaults to "pipe"', testStdOptionDefault, 'stdin'); +test('options.stdout defaults to "pipe"', testStdOptionDefault, 'stdout'); +test('options.stderr defaults to "pipe"', testStdOptionDefault, 'stderr'); + test('Can pass options.stdio array', async t => { const promise = nanoSpawn(...nodePrintStdout, {stdio: ['ignore', 'pipe', 'pipe', 'pipe']}); const {stdin, stdout, stderr, stdio} = await promise.nodeChildProcess; t.is(stdin, null); t.not(stdout, null); t.not(stderr, null); - t.not(stdio[3], null); + t.is(stdio.length, 4); await promise; }); @@ -76,29 +95,23 @@ test('Can pass options.stdio string', async t => { await promise; }); -test('options.stdio array has priority over options.stdout', async t => { - const promise = nanoSpawn(...nodePrintStdout, {stdio: ['pipe', 'pipe', 'pipe'], stdout: 'ignore'}); +const testStdioPriority = async (t, stdio) => { + const promise = nanoSpawn(...nodePrintStdout, {stdio, stdout: 'ignore'}); const {stdout} = await promise.nodeChildProcess; t.not(stdout, null); await promise; -}); +}; -test('options.stdio string has priority over options.stdout', async t => { - const promise = nanoSpawn(...nodePrintStdout, {stdio: 'pipe', stdout: 'ignore'}); - const {stdout} = await promise.nodeChildProcess; - t.not(stdout, null); - await promise; -}); +test('options.stdio array has priority over options.stdout', testStdioPriority, ['pipe', 'pipe', 'pipe']); +test('options.stdio string has priority over options.stdout', testStdioPriority, 'pipe'); -test('options.stdin can be {string: string}', async t => { - const {stdout} = await nanoSpawn(...nodePassThrough, {stdin: {string: testString}}); +const testInput = async (t, options) => { + const {stdout} = await nanoSpawn(...nodePassThrough, options); t.is(stdout, testString); -}); +}; -test('options.stdio[0] can be {string: string}', async t => { - const {stdout} = await nanoSpawn(...nodePassThrough, {stdio: [{string: testString}, 'pipe', 'pipe']}); - t.is(stdout, testString); -}); +test('options.stdin can be {string: string}', testInput, {stdin: {string: testString}}); +test('options.stdio[0] can be {string: string}', testInput, {stdio: [{string: testString}, 'pipe', 'pipe']}); const testLocalBinaryExec = async (t, cwd) => { const {stdout} = await nanoSpawn(...localBinary, {preferLocal: true, cwd}); @@ -142,6 +155,11 @@ test('options.preferLocal true uses options.env when empty', async t => { } }); +test('options.preferLocal true can use an empty PATH', async t => { + const {stdout} = await nanoSpawn(process.execPath, ['--version'], {preferLocal: true, env: {PATH: undefined, Path: undefined}}); + t.is(stdout, process.version); +}); + test('options.preferLocal true does not add node_modules/.bin if already present', async t => { const localDirectory = fileURLToPath(new URL('node_modules/.bin', import.meta.url)); const currentPath = process.env[pathKey()]; @@ -186,6 +204,18 @@ test('options.preferLocal true can pass arguments to local npm binaries, space', test('options.preferLocal true can pass arguments to local npm binaries, *', testLocalBinary, '*'); test('options.preferLocal true can pass arguments to local npm binaries, ?', testLocalBinary, '?'); +if (!isWindows) { + test('options.preferLocal true prefer local binaries over global ones', async t => { + const {stdout} = await nanoSpawn('git', {preferLocal: true, cwd: FIXTURES_URL}); + t.is(stdout, testString); + }); + + test('options.preferLocal true prefer subdirectories over parent directories', async t => { + const {stdout} = await nanoSpawn('git', {preferLocal: true, cwd: new URL('subdir', FIXTURES_URL)}); + t.is(stdout, secondTestString); + }); +} + test('Can run global npm binaries', async t => { const {stdout} = await nanoSpawn('npm', ['--version']); t.regex(stdout, VERSION_REGEXP);