preview-boot.e2e.ts 22 KB

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