preview-boot.e2e.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /**
  2. * Preview acceptance: the browser-only worker deployment boots the real Cordis
  3. * tree out of the packed VFS image and reaches an interactive page.
  4. *
  5. * `dist/preview.html` is the served page plus one bootstrap script tag, so this
  6. * run exercises the shipped startup chain: the worker mounts the image,
  7. * activates the tree, and answers the page's tunnel until the client settles.
  8. * Two milestones prove that happened — the host's `tree active` boot line,
  9. * whose lowering contract must be the one this checkout's packer emits, and the
  10. * workspace hero, which paints only after the client tree comes up over the
  11. * tunnel.
  12. *
  13. * The site is served the way a static host serves it: bytes from `dist/` with
  14. * no rewrite rules, so a missing file is a 404 rather than the index page.
  15. */
  16. import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  17. import { readFile } from 'node:fs/promises'
  18. import { createServer } from 'node:http'
  19. import type { IncomingMessage, ServerResponse } from 'node:http'
  20. import { tmpdir } from 'node:os'
  21. import { extname, join, normalize } from 'node:path'
  22. import { fileURLToPath } from 'node:url'
  23. import { chromium } from 'playwright'
  24. import type { Browser } from 'playwright'
  25. import { expect, it } from 'vitest'
  26. import {
  27. composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT,
  28. } from '@deepseek-ai/dsh-experimental-webworker-packer'
  29. import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime'
  30. import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
  31. const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
  32. /** Where the client looks for the image: the runtime's own name, beside the page. */
  33. const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME)
  34. /** Profile the preview deployment composes; `build:preview` packs the same one. */
  35. const PROFILE = 'web'
  36. /** Pages the preview needs; the Vite build emits both. */
  37. const PAGES = ['index.html', 'preview.html']
  38. /**
  39. * Content types the preview loads. Anything else is served as opaque bytes.
  40. *
  41. * The image goes out as `application/gzip` with no `content-encoding`: the
  42. * worker inflates the gzip member itself, so a transport-decoded body would
  43. * leave its `DecompressionStream('gzip')` with plain tar bytes to inflate.
  44. */
  45. const MIME: Record<string, string> = {
  46. '.html': 'text/html; charset=utf-8',
  47. '.js': 'text/javascript; charset=utf-8',
  48. '.css': 'text/css; charset=utf-8',
  49. '.json': 'application/json; charset=utf-8',
  50. '.map': 'application/json; charset=utf-8',
  51. '.svg': 'image/svg+xml',
  52. '.gz': 'application/gzip',
  53. '.webmanifest': 'application/manifest+json',
  54. '.woff2': 'font/woff2',
  55. }
  56. /** Boot line the worker host writes once its tree finished activating. */
  57. const TREE_ACTIVE = 'webworker host: tree active'
  58. /** Image fetch, mount, and tree activation on a loaded machine. */
  59. const BOOT_TIMEOUT_MS = 240_000
  60. /** Client tree settle after the tunnel starts answering. */
  61. const HERO_TIMEOUT_MS = 240_000
  62. /** One served origin over `dist/`. */
  63. interface Site {
  64. readonly origin: string
  65. /** Release the port; call after the browser is gone. */
  66. close(): Promise<void>
  67. }
  68. /**
  69. * Fail before the browser opens a page the build never produced.
  70. * @throws When either preview page is missing from `dist/`.
  71. */
  72. function requirePreviewPages(): void {
  73. for (const page of PAGES) {
  74. if (existsSync(join(DIST_ROOT, page))) continue
  75. throw new Error(`preview boot needs apps/web/dist/${page} — run \`pnpm run build\` from the repository root`)
  76. }
  77. }
  78. /**
  79. * The image file to serve, packed here when `dist/` carries none: `pnpm run
  80. * build` emits the pages but only `build:preview` packs, so this lane packs
  81. * for itself rather than skipping the deployment it is here to accept. An
  82. * image already in place is used as it stands — the worker refuses one lowered
  83. * against another wrapper contract, and that refusal names the rebuild. A
  84. * self-packed image lands in a temp directory, never in `dist/`: the
  85. * client-artifact digest record treats `dist/` as build-owned, so a test write
  86. * there fails the record check for every later consumer.
  87. * @returns The file to answer `preview/<image>` with, and its teardown.
  88. * @throws When the closure leaves dependencies unresolved, which would pack an
  89. * incomplete image the tree fails on later and further from the cause.
  90. */
  91. function requireVfsImage(): { path: string; cleanup(): void } {
  92. if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} }
  93. const packed = packVfsImage({
  94. config: composeProfile(REPO_ROOT, PROFILE),
  95. profile: PROFILE,
  96. workspaces: indexWorkspacePackages(REPO_ROOT),
  97. resolveFrom: REPO_ROOT,
  98. configTrees: configTrees(REPO_ROOT),
  99. })
  100. if (packed.missing.length > 0) {
  101. throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
  102. }
  103. const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-'))
  104. const path = join(directory, IMAGE_FILE_NAME)
  105. writeFileSync(path, packed.image)
  106. return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } }
  107. }
  108. /**
  109. * Answer one request with the file it names under `dist/`; the image path
  110. * answers from wherever {@link requireVfsImage} put the file.
  111. * @param request - Incoming request; only its path is read.
  112. * @param response - Response to write the bytes or the 404 to.
  113. * @param imagePath - File behind `preview/<image>`.
  114. */
  115. async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise<void> {
  116. const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
  117. const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '')
  118. try {
  119. const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative))
  120. response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' })
  121. response.end(body)
  122. } catch {
  123. // A miss is a miss: the deployment has no SPA fallback, and hiding one
  124. // behind the index page would make a broken asset URL look like a boot
  125. // failure.
  126. response.writeHead(404)
  127. response.end(`not found: ${relative}`)
  128. }
  129. }
  130. /**
  131. * Serve `dist/` over loopback with static-host semantics.
  132. * @param imagePath - File behind `preview/<image>`.
  133. * @returns The origin to navigate, and its teardown.
  134. */
  135. async function serveDist(imagePath: string): Promise<Site> {
  136. const server = createServer((request, response) => { void respond(request, response, imagePath) })
  137. await new Promise<void>((listening) => { server.listen(0, '127.0.0.1', listening) })
  138. const address = server.address()
  139. if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
  140. return {
  141. origin: `http://127.0.0.1:${String(address.port)}`,
  142. close: async () => {
  143. server.closeAllConnections()
  144. await new Promise<void>((closed, reject) => {
  145. server.close((error) => {
  146. if (error === undefined) closed()
  147. else reject(error)
  148. })
  149. })
  150. },
  151. }
  152. }
  153. /**
  154. * Bound one boot milestone so a stall names the milestone instead of surfacing
  155. * as the lane's generic test timeout.
  156. * @param work - The milestone to wait for.
  157. * @param ms - How long it may take.
  158. * @param stalled - Error message when it does not arrive in time.
  159. * @returns What `work` resolved to.
  160. */
  161. async function within<T>(work: Promise<T>, ms: number, stalled: string): Promise<T> {
  162. let timer: NodeJS.Timeout | undefined
  163. try {
  164. return await Promise.race([
  165. work,
  166. new Promise<never>((_, reject) => { timer = setTimeout(() => { reject(new Error(stalled)) }, ms) }),
  167. ])
  168. } finally {
  169. clearTimeout(timer)
  170. }
  171. }
  172. it('boots the packed worker deployment to an interactive page', async () => {
  173. requirePreviewPages()
  174. const image = requireVfsImage()
  175. try {
  176. const site = await serveDist(image.path)
  177. try {
  178. const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
  179. try {
  180. await bootPreview(site.origin, browser)
  181. } finally {
  182. await browser.close()
  183. }
  184. } finally {
  185. await site.close()
  186. }
  187. } finally {
  188. image.cleanup()
  189. }
  190. }, 600_000)
  191. /**
  192. * Open the preview page and hold it to both boot milestones.
  193. * @param origin - Origin serving `dist/`.
  194. * @param browser - Browser to open the page in.
  195. */
  196. async function bootPreview(origin: string, browser: Browser): Promise<void> {
  197. const page = await newEnglishPage(browser)
  198. const pageErrors: Error[] = []
  199. page.on('pageerror', (error) => { pageErrors.push(error) })
  200. // Registered before navigation: the worker reports its tree long before the
  201. // tunnel serves the client, so a listener added later would miss the line.
  202. const treeActive = new Promise<string>((reported) => {
  203. page.on('console', (message) => {
  204. const text = message.text()
  205. if (text.includes(TREE_ACTIVE)) reported(text)
  206. })
  207. })
  208. try {
  209. await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' })
  210. const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`)
  211. // The activated tree ran bodies lowered against the contract this
  212. // checkout's packer emits; a dist built before a contract change would
  213. // report the older one.
  214. expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
  215. // The hero's workspace picker is the client tree's first interactive
  216. // surface, so it appears only once the startup chain completed over the
  217. // tunnel.
  218. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
  219. expect(pageErrors.map(error => error.message)).toEqual([])
  220. } catch (error) {
  221. await saveFailureShot(page, 'preview-boot')
  222. throw pageErrors.length === 0
  223. ? error
  224. : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors')
  225. }
  226. }