Files
deepseek-harness/apps/desktop/tests/host-process.spec.ts
T
winewill 7b2094d04d fix(desktop): keep the Host's original diagnostic in startup crash reports
A Host startup failure reached the crash report as a fresh Error built from
a message string three hops away from the original: the Host sent only
error.message over the fatal IPC event, DesktopHostProcess wrapped it in a
new Error, desktopErrorState flattened it to text, and main.ts wrapped that
text again. Stack, cause, code, and path were gone, and the Host's stderr
tail arriving with the later exit was blocked by the first-failure latch.

The Host now sends its complete inspected error alongside the message
(bounded to 64 KiB; carried over IPC rather than stderr because the two
race and the shell reports the first failure it sees). DesktopHostProcess
keeps it on DesktopHostFatalError.diagnostic, the backend error state
carries the original failure object, main.ts reports that object, and the
crash report prints the Host diagnostic verbatim in its own section.
2026-09-22 17:31:17 +08:00

210 lines
10 KiB
TypeScript

import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from '../src/host-process.ts'
const roots: string[] = []
const hosts: DesktopHostProcess[] = []
const HTTP_HOST = `
import { createServer } from 'node:http'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
const server = createServer((request, response) => {
if (request.url === '/fatal') {
process.send({ type: 'fatal', message: 'plugin unavailable' })
response.end('reported')
return
}
if (request.url === '/crash') {
response.end('exiting', () => {
process.stderr.write('plugin crashed', () => process.exit(7))
})
return
}
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH, registry: process.env.NPM_CONFIG_REGISTRY, nodeOptions: process.env.NODE_OPTIONS, runAsNode: process.env.ELECTRON_RUN_AS_NODE, internals: process.execArgv.includes('--expose-internals')}))
})
server.listen(0, '127.0.0.1', () => {
process.send({ type: 'ready', url: 'http://127.0.0.1:' + server.address().port + '/?token=fixture' })
})
process.on('message', message => {
if (message.type === 'update-tasks') {
process.send({ type: 'update-tasks', requestId: message.requestId, active: message.action === 'lock' })
return
}
if (message.type !== 'shutdown') return
server.close(() => {
writeFileSync(join(process.argv[3], 'stopped'), '')
process.send({ type: 'shutdown-complete' }, () => process.disconnect())
})
server.closeAllConnections()
})
`
function projectWithHost(source = HTTP_HOST): string {
const project = mkdtempSync(join(tmpdir(), 'dsh-desktop-host-test-'))
roots.push(project)
const packageRoot = join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host')
mkdirSync(join(packageRoot, 'lib'), { recursive: true })
writeFileSync(join(packageRoot, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","type":"module"}\n')
writeFileSync(join(packageRoot, 'lib', 'index.js'), source)
return project
}
function hostProcess(
runtime: string, profile = runtime, onFailure?: (error: Error) => void, environment = process.env,
): DesktopHostProcess {
const host = new DesktopHostProcess(process.execPath, runtime, profile, undefined, environment, onFailure)
hosts.push(host)
return host
}
afterEach(async () => {
await Promise.all(hosts.splice(0).map(host => host.stop()))
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('desktop host process', () => {
it('correlates task inspections and admission changes over private IPC', async () => {
const host = hostProcess(projectWithHost())
await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable')
await host.start()
expect(await Promise.all([host.updateTasks('inspect'), host.updateTasks('lock'), host.updateTasks('unlock')]))
.toEqual([false, true, false])
await host.stop(true)
await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable')
})
it.each([
'process.exit(17)',
'process.exit(0)',
])('refuses installation when exit lacks successful teardown acknowledgement: %s', async (exit) => {
const host = hostProcess(projectWithHost(`
process.send({ type: 'ready', url: 'http://127.0.0.1:3080/' })
process.on('message', message => {
if (message.type === 'shutdown') process.stderr.write('token=fixture-secret', () => { ${exit} })
})
`))
await host.start()
const error = await host.stop(true).then(() => undefined, (error: unknown) => error)
expect(error).toBeInstanceOf(DesktopHostUncleanExitError)
expect(String(error)).toContain('shutdown acknowledged false')
expect(String(error)).toContain('graceful deadline exceeded false')
expect(String(error)).not.toContain('fixture-secret')
await expect(host.stop()).resolves.toBeUndefined()
})
it('returns the Web authentication URL and waits for graceful shutdown', async () => {
const runtime = projectWithHost()
const failure = vi.fn()
const host = hostProcess(runtime, runtime, failure)
const ready = await host.start()
expect(new URL(ready.url).searchParams.get('token')).toBe('fixture')
expect(await host.start()).toEqual(ready)
expect((await fetch(ready.url)).status).toBe(200)
await host.stop()
expect(existsSync(join(runtime, 'stopped'))).toBe(true)
await expect(fetch(ready.url)).rejects.toThrow()
expect(failure).not.toHaveBeenCalled()
})
it('passes external dependencies and package-manager paths to the Host', async () => {
const runtime = projectWithHost(HTTP_HOST.replace('runtime: process.argv[2]',
'pnpm: process.argv[5], nodeBin: process.argv[6], primaryRuntime: process.argv[4], runtime: process.argv[2]'))
const primaryRuntime = join(runtime, 'external-primary-runtime')
const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
undefined, primaryRuntime, { pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
hosts.push(host)
const { url } = await host.start()
expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
})
it('reports a fatal event after readiness once', async () => {
const runtime = projectWithHost()
const failure = vi.fn()
const host = hostProcess(runtime, runtime, failure)
const { url } = await host.start()
await fetch(new URL('/fatal', url))
await expect.poll(() => failure.mock.calls.length).toBe(1)
await host.stop()
expect(failure).toHaveBeenCalledTimes(1)
expect(failure).toHaveBeenCalledWith(new Error('plugin unavailable'))
})
it('reports a child crash after readiness with its stderr diagnostic', async () => {
const runtime = projectWithHost()
const failure = vi.fn()
const host = hostProcess(runtime, runtime, failure)
const { url } = await host.start()
await fetch(new URL('/crash', url))
await expect.poll(() => failure.mock.calls.length).toBe(1)
expect(failure).toHaveBeenCalledWith(new Error('dsh desktop host exited with 7: plugin crashed'))
})
it('retains only recent diagnostics from a noisy child', async () => {
const runtime = projectWithHost('process.stderr.write(\'discarded-prefix\' + \'x\'.repeat(70_000) + \'recent-failure\', () => { process.exitCode = 7; process.disconnect() })')
const failure = await hostProcess(runtime).start().catch((error: unknown) => error)
expect(failure).toBeInstanceOf(Error)
const message = (failure as Error).message
expect(message).not.toContain('discarded-prefix')
expect(message.endsWith('recent-failure')).toBe(true)
expect(message.length).toBeLessThan(66_000)
})
it('settles teardown when the executable cannot be spawned', async () => {
const runtime = projectWithHost()
const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime)
hosts.push(host)
await expect(host.start()).rejects.toThrow()
await host.stop()
})
it('loads the resource entry with a separate profile and inherits runtime and package-manager configuration', async () => {
const runtime = projectWithHost()
const profile = mkdtempSync(join(tmpdir(), 'desktop-external-profile-'))
roots.push(profile)
const host = hostProcess(runtime, profile, undefined, {
...process.env, NODE_OPTIONS: '--no-warnings', NODE_PATH: '/custom', NPM_CONFIG_REGISTRY: 'https://registry.example.test/',
})
const { url } = await host.start()
const response = await fetch(url)
expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile), nodePath: '/custom', registry: 'https://registry.example.test/', nodeOptions: '--no-warnings', runAsNode: '1', internals: true })
})
it.each([
["process.send({ type: 'fatal', message: 'startup failed' }); process.disconnect()", 'startup failed'],
["process.send({ type: 'ready', url: 4 })", 'invalid IPC event'],
["process.send({ type: 'fatal', message: 'startup failed', diagnostic: 42 })", 'invalid IPC event'],
['process.exit(0)', 'host stopped'],
])('rejects startup when the child fails before readiness: %s', async (source, message) => {
const host = hostProcess(projectWithHost(source))
await expect(host.start()).rejects.toThrow(message)
})
it('keeps the Host\'s inspected error separate from the message it reports', async () => {
const diagnostic = "Error: startup failed\\n at boot (lib/index.js:3:9) {\\n code: 'ENOENT',\\n path: '/profile/cordis.yml'\\n}"
const failures: Error[] = []
const host = hostProcess(projectWithHost(
`process.send({ type: 'fatal', message: 'startup failed', diagnostic: ${JSON.stringify(diagnostic)} }); process.disconnect()`,
), undefined, (error) => { failures.push(error) })
await expect(host.start()).rejects.toThrow('startup failed')
const [failure] = failures
expect(failure).toBeInstanceOf(DesktopHostFatalError)
expect((failure as DesktopHostFatalError).diagnostic).toBe(diagnostic)
expect(Object.keys(failure!)).not.toContain('diagnostic')
})
})
it('carries Platform credentials over private IPC and clears them on shutdown', async () => {
const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' } }); process.send({ type: 'ready'"))
const changed = vi.fn()
const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, undefined, undefined, undefined, changed)
hosts.push(host)
await host.start()
expect(changed).toHaveBeenCalledWith({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' })
await host.stop()
expect(changed).toHaveBeenLastCalledWith(null)
})