1
0

default-web-process.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /** Built-process owner shared by the Host and browser default-Web isolation smokes. */
  2. import { spawn } from 'node:child_process'
  3. import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import { get } from 'node:http'
  8. import type { IncomingHttpHeaders } from 'node:http'
  9. import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
  10. import ts from 'typescript'
  11. import { expect } from 'vitest'
  12. import type { TestContext } from 'vitest'
  13. import { PROCESS_SHUTDOWN_TIMEOUT_MS } from '../../../../src/process-shutdown.ts'
  14. import type { RuntimeRoster } from './runtime-roster.ts'
  15. const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
  16. /** Live process whose IPC observer reports independently collected runtime state. */
  17. interface DefaultWeb {
  18. url: string
  19. request: (command: 'roster' | 'mount-experimental' | 'mount-experimental-entry') => Promise<RuntimeRoster>
  20. }
  21. /**
  22. * Boot the built Web profile under plain Node and dispose it to quiescence after an assertion callback.
  23. * @param test - owning Vitest case, including its timeout, cancellation, and cleanup hooks.
  24. * @param inspect - assertions against the running process and its ephemeral loopback URL.
  25. */
  26. export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWeb) => Promise<void>): Promise<void> {
  27. const root = await mkdtemp(join(tmpdir(), 'dsh-web-default-isolation-'))
  28. let removal: Promise<void> | undefined
  29. const removeRoot = (): Promise<void> => removal ??= rm(root, { recursive: true, force: true })
  30. test.onTestFinished(removeRoot)
  31. try {
  32. await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' }) + '\n')
  33. for (const relative of ['runtime-roster.ts', 'fixtures/runtime-roster-observer.ts']) {
  34. const source = await readFile(new URL(relative, import.meta.url), 'utf8')
  35. const output = ts.transpileModule(source, {
  36. fileName: relative,
  37. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, rewriteRelativeImportExtensions: true },
  38. }).outputText
  39. const path = join(root, relative.replace(/\.ts$/, '.js'))
  40. await mkdir(dirname(path), { recursive: true })
  41. await writeFile(path, output)
  42. }
  43. const patch = join(root, 'observer.patch.yml')
  44. await writeFile(patch, JSON.stringify([{ insert: [{
  45. id: 'runtime-roster-observer',
  46. name: pathToFileURL(join(root, 'fixtures/runtime-roster-observer.js')).href,
  47. config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-agent-team/lib/index.js')).href },
  48. }] }]) + '\n')
  49. const launch = resolveExampleLaunch({
  50. srcBin: join(repoRoot, 'apps/cli/src/bin.ts'),
  51. mode: 'lib',
  52. configArgs: ['--profile', 'web', '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open'],
  53. env: {
  54. NODE_OPTIONS: undefined,
  55. NODE_PATH: undefined,
  56. TSX_TSCONFIG_PATH: undefined,
  57. DSH_HOME: join(root, 'home'),
  58. DSH_AGENTS_HOME: join(root, '.agents'),
  59. DSH_TELEMETRY_DISABLED: '1',
  60. DEEPSEEK_API_KEY: 'keyless-default-web-no-call',
  61. NODE_NO_WARNINGS: '1',
  62. },
  63. })
  64. test.signal.throwIfAborted()
  65. const args = ['--no-experimental-strip-types', ...launch.args]
  66. const child = spawn(launch.command, args, {
  67. cwd: root,
  68. env: { ...process.env, ...launch.env },
  69. stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
  70. })
  71. let stdout = ''
  72. let stderr = ''
  73. child.stdout!.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk })
  74. child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
  75. const completion = Promise.withResolvers<{ code: number | null; signal: NodeJS.Signals | null }>()
  76. let exited = false
  77. let processError: Error | undefined
  78. const pending = new Map<string, ReturnType<typeof Promise.withResolvers<RuntimeRoster>>>()
  79. const rejectPending = (error: Error): void => {
  80. for (const request of pending.values()) request.reject(error)
  81. pending.clear()
  82. }
  83. child.once('error', (error) => { processError = error; rejectPending(error) })
  84. child.once('close', (code, signal) => {
  85. exited = true
  86. rejectPending(new Error(`Web exited (code ${String(code)}, signal ${String(signal)})\n${stdout}\n${stderr}`))
  87. completion.resolve({ code, signal })
  88. })
  89. child.once('disconnect', () => { rejectPending(new Error(`Web IPC disconnected\n${stdout}\n${stderr}`)) })
  90. child.on('message', (message: { command?: string; roster?: RuntimeRoster; error?: string }) => {
  91. if (message.command === undefined) return
  92. const request = pending.get(message.command)
  93. if (request === undefined) return
  94. pending.delete(message.command)
  95. if (message.error !== undefined) request.reject(new Error(message.error))
  96. else if (message.roster !== undefined) request.resolve(message.roster)
  97. else request.reject(new Error('Web observer returned no runtime roster'))
  98. })
  99. const send = (command: string): Promise<void> => new Promise((resolve, reject) => {
  100. child.send(command, (error: Error | null) => { if (error) reject(error); else resolve() })
  101. })
  102. const request = async (command: string): Promise<RuntimeRoster> => {
  103. test.signal.throwIfAborted()
  104. if (exited || !child.connected) throw new Error(`Web is not connected during ${command}`)
  105. const reply = Promise.withResolvers<RuntimeRoster>()
  106. pending.set(command, reply)
  107. void send(command).catch((error: unknown) => {
  108. if (pending.get(command) !== reply) return
  109. pending.delete(command)
  110. reply.reject(error)
  111. })
  112. return reply.promise
  113. }
  114. let forced = false
  115. let closing: Promise<Awaited<typeof completion.promise>> | undefined
  116. const close = (): Promise<Awaited<typeof completion.promise>> => closing ??= (async () => {
  117. const force = (): void => {
  118. if (exited || child.pid === undefined) return
  119. forced = true
  120. child.kill('SIGKILL')
  121. }
  122. // The CLI owns its 5-second graceful deadline; the outer watchdog also allows that process exit to settle.
  123. const kill = setTimeout(force, PROCESS_SHUTDOWN_TIMEOUT_MS * 2)
  124. try {
  125. if (!exited && child.connected) {
  126. try { await send('stop') } catch (_closedChannel: unknown) { force() }
  127. } else force()
  128. return await completion.promise
  129. } finally { clearTimeout(kill) }
  130. })()
  131. const abort = (): void => {
  132. rejectPending(new Error('Web isolation test was cancelled', { cause: test.signal.reason }))
  133. void close()
  134. }
  135. test.signal.addEventListener('abort', abort, { once: true })
  136. test.onTestFinished(async () => { await close() })
  137. try {
  138. await expect.poll(() => {
  139. test.signal.throwIfAborted()
  140. if (exited) throw new Error(`Web exited before readiness\n${stdout}\n${stderr}`)
  141. return /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1]
  142. }, { timeout: test.task.timeout }).toBeDefined()
  143. const url = /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)![1]!
  144. await inspect({ url, request })
  145. } finally {
  146. const result = await close()
  147. test.signal.removeEventListener('abort', abort)
  148. expect(test.signal.aborted, stderr).toBe(false)
  149. expect(processError, stderr).toBeUndefined()
  150. expect(forced, stderr).toBe(false)
  151. expect(result.signal, stderr).toBeNull()
  152. expect(result.code, stderr).toBe(0)
  153. }
  154. } finally {
  155. await removeRoot()
  156. }
  157. }
  158. /**
  159. * Read an owned loopback response without inheriting Node's process-start proxy dispatcher.
  160. * @param url - URL of the test-owned Web process.
  161. * @param signal - owning test cancellation signal.
  162. * @param headers - optional authentication cookie.
  163. * @returns complete response after its stream ends.
  164. */
  165. export function webGet(url: string | URL, signal: AbortSignal, headers: Record<string, string> = {}): Promise<{
  166. status: number | undefined
  167. headers: IncomingHttpHeaders
  168. text: string
  169. }> {
  170. return new Promise((resolve, reject) => {
  171. const request = get(url, { headers, agent: false, signal }, (response) => {
  172. response.setEncoding('utf8')
  173. let text = ''
  174. response.on('data', (chunk: string) => { text += chunk })
  175. response.once('error', reject)
  176. response.once('end', () => { resolve({ status: response.statusCode, headers: response.headers, text }) })
  177. })
  178. request.once('error', reject)
  179. })
  180. }