shell-process.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /**
  2. * The process model: a command runs in its own worker, reaches the VFS only by
  3. * message, and dies when the host says so.
  4. *
  5. * The `Worker` here is a loopback that runs the REAL child half
  6. * (`runShellProcess`) against the REAL host half, so the frames, the
  7. * filesystem service, and the termination ladder are the shipped ones — only
  8. * the thread boundary is simulated, because a Node test host has no DOM
  9. * `Worker` to cross. The real browser Worker boundary is not exercised here.
  10. */
  11. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  12. import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
  13. import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
  14. import { startProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/host.ts'
  15. import { runShellProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/child.ts'
  16. import { isShellStartFrame } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/protocol.ts'
  17. import type { FromProcessFrame, ToProcessFrame } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/protocol.ts'
  18. const WORKSPACE = '/dsh/workspace'
  19. const WORKER_URL = 'https://example.test/assets/worker.js'
  20. let vfs: MemoryVfs
  21. /** Every loopback worker the code under test constructed. */
  22. let started: LoopbackWorker[]
  23. /**
  24. * A `Worker` that keeps the child half on this thread. Delivery is deferred so
  25. * neither half can observe the other's synchronous progress, which is the one
  26. * property of the real boundary that changes behaviour.
  27. */
  28. class LoopbackWorker {
  29. readonly url: string
  30. terminated = false
  31. private readonly hostListeners: ((event: MessageEvent) => void)[] = []
  32. private childListener: ((event: MessageEvent) => void) | undefined
  33. private closed = false
  34. constructor(url: string | URL, options?: { type?: string }) {
  35. this.url = String(url)
  36. expect(options?.type).toBe('module')
  37. started.push(this)
  38. }
  39. /** Host → child. The first frame starts the real child half. */
  40. postMessage(frame: ToProcessFrame): void {
  41. if (this.terminated) return
  42. queueMicrotask(() => {
  43. if (this.terminated) return
  44. if (isShellStartFrame(frame)) {
  45. runShellProcess(frame, {
  46. postMessage: (reply: FromProcessFrame) => { this.toHost(reply) },
  47. addEventListener: (_type: 'message', listener: (event: MessageEvent) => void) => { this.childListener = listener },
  48. close: () => { this.closed = true },
  49. })
  50. return
  51. }
  52. this.childListener?.({ data: frame } as MessageEvent)
  53. })
  54. }
  55. /** Child → host. */
  56. private toHost(frame: FromProcessFrame): void {
  57. if (this.terminated) return
  58. queueMicrotask(() => {
  59. if (this.terminated) return
  60. for (const listener of this.hostListeners) listener({ data: frame } as MessageEvent)
  61. })
  62. }
  63. addEventListener(type: 'message' | 'error', listener: (event: MessageEvent) => void): void {
  64. if (type === 'message') this.hostListeners.push(listener)
  65. }
  66. terminate(): void {
  67. this.terminated = true
  68. }
  69. /** Whether the child closed itself after reporting its status. */
  70. get childClosed(): boolean {
  71. return this.closed
  72. }
  73. }
  74. beforeEach(() => {
  75. vfs = new MemoryVfs()
  76. setActiveVfs(vfs)
  77. vfs.mkdirSync(WORKSPACE, { recursive: true })
  78. started = []
  79. // The selection in `startProcess` reads exactly these two globals.
  80. vi.stubGlobal('Worker', LoopbackWorker)
  81. vi.stubGlobal('self', { location: { href: WORKER_URL } })
  82. })
  83. afterEach(() => {
  84. vi.unstubAllGlobals()
  85. })
  86. /** Run one command line through the process model and collect everything. */
  87. async function run(script: string, stdin = ''): Promise<{ code: number; stdout: string; stderr: string }> {
  88. let stdout = ''
  89. let stderr = ''
  90. const code = await new Promise<number>((settle) => {
  91. startProcess({
  92. script,
  93. argv: ['bash', '-c', script],
  94. cwd: WORKSPACE,
  95. env: { HOME: '/dsh/home' },
  96. stdin,
  97. onOutput: (stream, text) => {
  98. if (stream === 'stdout') stdout += text
  99. else stderr += text
  100. },
  101. onExit: settle,
  102. })
  103. })
  104. return { code, stdout, stderr }
  105. }
  106. it('starts the command as a worker from this bundle, not on this thread', async () => {
  107. await run('echo hi')
  108. expect(started).toHaveLength(1)
  109. // The child is this very bundle in another role: no second asset to serve.
  110. expect(started[0]?.url).toBe(WORKER_URL)
  111. })
  112. it('runs the command in the child and reports its output and status', async () => {
  113. expect(await run('echo hi; echo oops >&2; exit 3')).toEqual({ code: 3, stdout: 'hi\n', stderr: 'oops\n' })
  114. })
  115. it('reaches the host filesystem by message', async () => {
  116. const written = await run('mkdir -p nested && echo carried > nested/file.txt && cat nested/file.txt')
  117. expect(written).toEqual({ code: 0, stdout: 'carried\n', stderr: '' })
  118. // The child holds no VFS of its own: the bytes can only have arrived here
  119. // through the filesystem frames.
  120. expect(vfs.readFileSync(`${WORKSPACE}/nested/file.txt`, 'utf8')).toBe('carried\n')
  121. })
  122. it('carries a filesystem failure back with its code, not as a lost exception', async () => {
  123. const missing = await run('cat nowhere.txt')
  124. expect(missing.code).toBe(1)
  125. expect(missing.stderr).toBe('cat: nowhere.txt: No such file or directory\n')
  126. })
  127. it('delivers standard input to the child', async () => {
  128. expect((await run('grep -c ""', 'a\nb\nc\n')).stdout).toBe('3\n')
  129. })
  130. it('closes the child once the command settles', async () => {
  131. await run('true')
  132. expect(started[0]?.childClosed).toBe(true)
  133. })
  134. it('asks first and terminates second', async () => {
  135. const events: number[] = []
  136. const running = startProcess({
  137. script: 'sleep 30',
  138. argv: ['bash', '-c', 'sleep 30'],
  139. cwd: WORKSPACE,
  140. env: {},
  141. stdin: '',
  142. onOutput: () => {},
  143. onExit: code => events.push(code),
  144. })
  145. // The first rung asks the command to stop; a `sleep` honours it.
  146. running.interrupt()
  147. await vi.waitFor(() => { expect(events).toHaveLength(1) })
  148. expect(events[0]).toBe(130)
  149. // The second rung does not ask: the worker is gone whatever it was doing.
  150. const stubborn = startProcess({
  151. script: 'sleep 30',
  152. argv: ['bash', '-c', 'sleep 30'],
  153. cwd: WORKSPACE,
  154. env: {},
  155. stdin: '',
  156. onOutput: () => {},
  157. onExit: code => events.push(code),
  158. })
  159. stubborn.destroy()
  160. await vi.waitFor(() => { expect(events).toHaveLength(2) })
  161. expect(started[1]?.terminated).toBe(true)
  162. })
  163. it('runs an explicit argv without a command line to parse', async () => {
  164. vfs.writeFileSync(`${WORKSPACE}/spaced name.txt`, 'kept\n')
  165. let stdout = ''
  166. const code = await new Promise<number>((settle) => {
  167. startProcess({
  168. argv: ['cat', 'spaced name.txt'],
  169. cwd: WORKSPACE,
  170. env: {},
  171. stdin: '',
  172. onOutput: (_stream, text) => { stdout += text },
  173. onExit: settle,
  174. })
  175. })
  176. expect({ code, stdout }).toEqual({ code: 0, stdout: 'kept\n' })
  177. })