hmr-live.e2e.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. /** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
  2. import { existsSync, globSync, statSync } 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 '@deepseek-ai/cordis'
  9. import type { Fiber } from '@deepseek-ai/cordis'
  10. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  11. import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  12. import { readClientBuildRecord } from '../../../scripts/client-build-environment.ts'
  13. import { REPO_ROOT } from './support.ts'
  14. const CLIENT_ARTIFACT_PATTERNS = [
  15. 'apps/web/dist/**/*',
  16. 'packages/*/*/lib/client.js',
  17. 'packages/*/*/lib/client.js.map',
  18. 'packages/*/*/lib/client.*.js',
  19. 'packages/*/*/lib/client.*.js.map',
  20. ]
  21. /** Return every artifact that `pnpm run dev:web` can rewrite. */
  22. function clientArtifactPaths(): string[] {
  23. return globSync(CLIENT_ARTIFACT_PATTERNS, { cwd: REPO_ROOT })
  24. .map(path => join(REPO_ROOT, path))
  25. .filter(path => statSync(path).isFile())
  26. .sort()
  27. }
  28. function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
  29. return {
  30. argv,
  31. cwd,
  32. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
  33. graceMs: 5_000,
  34. ...env === undefined ? {} : { env },
  35. }
  36. }
  37. function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
  38. return new Promise((resolveReady, reject) => {
  39. let output = ''
  40. let settled = false
  41. const cleanup = (): void => {
  42. clearTimeout(timer)
  43. child.stdout?.off('data', onData)
  44. child.stderr?.off('data', onData)
  45. }
  46. const resolveOnce = (value: string): void => {
  47. if (settled) return
  48. settled = true
  49. cleanup()
  50. resolveReady(value)
  51. }
  52. const rejectOnce = (error: Error): void => {
  53. if (settled) return
  54. settled = true
  55. cleanup()
  56. reject(error)
  57. }
  58. const onData = (chunk: Buffer): void => {
  59. output += chunk.toString()
  60. const match = pattern.exec(output)
  61. if (match === null) return
  62. resolveOnce(match[1] ?? match[0])
  63. }
  64. const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
  65. child.stdout?.on('data', onData)
  66. child.stderr?.on('data', onData)
  67. void child.done.then((outcome) => {
  68. rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
  69. }, (error: unknown) => {
  70. rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
  71. })
  72. })
  73. }
  74. async function stopTree(child: SubprocessHandle): Promise<void> {
  75. child.terminate()
  76. const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
  77. if (!stopped) throw new Error('managed process range did not stop after termination escalation')
  78. await child.done
  79. }
  80. it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
  81. const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
  82. const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
  83. const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
  84. if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
  85. const clientBuildEnvironment = readClientBuildRecord(REPO_ROOT).environment
  86. const originalClientArtifacts = await Promise.all(clientArtifactPaths()
  87. .map(async path => [path, await readFile(path)] as const))
  88. const originalClientArtifactPaths = new Set(originalClientArtifacts.map(([path]) => path))
  89. const originalSource = await readFile(sourcePath)
  90. const oldText = 'Into the Unknown'
  91. const sourceNeedle = "'hero.headline': 'Into the Unknown'"
  92. const newText = `HMR UPDATED ${'x'.repeat(80)}`
  93. const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
  94. if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
  95. const subprocessCtx = new Context()
  96. let subprocessFiber: Fiber | undefined
  97. let watcher: SubprocessHandle | undefined
  98. let host: SubprocessHandle | undefined
  99. let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
  100. const failures: unknown[] = []
  101. try {
  102. subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime)
  103. watcher = subprocessCtx.subprocess.spawn(spawnSpec(
  104. ['pnpm', 'run', 'dev:web'],
  105. REPO_ROOT,
  106. { ...clientBuildEnvironment },
  107. ))
  108. await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
  109. host = subprocessCtx.subprocess.spawn(spawnSpec(
  110. [process.execPath, binPath, 'web', '--no-open', '--port', '0'],
  111. world,
  112. {
  113. DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
  114. DSH_HOME: join(world, '.dsh'),
  115. },
  116. ))
  117. const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web')
  118. browser = await chromium.launch()
  119. const page = await browser.newPage()
  120. const pageErrors: string[] = []
  121. page.on('pageerror', error => pageErrors.push(String(error)))
  122. await page.goto(baseUrl, { waitUntil: 'load' })
  123. await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
  124. const pageIdentity = await page.evaluate(() => {
  125. // In-page code: an import would not survive serialization, and the page
  126. // entropy source available in every context is getRandomValues.
  127. const identity = Array.from(crypto.getRandomValues(new Uint8Array(8)), byte => byte.toString(16).padStart(2, '0')).join('')
  128. Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
  129. return identity
  130. })
  131. await writeFile(sourcePath, updatedSource)
  132. await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
  133. expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
  134. .toBe(pageIdentity)
  135. expect(pageErrors).toEqual([])
  136. } catch (error) {
  137. failures.push(error)
  138. } finally {
  139. await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
  140. if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
  141. if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
  142. await browser?.close().catch((error: unknown) => failures.push(error))
  143. await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
  144. await Promise.all(clientArtifactPaths()
  145. .filter(path => !originalClientArtifactPaths.has(path))
  146. .map(async (path) => { await rm(path, { force: true }) }))
  147. .catch((error: unknown) => failures.push(error))
  148. await Promise.all(originalClientArtifacts.map(async ([path, content]) => {
  149. await writeFile(path, content)
  150. })).catch((error: unknown) => failures.push(error))
  151. try {
  152. readClientBuildRecord(REPO_ROOT)
  153. } catch (error) {
  154. failures.push(error)
  155. }
  156. await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  157. }
  158. if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
  159. }, 120_000)