Reorganize packages into a modular hierarchy
Move the 18 flat packages/<name> packages into role-grouped dirs: core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are pure containers; each package keeps its @deepseek-ai/dsh-* name. Collapse the per-package tsconfig paths maps (base + typecheck) into one @deepseek-ai/dsh-* wildcard with a candidate per group, and derive the publint list from the hierarchy. Update all depth-coupled globs/configs (workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs, per-package tsconfigs, generators, doc-script scopes, type-equiv manifest) and the cross-package/script relative imports in tests. Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the TypeScript API instead of a regex comment-strip, which corrupted the new wildcard `/*/` path candidates. WIP: doc cross-links and package/RFC docs still to update.
This commit is contained in:
307
packages/bash/bash-local/tests/executor.spec.ts
Normal file
307
packages/bash/bash-local/tests/executor.spec.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalBashExecutor, config)
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
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`)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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/)
|
||||
|
||||
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/)
|
||||
})
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
|
||||
it('readOutput returns increments without re-delivery', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo first; sleep 0.3; echo second' }))
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
const first = bash.readOutput(task.id)
|
||||
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()
|
||||
expect(() => bash.readOutput('nope')).toThrow(/unknown bash task "nope"/)
|
||||
})
|
||||
|
||||
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)
|
||||
expect(() => bash.kill('nope')).toThrow(/unknown bash task "nope"/)
|
||||
})
|
||||
|
||||
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()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
|
||||
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()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: lifecycle hardening', () => {
|
||||
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()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir, graceMs: 200 }
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
339
packages/bash/bash-local/tests/run.spec.ts
Normal file
339
packages/bash/bash-local/tests/run.spec.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { mkdtempSync, readFileSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
closeSync(fd: number): void {
|
||||
if (failNextClose.value) {
|
||||
failNextClose.value = false
|
||||
throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
|
||||
}
|
||||
actual.closeSync(fd)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
|
||||
|
||||
function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
|
||||
return {
|
||||
command,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 0,
|
||||
maxOutputBytes: 64_000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
|
||||
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`)
|
||||
}
|
||||
|
||||
describe('runBash', () => {
|
||||
it('captures stdout on success', async () => {
|
||||
const result = await runBash(spec('echo hello')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(result.stdout.text).toBe('hello\n')
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stderr.text).toBe('')
|
||||
})
|
||||
|
||||
it('captures stderr separately', async () => {
|
||||
const result = await runBash(spec('echo oops >&2')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('')
|
||||
expect(result.stderr.text).toBe('oops\n')
|
||||
})
|
||||
|
||||
it('captures both streams', async () => {
|
||||
const result = await runBash(spec('echo out; echo err >&2')).done
|
||||
expect(result.stdout.text).toBe('out\n')
|
||||
expect(result.stderr.text).toBe('err\n')
|
||||
})
|
||||
|
||||
it('reports non-zero exit codes', async () => {
|
||||
const result = await runBash(spec('exit 42')).done
|
||||
expect(result.exitCode).toBe(42)
|
||||
expect(result.signal).toBeNull()
|
||||
})
|
||||
|
||||
it('applies model-friendly env overrides', async () => {
|
||||
const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
|
||||
expect(result.stdout.text).toBe('1/dumb/cat\n')
|
||||
})
|
||||
|
||||
it('runs in the requested cwd', async () => {
|
||||
const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
|
||||
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
|
||||
it('kills with SIGTERM on timeout', async () => {
|
||||
const start = Date.now()
|
||||
const result = await runBash(spec('sleep 60', { timeoutMs: 100 })).done
|
||||
expect(Date.now() - start).toBeLessThan(5_000)
|
||||
expect(result.timedOut).toBe(true)
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.exitCode).toBeNull()
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
const result = await runBash(
|
||||
spec('trap \'\' TERM; sleep 60', { timeoutMs: 100 }),
|
||||
{ graceMs: 200 },
|
||||
).done
|
||||
expect(result.timedOut).toBe(true)
|
||||
expect(result.signal).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('kills the whole process group (grandchildren die too)', async () => {
|
||||
// The subshell writes the sleep's pid then waits on it; killing the
|
||||
// group must take the sleep down with bash.
|
||||
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
|
||||
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
const grandchild = Number(readFileSync(pidFile, 'utf8').trim())
|
||||
expect(grandchild).toBeGreaterThan(0)
|
||||
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
await waitGone(grandchild)
|
||||
})
|
||||
|
||||
it('aborts via AbortSignal mid-run', async () => {
|
||||
const controller = new AbortController()
|
||||
const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort('user cancelled') }, 50)
|
||||
const result = await running.done
|
||||
expect(result.aborted).toBe(true)
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('throws when the signal is already aborted before spawn', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('too late')
|
||||
expect(() => runBash(spec('echo hi', { signal: controller.signal })))
|
||||
.toThrow(/aborted before spawn: too late/)
|
||||
})
|
||||
|
||||
it('rejects with a spawn error for a nonexistent cwd', async () => {
|
||||
await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
|
||||
.rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('kill() is idempotent (second call does not restart escalation)', async () => {
|
||||
const running = runBash(spec('sleep 60'))
|
||||
running.kill()
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('output truncation and spill', () => {
|
||||
it('keeps the tail and spills the full stream to disk', async () => {
|
||||
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
expect(result.stdout.truncated).toBe(true)
|
||||
expect(result.stdout.text.length).toBeLessThanOrEqual(500)
|
||||
expect(result.stdout.text).toContain('line-0200')
|
||||
expect(result.stdout.text).not.toContain('line-0001')
|
||||
expect(result.stdout.spillPath).toBeDefined()
|
||||
const full = readFileSync(result.stdout.spillPath!, 'utf8')
|
||||
expect(full).toContain('line-0001')
|
||||
expect(full).toContain('line-0200')
|
||||
})
|
||||
|
||||
it('does not truncate output exactly at the cap', async () => {
|
||||
const result = await runBash(
|
||||
spec('printf "%.0sx" $(seq 1 500)', { maxOutputBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stdout.text.length).toBe(500)
|
||||
expect(result.stdout.spillPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settles with the tail and no spill path when final spill close fails', async () => {
|
||||
failNextClose.value = true
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
expect(failNextClose.value).toBe(false)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.truncated).toBe(true)
|
||||
expect(result.stdout.text).toContain('line-0200')
|
||||
expect(result.stdout.spillPath).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OutputCollector', () => {
|
||||
it('keeps the tail of a single oversized chunk', () => {
|
||||
const collector = new OutputCollector(10, 'test', spillDir)
|
||||
collector.push(Buffer.from('0123456789abcdef'))
|
||||
const out = collector.finalize()
|
||||
expect(out.text).toBe('6789abcdef')
|
||||
expect(out.truncated).toBe(true)
|
||||
expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
|
||||
})
|
||||
|
||||
it('readFrom returns increments and flags lossy reads', () => {
|
||||
const collector = new OutputCollector(10, 'test', spillDir)
|
||||
collector.push(Buffer.from('aaaaa'))
|
||||
const first = collector.readFrom(0)
|
||||
expect(first.text).toBe('aaaaa')
|
||||
expect(first.lossy).toBe(false)
|
||||
expect(first.nextOffset).toBe(5)
|
||||
|
||||
collector.push(Buffer.from('bbbbb'))
|
||||
const second = collector.readFrom(first.nextOffset)
|
||||
expect(second.text).toBe('bbbbb')
|
||||
expect(second.lossy).toBe(false)
|
||||
|
||||
// Push enough to slide the window past the last offset.
|
||||
collector.push(Buffer.from('c'.repeat(20)))
|
||||
const third = collector.readFrom(second.nextOffset)
|
||||
expect(third.lossy).toBe(true)
|
||||
expect(third.text).toBe('c'.repeat(10))
|
||||
expect(third.spillPath).toBeDefined()
|
||||
})
|
||||
|
||||
it('tracks totalBytes across drops', () => {
|
||||
const collector = new OutputCollector(4, 'test', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
expect(collector.totalBytes).toBe(8)
|
||||
expect(collector.finalize().text).toBe('bbbb')
|
||||
})
|
||||
|
||||
it('contains close failures and drops the spill path', () => {
|
||||
const collector = new OutputCollector(4, 'closefail', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
expect(collector.snapshot().spillPath).toBeDefined()
|
||||
|
||||
failNextClose.value = true
|
||||
let out: ReturnType<typeof collector.finalize>
|
||||
expect(() => { out = collector.finalize() }).not.toThrow()
|
||||
|
||||
expect(failNextClose.value).toBe(false)
|
||||
expect(out!.text).toBe('bbbb')
|
||||
expect(out!.truncated).toBe(true)
|
||||
expect(out!.spillPath).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('killGroup', () => {
|
||||
it('ignores non-positive pids', () => {
|
||||
expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
|
||||
expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('swallows ESRCH for vanished groups', async () => {
|
||||
const running = runBash(spec('true'))
|
||||
await running.done
|
||||
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('abort edge cases', () => {
|
||||
it('reports a fallback reason for reason-less pre-aborted signals', () => {
|
||||
// Real AbortControllers always set a DOMException reason; signal-like
|
||||
// objects from other libraries may not — the fallback covers them.
|
||||
const bare = {
|
||||
aborted: true,
|
||||
reason: undefined,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
expect(() => runBash(spec('echo hi', { signal: bare })))
|
||||
.toThrow(/aborted before spawn: aborted/)
|
||||
})
|
||||
|
||||
it('reports an externally self-killed command without the timeout marker', async () => {
|
||||
const result = await runBash(spec('kill -TERM $$')).done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.aborted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: env scrubbing and spill hardening', () => {
|
||||
it('scrubs credential-shaped env vars from child processes', async () => {
|
||||
process.env.DSH_TEST_API_KEY = 'super-secret'
|
||||
process.env.DSH_TEST_TOKEN = 'also-secret'
|
||||
process.env.DSH_TEST_PLAIN = 'visible'
|
||||
try {
|
||||
const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
|
||||
expect(result.stdout.text.trim()).toBe('[absent|absent|visible]')
|
||||
} finally {
|
||||
delete process.env.DSH_TEST_API_KEY
|
||||
delete process.env.DSH_TEST_TOKEN
|
||||
delete process.env.DSH_TEST_PLAIN
|
||||
}
|
||||
})
|
||||
|
||||
it('creates spill files with owner-only permissions and random names', async () => {
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
const path = result.stdout.spillPath!
|
||||
expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
||||
const mode = statSync(path).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
|
||||
it('defaults spills into a private per-process directory', async () => {
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
|
||||
).done
|
||||
const dir = dirname(result.stdout.spillPath!)
|
||||
expect(dir).toMatch(/dsh-bash-/)
|
||||
const mode = statSync(dir).mode & 0o777
|
||||
expect(mode).toBe(0o700)
|
||||
})
|
||||
|
||||
it('killGroup never throws, even for EPERM-style failures', () => {
|
||||
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
|
||||
})
|
||||
try {
|
||||
expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('honors AbortSignal on background-style runs (no timeout)', async () => {
|
||||
const controller = new AbortController()
|
||||
const running = runBash(spec('sleep 60', { timeoutMs: 0, signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await running.done
|
||||
expect(result.aborted).toBe(true)
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user