|
|
@@ -1,19 +1,28 @@
|
|
|
/**
|
|
|
* 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.
|
|
|
+ * prompt section and bash runtime variables, and readiness publication through
|
|
|
+ * the URL line and default-browser handoff.
|
|
|
*/
|
|
|
|
|
|
+import { EventEmitter } from 'node:events'
|
|
|
+import { spawn, type ChildProcess } from 'node:child_process'
|
|
|
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 { PassThrough } from 'node:stream'
|
|
|
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
+import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
|
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
|
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
|
|
|
import { apply, Config, internals } from '../src/index.ts'
|
|
|
|
|
|
+vi.mock('node:child_process', async importOriginal => ({
|
|
|
+ ...await importOriginal<typeof import('node:child_process')>(),
|
|
|
+ spawn: vi.fn(),
|
|
|
+}))
|
|
|
+
|
|
|
vi.mock('node:os', async importOriginal => ({
|
|
|
...await importOriginal<typeof import('node:os')>(),
|
|
|
networkInterfaces: () => ({
|
|
|
@@ -24,14 +33,30 @@ vi.mock('node:os', async importOriginal => ({
|
|
|
|
|
|
let dist: string | undefined
|
|
|
|
|
|
+beforeEach(() => {
|
|
|
+ vi.stubEnv('SSH_CONNECTION', '')
|
|
|
+ vi.stubEnv('SSH_TTY', '')
|
|
|
+})
|
|
|
+
|
|
|
afterEach(() => {
|
|
|
vi.restoreAllMocks()
|
|
|
+ vi.mocked(spawn).mockReset()
|
|
|
+ vi.unstubAllEnvs()
|
|
|
internals.resolveDistIndex = originalResolve
|
|
|
+ internals.openBrowser = originalOpenBrowser
|
|
|
if (dist !== undefined) rmSync(dist, { recursive: true, force: true })
|
|
|
dist = undefined
|
|
|
})
|
|
|
|
|
|
const originalResolve = internals.resolveDistIndex
|
|
|
+const originalOpenBrowser = internals.openBrowser
|
|
|
+
|
|
|
+type BrowserLauncher = ChildProcess & { stderr: PassThrough }
|
|
|
+
|
|
|
+/** Minimal browser-launcher process for the native handoff adapter. */
|
|
|
+function launcher(): BrowserLauncher {
|
|
|
+ return Object.assign(new EventEmitter(), { stderr: new PassThrough() }) as unknown as BrowserLauncher
|
|
|
+}
|
|
|
|
|
|
/** Stage a dist fixture and point the bundle's resolver at it. */
|
|
|
function stageDist(): string {
|
|
|
@@ -70,9 +95,14 @@ interface BashContribution {
|
|
|
}
|
|
|
|
|
|
describe('web-app runtime glue', () => {
|
|
|
- it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => {
|
|
|
+ it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => {
|
|
|
stageDist()
|
|
|
const ctx = new Context()
|
|
|
+ // Editor markers and a project .env SSH value do not establish a remote launch.
|
|
|
+ ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([
|
|
|
+ { source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } },
|
|
|
+ { source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } },
|
|
|
+ ]))
|
|
|
const { server, seat } = fakeHttpServer('0.0.0.0')
|
|
|
ctx.provide('webServer', server)
|
|
|
const contributions: BashContribution[] = []
|
|
|
@@ -83,8 +113,11 @@ describe('web-app runtime glue', () => {
|
|
|
},
|
|
|
} as never)
|
|
|
provideLoader(ctx)
|
|
|
- const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
- apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
|
|
|
+ const lifecycle: string[] = []
|
|
|
+ const log = vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) })
|
|
|
+ const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) })
|
|
|
+ internals.openBrowser = openBrowser
|
|
|
+ apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
|
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
|
// Settle the injected registrations.
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
@@ -95,6 +128,13 @@ describe('web-app runtime glue', () => {
|
|
|
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)')
|
|
|
+ expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
|
|
|
+ expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567')
|
|
|
+ expect(lifecycle).toEqual([
|
|
|
+ 'dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)',
|
|
|
+ 'dsh web: opening the default browser; pass --no-open to disable',
|
|
|
+ 'open:http://127.0.0.1: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')
|
|
|
@@ -107,15 +147,18 @@ describe('web-app runtime glue', () => {
|
|
|
await ctx.fiber.dispose()
|
|
|
})
|
|
|
|
|
|
- it('stays quiet with printUrl off', async () => {
|
|
|
+ it('publishes no readiness side effect when printing and browser opening are disabled', async () => {
|
|
|
stageDist()
|
|
|
const ctx = new Context()
|
|
|
ctx.provide('webServer', fakeHttpServer().server)
|
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
- apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
|
|
+ const openBrowser = vi.fn(async () => {})
|
|
|
+ internals.openBrowser = openBrowser
|
|
|
+ apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
expect(log).not.toHaveBeenCalled()
|
|
|
+ expect(openBrowser).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')
|
|
|
@@ -133,7 +176,7 @@ describe('web-app runtime glue', () => {
|
|
|
return () => {}
|
|
|
},
|
|
|
} as never)
|
|
|
- apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
|
|
+ apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
const assembly = await ctx.systemPrompt.assemble()
|
|
|
@@ -148,44 +191,69 @@ describe('web-app runtime glue', () => {
|
|
|
const ctx = new Context()
|
|
|
ctx.provide('webServer', fakeHttpServer().server)
|
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
- apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
|
+ apply(ctx, new Config({ openBrowser: false, 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 () => {
|
|
|
+ it.each([
|
|
|
+ ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'],
|
|
|
+ ['SSH_TTY', '/dev/pts/3'],
|
|
|
+ ] as const)('prints the host URL but skips browser handoff when %s marks an SSH launch', async (name, value) => {
|
|
|
+ vi.stubEnv(name, value)
|
|
|
stageDist()
|
|
|
- // Settlement path: the line waits for loader.await() so supervisors can
|
|
|
- // RPC immediately after observing it.
|
|
|
+ const ctx = new Context()
|
|
|
+ ctx.provide('webServer', fakeHttpServer().server)
|
|
|
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
+ const openBrowser = vi.fn(async () => {})
|
|
|
+ internals.openBrowser = openBrowser
|
|
|
+ apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
+ expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
|
|
+ expect(openBrowser).not.toHaveBeenCalled()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('defers readiness publication until Loader settlement and drops it on failure or teardown', async () => {
|
|
|
+ stageDist()
|
|
|
+ const openBrowser = vi.fn(async () => {})
|
|
|
+ internals.openBrowser = openBrowser
|
|
|
+ // Settlement path: both actions wait for loader.await() so their consumers
|
|
|
+ // can request the complete app immediately.
|
|
|
const settled = new Context()
|
|
|
settled.provide('webServer', 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: [] }))
|
|
|
+ apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
expect(log).not.toHaveBeenCalled()
|
|
|
+ expect(openBrowser).not.toHaveBeenCalled()
|
|
|
release!()
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
|
|
+ expect(openBrowser).toHaveBeenCalledWith('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()
|
|
|
+ openBrowser.mockClear()
|
|
|
const failed = new Context()
|
|
|
failed.provide('webServer', fakeHttpServer().server)
|
|
|
provideLoader(failed, async () => { throw new Error('boot failed') })
|
|
|
- apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
|
+ apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
expect(log).not.toHaveBeenCalled()
|
|
|
+ expect(openBrowser).not.toHaveBeenCalled()
|
|
|
await failed.fiber.dispose()
|
|
|
|
|
|
// Torn-down path: settlement resolves after the webserver is gone — no
|
|
|
// line, no crash.
|
|
|
log.mockClear()
|
|
|
+ openBrowser.mockClear()
|
|
|
const torn = new Context()
|
|
|
const child = torn.plugin((childCtx: Context) => {
|
|
|
childCtx.provide('webServer', fakeHttpServer().server)
|
|
|
@@ -194,11 +262,12 @@ describe('web-app runtime glue', () => {
|
|
|
let releaseTorn: () => void
|
|
|
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
|
|
provideLoader(torn, () => tornSettlement)
|
|
|
- apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
|
+ apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
|
|
await child.dispose() // the webServer service goes away
|
|
|
releaseTorn!()
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
expect(log).not.toHaveBeenCalled()
|
|
|
+ expect(openBrowser).not.toHaveBeenCalled()
|
|
|
await torn.fiber.dispose()
|
|
|
})
|
|
|
|
|
|
@@ -210,7 +279,7 @@ describe('web-app runtime glue', () => {
|
|
|
const { server } = fakeHttpServer()
|
|
|
Object.defineProperty(server, 'port', { get: () => undefined })
|
|
|
ctx.provide('webServer', server)
|
|
|
- apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
|
|
+ apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing')
|
|
|
@@ -228,4 +297,82 @@ describe('web-app runtime glue', () => {
|
|
|
expect((error as Error).message).toContain('frontend dist not built')
|
|
|
}
|
|
|
})
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ ['Error', new Error('no desktop'), 'no desktop'],
|
|
|
+ ['non-Error', 'desktop unavailable', 'desktop unavailable'],
|
|
|
+ ] as const)('keeps the server running and reports the manual URL when a browser failure is %s', async (_kind, failure, reason) => {
|
|
|
+ stageDist()
|
|
|
+ const ctx = new Context()
|
|
|
+ ctx.provide('webServer', fakeHttpServer().server)
|
|
|
+ internals.openBrowser = vi.fn(async () => { throw failure })
|
|
|
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
+ const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
+ apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
+ expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
|
|
|
+ expect(diagnostic).toHaveBeenCalledWith(
|
|
|
+ `web-app: could not open the default browser because ${reason}; visit http://127.0.0.1:4567 manually`,
|
|
|
+ )
|
|
|
+ expect(ctx.get('webServer')).toBeDefined()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('scrubs the helper environment and reports helper spawn or exit failures', async () => {
|
|
|
+ vi.stubEnv('DEEPSEEK_API_KEY', 'must-not-reach-browser')
|
|
|
+ vi.stubEnv('DSH_HOME', '/must-not-reach-browser')
|
|
|
+ const completed = launcher()
|
|
|
+ vi.mocked(spawn).mockReturnValueOnce(completed)
|
|
|
+ const completion = originalOpenBrowser('http://127.0.0.1:4567')
|
|
|
+ const [command, args, options] = vi.mocked(spawn).mock.calls[0]!
|
|
|
+ expect(command).toBe(process.execPath)
|
|
|
+ expect(args).toEqual([
|
|
|
+ '--input-type=module',
|
|
|
+ '--eval', expect.stringContaining('await import('),
|
|
|
+ '--', 'http://127.0.0.1:4567',
|
|
|
+ ])
|
|
|
+ expect(args?.[2]).toContain("if (process.platform === 'win32')")
|
|
|
+ expect(args?.[2]).toContain('launcher.ref()')
|
|
|
+ expect(options?.env).not.toHaveProperty('DEEPSEEK_API_KEY')
|
|
|
+ expect(options?.env).not.toHaveProperty('DSH_HOME')
|
|
|
+ expect(options?.env?.PATH).toBe(process.env.PATH)
|
|
|
+ expect(options?.stdio).toEqual(['ignore', 'inherit', 'pipe'])
|
|
|
+ completed.emit('close', 0)
|
|
|
+ await expect(completion).resolves.toBeUndefined()
|
|
|
+ expect(completed.listenerCount('error')).toBe(0)
|
|
|
+
|
|
|
+ const completedWithStderr = launcher()
|
|
|
+ vi.mocked(spawn).mockReturnValueOnce(completedWithStderr)
|
|
|
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
|
+ const completionWithStderr = originalOpenBrowser('http://127.0.0.1:4567')
|
|
|
+ completedWithStderr.stderr?.write('launcher note\n')
|
|
|
+ completedWithStderr.emit('close', 0)
|
|
|
+ await expect(completionWithStderr).resolves.toBeUndefined()
|
|
|
+ expect(stderr).toHaveBeenCalledWith('launcher note\n')
|
|
|
+
|
|
|
+ const failedWithReason = launcher()
|
|
|
+ vi.mocked(spawn).mockReturnValueOnce(failedWithReason)
|
|
|
+ const reasonFailure = originalOpenBrowser('http://127.0.0.1:4567')
|
|
|
+ const reasonAssertion = expect(reasonFailure).rejects.toThrow('desktop unavailable')
|
|
|
+ failedWithReason.stderr?.write('Error: desktop unavailable\n at fixture')
|
|
|
+ failedWithReason.emit('close', 1)
|
|
|
+ await reasonAssertion
|
|
|
+
|
|
|
+ const failed = launcher()
|
|
|
+ vi.mocked(spawn).mockReturnValueOnce(failed)
|
|
|
+ const failure = originalOpenBrowser('http://127.0.0.1:4567')
|
|
|
+ const failureAssertion = expect(failure).rejects.toThrow('exited with code 3')
|
|
|
+ await Promise.resolve()
|
|
|
+ failed.emit('close', 3)
|
|
|
+ await failureAssertion
|
|
|
+
|
|
|
+ const errored = launcher()
|
|
|
+ vi.mocked(spawn).mockReturnValueOnce(errored)
|
|
|
+ const error = originalOpenBrowser('http://127.0.0.1:4567')
|
|
|
+ const errorAssertion = expect(error).rejects.toThrow('spawn failed')
|
|
|
+ await Promise.resolve()
|
|
|
+ errored.emit('error', new Error('spawn failed'))
|
|
|
+ await errorAssertion
|
|
|
+ expect(errored.listenerCount('close')).toBe(0)
|
|
|
+ })
|
|
|
})
|