| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- /**
- * The process model: a command runs in its own worker, reaches the VFS only by
- * message, and dies when the host says so.
- *
- * The `Worker` here is a loopback that runs the REAL child half
- * (`runShellProcess`) against the REAL host half, so the frames, the
- * filesystem service, and the termination ladder are the shipped ones — only
- * the thread boundary is simulated, because a Node test host has no DOM
- * `Worker` to cross. The real browser Worker boundary is not exercised here.
- */
- import { afterEach, beforeEach, expect, it, vi } 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 { startProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/host.ts'
- import { runShellProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/child.ts'
- import { isShellStartFrame } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/protocol.ts'
- import type { FromProcessFrame, ToProcessFrame } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/protocol.ts'
- const WORKSPACE = '/dsh/workspace'
- const WORKER_URL = 'https://example.test/assets/worker.js'
- let vfs: MemoryVfs
- /** Every loopback worker the code under test constructed. */
- let started: LoopbackWorker[]
- /**
- * A `Worker` that keeps the child half on this thread. Delivery is deferred so
- * neither half can observe the other's synchronous progress, which is the one
- * property of the real boundary that changes behaviour.
- */
- class LoopbackWorker {
- readonly url: string
- terminated = false
- private readonly hostListeners: ((event: MessageEvent) => void)[] = []
- private childListener: ((event: MessageEvent) => void) | undefined
- private closed = false
- constructor(url: string | URL, options?: { type?: string }) {
- this.url = String(url)
- expect(options?.type).toBe('module')
- started.push(this)
- }
- /** Host → child. The first frame starts the real child half. */
- postMessage(frame: ToProcessFrame): void {
- if (this.terminated) return
- queueMicrotask(() => {
- if (this.terminated) return
- if (isShellStartFrame(frame)) {
- runShellProcess(frame, {
- postMessage: (reply: FromProcessFrame) => { this.toHost(reply) },
- addEventListener: (_type: 'message', listener: (event: MessageEvent) => void) => { this.childListener = listener },
- close: () => { this.closed = true },
- })
- return
- }
- this.childListener?.({ data: frame } as MessageEvent)
- })
- }
- /** Child → host. */
- private toHost(frame: FromProcessFrame): void {
- if (this.terminated) return
- queueMicrotask(() => {
- if (this.terminated) return
- for (const listener of this.hostListeners) listener({ data: frame } as MessageEvent)
- })
- }
- addEventListener(type: 'message' | 'error', listener: (event: MessageEvent) => void): void {
- if (type === 'message') this.hostListeners.push(listener)
- }
- terminate(): void {
- this.terminated = true
- }
- /** Whether the child closed itself after reporting its status. */
- get childClosed(): boolean {
- return this.closed
- }
- }
- beforeEach(() => {
- vfs = new MemoryVfs()
- setActiveVfs(vfs)
- vfs.mkdirSync(WORKSPACE, { recursive: true })
- started = []
- // The selection in `startProcess` reads exactly these two globals.
- vi.stubGlobal('Worker', LoopbackWorker)
- vi.stubGlobal('self', { location: { href: WORKER_URL } })
- })
- afterEach(() => {
- vi.unstubAllGlobals()
- })
- /** Run one command line through the process model and collect everything. */
- async function run(script: string, stdin = ''): Promise<{ code: number; stdout: string; stderr: string }> {
- let stdout = ''
- let stderr = ''
- const code = await new Promise<number>((settle) => {
- startProcess({
- script,
- argv: ['bash', '-c', script],
- cwd: WORKSPACE,
- env: { HOME: '/dsh/home' },
- stdin,
- onOutput: (stream, text) => {
- if (stream === 'stdout') stdout += text
- else stderr += text
- },
- onExit: settle,
- })
- })
- return { code, stdout, stderr }
- }
- it('starts the command as a worker from this bundle, not on this thread', async () => {
- await run('echo hi')
- expect(started).toHaveLength(1)
- // The child is this very bundle in another role: no second asset to serve.
- expect(started[0]?.url).toBe(WORKER_URL)
- })
- it('runs the command in the child and reports its output and status', async () => {
- expect(await run('echo hi; echo oops >&2; exit 3')).toEqual({ code: 3, stdout: 'hi\n', stderr: 'oops\n' })
- })
- it('reaches the host filesystem by message', async () => {
- const written = await run('mkdir -p nested && echo carried > nested/file.txt && cat nested/file.txt')
- expect(written).toEqual({ code: 0, stdout: 'carried\n', stderr: '' })
- // The child holds no VFS of its own: the bytes can only have arrived here
- // through the filesystem frames.
- expect(vfs.readFileSync(`${WORKSPACE}/nested/file.txt`, 'utf8')).toBe('carried\n')
- })
- it('carries a filesystem failure back with its code, not as a lost exception', async () => {
- const missing = await run('cat nowhere.txt')
- expect(missing.code).toBe(1)
- expect(missing.stderr).toBe('cat: nowhere.txt: No such file or directory\n')
- })
- it('delivers standard input to the child', async () => {
- expect((await run('grep -c ""', 'a\nb\nc\n')).stdout).toBe('3\n')
- })
- it('closes the child once the command settles', async () => {
- await run('true')
- expect(started[0]?.childClosed).toBe(true)
- })
- it('asks first and terminates second', async () => {
- const events: number[] = []
- const running = startProcess({
- script: 'sleep 30',
- argv: ['bash', '-c', 'sleep 30'],
- cwd: WORKSPACE,
- env: {},
- stdin: '',
- onOutput: () => {},
- onExit: code => events.push(code),
- })
- // The first rung asks the command to stop; a `sleep` honours it.
- running.interrupt()
- await vi.waitFor(() => { expect(events).toHaveLength(1) })
- expect(events[0]).toBe(130)
- // The second rung does not ask: the worker is gone whatever it was doing.
- const stubborn = startProcess({
- script: 'sleep 30',
- argv: ['bash', '-c', 'sleep 30'],
- cwd: WORKSPACE,
- env: {},
- stdin: '',
- onOutput: () => {},
- onExit: code => events.push(code),
- })
- stubborn.destroy()
- await vi.waitFor(() => { expect(events).toHaveLength(2) })
- expect(started[1]?.terminated).toBe(true)
- })
- it('runs an explicit argv without a command line to parse', async () => {
- vfs.writeFileSync(`${WORKSPACE}/spaced name.txt`, 'kept\n')
- let stdout = ''
- const code = await new Promise<number>((settle) => {
- startProcess({
- argv: ['cat', 'spaced name.txt'],
- cwd: WORKSPACE,
- env: {},
- stdin: '',
- onOutput: (_stream, text) => { stdout += text },
- onExit: settle,
- })
- })
- expect({ code, stdout }).toEqual({ code: 0, stdout: 'kept\n' })
- })
|