preview-boot.e2e.ts 23 KB

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