preview-boot.e2e.ts 22 KB

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