Skip to content

Commit

Permalink
fix(exec2): separate spawnAsyncAndReturn and spawnAsync
Browse files Browse the repository at this point in the history
  • Loading branch information
kirillgroshkov committed Aug 31, 2024
1 parent bc40ff0 commit 5d684e1
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 122 deletions.
2 changes: 1 addition & 1 deletion scripts/exec2.script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ runScript(async () => {
await exec2.spawnAsync('node', {
args: ['scripts/dot.script.js', '--error'],
// log: true,
shell: true,
// shell: true,
// forceColor: false,
// passProcessEnv: true,
})
Expand Down
45 changes: 20 additions & 25 deletions src/util/exec2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,46 @@ import { _expectedErrorString, _stringify, pExpectedError } from '@naturalcycles
import { exec2, SpawnError } from './exec2'

test('spawn ok', () => {
exec2.spawn('git status', {
log: true,
})
exec2.spawn('git status')
// no error
})

test('spawn error', () => {
const err = _expectedErrorString(() =>
exec2.spawn('git stat', {
log: true,
}),
)
const err = _expectedErrorString(() => exec2.spawn('git stat'))
expect(err).toMatchInlineSnapshot(`"Error: spawn exited with code 1: git stat"`)
})

test('exec ok', () => {
const s = exec2.exec('git version', {
log: true,
})
const s = exec2.exec('git version')
expect(s.startsWith('git version')).toBe(true)
})

test('exec error', () => {
const err = _expectedErrorString(() =>
exec2.exec('git stat', {
log: true,
}),
)
const err = _expectedErrorString(() => exec2.exec('git stat'))
expect(err).toMatchInlineSnapshot(`"Error: exec exited with code 1: git stat"`)
})

test('spawnAsync ok', async () => {
const s = await exec2.spawnAsync('git version', {
log: true,
})
await exec2.spawnAsync('git version')
// no error
})

test('spawnAsync error', async () => {
const err = await pExpectedError(exec2.spawnAsync('git stat'), Error)
expect(_stringify(err)).toMatchInlineSnapshot(`"Error: spawnAsync exited with code 1: git stat"`)
})

test('spawnAsyncAndReturn ok', async () => {
const s = await exec2.spawnAsyncAndReturn('git version')
expect(s.exitCode).toBe(0)
expect(s.stderr).toBe('')
expect(s.stdout.startsWith('git version')).toBe(true)
})

test('spawnAsync error with throw', async () => {
const err = await pExpectedError(exec2.spawnAsync('git stat'), SpawnError)
test('spawnAsyncAndReturn error with throw', async () => {
const err = await pExpectedError(exec2.spawnAsyncAndReturn('git stat'), SpawnError)
expect(_stringify(err)).toMatchInlineSnapshot(
`"SpawnError: spawnAsync exited with code 1: git stat"`,
`"SpawnError: spawnAsyncAndReturn exited with code 1: git stat"`,
)
expect(err.data.exitCode).toBe(1)
expect(err.data.stdout).toBe('')
Expand All @@ -59,9 +55,8 @@ The most similar commands are
`)
})

test('spawnAsync error without throw', async () => {
const { exitCode, stdout, stderr } = await exec2.spawnAsync('git stat', {
log: true,
test('spawnAsyncAndReturn error without throw', async () => {
const { exitCode, stdout, stderr } = await exec2.spawnAsyncAndReturn('git stat', {
throwOnNonZeroCode: false,
})
expect(exitCode).toBe(1)
Expand Down
225 changes: 129 additions & 96 deletions src/util/exec2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { dimGrey, dimRed, hasColors, white } from '../colors/colors'
*
* Short-running job, no need to print the output, might want to return the output - use Exec.
*
* Need to both print and return the output - use SpawnAsync.
* Need to both print and return the output - use SpawnAsyncAndReturn.
*
* ***
*
Expand All @@ -31,101 +31,13 @@ import { dimGrey, dimRed, hasColors, white } from '../colors/colors'
* Exec always uses the shell (there's no option to disable it).
*/
class Exec2 {
/**
* Advanced/async version of Spawn.
* Consider simpler `spawn` or `exec` first, which are also sync.
*
* spawnAsync features:
*
* 1. Async
* 2. Allows to collect the output AND print it while running.
* 3. Returns SpawnOutput with stdout, stderr and exitCode.
* 4. Allows to not throw on error, but just return SpawnOutput for further inspection.
*
* Defaults:
*
* shell: true
* printWhileRunning: true
* collectOutputWhileRunning: true
* throwOnNonZeroCode: true
* log: true
*/
async spawnAsync(cmd: string, opt: SpawnAsyncOptions = {}): Promise<SpawnOutput> {
const {
shell = true,
printWhileRunning = true,
collectOutputWhileRunning = true,
throwOnNonZeroCode = true,
cwd,
env,
passProcessEnv = true,
forceColor = hasColors,
} = opt
opt.log ??= printWhileRunning // by default log should be true, as we are printing the output
opt.logStart ??= opt.log
opt.logFinish ??= opt.log
const started = Date.now()
this.logStart(cmd, opt)
let stdout = ''
let stderr = ''

// if (printWhileRunning) console.log('') // 1-line padding before the output

return await new Promise<SpawnOutput>((resolve, reject) => {
const p = cp.spawn(cmd, opt.args || [], {
shell,
cwd,
env: {
...(passProcessEnv ? process.env : {}),
...(forceColor ? { FORCE_COLOR: '1' } : {}),
...env,
},
})

p.stdout.on('data', data => {
if (collectOutputWhileRunning) {
stdout += data.toString()
// console.log('stdout:', data.toString())
}
if (printWhileRunning) {
process.stdout.write(data)
// console.log('stderr:', data.toString())
}
})
p.stderr.on('data', data => {
if (collectOutputWhileRunning) {
stderr += data.toString()
}
if (printWhileRunning) {
process.stderr.write(data)
}
})

p.on('close', code => {
// if (printWhileRunning) console.log('') // 1-line padding after the output
const isSuccessful = !code
this.logFinish(cmd, opt, started, isSuccessful)
const exitCode = code || 0
const o: SpawnOutput = {
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
}
if (throwOnNonZeroCode && code) {
return reject(new SpawnError(`spawnAsync exited with code ${code}: ${cmd}`, o))
}
resolve(o)
})
})
}

/**
* Reasons to use it:
* - Sync
* - Need to print output while running
*
* Limitations:
* - Cannot return stdout/stderr (use exec or spawnAsync for that)
* - Cannot return stdout/stderr (use exec, execAsync or spawnAsyncAndReturn for that)
*
* Defaults:
*
Expand All @@ -139,7 +51,6 @@ class Exec2 {
opt.logFinish ??= opt.log
const started = Date.now()
this.logStart(cmd, opt)
// console.log('') // 1-line padding before the output

const r = cp.spawnSync(cmd, opt.args, {
encoding: 'utf8',
Expand All @@ -153,7 +64,6 @@ class Exec2 {
},
})

// console.log('') // 1-line padding after the output
const isSuccessful = !r.error && !r.status
this.logFinish(cmd, opt, started, isSuccessful)

Expand Down Expand Up @@ -218,10 +128,133 @@ class Exec2 {
}
}

throwOnNonZeroExitCode(o: SpawnOutput): void {
if (o.exitCode) {
throw new SpawnError(`spawn exited with code ${o.exitCode}`, o)
}
/**
* Reasons to use it:
* - Async
* - Need to print output while running
*
* Limitations:
* - Cannot return stdout/stderr (use execAsync or spawnAsyncAndReturn for that)
*
* Defaults:
*
* shell: true
* log: true
*/
async spawnAsync(cmd: string, opt: SpawnOptions = {}): Promise<void> {
const { shell = true, cwd, env, passProcessEnv = true, forceColor = hasColors } = opt
opt.log ??= true // by default log should be true, as we are printing the output
opt.logStart ??= opt.log
opt.logFinish ??= opt.log
const started = Date.now()
this.logStart(cmd, opt)

await new Promise<void>((resolve, reject) => {
const p = cp.spawn(cmd, opt.args || [], {
shell,
cwd,
stdio: 'inherit',
env: {
...(passProcessEnv ? process.env : {}),
...(forceColor ? { FORCE_COLOR: '1' } : {}),
...env,
},
})

p.on('close', code => {
const isSuccessful = !code
this.logFinish(cmd, opt, started, isSuccessful)
if (code) {
return reject(new Error(`spawnAsync exited with code ${code}: ${cmd}`))
}
resolve()
})
})
}

/**
* Advanced/async version of Spawn.
* Consider simpler `spawn` or `exec` first, which are also sync.
*
* spawnAsyncAndReturn features:
*
* 1. Async
* 2. Allows to collect the output AND print it while running.
* 3. Returns SpawnOutput with stdout, stderr and exitCode.
* 4. Allows to not throw on error, but just return SpawnOutput for further inspection.
*
* Defaults:
*
* shell: true
* printWhileRunning: true
* collectOutputWhileRunning: true
* throwOnNonZeroCode: true
* log: true
*/
async spawnAsyncAndReturn(cmd: string, opt: SpawnAsyncOptions = {}): Promise<SpawnOutput> {
const {
shell = true,
printWhileRunning = true,
collectOutputWhileRunning = true,
throwOnNonZeroCode = true,
cwd,
env,
passProcessEnv = true,
forceColor = hasColors,
} = opt
opt.log ??= printWhileRunning // by default log should be true, as we are printing the output
opt.logStart ??= opt.log
opt.logFinish ??= opt.log
const started = Date.now()
this.logStart(cmd, opt)
let stdout = ''
let stderr = ''

return await new Promise<SpawnOutput>((resolve, reject) => {
const p = cp.spawn(cmd, opt.args || [], {
shell,
cwd,
env: {
...(passProcessEnv ? process.env : {}),
...(forceColor ? { FORCE_COLOR: '1' } : {}),
...env,
},
})

p.stdout.on('data', data => {
if (collectOutputWhileRunning) {
stdout += data.toString()
// console.log('stdout:', data.toString())
}
if (printWhileRunning) {
process.stdout.write(data)
// console.log('stderr:', data.toString())
}
})
p.stderr.on('data', data => {
if (collectOutputWhileRunning) {
stderr += data.toString()
}
if (printWhileRunning) {
process.stderr.write(data)
}
})

p.on('close', code => {
const isSuccessful = !code
this.logFinish(cmd, opt, started, isSuccessful)
const exitCode = code || 0
const o: SpawnOutput = {
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
}
if (throwOnNonZeroCode && code) {
return reject(new SpawnError(`spawnAsyncAndReturn exited with code ${code}: ${cmd}`, o))
}
resolve(o)
})
})
}

private logStart(cmd: string, opt: SpawnOptions | ExecOptions): void {
Expand Down

0 comments on commit 5d684e1

Please sign in to comment.