web-app.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * Web runtime glue behavior: dist resolution through the bundle's own hook,
  3. * the frontend-static child claiming the fallback seat, the web-surface
  4. * prompt section and bash runtime variables, and readiness publication through
  5. * the URL line and default-browser handoff.
  6. */
  7. import { EventEmitter } from 'node:events'
  8. import { spawn, type ChildProcess } from 'node:child_process'
  9. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  10. import { tmpdir } from 'node:os'
  11. import { join } from 'node:path'
  12. import { PassThrough } from 'node:stream'
  13. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  14. import { Context } from '@deepseek-ai/cordis'
  15. import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
  16. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  17. import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
  18. import { apply, Config, internals } from '../src/index.ts'
  19. vi.mock('node:child_process', async importOriginal => ({
  20. ...await importOriginal<typeof import('node:child_process')>(),
  21. spawn: vi.fn(),
  22. }))
  23. vi.mock('node:os', async importOriginal => ({
  24. ...await importOriginal<typeof import('node:os')>(),
  25. networkInterfaces: () => ({
  26. lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
  27. en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }],
  28. }),
  29. }))
  30. let dist: string | undefined
  31. beforeEach(() => {
  32. vi.stubEnv('SSH_CONNECTION', '')
  33. vi.stubEnv('SSH_TTY', '')
  34. })
  35. afterEach(() => {
  36. vi.restoreAllMocks()
  37. vi.mocked(spawn).mockReset()
  38. vi.unstubAllEnvs()
  39. internals.resolveDistIndex = originalResolve
  40. internals.openBrowser = originalOpenBrowser
  41. if (dist !== undefined) rmSync(dist, { recursive: true, force: true })
  42. dist = undefined
  43. })
  44. const originalResolve = internals.resolveDistIndex
  45. const originalOpenBrowser = internals.openBrowser
  46. type BrowserLauncher = ChildProcess & { stderr: PassThrough }
  47. /** Minimal browser-launcher process for the native handoff adapter. */
  48. function launcher(): BrowserLauncher {
  49. return Object.assign(new EventEmitter(), { stderr: new PassThrough() }) as unknown as BrowserLauncher
  50. }
  51. /** Stage a dist fixture and point the bundle's resolver at it. */
  52. function stageDist(): string {
  53. dist = mkdtempSync(join(tmpdir(), 'dsh-web-app-'))
  54. mkdirSync(join(dist, 'dist'))
  55. const index = join(dist, 'dist', 'index.html')
  56. writeFileSync(index, '<head></head><body>shell</body>')
  57. internals.resolveDistIndex = () => index
  58. return index
  59. }
  60. /** A fake webServer capturing the fallback seat and index taps. */
  61. function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: WebServer; seat: () => unknown } {
  62. let fallback: unknown
  63. const server = {
  64. host,
  65. port: 4567,
  66. registerFallback: (handler: unknown) => {
  67. fallback = handler
  68. return () => { fallback = undefined }
  69. },
  70. renderIndex: (html: string) => html,
  71. } as unknown as WebServer
  72. return { server, seat: () => fallback }
  73. }
  74. /** A fake Loader whose settlement the test controls (the URL line waits on it). */
  75. function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
  76. ctx.provide('loader', { await: settle } as never)
  77. }
  78. interface BashContribution {
  79. name: string
  80. variables: Record<string, { description: string }>
  81. resolve: () => Record<string, string>
  82. }
  83. describe('web-app runtime glue', () => {
  84. it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => {
  85. stageDist()
  86. const ctx = new Context()
  87. // Editor markers and a project .env SSH value do not establish a remote launch.
  88. ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([
  89. { source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } },
  90. { source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } },
  91. ]))
  92. const { server, seat } = fakeHttpServer('0.0.0.0')
  93. ctx.provide('webServer', server)
  94. const contributions: BashContribution[] = []
  95. ctx.provide('shellEnv', {
  96. register: (contribution: BashContribution) => {
  97. contributions.push(contribution)
  98. return () => {}
  99. },
  100. } as never)
  101. provideLoader(ctx)
  102. const lifecycle: string[] = []
  103. const log = vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) })
  104. const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) })
  105. internals.openBrowser = openBrowser
  106. apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
  107. await ctx.plugin(SystemPrompt, { persona: '' })
  108. // Settle the injected registrations.
  109. await new Promise(resolve => setTimeout(resolve, 0))
  110. expect(seat()).toBeDefined() // frontend-static claimed the fallback
  111. expect(ctx.get('webRuntime')).toEqual({
  112. lanAddresses: ['192.168.1.5'],
  113. trustedHosts: ['192.168.1.5', 'lab.internal'],
  114. })
  115. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
  116. expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
  117. expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567')
  118. expect(lifecycle).toEqual([
  119. 'dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)',
  120. 'dsh web: opening the default browser; pass --no-open to disable',
  121. 'open:http://127.0.0.1:4567',
  122. ])
  123. const assembly = await ctx.systemPrompt.assemble()
  124. expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
  125. const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
  126. expect(section?.text).toContain('http://127.0.0.1:4567')
  127. // The single update contract: the receiver is always on; no-refresh
  128. // reloads additionally need the rebuild watcher.
  129. expect(section?.text).toContain('pnpm run dev:web')
  130. const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
  131. expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
  132. await ctx.fiber.dispose()
  133. })
  134. it('publishes no readiness side effect when printing and browser opening are disabled', async () => {
  135. stageDist()
  136. const ctx = new Context()
  137. ctx.provide('webServer', fakeHttpServer().server)
  138. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  139. const openBrowser = vi.fn(async () => {})
  140. internals.openBrowser = openBrowser
  141. apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
  142. await ctx.plugin(SystemPrompt, { persona: '' })
  143. await new Promise(resolve => setTimeout(resolve, 0))
  144. expect(log).not.toHaveBeenCalled()
  145. expect(openBrowser).not.toHaveBeenCalled()
  146. const assembly = await ctx.systemPrompt.assemble()
  147. expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
  148. .toContain('rebuilding the affected Web artifacts')
  149. await ctx.fiber.dispose()
  150. })
  151. it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
  152. stageDist()
  153. const ctx = new Context()
  154. ctx.provide('webServer', fakeHttpServer().server)
  155. const contributions: BashContribution[] = []
  156. ctx.provide('shellEnv', {
  157. register: (contribution: BashContribution) => {
  158. contributions.push(contribution)
  159. return () => {}
  160. },
  161. } as never)
  162. apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] }))
  163. await ctx.plugin(SystemPrompt, { persona: '' })
  164. await new Promise(resolve => setTimeout(resolve, 0))
  165. const assembly = await ctx.systemPrompt.assemble()
  166. expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
  167. expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false)
  168. expect(contributions).toEqual([])
  169. await ctx.fiber.dispose()
  170. })
  171. it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
  172. stageDist()
  173. const ctx = new Context()
  174. ctx.provide('webServer', fakeHttpServer().server)
  175. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  176. apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  177. await new Promise(resolve => setTimeout(resolve, 0))
  178. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  179. await ctx.fiber.dispose()
  180. })
  181. it.each([
  182. ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'],
  183. ['SSH_TTY', '/dev/pts/3'],
  184. ] as const)('prints the host URL but skips browser handoff when %s marks an SSH launch', async (name, value) => {
  185. vi.stubEnv(name, value)
  186. stageDist()
  187. const ctx = new Context()
  188. ctx.provide('webServer', fakeHttpServer().server)
  189. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  190. const openBrowser = vi.fn(async () => {})
  191. internals.openBrowser = openBrowser
  192. apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
  193. await new Promise(resolve => setTimeout(resolve, 0))
  194. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  195. expect(openBrowser).not.toHaveBeenCalled()
  196. await ctx.fiber.dispose()
  197. })
  198. it('defers readiness publication until Loader settlement and drops it on failure or teardown', async () => {
  199. stageDist()
  200. const openBrowser = vi.fn(async () => {})
  201. internals.openBrowser = openBrowser
  202. // Settlement path: both actions wait for loader.await() so their consumers
  203. // can request the complete app immediately.
  204. const settled = new Context()
  205. settled.provide('webServer', fakeHttpServer().server)
  206. let release: () => void
  207. const settlement = new Promise<void>((resolve) => { release = resolve })
  208. provideLoader(settled, () => settlement)
  209. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  210. apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  211. await new Promise(resolve => setTimeout(resolve, 0))
  212. expect(log).not.toHaveBeenCalled()
  213. expect(openBrowser).not.toHaveBeenCalled()
  214. release!()
  215. await new Promise(resolve => setTimeout(resolve, 0))
  216. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  217. expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567')
  218. await settled.fiber.dispose()
  219. // Failed path: Loader reports the sibling failure; the app prints no URL
  220. // for a process that is about to exit.
  221. log.mockClear()
  222. openBrowser.mockClear()
  223. const failed = new Context()
  224. failed.provide('webServer', fakeHttpServer().server)
  225. provideLoader(failed, async () => { throw new Error('boot failed') })
  226. apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  227. await new Promise(resolve => setTimeout(resolve, 0))
  228. expect(log).not.toHaveBeenCalled()
  229. expect(openBrowser).not.toHaveBeenCalled()
  230. await failed.fiber.dispose()
  231. // Torn-down path: settlement resolves after the webserver is gone — no
  232. // line, no crash.
  233. log.mockClear()
  234. openBrowser.mockClear()
  235. const torn = new Context()
  236. const child = torn.plugin((childCtx: Context) => {
  237. childCtx.provide('webServer', fakeHttpServer().server)
  238. })
  239. await child
  240. let releaseTorn: () => void
  241. const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
  242. provideLoader(torn, () => tornSettlement)
  243. apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  244. await child.dispose() // the webServer service goes away
  245. releaseTorn!()
  246. await new Promise(resolve => setTimeout(resolve, 0))
  247. expect(log).not.toHaveBeenCalled()
  248. expect(openBrowser).not.toHaveBeenCalled()
  249. await torn.fiber.dispose()
  250. })
  251. it('fails loud when the prompt section resolves against a portless webserver', async () => {
  252. stageDist()
  253. const ctx = new Context()
  254. // A webserver whose bound port is gone (torn down mid-request): the
  255. // section must throw, never render a URL with an undefined port.
  256. const { server } = fakeHttpServer()
  257. Object.defineProperty(server, 'port', { get: () => undefined })
  258. ctx.provide('webServer', server)
  259. apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
  260. await ctx.plugin(SystemPrompt, { persona: '' })
  261. await new Promise(resolve => setTimeout(resolve, 0))
  262. await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing')
  263. await ctx.fiber.dispose()
  264. })
  265. it('anchors the dist index on the frontend package manifest without requiring a built dist', () => {
  266. // The production resolver (not the test hook): the anchor resolves on any
  267. // checkout, built or not — dist existence is the fallback owner's
  268. // request-time concern, so a dist-less composition (the static worker
  269. // preview ships its own page) still boots.
  270. expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
  271. })
  272. it.each([
  273. ['Error', new Error('no desktop'), 'no desktop'],
  274. ['non-Error', 'desktop unavailable', 'desktop unavailable'],
  275. ] as const)('keeps the server running and reports the manual URL when a browser failure is %s', async (_kind, failure, reason) => {
  276. stageDist()
  277. const ctx = new Context()
  278. ctx.provide('webServer', fakeHttpServer().server)
  279. internals.openBrowser = vi.fn(async () => { throw failure })
  280. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  281. const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {})
  282. apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] }))
  283. await new Promise(resolve => setTimeout(resolve, 0))
  284. expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
  285. expect(diagnostic).toHaveBeenCalledWith(
  286. `web-app: could not open the default browser because ${reason}; visit http://127.0.0.1:4567 manually`,
  287. )
  288. expect(ctx.get('webServer')).toBeDefined()
  289. await ctx.fiber.dispose()
  290. })
  291. it('scrubs the helper environment and reports helper spawn or exit failures', async () => {
  292. vi.stubEnv('DEEPSEEK_API_KEY', 'must-not-reach-browser')
  293. vi.stubEnv('DSH_HOME', '/must-not-reach-browser')
  294. const completed = launcher()
  295. vi.mocked(spawn).mockReturnValueOnce(completed)
  296. const completion = originalOpenBrowser('http://127.0.0.1:4567')
  297. const [command, args, options] = vi.mocked(spawn).mock.calls[0]!
  298. expect(command).toBe(process.execPath)
  299. expect(args).toEqual([
  300. '--input-type=module',
  301. '--eval', expect.stringContaining('await import('),
  302. '--', 'http://127.0.0.1:4567',
  303. ])
  304. expect(args?.[2]).toContain("if (process.platform === 'win32')")
  305. expect(args?.[2]).toContain('launcher.ref()')
  306. expect(options?.env).not.toHaveProperty('DEEPSEEK_API_KEY')
  307. expect(options?.env).not.toHaveProperty('DSH_HOME')
  308. expect(options?.env?.PATH).toBe(process.env.PATH)
  309. expect(options?.stdio).toEqual(['ignore', 'inherit', 'pipe'])
  310. completed.emit('close', 0)
  311. await expect(completion).resolves.toBeUndefined()
  312. expect(completed.listenerCount('error')).toBe(0)
  313. const completedWithStderr = launcher()
  314. vi.mocked(spawn).mockReturnValueOnce(completedWithStderr)
  315. const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  316. const completionWithStderr = originalOpenBrowser('http://127.0.0.1:4567')
  317. completedWithStderr.stderr?.write('launcher note\n')
  318. completedWithStderr.emit('close', 0)
  319. await expect(completionWithStderr).resolves.toBeUndefined()
  320. expect(stderr).toHaveBeenCalledWith('launcher note\n')
  321. const failedWithReason = launcher()
  322. vi.mocked(spawn).mockReturnValueOnce(failedWithReason)
  323. const reasonFailure = originalOpenBrowser('http://127.0.0.1:4567')
  324. const reasonAssertion = expect(reasonFailure).rejects.toThrow('desktop unavailable')
  325. failedWithReason.stderr?.write('Error: desktop unavailable\n at fixture')
  326. failedWithReason.emit('close', 1)
  327. await reasonAssertion
  328. const failed = launcher()
  329. vi.mocked(spawn).mockReturnValueOnce(failed)
  330. const failure = originalOpenBrowser('http://127.0.0.1:4567')
  331. const failureAssertion = expect(failure).rejects.toThrow('exited with code 3')
  332. await Promise.resolve()
  333. failed.emit('close', 3)
  334. await failureAssertion
  335. const errored = launcher()
  336. vi.mocked(spawn).mockReturnValueOnce(errored)
  337. const error = originalOpenBrowser('http://127.0.0.1:4567')
  338. const errorAssertion = expect(error).rejects.toThrow('spawn failed')
  339. await Promise.resolve()
  340. errored.emit('error', new Error('spawn failed'))
  341. await errorAssertion
  342. expect(errored.listenerCount('close')).toBe(0)
  343. })
  344. })