2026-06-12 23:28:44 +08:00
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe , expect , it , vi } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
2026-06-21 07:17:25 +08:00
import { BashTaskId } from '@deepseek-ai/dsh-bash'
2026-06-23 16:27:49 +08:00
import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
2026-06-12 23:28:44 +08:00
const spillDir = mkdtempSync ( join ( tmpdir ( ) , 'dsh-bash-exec-spec-' ) )
async function setup ( config : ConstructorParameters < typeof LocalBashExecutor > [ 1 ] = { } ) {
const ctx = new Context ( )
2026-07-04 17:37:23 +08:00
// A short kill grace via the REAL config path, so escalation tests stay fast.
await ctx . plugin ( LocalBashExecutor , { graceMs : 200 , . . . config } )
2026-06-12 23:28:44 +08:00
const bash = ctx . bash as LocalBashExecutor
2026-07-04 17:37:23 +08:00
bash . internals = { spillDir }
2026-06-12 23:28:44 +08:00
return { ctx , bash }
}
/** Poll until a pid no longer exists. */
async function waitGone ( pid : number , timeoutMs = 5 _000 ) : Promise < void > {
const deadline = Date . now ( ) + timeoutMs
while ( Date . now ( ) < deadline ) {
try {
process . kill ( pid , 0 )
} catch {
return
}
await new Promise ( resolve = > setTimeout ( resolve , 20 ) )
}
throw new Error ( ` pid ${ pid } still alive after ${ timeoutMs } ms ` )
}
2026-06-23 16:27:49 +08:00
async function readUntil (
bash : LocalBashExecutor ,
id : BashTaskId ,
expected : string ,
timeoutMs = 5 _000 ,
) : Promise < BashTaskRead > {
const deadline = Date . now ( ) + timeoutMs
let last : BashTaskRead | undefined
2026-07-08 11:09:20 +08:00
let delta = ''
2026-06-23 16:27:49 +08:00
while ( Date . now ( ) < deadline ) {
last = bash . readOutput ( id )
2026-07-08 11:09:20 +08:00
delta += last . delta
if ( delta . includes ( expected ) ) return { . . . last , delta }
2026-06-23 16:27:49 +08:00
await new Promise ( resolve = > setTimeout ( resolve , 20 ) )
}
2026-07-08 11:09:20 +08:00
throw new Error ( ` task ${ id } output did not include ${ JSON . stringify ( expected ) } ; output was ${ JSON . stringify ( delta ) } , last delta was ${ JSON . stringify ( last ? . delta ? ? '' ) } ` )
2026-06-23 16:27:49 +08:00
}
2026-06-12 23:28:44 +08:00
describe ( 'LocalBashExecutor.run' , ( ) = > {
it ( 'resolves with output and the effective timeout' , async ( ) = > {
const { bash } = await setup ( { timeoutMs : 5_000 } )
const result = await bash . run ( bash . resolve ( { command : 'echo hi' } ) )
expect ( result . exitCode ) . toBe ( 0 )
expect ( result . stdout . text ) . toBe ( 'hi\n' )
expect ( result . timeoutMs ) . toBe ( 5 _000 )
} )
it ( 'uses config cwd, overridable per call' , async ( ) = > {
const { bash } = await setup ( { cwd : '/tmp' } )
const fromConfig = await bash . run ( bash . resolve ( { command : 'pwd' } ) )
expect ( fromConfig . stdout . text . trim ( ) ) . toMatch ( /\/tmp$/ )
const fromCall = await bash . run ( bash . resolve ( { command : 'pwd' , workdir : '/' } ) )
expect ( fromCall . stdout . text . trim ( ) ) . toBe ( '/' )
} )
it ( 'defaults cwd to process.cwd()' , async ( ) = > {
const { bash } = await setup ( )
const result = await bash . run ( bash . resolve ( { command : 'pwd' } ) )
expect ( result . stdout . text . trim ( ) ) . toBe ( process . cwd ( ) )
} )
it ( 'caps per-call timeouts at maxTimeoutMs' , async ( ) = > {
const { bash } = await setup ( { timeoutMs : 1_000 , maxTimeoutMs : 2_000 } )
const result = await bash . run ( bash . resolve ( { command : 'true' , timeoutMs : 99_999 } ) )
expect ( result . timeoutMs ) . toBe ( 2 _000 )
} )
2026-06-17 21:26:44 +08:00
it ( 'rejects invalid numeric config and timeout overrides' , async ( ) = > {
await expect ( setup ( { timeoutMs : Number.NaN } ) ) . rejects . toThrow ( /timeoutMs/ )
await expect ( setup ( { maxTimeoutMs : 0 } ) ) . rejects . toThrow ( /maxTimeoutMs/ )
await expect ( setup ( { maxOutputBytes : - 1 } ) ) . rejects . toThrow ( /maxOutputBytes/ )
2026-07-04 17:37:23 +08:00
await expect ( setup ( { graceMs : 0 } ) ) . rejects . toThrow ( /graceMs/ )
2026-06-17 21:26:44 +08:00
const { bash } = await setup ( )
expect ( ( ) = > bash . resolve ( { command : 'true' , timeoutMs : Number.NaN } ) ) . toThrow ( /request\.timeoutMs/ )
expect ( ( ) = > bash . resolve ( { command : 'true' , timeoutMs : - 1 } ) ) . toThrow ( /request\.timeoutMs/ )
} )
2026-07-04 17:37:23 +08:00
it ( 'kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)' , async ( ) = > {
const { bash } = await setup ( ) // setup pins graceMs: 200 via config
2026-07-08 21:45:10 +08:00
const task = bash . start ( bash . resolve ( { command : 'trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done' } ) )
await readUntil ( bash , task . id , 'ready\n' )
2026-07-04 17:37:23 +08:00
bash . kill ( task . id )
await task . done
expect ( task . signal ) . toBe ( 'SIGKILL' )
} )
2026-06-12 23:28:44 +08:00
it ( 'per-call timeout takes precedence under the cap and kills on expiry' , async ( ) = > {
const { bash } = await setup ( { timeoutMs : 60_000 } )
const result = await bash . run ( bash . resolve ( { command : 'sleep 60' , timeoutMs : 100 } ) )
expect ( result . timedOut ) . toBe ( true )
2026-07-06 16:23:52 +08:00
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
expect ( result . aborted ) . toBe ( false )
2026-06-12 23:28:44 +08:00
expect ( result . timeoutMs ) . toBe ( 100 )
} )
it ( 'propagates abort signals' , async ( ) = > {
const { bash } = await setup ( )
const controller = new AbortController ( )
const pending = bash . run ( bash . resolve ( { command : 'sleep 60' , signal : controller.signal } ) )
setTimeout ( ( ) = > { controller . abort ( ) } , 50 )
const result = await pending
expect ( result . aborted ) . toBe ( true )
2026-07-06 16:23:52 +08:00
// Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
expect ( result . timedOut ) . toBe ( false )
} )
it ( 'classifies a self-killed command as neither timed out nor aborted' , async ( ) = > {
// The command kills itself (SIGTERM) with no timeout and no upstream abort:
// the deadline signal never fires, so both classifications are false — the
// fused-signal classification reports the cause that cut the command short,
// and here nothing the executor owns did.
const { bash } = await setup ( { timeoutMs : 60_000 } )
const result = await bash . run ( bash . resolve ( { command : 'kill -TERM $$' } ) )
expect ( result . signal ) . toBe ( 'SIGTERM' )
expect ( result . timedOut ) . toBe ( false )
expect ( result . aborted ) . toBe ( false )
2026-06-12 23:28:44 +08:00
} )
it ( 'rejects on spawn failure (bad workdir)' , async ( ) = > {
const { bash } = await setup ( )
await expect ( bash . run ( bash . resolve ( { command : 'true' , workdir : '/nonexistent-dsh' } ) ) ) . rejects . toThrow ( /ENOENT/ )
} )
2026-06-30 13:52:25 +08:00
it ( 'resolve() carries stdin/env onto the spec, and run() threads them to the command' , async ( ) = > {
const { bash } = await setup ( )
const spec = bash . resolve ( { command : 'cat; echo "[$DSH_SEAM_VAR]"' , stdin : 'piped\n' , env : { DSH_SEAM_VAR : 'env-ok' } } )
2026-07-02 04:30:40 +08:00
// resolve() keeps the stdin/env fields verbatim (optional, no default).
2026-06-30 13:52:25 +08:00
expect ( spec . stdin ) . toBe ( 'piped\n' )
expect ( spec . env ) . toEqual ( { DSH_SEAM_VAR : 'env-ok' } )
const result = await bash . run ( spec )
expect ( result . stdout . text ) . toBe ( 'piped\n[env-ok]\n' )
} )
it ( 'resolve() omits stdin/env when the request supplies neither' , async ( ) = > {
const { bash } = await setup ( )
const spec = bash . resolve ( { command : 'true' } )
expect ( 'stdin' in spec ) . toBe ( false )
expect ( 'env' in spec ) . toBe ( false )
} )
2026-06-12 23:28:44 +08:00
} )
describe ( 'LocalBashExecutor background tasks' , ( ) = > {
it ( 'start returns immediately with a registered running task' , async ( ) = > {
const { bash } = await setup ( )
const before = Date . now ( )
const task = bash . start ( bash . resolve ( { command : 'sleep 0.2; echo done' } ) )
expect ( Date . now ( ) - before ) . toBeLessThan ( 150 )
expect ( task . status ) . toBe ( 'running' )
expect ( bash . get ( task . id ) ) . toBe ( task )
expect ( bash . list ( ) ) . toContain ( task )
await task . done
expect ( task . status ) . toBe ( 'completed' )
expect ( task . exitCode ) . toBe ( 0 )
} )
it ( 'assigns sequential ids' , async ( ) = > {
const { bash } = await setup ( )
const first = bash . start ( bash . resolve ( { command : 'true' } ) )
const second = bash . start ( bash . resolve ( { command : 'true' } ) )
expect ( first . id ) . toBe ( 'bash-1' )
expect ( second . id ) . toBe ( 'bash-2' )
await Promise . all ( [ first . done , second . done ] )
} )
2026-06-30 13:52:25 +08:00
it ( 'threads stdin and extra env into a background task' , async ( ) = > {
const { bash } = await setup ( )
const task = bash . start ( bash . resolve ( {
command : 'cat; echo "[$DSH_BG_VAR]"' ,
stdin : 'bg-stdin\n' ,
env : { DSH_BG_VAR : 'bg-env' } ,
} ) )
const read = await readUntil ( bash , task . id , '[bg-env]' )
expect ( read . delta ) . toContain ( 'bg-stdin' )
await task . done
expect ( task . exitCode ) . toBe ( 0 )
} )
2026-06-12 23:28:44 +08:00
it ( 'readOutput returns increments without re-delivery' , async ( ) = > {
const { bash } = await setup ( )
2026-06-23 16:27:49 +08:00
const task = bash . start ( bash . resolve ( { command : 'echo first; sleep 1; echo second' } ) )
const first = await readUntil ( bash , task . id , 'first\n' )
2026-06-12 23:28:44 +08:00
expect ( first . delta ) . toBe ( 'first\n' )
expect ( first . lossy ) . toBe ( false )
await task . done
const second = bash . readOutput ( task . id )
expect ( second . delta ) . toBe ( 'second\n' )
const third = bash . readOutput ( task . id )
expect ( third . delta ) . toBe ( '' )
} )
it ( 'readOutput marks stderr sections' , async ( ) = > {
const { bash } = await setup ( )
const task = bash . start ( bash . resolve ( { command : 'echo out; echo err >&2' } ) )
await task . done
const read = bash . readOutput ( task . id )
expect ( read . delta ) . toBe ( 'out\n[stderr]\nerr\n' )
} )
it ( 'readOutput reports stderr-only deltas without a leading newline' , async ( ) = > {
const { bash } = await setup ( )
const task = bash . start ( bash . resolve ( { command : 'echo err >&2' } ) )
await task . done
expect ( bash . readOutput ( task . id ) . delta ) . toBe ( '[stderr]\nerr\n' )
} )
it ( 'readOutput flags lossy reads and reports spill paths' , async ( ) = > {
const { bash } = await setup ( { maxOutputBytes : 100 } )
const task = bash . start ( bash . resolve ( { command : 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' } ) )
await task . done
const read = bash . readOutput ( task . id )
// Window slid past offset 0 → lossy, spill path points at the full stream.
expect ( read . lossy ) . toBe ( true )
expect ( read . stdoutSpillPath ) . toBeDefined ( )
} )
it ( 'readOutput throws for unknown ids' , async ( ) = > {
const { bash } = await setup ( )
2026-06-21 07:17:25 +08:00
expect ( ( ) = > bash . readOutput ( BashTaskId ( 'nope' ) ) ) . toThrow ( /unknown bash task "nope"/ )
2026-06-12 23:28:44 +08:00
} )
it ( 'kill terminates the process group and reports status killed' , async ( ) = > {
const { bash } = await setup ( )
const task = bash . start ( bash . resolve ( { command : 'sleep 60' } ) )
expect ( bash . kill ( task . id ) ) . toBe ( true )
await task . done
expect ( task . status ) . toBe ( 'killed' )
expect ( task . signal ) . toBe ( 'SIGTERM' )
} )
it ( 'kill returns false for finished tasks and throws for unknown ids' , async ( ) = > {
const { bash } = await setup ( )
const task = bash . start ( bash . resolve ( { command : 'true' } ) )
await task . done
expect ( bash . kill ( task . id ) ) . toBe ( false )
2026-06-21 07:17:25 +08:00
expect ( ( ) = > bash . kill ( BashTaskId ( 'nope' ) ) ) . toThrow ( /unknown bash task "nope"/ )
2026-06-12 23:28:44 +08:00
} )
it ( 'notifies onTaskDone listeners on completion' , async ( ) = > {
const { bash } = await setup ( )
const seen : [ string , string ] [ ] = [ ]
bash . onTaskDone ( task = > void seen . push ( [ task . id , task . status ] ) )
const task = bash . start ( bash . resolve ( { command : 'true' } ) )
await task . done
expect ( seen ) . toEqual ( [ [ task . id , 'completed' ] ] )
} )
it ( 'notifies onTaskDone for killed tasks too' , async ( ) = > {
const { bash } = await setup ( )
const listener = vi . fn ( )
bash . onTaskDone ( listener )
const task = bash . start ( bash . resolve ( { command : 'sleep 60' } ) )
bash . kill ( task . id )
await task . done
expect ( listener ) . toHaveBeenCalledWith ( task )
expect ( task . status ) . toBe ( 'killed' )
} )
it ( 'marks tasks killed when the background spawn itself fails' , async ( ) = > {
const { bash } = await setup ( )
const listener = vi . fn ( )
bash . onTaskDone ( listener )
const task = bash . start ( bash . resolve ( { command : 'true' , workdir : '/nonexistent-dsh' } ) )
await task . done
expect ( task . status ) . toBe ( 'killed' )
expect ( listener ) . toHaveBeenCalledWith ( task )
expect ( bash . readOutput ( task . id ) . delta ) . toContain ( 'spawn failed' )
} )
it ( 'readOutput adds a separator only when stdout lacks a trailing newline' , async ( ) = > {
const { bash } = await setup ( )
const task = bash . start ( bash . resolve ( { command : 'printf out; echo err >&2' } ) )
await task . done
expect ( bash . readOutput ( task . id ) . delta ) . toBe ( 'out\n[stderr]\nerr\n' )
} )
it ( 'readOutput reports stderr spill paths' , async ( ) = > {
const { bash } = await setup ( { maxOutputBytes : 100 } )
const task = bash . start ( bash . resolve ( { command : 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' } ) )
await task . done
const read = bash . readOutput ( task . id )
expect ( read . lossy ) . toBe ( true )
expect ( read . stderrSpillPath ) . toBeDefined ( )
expect ( read . delta ) . toContain ( '[stderr]' )
} )
it ( 'disposing with already-finished tasks only kills the running ones' , async ( ) = > {
const ctx = new Context ( )
2026-07-04 17:37:23 +08:00
const fiber = await ctx . plugin ( LocalBashExecutor , { graceMs : 200 } )
2026-06-12 23:28:44 +08:00
const bash = ctx . bash as LocalBashExecutor
2026-07-04 17:37:23 +08:00
bash . internals = { spillDir }
2026-06-12 23:28:44 +08:00
const finished = bash . start ( bash . resolve ( { command : 'true' } ) )
await finished . done
const running = bash . start ( bash . resolve ( { command : 'sleep 60' } ) )
await fiber . dispose ( )
await running . done
expect ( finished . status ) . toBe ( 'completed' )
expect ( running . signal ) . toBe ( 'SIGTERM' )
expect ( bash . list ( ) ) . toEqual ( [ ] )
} )
it ( 'disposing the executor fiber kills running tasks (no orphans)' , async ( ) = > {
const ctx = new Context ( )
2026-07-04 17:37:23 +08:00
const fiber = await ctx . plugin ( LocalBashExecutor , { graceMs : 200 } )
2026-06-12 23:28:44 +08:00
const bash = ctx . bash as LocalBashExecutor
2026-07-04 17:37:23 +08:00
bash . internals = { spillDir }
2026-06-12 23:28:44 +08:00
const listener = vi . fn ( )
bash . onTaskDone ( listener )
const task = bash . start ( bash . resolve ( { command : 'sleep 60' } ) )
const running = bash . get ( task . id ) !
await new Promise ( resolve = > setTimeout ( resolve , 50 ) )
// Grab the pid before dispose clears the registry.
const pid = ( running as unknown as { running : { pid : number } } ) . running . pid
await fiber . dispose ( )
await waitGone ( pid )
expect ( bash . list ( ) ) . toEqual ( [ ] )
// Listener silenced by base-class teardown — no late notifications.
expect ( listener ) . not . toHaveBeenCalled ( )
} )
} )
2026-07-13 23:27:00 +08:00
describe ( 'executor cancellation, callback, and disposal contracts' , ( ) = > {
2026-06-12 23:28:44 +08:00
it ( 'start honors a pre-aborted or later-aborted AbortSignal' , async ( ) = > {
const { bash } = await setup ( )
const controller = new AbortController ( )
const task = bash . start ( bash . resolve ( { command : 'sleep 60' , signal : controller.signal } ) )
controller . abort ( )
await task . done
expect ( task . status ) . toBe ( 'killed' )
expect ( task . signal ) . toBe ( 'SIGTERM' )
} )
it ( 'a throwing onTaskDone listener does not reject task.done or starve later listeners' , async ( ) = > {
const { bash } = await setup ( )
const errorSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) = > undefined )
const second = vi . fn ( )
try {
bash . onTaskDone ( ( ) = > { throw new Error ( 'listener bug' ) } )
bash . onTaskDone ( second )
const task = bash . start ( bash . resolve ( { command : 'true' } ) )
await expect ( task . done ) . resolves . toBeUndefined ( )
expect ( second ) . toHaveBeenCalledWith ( task )
expect ( errorSpy ) . toHaveBeenCalled ( )
} finally {
errorSpy . mockRestore ( )
}
} )
it ( 'dispose AWAITS a TERM-trapping process (SIGKILL escalation included)' , async ( ) = > {
const ctx = new Context ( )
2026-07-04 17:37:23 +08:00
const fiber = await ctx . plugin ( LocalBashExecutor , { graceMs : 200 } )
2026-06-12 23:28:44 +08:00
const bash = ctx . bash as LocalBashExecutor
2026-07-04 17:37:23 +08:00
bash . internals = { spillDir }
2026-06-12 23:28:44 +08:00
const task = bash . start ( bash . resolve ( { command : 'trap \'\' TERM; sleep 60' } ) )
await new Promise ( resolve = > setTimeout ( resolve , 100 ) )
const pid = ( task as unknown as { running : { pid : number } } ) . running . pid
await fiber . dispose ( )
// Disposal itself waited: the pid must already be gone, no grace left.
expect ( ( ) = > process . kill ( pid , 0 ) ) . toThrow ( )
expect ( task . status ) . toBe ( 'killed' )
} )
} )