The client-hmr row joins the web bundle as an ordinary always-on roster row: without a rebuild watcher rewriting client bundles it polls unchanged files and stays idle. This deletes the --dev flag, the web runtime's mode config, the mode-forked prompt contract, the DSH_WEB_MODE bash variable, and the post-settlement row-creation machinery the conditional row required. dsh web + pnpm run dev:web remains the development loop.
232 lines
9.8 KiB
TypeScript
232 lines
9.8 KiB
TypeScript
/**
|
|
* Web runtime glue behavior: dist resolution through the bundle's own hook,
|
|
* the frontend-static child claiming the fallback seat, the web-surface
|
|
* prompt section and bash runtime variables, and URL-line printing with the
|
|
* runtime's bind-dependent LAN snapshot.
|
|
*/
|
|
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
|
import { apply, Config, internals } from '../src/index.ts'
|
|
|
|
vi.mock('node:os', async importOriginal => ({
|
|
...await importOriginal<typeof import('node:os')>(),
|
|
networkInterfaces: () => ({
|
|
lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
|
|
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }],
|
|
}),
|
|
}))
|
|
|
|
let dist: string | undefined
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
internals.resolveDistIndex = originalResolve
|
|
if (dist !== undefined) rmSync(dist, { recursive: true, force: true })
|
|
dist = undefined
|
|
})
|
|
|
|
const originalResolve = internals.resolveDistIndex
|
|
|
|
/** Stage a dist fixture and point the bundle's resolver at it. */
|
|
function stageDist(): string {
|
|
dist = mkdtempSync(join(tmpdir(), 'dsh-web-app-'))
|
|
mkdirSync(join(dist, 'dist'))
|
|
const index = join(dist, 'dist', 'index.html')
|
|
writeFileSync(index, '<head></head><body>shell</body>')
|
|
internals.resolveDistIndex = () => index
|
|
return index
|
|
}
|
|
|
|
/** A fake httpServer capturing the fallback seat and index taps. */
|
|
function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: HttpServerService; seat: () => unknown } {
|
|
let fallback: unknown
|
|
const server = {
|
|
host,
|
|
port: 4567,
|
|
registerFallback: (handler: unknown) => {
|
|
fallback = handler
|
|
return () => { fallback = undefined }
|
|
},
|
|
applyIndexTaps: (html: string) => html,
|
|
} as unknown as HttpServerService
|
|
return { server, seat: () => fallback }
|
|
}
|
|
|
|
/** A fake Loader whose settlement the test controls (the URL line waits on it). */
|
|
function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
|
|
ctx.provide('loader', { await: settle } as never)
|
|
}
|
|
|
|
interface BashContribution {
|
|
name: string
|
|
variables: Record<string, { description: string }>
|
|
resolve: () => Record<string, string>
|
|
}
|
|
|
|
describe('web-app runtime glue', () => {
|
|
it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => {
|
|
stageDist()
|
|
const ctx = new Context()
|
|
const { server, seat } = fakeHttpServer('0.0.0.0')
|
|
ctx.provide('httpServer', server)
|
|
const contributions: BashContribution[] = []
|
|
ctx.provide('bashEnv', {
|
|
register: (contribution: BashContribution) => {
|
|
contributions.push(contribution)
|
|
return () => {}
|
|
},
|
|
} as never)
|
|
provideLoader(ctx)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
// Settle the injected registrations.
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
|
expect(seat()).toBeDefined() // frontend-static claimed the fallback
|
|
expect(ctx.get('webRuntime')).toEqual({
|
|
lanAddresses: ['192.168.1.5'],
|
|
trustedHosts: ['192.168.1.5', 'lab.internal'],
|
|
})
|
|
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
|
|
const assembly = await ctx.systemPrompt.assemble()
|
|
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
|
|
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
|
|
expect(section?.text).toContain('http://127.0.0.1:4567')
|
|
// The single update contract: the receiver is always on; no-refresh
|
|
// reloads additionally need the rebuild watcher.
|
|
expect(section?.text).toContain('pnpm run dev:web')
|
|
const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
|
|
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('stays quiet with printUrl off', async () => {
|
|
stageDist()
|
|
const ctx = new Context()
|
|
ctx.provide('httpServer', fakeHttpServer().server)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(log).not.toHaveBeenCalled()
|
|
const assembly = await ctx.systemPrompt.assemble()
|
|
expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
|
|
.toContain('rebuilding the affected Web artifacts')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
|
|
stageDist()
|
|
const ctx = new Context()
|
|
ctx.provide('httpServer', fakeHttpServer().server)
|
|
const contributions: BashContribution[] = []
|
|
ctx.provide('bashEnv', {
|
|
register: (contribution: BashContribution) => {
|
|
contributions.push(contribution)
|
|
return () => {}
|
|
},
|
|
} as never)
|
|
apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
const assembly = await ctx.systemPrompt.assemble()
|
|
expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
|
|
expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false)
|
|
expect(contributions).toEqual([])
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
|
|
stageDist()
|
|
const ctx = new Context()
|
|
ctx.provide('httpServer', fakeHttpServer().server)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => {
|
|
stageDist()
|
|
// Settlement path: the line waits for loader.await() so supervisors can
|
|
// RPC immediately after observing it.
|
|
const settled = new Context()
|
|
settled.provide('httpServer', fakeHttpServer().server)
|
|
let release: () => void
|
|
const settlement = new Promise<void>((resolve) => { release = resolve })
|
|
provideLoader(settled, () => settlement)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(log).not.toHaveBeenCalled()
|
|
release!()
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
|
await settled.fiber.dispose()
|
|
|
|
// Failed path: Loader reports the sibling failure; the app prints no URL
|
|
// for a process that is about to exit.
|
|
log.mockClear()
|
|
const failed = new Context()
|
|
failed.provide('httpServer', fakeHttpServer().server)
|
|
provideLoader(failed, async () => { throw new Error('boot failed') })
|
|
apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(log).not.toHaveBeenCalled()
|
|
await failed.fiber.dispose()
|
|
|
|
// Torn-down path: settlement resolves after the webserver is gone — no
|
|
// line, no crash.
|
|
log.mockClear()
|
|
const torn = new Context()
|
|
const child = torn.plugin((childCtx: Context) => {
|
|
childCtx.provide('httpServer', fakeHttpServer().server)
|
|
})
|
|
await child
|
|
let releaseTorn: () => void
|
|
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
|
provideLoader(torn, () => tornSettlement)
|
|
apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
await child.dispose() // the httpServer service goes away
|
|
releaseTorn!()
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(log).not.toHaveBeenCalled()
|
|
await torn.fiber.dispose()
|
|
})
|
|
|
|
it('fails loud when the prompt section resolves against a portless webserver', async () => {
|
|
stageDist()
|
|
const ctx = new Context()
|
|
// A webserver whose bound port is gone (torn down mid-request): the
|
|
// section must throw, never render a URL with an undefined port.
|
|
const { server } = fakeHttpServer()
|
|
Object.defineProperty(server, 'port', { get: () => undefined })
|
|
ctx.provide('httpServer', server)
|
|
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
|
|
// The production resolver (not the test hook). A built checkout resolves
|
|
// the frontend package's index.html; a dist-less one (the CI coverage
|
|
// lane runs before any build) must fail with the build hint, never a
|
|
// silent fallback.
|
|
try {
|
|
expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
|
|
} catch (error) {
|
|
expect((error as Error).message).toContain('frontend dist not built')
|
|
}
|
|
})
|
|
})
|