preview-boot.e2e.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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. The same page opens the seeded Workspace and showcase Session,
  12. * verifies its tool/subagent/history examples, then writes through the
  13. * settings and credentials providers. That keeps the upstream Chokidar
  14. * instances exercised over the Worker filesystem implementation.
  15. *
  16. * The site is served the way a static host serves it: bytes from `dist/` with
  17. * no rewrite rules, so a missing file is a 404 rather than the index page.
  18. */
  19. import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  20. import { readFile } from 'node:fs/promises'
  21. import { createServer } from 'node:http'
  22. import type { IncomingMessage, ServerResponse } from 'node:http'
  23. import { tmpdir } from 'node:os'
  24. import { dirname, extname, join, normalize } from 'node:path'
  25. import { fileURLToPath } from 'node:url'
  26. import { chromium } from 'playwright'
  27. import type { Browser } from 'playwright'
  28. import { expect, it } from 'vitest'
  29. import {
  30. composeProfile, configTrees, indexWorkspacePackages, packVfsImage, packVfsOverlay,
  31. previewFixtures, WRAPPER_CONTRACT,
  32. } from '@deepseek-ai/dsh-experimental-webworker-packer'
  33. import {
  34. IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION,
  35. type PreviewFixtureManifest,
  36. } from '@deepseek-ai/dsh-experimental-webworker-runtime'
  37. import {
  38. VFS_EXAMPLE_SESSION_IDS,
  39. buildVfsExampleFiles,
  40. } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts'
  41. import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
  42. import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
  43. const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
  44. /** Where the client looks for the image: the runtime's own name, beside the page. */
  45. const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME)
  46. /** Keyless browser golden for the pre-Worker source chooser. */
  47. const SOURCE_CHOOSER_EXPECTED = fileURLToPath(new URL('./snapshots/preview-boot/source-chooser.expected.md', import.meta.url))
  48. const SNAPSHOT_MODE = webSnapshotMode()
  49. /** Profile the preview deployment composes; `build:preview` packs the same one. */
  50. const PROFILE = 'web'
  51. /** Stable labels authored by the deterministic VFS example fixture. */
  52. const SHOWCASE_TITLE = 'WebWorker Preview Showcase'
  53. const SHOWCASE_TAIL = 'Preview tour complete'
  54. const SHOWCASE_OLDEST = 'History checkpoint 01: verify deterministic preview state.'
  55. /** Pages the preview needs; the Vite build emits both. */
  56. const PAGES = ['index.html', 'preview.html']
  57. /**
  58. * Content types the preview loads. Anything else is served as opaque bytes.
  59. *
  60. * The image goes out as `application/gzip` with no `content-encoding`: the
  61. * worker inflates the gzip member itself, so a transport-decoded body would
  62. * leave its `DecompressionStream('gzip')` with plain tar bytes to inflate.
  63. */
  64. const MIME: Record<string, string> = {
  65. '.html': 'text/html; charset=utf-8',
  66. '.js': 'text/javascript; charset=utf-8',
  67. '.css': 'text/css; charset=utf-8',
  68. '.json': 'application/json; charset=utf-8',
  69. '.map': 'application/json; charset=utf-8',
  70. '.svg': 'image/svg+xml',
  71. '.gz': 'application/gzip',
  72. '.webmanifest': 'application/manifest+json',
  73. '.woff2': 'font/woff2',
  74. }
  75. /** Boot line the worker host writes once its tree finished activating. */
  76. const TREE_ACTIVE = 'webworker host: tree active'
  77. /** Image fetch, mount, and tree activation on a loaded machine. */
  78. const BOOT_TIMEOUT_MS = 240_000
  79. /** Client tree settle after the tunnel starts answering. */
  80. const HERO_TIMEOUT_MS = 240_000
  81. /** One served origin over `dist/`. */
  82. interface Site {
  83. readonly origin: string
  84. /** Release the port; call after the browser is gone. */
  85. close(): Promise<void>
  86. }
  87. interface PreviewAssets {
  88. /** Static-host-relative path to a generated file outside `dist/`. */
  89. readonly overrides: ReadonlyMap<string, string>
  90. cleanup(): void
  91. }
  92. /**
  93. * Fail before the browser opens a page the build never produced.
  94. * @throws When either preview page is missing from `dist/`.
  95. */
  96. function requirePreviewPages(): void {
  97. for (const page of PAGES) {
  98. if (existsSync(join(DIST_ROOT, page))) continue
  99. throw new Error(`preview boot needs apps/web/dist/${page} — run \`pnpm run build\` from the repository root`)
  100. }
  101. }
  102. /**
  103. * The base image, fixture manifest, and overlays to serve. `pnpm run build`
  104. * emits the pages but only `build:preview` packs the image, so this lane packs
  105. * a missing image rather than skipping the deployment it accepts. The example
  106. * overlay pairs its committed Session generations with the generator-owned
  107. * current projection cache. The worker therefore exercises historical reads
  108. * without relying on a stale cache schema. Generated files land in a temp
  109. * directory, never in `dist/`: the
  110. * client-artifact digest record treats `dist/` as build-owned, so a test write
  111. * there fails the record check for every later consumer.
  112. * @returns Static-path overrides and their teardown.
  113. * @throws When the closure leaves dependencies unresolved, which would pack an
  114. * incomplete image the tree fails on later and further from the cause.
  115. */
  116. function requireVfsAssets(): PreviewAssets {
  117. const fixtureDefinitions = previewFixtures(REPO_ROOT)
  118. const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-'))
  119. const overrides = new Map<string, string>()
  120. const writeAsset = (relativePath: string, bytes: Uint8Array | string): void => {
  121. const path = join(directory, relativePath)
  122. mkdirSync(dirname(path), { recursive: true })
  123. writeFileSync(path, bytes)
  124. overrides.set(relativePath, path)
  125. }
  126. if (!existsSync(IMAGE_FILE)) {
  127. const packed = packVfsImage({
  128. config: composeProfile(REPO_ROOT, PROFILE),
  129. profile: PROFILE,
  130. workspaces: indexWorkspacePackages(REPO_ROOT),
  131. resolveFrom: REPO_ROOT,
  132. configTrees: configTrees(REPO_ROOT),
  133. })
  134. if (packed.missing.length > 0) {
  135. throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
  136. }
  137. writeAsset(`preview/${IMAGE_FILE_NAME}`, packed.image)
  138. }
  139. const currentCache = buildVfsExampleFiles().get('home/storages/session_projcache.json')
  140. if (currentCache === undefined) throw new Error('preview boot: generated example has no projection cache')
  141. const cacheDirectory = join(directory, 'current-projection-cache')
  142. mkdirSync(cacheDirectory, { recursive: true })
  143. writeFileSync(join(cacheDirectory, 'session_projcache.json'), currentCache)
  144. const fixtures = fixtureDefinitions.map((fixture) => {
  145. const relativePath = `preview/fixtures/${fixture.id}.tar.gz`
  146. const trees = fixture.id === 'vfs-example'
  147. ? [...fixture.trees, { mount: 'home/storages', directory: cacheDirectory }]
  148. : fixture.trees
  149. writeAsset(relativePath, packVfsOverlay(trees).image)
  150. return {
  151. id: fixture.id,
  152. label: fixture.label,
  153. description: fixture.description,
  154. overlays: [`fixtures/${fixture.id}.tar.gz`],
  155. }
  156. })
  157. const manifest: PreviewFixtureManifest = {
  158. version: PREVIEW_FIXTURE_MANIFEST_VERSION,
  159. defaultFixture: fixtures[0]?.id ?? null,
  160. fixtures,
  161. }
  162. writeAsset(`preview/${PREVIEW_FIXTURE_MANIFEST_FILE}`, `${JSON.stringify(manifest, null, 2)}\n`)
  163. return { overrides, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } }
  164. }
  165. /**
  166. * Answer one request with its generated override or the file under `dist/`.
  167. * @param request - Incoming request; only its path is read.
  168. * @param response - Response to write the bytes or the 404 to.
  169. * @param overrides - Generated deployment files served before `dist/`.
  170. */
  171. async function respond(
  172. request: IncomingMessage,
  173. response: ServerResponse,
  174. overrides: ReadonlyMap<string, string>,
  175. ): Promise<void> {
  176. const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
  177. const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '')
  178. try {
  179. const body = await readFile(overrides.get(relative) ?? join(DIST_ROOT, relative))
  180. response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' })
  181. response.end(body)
  182. } catch {
  183. // A miss is a miss: the deployment has no SPA fallback, and hiding one
  184. // behind the index page would make a broken asset URL look like a boot
  185. // failure.
  186. response.writeHead(404)
  187. response.end(`not found: ${relative}`)
  188. }
  189. }
  190. /**
  191. * Serve `dist/` over loopback with static-host semantics.
  192. * @param overrides - Generated deployment files used when `dist/` has none.
  193. * @returns The origin to navigate, and its teardown.
  194. */
  195. async function serveDist(overrides: ReadonlyMap<string, string>): Promise<Site> {
  196. const server = createServer((request, response) => { void respond(request, response, overrides) })
  197. await new Promise<void>((listening) => { server.listen(0, '127.0.0.1', listening) })
  198. const address = server.address()
  199. if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
  200. return {
  201. origin: `http://127.0.0.1:${String(address.port)}`,
  202. close: async () => {
  203. server.closeAllConnections()
  204. await new Promise<void>((closed, reject) => {
  205. server.close((error) => {
  206. if (error === undefined) closed()
  207. else reject(error)
  208. })
  209. })
  210. },
  211. }
  212. }
  213. /**
  214. * Bound one boot milestone so a stall names the milestone instead of surfacing
  215. * as the lane's generic test timeout.
  216. * @param work - The milestone to wait for.
  217. * @param ms - How long it may take.
  218. * @param stalled - Error message when it does not arrive in time.
  219. * @returns What `work` resolved to.
  220. */
  221. async function within<T>(work: Promise<T>, ms: number, stalled: string): Promise<T> {
  222. let timer: NodeJS.Timeout | undefined
  223. try {
  224. return await Promise.race([
  225. work,
  226. new Promise<never>((_, reject) => { timer = setTimeout(() => { reject(new Error(stalled)) }, ms) }),
  227. ])
  228. } finally {
  229. clearTimeout(timer)
  230. }
  231. }
  232. it('boots the packed worker deployment to an interactive page', async () => {
  233. requirePreviewPages()
  234. const assets = requireVfsAssets()
  235. try {
  236. const site = await serveDist(assets.overrides)
  237. try {
  238. const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
  239. try {
  240. await bootEmptyPreview(site.origin, browser)
  241. await bootPreview(site.origin, browser)
  242. } finally {
  243. await browser.close()
  244. }
  245. } finally {
  246. await site.close()
  247. }
  248. } finally {
  249. assets.cleanup()
  250. }
  251. }, 600_000)
  252. /**
  253. * Open the preview page and hold it to both boot milestones.
  254. * @param origin - Origin serving `dist/`.
  255. * @param browser - Browser to open the page in.
  256. */
  257. async function bootPreview(origin: string, browser: Browser): Promise<void> {
  258. const page = await newEnglishPage(browser)
  259. const pageErrors: Error[] = []
  260. const consoleErrors: string[] = []
  261. page.on('pageerror', (error) => { pageErrors.push(error) })
  262. // Registered before navigation: the worker reports its tree long before the
  263. // tunnel serves the client, so a listener added later would miss the line.
  264. const treeActive = new Promise<string>((reported) => {
  265. page.on('console', (message) => {
  266. const text = message.text()
  267. if (text.includes(TREE_ACTIVE)) reported(text)
  268. if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text)
  269. })
  270. })
  271. try {
  272. await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' })
  273. await page.getByRole('heading', { name: 'Choose Preview data' }).waitFor()
  274. expect(await page.locator('input[name="preview-source"][value="vfs-example"]').isChecked()).toBe(true)
  275. expect(await page.getByText('Empty environment', { exact: true }).count()).toBe(1)
  276. expect(await page.getByText('WebFS directory', { exact: true }).count()).toBe(1)
  277. expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true)
  278. expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0)
  279. await compareOrRefreshGolden(
  280. SOURCE_CHOOSER_EXPECTED,
  281. await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'),
  282. SNAPSHOT_MODE,
  283. )
  284. await page.getByRole('button', { name: 'Start Preview' }).click()
  285. await page.getByText('Loading plugins…', { exact: true }).waitFor({ timeout: 10_000 })
  286. const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`)
  287. // The activated tree ran bodies lowered against the contract this
  288. // checkout's packer emits; a dist built before a contract change would
  289. // report the older one.
  290. expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
  291. expect(bootLine).toContain('data overlays=1')
  292. // The versioned notice is the seeded preview's first stable interactive
  293. // surface after the startup chain completes over the tunnel.
  294. const continueButton = page.getByRole('button', { name: 'Continue' })
  295. await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS })
  296. await continueButton.click()
  297. const configureLater = page.getByRole('button', { name: 'Configure later' })
  298. await configureLater.waitFor({ timeout: 30_000 })
  299. await configureLater.click()
  300. await page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
  301. .waitFor({ timeout: 30_000 })
  302. const exercised = await page.evaluate(async ({ seededSessionId, seededSessionTitle }) => {
  303. type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } }
  304. interface PreviewTransport {
  305. fetch(input: string, init: RequestInit): Promise<Response>
  306. }
  307. const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: PreviewTransport }).__DSH_TRANSPORT__
  308. if (transport === undefined) throw new Error('preview transport is absent after boot')
  309. const response = await transport.fetch('/api/session/list', {
  310. method: 'POST',
  311. headers: { 'content-type': 'application/json' },
  312. body: JSON.stringify({
  313. type: 'client-request', rpcId: 'preview-session-list', method: 'session/list',
  314. payload: { args: { _request: {} } },
  315. }),
  316. })
  317. const sessions = await response.json() as Result<{ items: Array<{ sessionId: string }> }>
  318. if (!sessions.result.ok) throw new Error(`session/list failed: ${sessions.result.error.message}`)
  319. const sessionId = sessions.result.value.items[0]?.sessionId
  320. if (sessionId === undefined) throw new Error('workspace adoption created no Session')
  321. // Remote namespaces answer over the same unary carrier; the args object
  322. // keys every wire parameter by its name.
  323. const remote = async <T>(endpoint: string, args: object): Promise<T> => {
  324. const answered = await transport.fetch(`/api/${endpoint}`, {
  325. method: 'POST',
  326. headers: { 'content-type': 'application/json' },
  327. body: JSON.stringify({
  328. type: 'client-request', rpcId: `preview-${endpoint.replace('/', '-')}`,
  329. method: endpoint, payload: { args },
  330. }),
  331. })
  332. const body = await answered.json() as Result<T>
  333. if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`)
  334. return body.result.value
  335. }
  336. // Keep the fixture title stable for later UI assertions; increasing seqs
  337. // prove that the cold Session acquired its write lease and appended.
  338. const firstRename = await remote<{ title: string; seq: number }>('session/rename', {
  339. request: { sessionId: seededSessionId, title: seededSessionTitle },
  340. })
  341. const secondRename = await remote<{ title: string; seq: number }>('session/rename', {
  342. request: { sessionId: seededSessionId, title: seededSessionTitle },
  343. })
  344. const skills = await remote<{ skills: Array<{ name: string }> }>(
  345. 'skills/list', { request: { sessionId } },
  346. )
  347. const createDirectory = async (path: string, name: string): Promise<void> => {
  348. await remote<string>('directoryPicker/createDirectory', { path, name })
  349. await new Promise((resolve) => { setTimeout(resolve, 250) })
  350. await remote<{ skills: Array<{ name: string }> }>(
  351. 'skills/list', { request: { sessionId } },
  352. )
  353. }
  354. await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created')
  355. // Settings and credentials both answer over the Remote carrier, so this
  356. // half of the sweep posts the generated endpoints directly like the
  357. // session read above.
  358. const settings = await remote<{ namespaces: { ns: string; revision: number }[] }>(
  359. 'settings/describe', {},
  360. )
  361. const shell = settings.namespaces.find(namespace => namespace.ns === 'shell')
  362. if (shell === undefined) throw new Error('settings/describe omitted the shell namespace')
  363. await remote('settings/update', {
  364. ns: 'shell',
  365. patch: { timeoutMs: 61_000 },
  366. expectedRevision: shell.revision,
  367. })
  368. await remote('credentials/set', { ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' })
  369. const credentials = await remote<Record<string, { configured: boolean }>>(
  370. 'credentials/describe',
  371. { refs: ['PREVIEW_TEST_SECRET'] },
  372. )
  373. await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' })
  374. await new Promise((resolve) => { setTimeout(resolve, 250) })
  375. return {
  376. renamedTitle: secondRename.title,
  377. renameAdvanced: secondRename.seq > firstRename.seq,
  378. skillCount: skills.skills.length,
  379. credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured,
  380. }
  381. }, {
  382. seededSessionId: VFS_EXAMPLE_SESSION_IDS.main,
  383. seededSessionTitle: SHOWCASE_TITLE,
  384. })
  385. expect(exercised.renamedTitle).toBe(SHOWCASE_TITLE)
  386. expect(exercised.renameAdvanced).toBe(true)
  387. expect(exercised.skillCount).toBeGreaterThan(0)
  388. expect(exercised.credentialConfigured).toBe(true)
  389. const sessions = page.getByRole('tree', { name: 'Sessions' })
  390. const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE })
  391. await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1)
  392. await showcase.click()
  393. await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 })
  394. expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0)
  395. await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor()
  396. await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor()
  397. await page.getByText('Update to-do list', { exact: true }).waitFor()
  398. await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor()
  399. const subagents = page.getByRole('button', { name: '2 subagents' })
  400. await subagents.waitFor({ timeout: 15_000 })
  401. await subagents.hover()
  402. const catalog = page.getByRole('tree', { name: 'Subagent sessions' })
  403. await catalog.getByRole('treeitem', { name: /Review preview architecture/ }).waitFor()
  404. await catalog.getByRole('treeitem', { name: /Continue preview verification/ }).waitFor()
  405. await catalog.press('Escape')
  406. await page.getByRole('button', { name: 'Load earlier', exact: true }).click()
  407. await page.getByText(SHOWCASE_OLDEST, { exact: true }).waitFor({ timeout: 15_000 })
  408. expect(pageErrors.map(error => error.message)).toEqual([])
  409. expect(consoleErrors.filter(line =>
  410. /watchFile|failed to watch|node-addon-system\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([])
  411. } catch (error) {
  412. await saveFailureShot(page, 'preview-boot')
  413. throw pageErrors.length === 0
  414. ? error
  415. : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors')
  416. }
  417. }
  418. /** Verify the chooser can boot the untouched base image and reach first-run UI. */
  419. async function bootEmptyPreview(origin: string, browser: Browser): Promise<void> {
  420. const page = await newEnglishPage(browser)
  421. const pageErrors: Error[] = []
  422. const consoleErrors: string[] = []
  423. const failedResponses: string[] = []
  424. page.on('pageerror', (error) => { pageErrors.push(error) })
  425. page.on('response', (response) => {
  426. if (response.status() >= 400) failedResponses.push(new URL(response.url()).pathname)
  427. })
  428. const treeActive = new Promise<string>((reported) => {
  429. page.on('console', (message) => {
  430. const text = message.text()
  431. if (text.includes(TREE_ACTIVE)) reported(text)
  432. if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text)
  433. })
  434. })
  435. try {
  436. await page.goto(`${origin}/preview.html?preview-fixture=none`, { waitUntil: 'domcontentloaded' })
  437. expect(await page.getByRole('heading', { name: '选择 Preview 数据源' }).count()).toBe(0)
  438. const bootLine = await within(
  439. treeActive,
  440. BOOT_TIMEOUT_MS,
  441. `empty preview boot: the worker never reported "${TREE_ACTIVE}"`,
  442. )
  443. expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
  444. expect(bootLine).toContain('data overlays=0')
  445. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
  446. const sessionCount = await page.evaluate(async () => {
  447. const transport = (globalThis as typeof globalThis & {
  448. __DSH_TRANSPORT__?: { fetch(input: string, init: RequestInit): Promise<Response> }
  449. }).__DSH_TRANSPORT__
  450. if (transport === undefined) throw new Error('empty preview transport is absent after boot')
  451. const response = await transport.fetch('/api/session/list', {
  452. method: 'POST',
  453. headers: { 'content-type': 'application/json' },
  454. body: JSON.stringify({
  455. type: 'client-request', rpcId: 'empty-preview-session-list', method: 'session/list',
  456. payload: { args: { _request: {} } },
  457. }),
  458. })
  459. const body = await response.json() as {
  460. result: { ok: true; value: { items: unknown[] } } | { ok: false; error: { message: string } }
  461. }
  462. if (!body.result.ok) throw new Error(`empty session/list failed: ${body.result.error.message}`)
  463. return body.result.value.items.length
  464. })
  465. expect(sessionCount).toBe(0)
  466. expect(pageErrors.map(error => error.message)).toEqual([])
  467. // Two accepted static-host 404s, sorted (the boot fetches race): the HMR
  468. // event stream has no server here, and the open-in-app availability read
  469. // has no host routes — the controller publishes an empty list and the
  470. // header renders no button, which is that surface's designed degradation.
  471. expect([...failedResponses].sort()).toEqual(['/open-in-app/apps', '/plugins/events'])
  472. expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404')))
  473. .toEqual([])
  474. } catch (error) {
  475. await saveFailureShot(page, 'preview-boot-empty')
  476. throw pageErrors.length === 0
  477. ? error
  478. : new AggregateError([error, ...pageErrors], 'empty preview boot failed, with uncaught page errors')
  479. } finally {
  480. await page.close()
  481. }
  482. }