mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
133 lines
5.8 KiB
TypeScript
133 lines
5.8 KiB
TypeScript
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
|
|
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { chromium } from 'playwright'
|
|
import { expect, it } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import type { Fiber } from '@deepseek-ai/cordis'
|
|
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
|
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
|
import { REPO_ROOT } from './support.ts'
|
|
|
|
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
|
|
return {
|
|
argv,
|
|
cwd,
|
|
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
graceMs: 5_000,
|
|
...env === undefined ? {} : { env },
|
|
}
|
|
}
|
|
|
|
function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
|
|
return new Promise((resolveReady, reject) => {
|
|
let output = ''
|
|
let settled = false
|
|
const cleanup = (): void => {
|
|
clearTimeout(timer)
|
|
child.stdout?.off('data', onData)
|
|
child.stderr?.off('data', onData)
|
|
}
|
|
const resolveOnce = (value: string): void => {
|
|
if (settled) return
|
|
settled = true
|
|
cleanup()
|
|
resolveReady(value)
|
|
}
|
|
const rejectOnce = (error: Error): void => {
|
|
if (settled) return
|
|
settled = true
|
|
cleanup()
|
|
reject(error)
|
|
}
|
|
const onData = (chunk: Buffer): void => {
|
|
output += chunk.toString()
|
|
const match = pattern.exec(output)
|
|
if (match === null) return
|
|
resolveOnce(match[1] ?? match[0])
|
|
}
|
|
const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
|
|
child.stdout?.on('data', onData)
|
|
child.stderr?.on('data', onData)
|
|
void child.done.then((outcome) => {
|
|
rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
|
|
}, (error: unknown) => {
|
|
rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
|
|
})
|
|
})
|
|
}
|
|
|
|
async function stopTree(child: SubprocessHandle): Promise<void> {
|
|
child.terminate()
|
|
const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
|
|
if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
|
|
await child.done
|
|
}
|
|
|
|
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
|
|
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
|
|
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
|
|
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
|
|
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
|
|
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
|
|
const originalSource = await readFile(sourcePath)
|
|
const originalBundle = await readFile(bundlePath)
|
|
const oldText = 'Into the Unknown'
|
|
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
|
|
const newText = `HMR UPDATED ${'x'.repeat(80)}`
|
|
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
|
|
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
|
|
|
|
const subprocessCtx = new Context()
|
|
let subprocessFiber: Fiber | undefined
|
|
let watcher: SubprocessHandle | undefined
|
|
let host: SubprocessHandle | undefined
|
|
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
|
const failures: unknown[] = []
|
|
try {
|
|
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
|
|
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
|
|
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
|
|
host = subprocessCtx.subprocess.spawn(spawnSpec(
|
|
[process.execPath, binPath, 'web', '--port', '0'],
|
|
world,
|
|
{
|
|
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
|
|
DSH_HOME: join(world, '.dsh'),
|
|
},
|
|
))
|
|
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web')
|
|
browser = await chromium.launch()
|
|
const page = await browser.newPage()
|
|
const pageErrors: string[] = []
|
|
page.on('pageerror', error => pageErrors.push(String(error)))
|
|
await page.goto(baseUrl, { waitUntil: 'load' })
|
|
await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
|
|
const pageIdentity = await page.evaluate(() => {
|
|
const identity = crypto.randomUUID()
|
|
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
|
|
return identity
|
|
})
|
|
|
|
await writeFile(sourcePath, updatedSource)
|
|
await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
|
|
expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
|
|
.toBe(pageIdentity)
|
|
expect(pageErrors).toEqual([])
|
|
} catch (error) {
|
|
failures.push(error)
|
|
} finally {
|
|
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
|
|
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
|
|
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
|
|
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
|
|
await browser?.close().catch((error: unknown) => failures.push(error))
|
|
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
|
|
await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
|
}
|
|
if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
|
|
}, 120_000)
|