/** Published dsh web --dev + 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 'cordis' import type { Fiber } from '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): 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 { 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 { 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 = "Let's start building" const sourceNeedle = "'hero.headline': 'Let\\'s start building'" 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> | 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', '--dev', '--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 --dev') 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)