mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
connection binds the web transport: it injects httpServer + apiProxy and registers toFetchHandler(ctx.apiProxy) under the /api prefix (the node:http to fetch bridge moves in from the webserver, keeping the res-close disconnect detection and drain/close backpressure waits). hmr owns dev reload: a stat-poll watch per graph row driven by clientModuleHost.onGraphChanged, rebuilt(id) on content change, and the /plugins/events SSE route (GET/HEAD guarded); frame types are single-sourced in events.ts shared by both halves.
117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
/**
|
|
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
|
|
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
|
|
*/
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Context } from 'cordis'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
|
|
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
|
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
|
|
|
|
const POLL_MS = 20
|
|
|
|
let dir: string
|
|
|
|
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
|
|
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
|
|
/**
|
|
* Controllable clientModuleHost fake over a mutable id → bundle-path table.
|
|
* Structural (Pick+cast): the plugin only touches the read/notify surface;
|
|
* the service class carries private scan state a literal need not reproduce.
|
|
*/
|
|
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
|
|
function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
|
|
const graphListeners = new Set<() => void>()
|
|
const rebuiltCalls: string[] = []
|
|
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
|
|
rebuiltCalls,
|
|
fireGraphChanged: () => { for (const l of graphListeners) l() },
|
|
graph: (): WebBootGraph => ({
|
|
rev: 'r',
|
|
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
|
}),
|
|
clientPath: id => rows.get(id),
|
|
rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
|
|
onRebuilt: () => () => {},
|
|
onGraphChanged: (listener) => {
|
|
graphListeners.add(listener)
|
|
return () => { graphListeners.delete(listener) }
|
|
},
|
|
}
|
|
return fake as FakeHost
|
|
}
|
|
|
|
// Structural fake: the plugin only touches register(); the service class
|
|
// carries private state a literal cannot (and need not) reproduce.
|
|
function fakeHttpServer(routes: WebRoute[]): HttpServerService {
|
|
const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
|
register(route) {
|
|
routes.push(route)
|
|
return () => { routes.splice(routes.indexOf(route), 1) }
|
|
},
|
|
tapIndex: () => () => {},
|
|
port: 0,
|
|
}
|
|
return fake as HttpServerService
|
|
}
|
|
|
|
async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
|
|
const ctx = new Context()
|
|
ctx.provide('clientModuleHost', clientModuleHost)
|
|
ctx.provide('httpServer', httpServer)
|
|
const fiber = ctx.plugin(
|
|
{ inject: [...inject], Config, apply },
|
|
{ pollIntervalMs: POLL_MS },
|
|
)
|
|
await fiber.await()
|
|
return fiber
|
|
}
|
|
|
|
describe('hmr node half', () => {
|
|
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
|
|
const bundle = join(dir, 'a.js')
|
|
writeFileSync(bundle, 'v1')
|
|
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
|
|
const routes: WebRoute[] = []
|
|
const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
|
|
|
|
expect(routes).toHaveLength(1)
|
|
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
|
|
|
|
// Nudge mtime past stat granularity so the poller sees a content signal.
|
|
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
|
writeFileSync(bundle, 'v2-longer')
|
|
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
|
|
|
|
await fiber.dispose()
|
|
expect(routes).toHaveLength(0)
|
|
// Watcher gone: further file changes report nothing.
|
|
clientModuleHost.rebuiltCalls.length = 0
|
|
writeFileSync(bundle, 'v3-even-longer')
|
|
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
|
|
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
|
})
|
|
|
|
it('follows graph changes: rows added after activation get watched', async () => {
|
|
const early = join(dir, 'early.js')
|
|
const late = join(dir, 'late.js')
|
|
writeFileSync(early, 'v1')
|
|
const rows = new Map([['pkg-early', early]])
|
|
const clientModuleHost = fakeClientModuleHost(rows)
|
|
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
|
|
|
writeFileSync(late, 'v1')
|
|
rows.set('pkg-late', late)
|
|
clientModuleHost.fireGraphChanged()
|
|
|
|
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
|
writeFileSync(late, 'v2-longer')
|
|
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
|
|
await fiber.dispose()
|
|
})
|
|
})
|