web-app.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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 URL-line printing with the
  5. * runtime's bind-dependent LAN snapshot.
  6. */
  7. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import { Context } from '@deepseek-ai/cordis'
  12. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  13. import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
  14. import { apply, Config, internals } from '../src/index.ts'
  15. vi.mock('node:os', async importOriginal => ({
  16. ...await importOriginal<typeof import('node:os')>(),
  17. networkInterfaces: () => ({
  18. lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
  19. en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }],
  20. }),
  21. }))
  22. let dist: string | undefined
  23. afterEach(() => {
  24. vi.restoreAllMocks()
  25. internals.resolveDistIndex = originalResolve
  26. if (dist !== undefined) rmSync(dist, { recursive: true, force: true })
  27. dist = undefined
  28. })
  29. const originalResolve = internals.resolveDistIndex
  30. /** Stage a dist fixture and point the bundle's resolver at it. */
  31. function stageDist(): string {
  32. dist = mkdtempSync(join(tmpdir(), 'dsh-web-app-'))
  33. mkdirSync(join(dist, 'dist'))
  34. const index = join(dist, 'dist', 'index.html')
  35. writeFileSync(index, '<head></head><body>shell</body>')
  36. internals.resolveDistIndex = () => index
  37. return index
  38. }
  39. /** A fake httpServer capturing the fallback seat and index taps. */
  40. function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: HttpServerService; seat: () => unknown } {
  41. let fallback: unknown
  42. const server = {
  43. host,
  44. port: 4567,
  45. registerFallback: (handler: unknown) => {
  46. fallback = handler
  47. return () => { fallback = undefined }
  48. },
  49. applyIndexTaps: (html: string) => html,
  50. } as unknown as HttpServerService
  51. return { server, seat: () => fallback }
  52. }
  53. /** Install the optional HMR row the runtime sequences before client discovery. */
  54. function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
  55. const updates: string[] = []
  56. ctx.provide('loader', {
  57. entries: () => [{
  58. options: { id: 'client-hmr' },
  59. enableRuntime: async () => { updates.push('client-hmr') },
  60. }],
  61. await: settle,
  62. } as never)
  63. return updates
  64. }
  65. interface BashContribution {
  66. name: string
  67. variables: Record<string, { description: string }>
  68. resolve: () => Record<string, string>
  69. }
  70. describe('web-app runtime glue', () => {
  71. it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => {
  72. stageDist()
  73. const ctx = new Context()
  74. const { server, seat } = fakeHttpServer('0.0.0.0')
  75. ctx.provide('httpServer', server)
  76. const contributions: BashContribution[] = []
  77. ctx.provide('bashEnv', {
  78. register: (contribution: BashContribution) => {
  79. contributions.push(contribution)
  80. return () => {}
  81. },
  82. } as never)
  83. const enabledRows = provideHmrRow(ctx)
  84. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  85. await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
  86. await ctx.plugin(SystemPrompt, { persona: '' })
  87. // Settle the injected registrations.
  88. await new Promise(resolve => setTimeout(resolve, 0))
  89. expect(seat()).toBeDefined() // frontend-static claimed the fallback
  90. expect(enabledRows).toEqual(['client-hmr'])
  91. expect(ctx.get('webRuntime')).toEqual({
  92. lanAddresses: ['192.168.1.5'],
  93. trustedHosts: ['192.168.1.5', 'lab.internal'],
  94. })
  95. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
  96. const assembly = await ctx.systemPrompt.assemble()
  97. expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
  98. const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
  99. expect(section?.text).toContain('http://127.0.0.1:4567')
  100. expect(section?.text).toContain('--dev')
  101. const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
  102. expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' })
  103. await ctx.fiber.dispose()
  104. })
  105. it('stays quiet in production mode with printUrl off and reports the production update contract', async () => {
  106. stageDist()
  107. const ctx = new Context()
  108. ctx.provide('httpServer', fakeHttpServer().server)
  109. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  110. await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
  111. await ctx.plugin(SystemPrompt, { persona: '' })
  112. await new Promise(resolve => setTimeout(resolve, 0))
  113. expect(log).not.toHaveBeenCalled()
  114. const assembly = await ctx.systemPrompt.assemble()
  115. expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
  116. .toContain('without `--dev`')
  117. await ctx.fiber.dispose()
  118. })
  119. it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
  120. stageDist()
  121. const ctx = new Context()
  122. ctx.provide('httpServer', fakeHttpServer().server)
  123. const contributions: BashContribution[] = []
  124. ctx.provide('bashEnv', {
  125. register: (contribution: BashContribution) => {
  126. contributions.push(contribution)
  127. return () => {}
  128. },
  129. } as never)
  130. await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
  131. await ctx.plugin(SystemPrompt, { persona: '' })
  132. await new Promise(resolve => setTimeout(resolve, 0))
  133. const assembly = await ctx.systemPrompt.assemble()
  134. expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
  135. expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false)
  136. expect(contributions).toEqual([])
  137. await ctx.fiber.dispose()
  138. })
  139. it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
  140. stageDist()
  141. const ctx = new Context()
  142. ctx.provide('httpServer', fakeHttpServer().server)
  143. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  144. await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
  145. await new Promise(resolve => setTimeout(resolve, 0))
  146. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  147. await ctx.fiber.dispose()
  148. })
  149. it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => {
  150. stageDist()
  151. // Settlement path: the line waits for loader.await() so supervisors can
  152. // RPC immediately after observing it.
  153. const settled = new Context()
  154. settled.provide('httpServer', fakeHttpServer().server)
  155. let release: () => void
  156. const settlement = new Promise<void>((resolve) => { release = resolve })
  157. provideHmrRow(settled, () => settlement)
  158. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  159. await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
  160. await new Promise(resolve => setTimeout(resolve, 0))
  161. expect(log).not.toHaveBeenCalled()
  162. release!()
  163. await new Promise(resolve => setTimeout(resolve, 0))
  164. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  165. await settled.fiber.dispose()
  166. // Failed path: Loader reports the sibling failure; the app prints no URL
  167. // for a process that is about to exit.
  168. log.mockClear()
  169. const failed = new Context()
  170. failed.provide('httpServer', fakeHttpServer().server)
  171. provideHmrRow(failed, async () => { throw new Error('boot failed') })
  172. await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
  173. await new Promise(resolve => setTimeout(resolve, 0))
  174. expect(log).not.toHaveBeenCalled()
  175. await failed.fiber.dispose()
  176. // Torn-down path: settlement resolves after the webserver is gone — no
  177. // line, no crash.
  178. log.mockClear()
  179. const torn = new Context()
  180. const child = torn.plugin((childCtx: Context) => {
  181. childCtx.provide('httpServer', fakeHttpServer().server)
  182. })
  183. await child
  184. let releaseTorn: () => void
  185. const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
  186. provideHmrRow(torn, () => tornSettlement)
  187. await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
  188. await child.dispose() // the httpServer service goes away
  189. releaseTorn!()
  190. await new Promise(resolve => setTimeout(resolve, 0))
  191. expect(log).not.toHaveBeenCalled()
  192. await torn.fiber.dispose()
  193. })
  194. it('fails loud when the prompt section resolves against a portless webserver', async () => {
  195. stageDist()
  196. const ctx = new Context()
  197. // A webserver whose bound port is gone (torn down mid-request): the
  198. // section must throw, never render a URL with an undefined port.
  199. const { server } = fakeHttpServer()
  200. Object.defineProperty(server, 'port', { get: () => undefined })
  201. ctx.provide('httpServer', server)
  202. await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
  203. await ctx.plugin(SystemPrompt, { persona: '' })
  204. await new Promise(resolve => setTimeout(resolve, 0))
  205. await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
  206. await ctx.fiber.dispose()
  207. })
  208. it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
  209. // The production resolver (not the test hook). A built checkout resolves
  210. // the frontend package's index.html; a dist-less one (the CI coverage
  211. // lane runs before any build) must fail with the build hint, never a
  212. // silent fallback.
  213. try {
  214. expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
  215. } catch (error) {
  216. expect((error as Error).message).toContain('frontend dist not built')
  217. }
  218. })
  219. })