Files
deepseek-harness/packages/experimental/webworker-runtime/tests/shell/shell.spec.ts
T
imccyu 4779ec9af9 feat(webworker): model-executed shell over nested worker processes
Buy the grammar, own the execution: @yarnpkg/parsers parses the command
line - aliased at bundle time to its shell entry so the root barrel's
syml/js-yaml closure stays out of the worker - and a VFS-backed evaluator
with a coreutils command table runs it inside the worker host. Each shell
process is a real child WebWorker spawned from the same bundle (the first
frame decides the role), so the TERM-then-KILL ladder is real - TERM
requests, KILL terminates the worker - and the file face stays
asynchronous end to end, since the deployment target serves no COOP/COEP
headers and SharedArrayBuffer never exists there. node:child_process
reports through the ChildProcess surface the subprocess service consumes;
execSync, execFileSync and fork refuse, and node-pty stays stubbed.
2026-08-21 20:35:32 +08:00

226 lines
8.9 KiB
TypeScript

/**
* The in-worker shell: structure (pipelines, chaining, subshells, redirections,
* expansion) and the command table's effects on a real MemoryVfs.
*
* ONE module instance, like `../node/fs.spec.ts`: the command table reaches the VFS
* through the module-level slot, so the mount here and the programs under test
* must be the same copy of `src/storage/memory.ts`.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
import { runShellCommand } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/interpret.ts'
import type { ShellRunOutcome } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/types.ts'
const WORKSPACE = '/dsh/workspace'
let vfs: MemoryVfs
/** Run one command line in a fresh workspace with a fixed environment. */
async function run(command: string, options: { stdin?: string; cwd?: string } = {}): Promise<ShellRunOutcome> {
return await runShellCommand(command, {
cwd: options.cwd ?? WORKSPACE,
env: { HOME: '/dsh/home', PWD: WORKSPACE, GREETING: 'hello world' },
stdin: options.stdin,
})
}
beforeEach(() => {
vfs = new MemoryVfs()
setActiveVfs(vfs)
vfs.mkdirSync(WORKSPACE, { recursive: true })
vfs.mkdirSync(`${WORKSPACE}/src`, { recursive: true })
vfs.writeFileSync(`${WORKSPACE}/notes.txt`, 'alpha\nbeta\ngamma\n')
vfs.writeFileSync(`${WORKSPACE}/src/a.ts`, 'export const a = 1\n')
vfs.writeFileSync(`${WORKSPACE}/src/b.ts`, 'export const b = 2\n')
})
describe('command execution', () => {
it('runs a program and reports its output and status', async () => {
expect(await run('echo hi')).toEqual({ exitCode: 0, stdout: 'hi\n', stderr: '' })
})
it('reports an unknown command the way a shell does', async () => {
const result = await run('definitely-not-a-program --help')
expect(result.exitCode).toBe(127)
expect(result.stderr).toBe('bash: definitely-not-a-program: command not found\n')
})
it('reads a file through the VFS', async () => {
expect((await run('cat notes.txt')).stdout).toBe('alpha\nbeta\ngamma\n')
})
it('reports a missing file as the utility does, with a nonzero status', async () => {
const result = await run('cat missing.txt')
expect(result.exitCode).toBe(1)
expect(result.stderr).toBe('cat: missing.txt: No such file or directory\n')
})
})
describe('structure', () => {
it('pipes standard output into the next stage', async () => {
expect((await run('cat notes.txt | grep -n "^[ab]"')).stdout).toBe('1:alpha\n2:beta\n')
})
it('takes the pipeline status from its last stage', async () => {
expect((await run('cat notes.txt | grep zeta')).exitCode).toBe(1)
})
it('honours && and || on the previous status', async () => {
expect((await run('true && echo yes || echo no')).stdout).toBe('yes\n')
expect((await run('false && echo yes || echo no')).stdout).toBe('no\n')
})
it('runs ; separated commands in order', async () => {
expect((await run('echo one; echo two')).stdout).toBe('one\ntwo\n')
})
it('keeps a subshell directory change out of the parent', async () => {
expect((await run('(cd src && pwd); pwd')).stdout).toBe(`${WORKSPACE}/src\n${WORKSPACE}\n`)
})
it('keeps a directory change made by the line itself', async () => {
expect((await run('cd src; pwd')).stdout).toBe(`${WORKSPACE}/src\n`)
})
it('stops the line at exit and reports its status', async () => {
const result = await run('echo before; exit 3; echo after')
expect(result).toEqual({ exitCode: 3, stdout: 'before\n', stderr: '' })
})
})
describe('redirections', () => {
it('writes standard output to a file and truncates it first', async () => {
await run('echo first > out.txt')
await run('echo second > out.txt')
expect(vfs.readFileSync(`${WORKSPACE}/out.txt`, 'utf8')).toBe('second\n')
})
it('creates an empty file when the command writes nothing', async () => {
await run('true > empty.txt')
expect(vfs.readFileSync(`${WORKSPACE}/empty.txt`, 'utf8')).toBe('')
})
it('appends with >>', async () => {
await run('echo one > log.txt; echo two >> log.txt')
expect(vfs.readFileSync(`${WORKSPACE}/log.txt`, 'utf8')).toBe('one\ntwo\n')
})
it('reads standard input from a file and from a here-string', async () => {
expect((await run('grep beta < notes.txt')).stdout).toBe('beta\n')
expect((await run('cat <<< inline')).stdout).toBe('inline\n')
})
it('sends standard error to its own file with 2>', async () => {
const result = await run('cat missing.txt 2> err.txt')
expect(result.stderr).toBe('')
expect(vfs.readFileSync(`${WORKSPACE}/err.txt`, 'utf8')).toBe('cat: missing.txt: No such file or directory\n')
})
it('merges standard error into standard output with 2>&1', async () => {
const result = await run('cat missing.txt 2>&1')
expect(result.stderr).toBe('')
expect(result.stdout).toBe('cat: missing.txt: No such file or directory\n')
})
it('reports a missing input file itself and never runs the command', async () => {
// Setting up the redirection is the shell's own work, so the diagnostic is
// prefixed `bash` on the resolved path rather than by the utility.
expect(await run('cat < missing.txt')).toEqual({
exitCode: 1,
stdout: '',
stderr: `bash: ${WORKSPACE}/missing.txt: No such file or directory\n`,
})
})
it('refuses a target that expands to more than one word', async () => {
const result = await run('cat < src/*.ts')
expect(result.exitCode).toBe(1)
expect(result.stderr).toBe('bash: ambiguous redirect\n')
})
it('refuses a descriptor duplication other than between stdout and stderr', async () => {
const result = await run('echo hi 3>&1')
expect(result.exitCode).toBe(1)
expect(result.stderr).toBe('bash: 3>&1: unsupported descriptor redirection\n')
})
})
describe('expansion', () => {
it('expands variables, quoted and unquoted', async () => {
expect((await run('echo "$GREETING"')).stdout).toBe('hello world\n')
expect((await run('echo ${MISSING:-fallback}')).stdout).toBe('fallback\n')
})
it('reports the previous status as $?', async () => {
expect((await run('false; echo $?')).stdout).toBe('1\n')
})
it('substitutes command output', async () => {
expect((await run('echo "[$(head -n 1 notes.txt)]"')).stdout).toBe('[alpha]\n')
})
it('evaluates arithmetic', async () => {
expect((await run('echo $((1 + 2 * 3))')).stdout).toBe('7\n')
})
it('expands globs against the VFS and keeps an unmatched pattern literal', async () => {
expect((await run('echo src/*.ts')).stdout).toBe('src/a.ts src/b.ts\n')
expect((await run('echo *.missing')).stdout).toBe('*.missing\n')
})
it('passes an assignment prefix as environment for that command only', async () => {
expect((await run('MARK=set printenv MARK; echo "[${MARK}]"')).stdout).toBe('set\n[]\n')
})
})
describe('file utilities', () => {
it('lists a directory one entry per line', async () => {
expect((await run('ls src')).stdout).toBe('a.ts\nb.ts\n')
})
it('creates, copies, moves, and removes trees', async () => {
const result = await run('mkdir -p deep/nested && cp -r src deep/nested/copy && mv notes.txt deep/ && rm -r src')
expect(result.exitCode).toBe(0)
expect(vfs.existsSync(`${WORKSPACE}/deep/nested/copy/a.ts`)).toBe(true)
expect(vfs.existsSync(`${WORKSPACE}/deep/notes.txt`)).toBe(true)
expect(vfs.existsSync(`${WORKSPACE}/src`)).toBe(false)
})
it('finds by name and type', async () => {
expect((await run('find . -name "*.ts"')).stdout).toBe('./src/a.ts\n./src/b.ts\n')
expect((await run('find . -type d')).stdout).toBe('.\n./src\n')
})
it('counts, sorts, and deduplicates text', async () => {
expect((await run('wc -l notes.txt')).stdout.trim()).toBe('3 notes.txt')
expect((await run('sort -r notes.txt | head -n 1')).stdout).toBe('gamma\n')
expect((await run('printf "b\\nb\\na\\n" | sort | uniq')).stdout).toBe('a\nb\n')
})
it('translates and deletes characters by range', async () => {
expect((await run('echo shell-works | tr a-z A-Z')).stdout).toBe('SHELL-WORKS\n')
expect((await run('echo a1b2c3 | tr -d 0-9')).stdout).toBe('abc\n')
})
it('substitutes with sed and refuses any other script', async () => {
expect((await run('sed s/alpha/ALPHA/ notes.txt | head -n 1')).stdout).toBe('ALPHA\n')
const refused = await run('sed 1d notes.txt')
expect(refused.exitCode).toBe(2)
expect(refused.stderr).toContain('only substitution scripts')
})
})
describe('cancellation', () => {
it('stops before the next command once the caller aborts', async () => {
const controller = new AbortController()
controller.abort()
const result = await runShellCommand('echo never', {
cwd: WORKSPACE,
env: {},
signal: controller.signal,
})
expect(result).toEqual({ exitCode: 130, stdout: '', stderr: '' })
})
})