web-app.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. /**
  2. * Web runtime glue behavior: dist resolution through the bundle's own seam,
  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. * launcher's 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 '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. let dist: string | undefined
  16. afterEach(() => {
  17. vi.restoreAllMocks()
  18. internals.resolveDistIndex = originalResolve
  19. if (dist !== undefined) rmSync(dist, { recursive: true, force: true })
  20. dist = undefined
  21. })
  22. const originalResolve = internals.resolveDistIndex
  23. /** Stage a dist fixture and point the bundle's resolver at it. */
  24. function stageDist(): string {
  25. dist = mkdtempSync(join(tmpdir(), 'dsh-web-app-'))
  26. mkdirSync(join(dist, 'dist'))
  27. const index = join(dist, 'dist', 'index.html')
  28. writeFileSync(index, '<head></head><body>shell</body>')
  29. internals.resolveDistIndex = () => index
  30. return index
  31. }
  32. /** A fake httpServer capturing the fallback seat and index taps. */
  33. function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } {
  34. let fallback: unknown
  35. const server = {
  36. port: 4567,
  37. registerFallback: (handler: unknown) => {
  38. fallback = handler
  39. return () => { fallback = undefined }
  40. },
  41. applyIndexTaps: (html: string) => html,
  42. } as unknown as HttpServerService
  43. return { server, seat: () => fallback }
  44. }
  45. interface BashContribution {
  46. name: string
  47. variables: Record<string, { description: string }>
  48. resolve: () => Record<string, string>
  49. }
  50. describe('web-app runtime glue', () => {
  51. it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => {
  52. stageDist()
  53. const ctx = new Context()
  54. const { server, seat } = fakeHttpServer()
  55. ctx.provide('httpServer', server)
  56. const contributions: BashContribution[] = []
  57. ctx.provide('bashEnv', {
  58. register: (contribution: BashContribution) => {
  59. contributions.push(contribution)
  60. return () => {}
  61. },
  62. } as never)
  63. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  64. apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
  65. await ctx.plugin(SystemPrompt, { persona: '' })
  66. // Settle the injected registrations.
  67. await new Promise(resolve => setTimeout(resolve, 0))
  68. expect(seat()).toBeDefined() // frontend-static claimed the fallback
  69. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
  70. const assembly = await ctx.systemPrompt.assemble()
  71. const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
  72. expect(section?.text).toContain('http://127.0.0.1:4567')
  73. expect(section?.text).toContain('--dev')
  74. const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
  75. expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' })
  76. await ctx.fiber.dispose()
  77. })
  78. it('stays quiet in production mode with printUrl off and reports the production update contract', async () => {
  79. stageDist()
  80. const ctx = new Context()
  81. ctx.provide('httpServer', fakeHttpServer().server)
  82. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  83. apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
  84. await ctx.plugin(SystemPrompt, { persona: '' })
  85. await new Promise(resolve => setTimeout(resolve, 0))
  86. expect(log).not.toHaveBeenCalled()
  87. const assembly = await ctx.systemPrompt.assemble()
  88. expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
  89. .toContain('without `--dev`')
  90. await ctx.fiber.dispose()
  91. })
  92. it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
  93. stageDist()
  94. const ctx = new Context()
  95. ctx.provide('httpServer', fakeHttpServer().server)
  96. const contributions: BashContribution[] = []
  97. ctx.provide('bashEnv', {
  98. register: (contribution: BashContribution) => {
  99. contributions.push(contribution)
  100. return () => {}
  101. },
  102. } as never)
  103. apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
  104. await ctx.plugin(SystemPrompt, { persona: '' })
  105. await new Promise(resolve => setTimeout(resolve, 0))
  106. const assembly = await ctx.systemPrompt.assemble()
  107. expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
  108. expect(contributions).toEqual([])
  109. await ctx.fiber.dispose()
  110. })
  111. it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
  112. stageDist()
  113. const ctx = new Context()
  114. ctx.provide('httpServer', fakeHttpServer().server)
  115. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  116. apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
  117. await new Promise(resolve => setTimeout(resolve, 0))
  118. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  119. await ctx.fiber.dispose()
  120. })
  121. it('defers the URL line until Loader settlement and drops it when the server is gone', async () => {
  122. stageDist()
  123. // Settlement path: the line waits for loader.await() so supervisors can
  124. // RPC immediately after observing it.
  125. const settled = new Context()
  126. settled.provide('httpServer', fakeHttpServer().server)
  127. let release: () => void
  128. const settlement = new Promise<void>((resolve) => { release = resolve })
  129. settled.provide('loader', { await: () => settlement } as never)
  130. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  131. apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
  132. await new Promise(resolve => setTimeout(resolve, 0))
  133. expect(log).not.toHaveBeenCalled()
  134. release!()
  135. await new Promise(resolve => setTimeout(resolve, 0))
  136. expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
  137. await settled.fiber.dispose()
  138. // Torn-down path: settlement resolves after the webserver is gone — no
  139. // line, no crash.
  140. log.mockClear()
  141. const torn = new Context()
  142. const child = torn.plugin((childCtx: Context) => {
  143. childCtx.provide('httpServer', fakeHttpServer().server)
  144. })
  145. await child
  146. let releaseTorn: () => void
  147. const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
  148. torn.provide('loader', { await: () => tornSettlement } as never)
  149. apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
  150. await child.dispose() // the httpServer service goes away
  151. releaseTorn!()
  152. await new Promise(resolve => setTimeout(resolve, 0))
  153. expect(log).not.toHaveBeenCalled()
  154. await torn.fiber.dispose()
  155. })
  156. it('fails loud when the prompt section resolves against a portless webserver', async () => {
  157. stageDist()
  158. const ctx = new Context()
  159. // A webserver whose bound port is gone (torn down mid-request): the
  160. // section must throw, never render a URL with an undefined port.
  161. const { server } = fakeHttpServer()
  162. Object.defineProperty(server, 'port', { get: () => undefined })
  163. ctx.provide('httpServer', server)
  164. apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
  165. await ctx.plugin(SystemPrompt, { persona: '' })
  166. await new Promise(resolve => setTimeout(resolve, 0))
  167. await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
  168. await ctx.fiber.dispose()
  169. })
  170. it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
  171. // The production resolver (not the test seam). A built checkout resolves
  172. // the frontend package's index.html; a dist-less one (the CI coverage
  173. // lane runs before any build) must fail with the build hint, never a
  174. // silent fallback.
  175. try {
  176. expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
  177. } catch (error) {
  178. expect((error as Error).message).toContain('frontend dist not built')
  179. }
  180. })
  181. })