web-app.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  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. /** Deterministic Host Connection face for URL publication and frontend injection. */
  75. function provideConnection(ctx: Context): void {
  76. ctx.provide('connection', {
  77. authenticatedUrl(baseUrl: string) {
  78. const url = new URL(baseUrl)
  79. url.pathname = '/'
  80. url.searchParams.set('token', 'test-token')
  81. return url.href
  82. },
  83. authorizeIndex: () => true,
  84. requestRejection: () => undefined,
  85. rpc: {},
  86. } as never)
  87. }
  88. /** A fake Loader whose settlement the test controls (the URL line waits on it). */
  89. function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
  90. ctx.provide('loader', { await: settle } as never)
  91. }
  92. interface BashContribution {
  93. name: string
  94. variables: Record<string, { description: string }>
  95. resolve: () => Record<string, string>
  96. }
  97. describe('web-app runtime glue', () => {
  98. it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => {
  99. stageDist()
  100. const ctx = new Context()
  101. // Editor markers and a project .env SSH value do not establish a remote launch.
  102. ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([
  103. { source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } },
  104. { source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } },
  105. ]))
  106. const { server, seat } = fakeHttpServer('0.0.0.0')
  107. ctx.provide('webServer', server)
  108. provideConnection(ctx)
  109. const contributions: BashContribution[] = []
  110. ctx.provide('shellEnv', {
  111. register: (contribution: BashContribution) => {
  112. contributions.push(contribution)
  113. return () => {}
  114. },
  115. } as never)
  116. provideLoader(ctx)
  117. const lifecycle: string[] = []
  118. const log = vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) })
  119. const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) })
  120. internals.openBrowser = openBrowser
  121. apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
  122. await ctx.plugin(SystemPrompt, { personaPrefix: '' })
  123. // Settle the injected registrations.
  124. await new Promise(resolve => setTimeout(resolve, 0))
  125. expect(seat()).toBeDefined() // frontend-static claimed the fallback
  126. expect(ctx.get('webRuntime')).toEqual({
  127. lanAddresses: ['192.168.1.5'],
  128. trustedHosts: ['192.168.1.5', 'lab.internal'],
  129. })
  130. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)')
  131. expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
  132. expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
  133. expect(lifecycle).toEqual([
  134. 'dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)',
  135. 'dsh web: opening the default browser; pass --no-open to disable',
  136. 'open:http://127.0.0.1:4567/?token=test-token',
  137. ])
  138. const assembly = await ctx.systemPrompt.assemble()
  139. expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
  140. const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
  141. expect(section?.text).toContain('http://127.0.0.1:4567')
  142. // The single update contract: the receiver is always on; no-refresh
  143. // reloads additionally need the rebuild watcher.
  144. expect(section?.text).toContain('pnpm run dev:web')
  145. const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
  146. expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
  147. await ctx.fiber.dispose()
  148. })
  149. it('publishes no readiness side effect when printing and browser opening are disabled', async () => {
  150. stageDist()
  151. const ctx = new Context()
  152. ctx.provide('webServer', fakeHttpServer().server)
  153. provideConnection(ctx)
  154. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  155. const openBrowser = vi.fn(async () => {})
  156. internals.openBrowser = openBrowser
  157. apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
  158. await ctx.plugin(SystemPrompt, { personaPrefix: '' })
  159. await new Promise(resolve => setTimeout(resolve, 0))
  160. expect(log).not.toHaveBeenCalled()
  161. expect(openBrowser).not.toHaveBeenCalled()
  162. const assembly = await ctx.systemPrompt.assemble()
  163. expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
  164. .toContain('rebuilding the affected Web artifacts')
  165. await ctx.fiber.dispose()
  166. })
  167. it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
  168. stageDist()
  169. const ctx = new Context()
  170. ctx.provide('webServer', fakeHttpServer().server)
  171. provideConnection(ctx)
  172. const contributions: BashContribution[] = []
  173. ctx.provide('shellEnv', {
  174. register: (contribution: BashContribution) => {
  175. contributions.push(contribution)
  176. return () => {}
  177. },
  178. } as never)
  179. apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] }))
  180. await ctx.plugin(SystemPrompt, { personaPrefix: '' })
  181. await new Promise(resolve => setTimeout(resolve, 0))
  182. const assembly = await ctx.systemPrompt.assemble()
  183. expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
  184. expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false)
  185. expect(contributions).toEqual([])
  186. await ctx.fiber.dispose()
  187. })
  188. it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
  189. stageDist()
  190. const ctx = new Context()
  191. ctx.provide('webServer', fakeHttpServer().server)
  192. provideConnection(ctx)
  193. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  194. apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  195. await new Promise(resolve => setTimeout(resolve, 0))
  196. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
  197. await ctx.fiber.dispose()
  198. })
  199. it('does not publish readiness again when Connection reloads', async () => {
  200. stageDist()
  201. const ctx = new Context()
  202. ctx.provide('webServer', fakeHttpServer().server)
  203. const first = ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) })
  204. await first
  205. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  206. apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  207. await new Promise(resolve => setTimeout(resolve, 0))
  208. expect(log).toHaveBeenCalledTimes(1)
  209. await first.dispose()
  210. await ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) })
  211. await new Promise(resolve => setTimeout(resolve, 0))
  212. expect(log).toHaveBeenCalledTimes(1)
  213. await ctx.fiber.dispose()
  214. })
  215. it.each([
  216. ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'],
  217. ['SSH_TTY', '/dev/pts/3'],
  218. ] as const)('prints the host URL but skips browser handoff when %s marks an SSH launch', async (name, value) => {
  219. vi.stubEnv(name, value)
  220. stageDist()
  221. const ctx = new Context()
  222. ctx.provide('webServer', fakeHttpServer().server)
  223. provideConnection(ctx)
  224. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  225. const openBrowser = vi.fn(async () => {})
  226. internals.openBrowser = openBrowser
  227. apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
  228. await new Promise(resolve => setTimeout(resolve, 0))
  229. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
  230. expect(openBrowser).not.toHaveBeenCalled()
  231. await ctx.fiber.dispose()
  232. })
  233. it('defers readiness publication until Loader settlement and drops it on failure or teardown', async () => {
  234. stageDist()
  235. const openBrowser = vi.fn(async () => {})
  236. internals.openBrowser = openBrowser
  237. // Settlement path: both actions wait for loader.await() so their consumers
  238. // can request the complete app immediately.
  239. const settled = new Context()
  240. settled.provide('webServer', fakeHttpServer().server)
  241. provideConnection(settled)
  242. let release: () => void
  243. const settlement = new Promise<void>((resolve) => { release = resolve })
  244. provideLoader(settled, () => settlement)
  245. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  246. apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  247. await new Promise(resolve => setTimeout(resolve, 0))
  248. expect(log).not.toHaveBeenCalled()
  249. expect(openBrowser).not.toHaveBeenCalled()
  250. release!()
  251. await new Promise(resolve => setTimeout(resolve, 0))
  252. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
  253. expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
  254. await settled.fiber.dispose()
  255. // Failed path: Loader reports the sibling failure; the app prints no URL
  256. // for a process that is about to exit.
  257. log.mockClear()
  258. openBrowser.mockClear()
  259. const failed = new Context()
  260. failed.provide('webServer', fakeHttpServer().server)
  261. provideConnection(failed)
  262. provideLoader(failed, async () => { throw new Error('boot failed') })
  263. apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  264. await new Promise(resolve => setTimeout(resolve, 0))
  265. expect(log).not.toHaveBeenCalled()
  266. expect(openBrowser).not.toHaveBeenCalled()
  267. await failed.fiber.dispose()
  268. // Torn-down path: settlement resolves after the webserver is gone — no
  269. // line, no crash.
  270. log.mockClear()
  271. openBrowser.mockClear()
  272. const torn = new Context()
  273. const child = torn.plugin((childCtx: Context) => {
  274. childCtx.provide('webServer', fakeHttpServer().server)
  275. provideConnection(childCtx)
  276. })
  277. await child
  278. let releaseTorn: () => void
  279. const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
  280. provideLoader(torn, () => tornSettlement)
  281. apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
  282. await new Promise(resolve => setTimeout(resolve, 0))
  283. await child.dispose() // the webServer service goes away
  284. releaseTorn!()
  285. await new Promise(resolve => setTimeout(resolve, 0))
  286. expect(log).not.toHaveBeenCalled()
  287. expect(openBrowser).not.toHaveBeenCalled()
  288. await torn.fiber.dispose()
  289. })
  290. it('fails loud when the prompt section resolves against a portless webserver', async () => {
  291. stageDist()
  292. const ctx = new Context()
  293. // A webserver whose bound port is gone (torn down mid-request): the
  294. // section must throw, never render a URL with an undefined port.
  295. const { server } = fakeHttpServer()
  296. Object.defineProperty(server, 'port', { get: () => undefined })
  297. ctx.provide('webServer', server)
  298. provideConnection(ctx)
  299. apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
  300. await ctx.plugin(SystemPrompt, { personaPrefix: '' })
  301. await new Promise(resolve => setTimeout(resolve, 0))
  302. await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing')
  303. await ctx.fiber.dispose()
  304. })
  305. it('anchors the dist index on the frontend package manifest without requiring a built dist', () => {
  306. // The production resolver (not the test hook): the anchor resolves on any
  307. // checkout, built or not — dist existence is the fallback owner's
  308. // request-time concern, so a dist-less composition (the static worker
  309. // preview ships its own page) still boots.
  310. expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
  311. })
  312. it.each([
  313. ['Error', new Error('no desktop'), 'no desktop'],
  314. ['non-Error', 'desktop unavailable', 'desktop unavailable'],
  315. ] as const)('keeps the server running and reports the manual URL when a browser failure is %s', async (_kind, failure, reason) => {
  316. stageDist()
  317. const ctx = new Context()
  318. ctx.provide('webServer', fakeHttpServer().server)
  319. provideConnection(ctx)
  320. internals.openBrowser = vi.fn(async () => { throw failure })
  321. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  322. const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {})
  323. apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] }))
  324. await new Promise(resolve => setTimeout(resolve, 0))
  325. expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
  326. expect(diagnostic).toHaveBeenCalledWith(
  327. `web-app: could not open the default browser because ${reason}; use the dsh web URL printed at startup`,
  328. )
  329. expect(ctx.get('webServer')).toBeDefined()
  330. await ctx.fiber.dispose()
  331. })
  332. it('scrubs the helper environment and reports helper spawn or exit failures', async () => {
  333. vi.stubEnv('DEEPSEEK_API_KEY', 'must-not-reach-browser')
  334. vi.stubEnv('DSH_HOME', '/must-not-reach-browser')
  335. const completed = launcher()
  336. vi.mocked(spawn).mockReturnValueOnce(completed)
  337. const completion = originalOpenBrowser('http://127.0.0.1:4567')
  338. const [command, args, options] = vi.mocked(spawn).mock.calls[0]!
  339. expect(command).toBe(process.execPath)
  340. expect(args).toEqual([
  341. '--input-type=module',
  342. '--eval', expect.stringContaining('await import('),
  343. '--', 'http://127.0.0.1:4567',
  344. ])
  345. expect(args?.[2]).toContain("if (process.platform === 'win32')")
  346. expect(args?.[2]).toContain('launcher.ref()')
  347. expect(options?.env).not.toHaveProperty('DEEPSEEK_API_KEY')
  348. expect(options?.env).not.toHaveProperty('DSH_HOME')
  349. expect(options?.env?.PATH).toBe(process.env.PATH)
  350. expect(options?.stdio).toEqual(['ignore', 'inherit', 'pipe'])
  351. completed.emit('close', 0)
  352. await expect(completion).resolves.toBeUndefined()
  353. expect(completed.listenerCount('error')).toBe(0)
  354. const completedWithStderr = launcher()
  355. vi.mocked(spawn).mockReturnValueOnce(completedWithStderr)
  356. const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  357. const completionWithStderr = originalOpenBrowser('http://127.0.0.1:4567')
  358. completedWithStderr.stderr?.write('launcher note\n')
  359. completedWithStderr.emit('close', 0)
  360. await expect(completionWithStderr).resolves.toBeUndefined()
  361. expect(stderr).toHaveBeenCalledWith('launcher note\n')
  362. const failedWithReason = launcher()
  363. vi.mocked(spawn).mockReturnValueOnce(failedWithReason)
  364. const reasonFailure = originalOpenBrowser('http://127.0.0.1:4567')
  365. const reasonAssertion = expect(reasonFailure).rejects.toThrow('desktop unavailable')
  366. failedWithReason.stderr?.write('Error: desktop unavailable\n at fixture')
  367. failedWithReason.emit('close', 1)
  368. await reasonAssertion
  369. const failed = launcher()
  370. vi.mocked(spawn).mockReturnValueOnce(failed)
  371. const failure = originalOpenBrowser('http://127.0.0.1:4567')
  372. const failureAssertion = expect(failure).rejects.toThrow('exited with code 3')
  373. await Promise.resolve()
  374. failed.emit('close', 3)
  375. await failureAssertion
  376. const errored = launcher()
  377. vi.mocked(spawn).mockReturnValueOnce(errored)
  378. const error = originalOpenBrowser('http://127.0.0.1:4567')
  379. const errorAssertion = expect(error).rejects.toThrow('spawn failed')
  380. await Promise.resolve()
  381. errored.emit('error', new Error('spawn failed'))
  382. await errorAssertion
  383. expect(errored.listenerCount('close')).toBe(0)
  384. })
  385. })