| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- /**
- * 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: '' })
- })
- })
|