shell.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. /**
  2. * The in-worker shell: structure (pipelines, chaining, subshells, redirections,
  3. * expansion) and the command table's effects on a real MemoryVfs.
  4. *
  5. * ONE module instance, like `../node/fs.spec.ts`: the command table reaches the VFS
  6. * through the module-level slot, so the mount here and the programs under test
  7. * must be the same copy of `src/storage/memory.ts`.
  8. */
  9. import { beforeEach, describe, expect, it } from 'vitest'
  10. import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
  11. import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
  12. import { runShellCommand } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/interpret.ts'
  13. import type { ShellRunOutcome } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/types.ts'
  14. const WORKSPACE = '/dsh/workspace'
  15. let vfs: MemoryVfs
  16. /** Run one command line in a fresh workspace with a fixed environment. */
  17. async function run(command: string, options: { stdin?: string; cwd?: string } = {}): Promise<ShellRunOutcome> {
  18. return await runShellCommand(command, {
  19. cwd: options.cwd ?? WORKSPACE,
  20. env: { HOME: '/dsh/home', PWD: WORKSPACE, GREETING: 'hello world' },
  21. stdin: options.stdin,
  22. })
  23. }
  24. beforeEach(() => {
  25. vfs = new MemoryVfs()
  26. setActiveVfs(vfs)
  27. vfs.mkdirSync(WORKSPACE, { recursive: true })
  28. vfs.mkdirSync(`${WORKSPACE}/src`, { recursive: true })
  29. vfs.writeFileSync(`${WORKSPACE}/notes.txt`, 'alpha\nbeta\ngamma\n')
  30. vfs.writeFileSync(`${WORKSPACE}/src/a.ts`, 'export const a = 1\n')
  31. vfs.writeFileSync(`${WORKSPACE}/src/b.ts`, 'export const b = 2\n')
  32. })
  33. describe('command execution', () => {
  34. it('runs a program and reports its output and status', async () => {
  35. expect(await run('echo hi')).toEqual({ exitCode: 0, stdout: 'hi\n', stderr: '' })
  36. })
  37. it('reports an unknown command the way a shell does', async () => {
  38. const result = await run('definitely-not-a-program --help')
  39. expect(result.exitCode).toBe(127)
  40. expect(result.stderr).toBe('bash: definitely-not-a-program: command not found\n')
  41. })
  42. it('reads a file through the VFS', async () => {
  43. expect((await run('cat notes.txt')).stdout).toBe('alpha\nbeta\ngamma\n')
  44. })
  45. it('reports a missing file as the utility does, with a nonzero status', async () => {
  46. const result = await run('cat missing.txt')
  47. expect(result.exitCode).toBe(1)
  48. expect(result.stderr).toBe('cat: missing.txt: No such file or directory\n')
  49. })
  50. })
  51. describe('structure', () => {
  52. it('pipes standard output into the next stage', async () => {
  53. expect((await run('cat notes.txt | grep -n "^[ab]"')).stdout).toBe('1:alpha\n2:beta\n')
  54. })
  55. it('takes the pipeline status from its last stage', async () => {
  56. expect((await run('cat notes.txt | grep zeta')).exitCode).toBe(1)
  57. })
  58. it('honours && and || on the previous status', async () => {
  59. expect((await run('true && echo yes || echo no')).stdout).toBe('yes\n')
  60. expect((await run('false && echo yes || echo no')).stdout).toBe('no\n')
  61. })
  62. it('runs ; separated commands in order', async () => {
  63. expect((await run('echo one; echo two')).stdout).toBe('one\ntwo\n')
  64. })
  65. it('keeps a subshell directory change out of the parent', async () => {
  66. expect((await run('(cd src && pwd); pwd')).stdout).toBe(`${WORKSPACE}/src\n${WORKSPACE}\n`)
  67. })
  68. it('keeps a directory change made by the line itself', async () => {
  69. expect((await run('cd src; pwd')).stdout).toBe(`${WORKSPACE}/src\n`)
  70. })
  71. it('stops the line at exit and reports its status', async () => {
  72. const result = await run('echo before; exit 3; echo after')
  73. expect(result).toEqual({ exitCode: 3, stdout: 'before\n', stderr: '' })
  74. })
  75. })
  76. describe('redirections', () => {
  77. it('writes standard output to a file and truncates it first', async () => {
  78. await run('echo first > out.txt')
  79. await run('echo second > out.txt')
  80. expect(vfs.readFileSync(`${WORKSPACE}/out.txt`, 'utf8')).toBe('second\n')
  81. })
  82. it('creates an empty file when the command writes nothing', async () => {
  83. await run('true > empty.txt')
  84. expect(vfs.readFileSync(`${WORKSPACE}/empty.txt`, 'utf8')).toBe('')
  85. })
  86. it('appends with >>', async () => {
  87. await run('echo one > log.txt; echo two >> log.txt')
  88. expect(vfs.readFileSync(`${WORKSPACE}/log.txt`, 'utf8')).toBe('one\ntwo\n')
  89. })
  90. it('reads standard input from a file and from a here-string', async () => {
  91. expect((await run('grep beta < notes.txt')).stdout).toBe('beta\n')
  92. expect((await run('cat <<< inline')).stdout).toBe('inline\n')
  93. })
  94. it('sends standard error to its own file with 2>', async () => {
  95. const result = await run('cat missing.txt 2> err.txt')
  96. expect(result.stderr).toBe('')
  97. expect(vfs.readFileSync(`${WORKSPACE}/err.txt`, 'utf8')).toBe('cat: missing.txt: No such file or directory\n')
  98. })
  99. it('merges standard error into standard output with 2>&1', async () => {
  100. const result = await run('cat missing.txt 2>&1')
  101. expect(result.stderr).toBe('')
  102. expect(result.stdout).toBe('cat: missing.txt: No such file or directory\n')
  103. })
  104. it('reports a missing input file itself and never runs the command', async () => {
  105. // Setting up the redirection is the shell's own work, so the diagnostic is
  106. // prefixed `bash` on the resolved path rather than by the utility.
  107. expect(await run('cat < missing.txt')).toEqual({
  108. exitCode: 1,
  109. stdout: '',
  110. stderr: `bash: ${WORKSPACE}/missing.txt: No such file or directory\n`,
  111. })
  112. })
  113. it('refuses a target that expands to more than one word', async () => {
  114. const result = await run('cat < src/*.ts')
  115. expect(result.exitCode).toBe(1)
  116. expect(result.stderr).toBe('bash: ambiguous redirect\n')
  117. })
  118. it('refuses a descriptor duplication other than between stdout and stderr', async () => {
  119. const result = await run('echo hi 3>&1')
  120. expect(result.exitCode).toBe(1)
  121. expect(result.stderr).toBe('bash: 3>&1: unsupported descriptor redirection\n')
  122. })
  123. })
  124. describe('expansion', () => {
  125. it('expands variables, quoted and unquoted', async () => {
  126. expect((await run('echo "$GREETING"')).stdout).toBe('hello world\n')
  127. expect((await run('echo ${MISSING:-fallback}')).stdout).toBe('fallback\n')
  128. })
  129. it('reports the previous status as $?', async () => {
  130. expect((await run('false; echo $?')).stdout).toBe('1\n')
  131. })
  132. it('substitutes command output', async () => {
  133. expect((await run('echo "[$(head -n 1 notes.txt)]"')).stdout).toBe('[alpha]\n')
  134. })
  135. it('evaluates arithmetic', async () => {
  136. expect((await run('echo $((1 + 2 * 3))')).stdout).toBe('7\n')
  137. })
  138. it('expands globs against the VFS and keeps an unmatched pattern literal', async () => {
  139. expect((await run('echo src/*.ts')).stdout).toBe('src/a.ts src/b.ts\n')
  140. expect((await run('echo *.missing')).stdout).toBe('*.missing\n')
  141. })
  142. it('passes an assignment prefix as environment for that command only', async () => {
  143. expect((await run('MARK=set printenv MARK; echo "[${MARK}]"')).stdout).toBe('set\n[]\n')
  144. })
  145. })
  146. describe('file utilities', () => {
  147. it('lists a directory one entry per line', async () => {
  148. expect((await run('ls src')).stdout).toBe('a.ts\nb.ts\n')
  149. })
  150. it('creates, copies, moves, and removes trees', async () => {
  151. const result = await run('mkdir -p deep/nested && cp -r src deep/nested/copy && mv notes.txt deep/ && rm -r src')
  152. expect(result.exitCode).toBe(0)
  153. expect(vfs.existsSync(`${WORKSPACE}/deep/nested/copy/a.ts`)).toBe(true)
  154. expect(vfs.existsSync(`${WORKSPACE}/deep/notes.txt`)).toBe(true)
  155. expect(vfs.existsSync(`${WORKSPACE}/src`)).toBe(false)
  156. })
  157. it('finds by name and type', async () => {
  158. expect((await run('find . -name "*.ts"')).stdout).toBe('./src/a.ts\n./src/b.ts\n')
  159. expect((await run('find . -type d')).stdout).toBe('.\n./src\n')
  160. })
  161. it('counts, sorts, and deduplicates text', async () => {
  162. expect((await run('wc -l notes.txt')).stdout.trim()).toBe('3 notes.txt')
  163. expect((await run('sort -r notes.txt | head -n 1')).stdout).toBe('gamma\n')
  164. expect((await run('printf "b\\nb\\na\\n" | sort | uniq')).stdout).toBe('a\nb\n')
  165. })
  166. it('translates and deletes characters by range', async () => {
  167. expect((await run('echo shell-works | tr a-z A-Z')).stdout).toBe('SHELL-WORKS\n')
  168. expect((await run('echo a1b2c3 | tr -d 0-9')).stdout).toBe('abc\n')
  169. })
  170. it('substitutes with sed and refuses any other script', async () => {
  171. expect((await run('sed s/alpha/ALPHA/ notes.txt | head -n 1')).stdout).toBe('ALPHA\n')
  172. const refused = await run('sed 1d notes.txt')
  173. expect(refused.exitCode).toBe(2)
  174. expect(refused.stderr).toContain('only substitution scripts')
  175. })
  176. })
  177. describe('cancellation', () => {
  178. it('stops before the next command once the caller aborts', async () => {
  179. const controller = new AbortController()
  180. controller.abort()
  181. const result = await runShellCommand('echo never', {
  182. cwd: WORKSPACE,
  183. env: {},
  184. signal: controller.signal,
  185. })
  186. expect(result).toEqual({ exitCode: 130, stdout: '', stderr: '' })
  187. })
  188. })