web-app.spec.ts 20 KB

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