hmr-live.e2e.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. /** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
  2. import { existsSync } from 'node:fs'
  3. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { chromium } from 'playwright'
  7. import { expect, it } from 'vitest'
  8. import { Context } from 'cordis'
  9. import type { Fiber } from 'cordis'
  10. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  11. import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  12. import { REPO_ROOT } from './support.ts'
  13. function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
  14. return {
  15. argv,
  16. cwd,
  17. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
  18. graceMs: 5_000,
  19. ...env === undefined ? {} : { env },
  20. }
  21. }
  22. function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
  23. return new Promise((resolveReady, reject) => {
  24. let output = ''
  25. let settled = false
  26. const cleanup = (): void => {
  27. clearTimeout(timer)
  28. child.stdout?.off('data', onData)
  29. child.stderr?.off('data', onData)
  30. }
  31. const resolveOnce = (value: string): void => {
  32. if (settled) return
  33. settled = true
  34. cleanup()
  35. resolveReady(value)
  36. }
  37. const rejectOnce = (error: Error): void => {
  38. if (settled) return
  39. settled = true
  40. cleanup()
  41. reject(error)
  42. }
  43. const onData = (chunk: Buffer): void => {
  44. output += chunk.toString()
  45. const match = pattern.exec(output)
  46. if (match === null) return
  47. resolveOnce(match[1] ?? match[0])
  48. }
  49. const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
  50. child.stdout?.on('data', onData)
  51. child.stderr?.on('data', onData)
  52. void child.done.then((outcome) => {
  53. rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
  54. }, (error: unknown) => {
  55. rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
  56. })
  57. })
  58. }
  59. async function stopTree(child: SubprocessHandle): Promise<void> {
  60. child.terminate()
  61. const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
  62. if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
  63. await child.done
  64. }
  65. it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
  66. const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
  67. const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
  68. const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
  69. const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
  70. if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
  71. const originalSource = await readFile(sourcePath)
  72. const originalBundle = await readFile(bundlePath)
  73. const oldText = "Let's start building"
  74. const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
  75. const newText = `HMR UPDATED ${'x'.repeat(80)}`
  76. const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
  77. if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
  78. const subprocessCtx = new Context()
  79. let subprocessFiber: Fiber | undefined
  80. let watcher: SubprocessHandle | undefined
  81. let host: SubprocessHandle | undefined
  82. let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
  83. const failures: unknown[] = []
  84. try {
  85. subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
  86. watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
  87. await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
  88. host = subprocessCtx.subprocess.spawn(spawnSpec(
  89. [process.execPath, binPath, 'web', '--dev', '--port', '0'],
  90. world,
  91. {
  92. DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
  93. DSH_HOME: join(world, '.dsh'),
  94. },
  95. ))
  96. const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
  97. browser = await chromium.launch()
  98. const page = await browser.newPage()
  99. const pageErrors: string[] = []
  100. page.on('pageerror', error => pageErrors.push(String(error)))
  101. await page.goto(baseUrl, { waitUntil: 'load' })
  102. await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
  103. const pageIdentity = await page.evaluate(() => {
  104. const identity = crypto.randomUUID()
  105. Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
  106. return identity
  107. })
  108. await writeFile(sourcePath, updatedSource)
  109. await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
  110. expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
  111. .toBe(pageIdentity)
  112. expect(pageErrors).toEqual([])
  113. } catch (error) {
  114. failures.push(error)
  115. } finally {
  116. await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
  117. if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
  118. await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
  119. if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
  120. await browser?.close().catch((error: unknown) => failures.push(error))
  121. await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
  122. await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  123. }
  124. if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
  125. }, 120_000)