web-app.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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. /** A fake Loader whose settlement the test controls (the URL line waits on it). */
  54. function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
  55. ctx.provide('loader', { await: settle } as never)
  56. }
  57. interface BashContribution {
  58. name: string
  59. variables: Record<string, { description: string }>
  60. resolve: () => Record<string, string>
  61. }
  62. describe('web-app runtime glue', () => {
  63. it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => {
  64. stageDist()
  65. const ctx = new Context()
  66. const { server, seat } = fakeHttpServer('0.0.0.0')
  67. ctx.provide('httpServer', server)
  68. const contributions: BashContribution[] = []
  69. ctx.provide('bashEnv', {
  70. register: (contribution: BashContribution) => {
  71. contributions.push(contribution)
  72. return () => {}
  73. },
  74. } as never)
  75. provideLoader(ctx)
  76. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  77. apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
  78. await ctx.plugin(SystemPrompt, { persona: '' })
  79. // Settle the injected registrations.
  80. await new Promise(resolve => setTimeout(resolve, 0))
  81. expect(seat()).toBeDefined() // frontend-static claimed the fallback
  82. expect(ctx.get('webRuntime')).toEqual({
  83. lanAddresses: ['192.168.1.5'],
  84. trustedHosts: ['192.168.1.5', 'lab.internal'],
  85. })
  86. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
  87. const assembly = await ctx.systemPrompt.assemble()
  88. expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
  89. const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
  90. expect(section?.text).toContain('http://127.0.0.1:4567')
  91. // The single update contract: the receiver is always on; no-refresh
  92. // reloads additionally need the rebuild watcher.
  93. expect(section?.text).toContain('pnpm run dev:web')
  94. const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
  95. expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
  96. await ctx.fiber.dispose()
  97. })
  98. it('stays quiet with printUrl off', async () => {
  99. stageDist()
  100. const ctx = new Context()
  101. ctx.provide('httpServer', fakeHttpServer().server)
  102. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  103. apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
  104. await ctx.plugin(SystemPrompt, { persona: '' })
  105. await new Promise(resolve => setTimeout(resolve, 0))
  106. expect(log).not.toHaveBeenCalled()
  107. const assembly = await ctx.systemPrompt.assemble()
  108. expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
  109. .toContain('rebuilding the affected Web artifacts')
  110. await ctx.fiber.dispose()
  111. })
  112. it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
  113. stageDist()
  114. const ctx = new Context()
  115. ctx.provide('httpServer', fakeHttpServer().server)
  116. const contributions: BashContribution[] = []
  117. ctx.provide('bashEnv', {
  118. register: (contribution: BashContribution) => {
  119. contributions.push(contribution)
  120. return () => {}
  121. },
  122. } as never)
  123. apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] }))
  124. await ctx.plugin(SystemPrompt, { persona: '' })
  125. await new Promise(resolve => setTimeout(resolve, 0))
  126. const assembly = await ctx.systemPrompt.assemble()
  127. expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
  128. expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false)
  129. expect(contributions).toEqual([])
  130. await ctx.fiber.dispose()
  131. })
  132. it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
  133. stageDist()
  134. const ctx = new Context()
  135. ctx.provide('httpServer', fakeHttpServer().server)
  136. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  137. apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
  138. await new Promise(resolve => setTimeout(resolve, 0))
  139. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  140. await ctx.fiber.dispose()
  141. })
  142. it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => {
  143. stageDist()
  144. // Settlement path: the line waits for loader.await() so supervisors can
  145. // RPC immediately after observing it.
  146. const settled = new Context()
  147. settled.provide('httpServer', fakeHttpServer().server)
  148. let release: () => void
  149. const settlement = new Promise<void>((resolve) => { release = resolve })
  150. provideLoader(settled, () => settlement)
  151. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  152. apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
  153. await new Promise(resolve => setTimeout(resolve, 0))
  154. expect(log).not.toHaveBeenCalled()
  155. release!()
  156. await new Promise(resolve => setTimeout(resolve, 0))
  157. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  158. await settled.fiber.dispose()
  159. // Failed path: Loader reports the sibling failure; the app prints no URL
  160. // for a process that is about to exit.
  161. log.mockClear()
  162. const failed = new Context()
  163. failed.provide('httpServer', fakeHttpServer().server)
  164. provideLoader(failed, async () => { throw new Error('boot failed') })
  165. apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
  166. await new Promise(resolve => setTimeout(resolve, 0))
  167. expect(log).not.toHaveBeenCalled()
  168. await failed.fiber.dispose()
  169. // Torn-down path: settlement resolves after the webserver is gone — no
  170. // line, no crash.
  171. log.mockClear()
  172. const torn = new Context()
  173. const child = torn.plugin((childCtx: Context) => {
  174. childCtx.provide('httpServer', fakeHttpServer().server)
  175. })
  176. await child
  177. let releaseTorn: () => void
  178. const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
  179. provideLoader(torn, () => tornSettlement)
  180. apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
  181. await child.dispose() // the httpServer service goes away
  182. releaseTorn!()
  183. await new Promise(resolve => setTimeout(resolve, 0))
  184. expect(log).not.toHaveBeenCalled()
  185. await torn.fiber.dispose()
  186. })
  187. it('fails loud when the prompt section resolves against a portless webserver', async () => {
  188. stageDist()
  189. const ctx = new Context()
  190. // A webserver whose bound port is gone (torn down mid-request): the
  191. // section must throw, never render a URL with an undefined port.
  192. const { server } = fakeHttpServer()
  193. Object.defineProperty(server, 'port', { get: () => undefined })
  194. ctx.provide('httpServer', server)
  195. apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
  196. await ctx.plugin(SystemPrompt, { persona: '' })
  197. await new Promise(resolve => setTimeout(resolve, 0))
  198. await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
  199. await ctx.fiber.dispose()
  200. })
  201. it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
  202. // The production resolver (not the test hook). A built checkout resolves
  203. // the frontend package's index.html; a dist-less one (the CI coverage
  204. // lane runs before any build) must fail with the build hint, never a
  205. // silent fallback.
  206. try {
  207. expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
  208. } catch (error) {
  209. expect((error as Error).message).toContain('frontend dist not built')
  210. }
  211. })
  212. })