injected-run.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /**
  2. * The two run options a command inside a process worker supplies: the
  3. * filesystem it acts on, and the callback that reports output before the run
  4. * settles. `src/shell/process/child.ts` passes a message-backed filesystem and
  5. * posts a frame per write, so both are load-bearing for every backgrounded
  6. * command the bash tool starts.
  7. *
  8. * No VFS is mounted here, deliberately. The in-host filesystem reads the
  9. * process-wide slot on first use, so a program that reached it instead of the
  10. * injected face fails with `no filesystem is mounted` — a suite that mounted a
  11. * VFS as well would pass either way.
  12. */
  13. import { describe, expect, it } from 'vitest'
  14. import { runShellCommand } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/interpret.ts'
  15. import { filesystemError } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/fs-access.ts'
  16. import type {
  17. ShellDirent, ShellFileSystem, ShellRunOutcome, ShellStats,
  18. } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/types.ts'
  19. const WORKSPACE = '/dsh/workspace'
  20. /** One call a program made on the injected filesystem. */
  21. interface Call {
  22. readonly op: string
  23. readonly path: string
  24. readonly text?: string
  25. readonly append?: boolean
  26. }
  27. /**
  28. * A filesystem over a flat map of absolute paths, recording every call.
  29. *
  30. * Directories are the parents of the files it holds, which is all the programs
  31. * below ask about; nothing here reaches the mounted VFS.
  32. */
  33. function recordingFileSystem(files: Record<string, string>): {
  34. fs: ShellFileSystem
  35. calls: Call[]
  36. contents: Map<string, string>
  37. } {
  38. const contents = new Map(Object.entries(files))
  39. const calls: Call[] = []
  40. const directories = (): Set<string> => {
  41. const known = new Set<string>()
  42. for (const path of contents.keys()) {
  43. for (let parent = path.slice(0, path.lastIndexOf('/')); parent !== ''; parent = parent.slice(0, parent.lastIndexOf('/'))) {
  44. known.add(parent)
  45. }
  46. }
  47. return known
  48. }
  49. const fs: ShellFileSystem = {
  50. stat: async (path: string): Promise<ShellStats | undefined> => {
  51. calls.push({ op: 'stat', path })
  52. const text = contents.get(path)
  53. if (text !== undefined) return { directory: false, size: text.length, mtimeMs: 1 }
  54. return directories().has(path) ? { directory: true, size: 0, mtimeMs: 1 } : undefined
  55. },
  56. list: async (path: string): Promise<ShellDirent[]> => {
  57. calls.push({ op: 'list', path })
  58. if (!directories().has(path)) throw filesystemError('ENOENT', 'scandir', path)
  59. const prefix = `${path}/`
  60. const names = new Set<string>()
  61. for (const candidate of [...contents.keys(), ...directories()]) {
  62. if (!candidate.startsWith(prefix)) continue
  63. names.add(candidate.slice(prefix.length).split('/')[0] as string)
  64. }
  65. return [...names].sort().map(name => ({ name, directory: directories().has(`${prefix}${name}`) }))
  66. },
  67. readText: async (path: string): Promise<string> => {
  68. calls.push({ op: 'readText', path })
  69. const text = contents.get(path)
  70. if (text === undefined) throw filesystemError('ENOENT', 'open', path)
  71. return text
  72. },
  73. writeText: async (path: string, text: string, append = false): Promise<void> => {
  74. calls.push({ op: 'writeText', path, text, append })
  75. contents.set(path, append ? `${contents.get(path) ?? ''}${text}` : text)
  76. },
  77. mkdir: async (path: string, recursive: boolean): Promise<void> => {
  78. calls.push({ op: 'mkdir', path, append: recursive })
  79. },
  80. remove: async (path: string): Promise<void> => {
  81. calls.push({ op: 'remove', path })
  82. contents.delete(path)
  83. },
  84. rename: async (from: string, to: string): Promise<void> => {
  85. calls.push({ op: 'rename', path: from, text: to })
  86. const text = contents.get(from)
  87. if (text === undefined) throw filesystemError('ENOENT', 'rename', from)
  88. contents.delete(from)
  89. contents.set(to, text)
  90. },
  91. }
  92. return { fs, calls, contents }
  93. }
  94. describe('injected filesystem', () => {
  95. it('reads through the injected face, at the path the shell resolved', async () => {
  96. const { fs, calls } = recordingFileSystem({ [`${WORKSPACE}/notes.txt`]: 'alpha\nbeta\n' })
  97. const result = await runShellCommand('cat notes.txt', { cwd: WORKSPACE, env: {}, fs })
  98. expect(result).toEqual({ exitCode: 0, stdout: 'alpha\nbeta\n', stderr: '' })
  99. // Programs receive the word as written; the absolute path is the shell's work.
  100. expect(calls.filter(call => call.op === 'readText').map(call => call.path)).toEqual([`${WORKSPACE}/notes.txt`])
  101. })
  102. it('performs a redirection as a truncating write followed by appends', async () => {
  103. const { fs, calls, contents } = recordingFileSystem({})
  104. const result = await runShellCommand('echo one > out.txt; echo two >> out.txt', { cwd: WORKSPACE, env: {}, fs })
  105. expect(result.exitCode).toBe(0)
  106. expect(contents.get(`${WORKSPACE}/out.txt`)).toBe('one\ntwo\n')
  107. expect(calls.filter(call => call.op === 'writeText').map(call => [call.text, call.append])).toEqual([
  108. // `> file` empties the file when the redirection is set up, so a command
  109. // that writes nothing still leaves it empty.
  110. ['', false],
  111. ['one\n', true],
  112. ['two\n', true],
  113. ])
  114. })
  115. it('reports an injected failure as the utility does, not as a filesystem error', async () => {
  116. const { fs } = recordingFileSystem({})
  117. const result = await runShellCommand('cat missing.txt', { cwd: WORKSPACE, env: {}, fs })
  118. expect(result.exitCode).toBe(1)
  119. expect(result.stderr).toBe('cat: missing.txt: No such file or directory\n')
  120. })
  121. })
  122. describe('incremental output', () => {
  123. /** Run a line, collecting what the callback saw in order. */
  124. async function reported(command: string): Promise<{ seen: [string, string][]; outcome: ShellRunOutcome }> {
  125. const { fs } = recordingFileSystem({ [`${WORKSPACE}/notes.txt`]: 'alpha\n' })
  126. const seen: [string, string][] = []
  127. const outcome = await runShellCommand(command, {
  128. cwd: WORKSPACE,
  129. env: {},
  130. fs,
  131. onOutput: (stream, text) => { seen.push([stream, text]) },
  132. })
  133. return { seen, outcome }
  134. }
  135. it('reports each write as it happens and still returns the complete text', async () => {
  136. const { seen, outcome } = await reported('echo one; echo two')
  137. expect(seen).toEqual([['stdout', 'one\n'], ['stdout', 'two\n']])
  138. expect(outcome.stdout).toBe('one\ntwo\n')
  139. })
  140. it('tags a diagnostic as standard error', async () => {
  141. const { seen, outcome } = await reported('definitely-not-a-program')
  142. expect(seen).toEqual([['stderr', 'bash: definitely-not-a-program: command not found\n']])
  143. expect(outcome).toEqual({ exitCode: 127, stdout: '', stderr: 'bash: definitely-not-a-program: command not found\n' })
  144. })
  145. it('reports only what the line writes out, not what it hands along or captures', async () => {
  146. // A pipeline stage writes into the next stage's input and a redirection
  147. // writes into a file: neither is output of the line, so a caller polling for
  148. // progress must not see it.
  149. const piped = await reported('cat notes.txt | cat')
  150. expect(piped.seen).toEqual([['stdout', 'alpha\n']])
  151. const redirected = await reported('echo captured > out.txt')
  152. expect(redirected.seen).toEqual([])
  153. expect(redirected.outcome.stdout).toBe('')
  154. })
  155. })