smoke-fixture.e2e.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. // Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
  2. // graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
  3. // chromium. First describe: graph injection + the fail-loud half. Second
  4. // describe: the settled success pass — all nine REAL tsdown bundles load
  5. // through the module system + vendored Loader chain in ?fixture mode (the
  6. // infrastructure four ride the immediately prefetch tier, the UI rows fetch
  7. // on demand), the three-column frame appears in one flip, and the resident
  8. // question completes through the real UI stack. The full model round lands
  9. // in smoke-real under the W5 real-host standard.
  10. import { existsSync } from 'node:fs'
  11. import { fileURLToPath } from 'node:url'
  12. import type { Browser, Page } from 'playwright'
  13. import { chromium } from 'playwright'
  14. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  15. import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
  16. import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver'
  17. import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
  18. const bundlePath = (dir: string): string =>
  19. fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
  20. const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
  21. const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
  22. /** id ↔ bundle table for the success pass (the complete Web UI assembly). */
  23. const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
  24. { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true },
  25. { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
  26. { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true },
  27. { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
  28. { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
  29. { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
  30. { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
  31. { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
  32. { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
  33. ]
  34. const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
  35. const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
  36. ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
  37. const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
  38. ...(p.inject !== undefined ? { inject: p.inject } : {}),
  39. ...(p.immediately === true ? { immediately: true } : {}),
  40. }))
  41. /** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
  42. const FAIL_GRAPH: WebBootGraph = {
  43. rev: 'e2e-fail',
  44. entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
  45. }
  46. /** Graph for the success pass: the complete assembly. */
  47. const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
  48. /** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
  49. function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
  50. return {
  51. graph: () => graph,
  52. clientPath: (id: string) => byId.get(id),
  53. onRebuilt: () => () => undefined,
  54. }
  55. }
  56. describe('web boot chain (keyless, real carrier)', () => {
  57. let server: Awaited<ReturnType<typeof startWebServer>>
  58. let browser: Browser
  59. let page: Page
  60. const pageErrors: string[] = []
  61. beforeAll(async () => {
  62. requireDist()
  63. const port = await probeFreePort()
  64. const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
  65. server = await startWebServer({
  66. host: '127.0.0.1',
  67. port,
  68. distIndex: DIST_INDEX,
  69. apiHandler,
  70. webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
  71. }, (err) => { pageErrors.push(`server: ${String(err)}`) })
  72. browser = await chromium.launch()
  73. page = await browser.newPage()
  74. page.on('pageerror', e => pageErrors.push(String(e)))
  75. await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
  76. })
  77. afterAll(async () => {
  78. await browser?.close()
  79. await server?.close()
  80. })
  81. it('GET / injects the entry graph verbatim', async () => {
  82. onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
  83. const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
  84. expect(boot).toEqual(FAIL_GRAPH)
  85. })
  86. it('serves a real bundle through the plugins endpoint', async () => {
  87. const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
  88. expect(res.status()).toBe(200)
  89. expect(await res.text()).toContain('window.__ModuleLoader__.load')
  90. })
  91. it('boots to the loading page and fail-louds the absent entry', async () => {
  92. onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
  93. await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
  94. await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
  95. await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
  96. // The real UI must not have flipped in: the gate opens only on settled.
  97. expect(await page.locator('[class*="frame"]').count()).toBe(0)
  98. })
  99. it('applies the token sheets before any plugin CSS', async () => {
  100. const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
  101. expect(family.trim().length).toBeGreaterThan(0)
  102. })
  103. })
  104. describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
  105. let server: Awaited<ReturnType<typeof startWebServer>>
  106. let browser: Browser
  107. let page: Page
  108. const pageErrors: string[] = []
  109. beforeAll(async () => {
  110. requireDist()
  111. const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
  112. if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
  113. const port = await probeFreePort()
  114. // ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
  115. const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
  116. server = await startWebServer({
  117. host: '127.0.0.1',
  118. port,
  119. distIndex: DIST_INDEX,
  120. apiHandler,
  121. webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
  122. }, (err) => { pageErrors.push(`server: ${String(err)}`) })
  123. browser = await chromium.launch()
  124. page = await browser.newPage()
  125. page.on('pageerror', e => pageErrors.push(String(e)))
  126. await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
  127. })
  128. afterAll(async () => {
  129. await browser?.close()
  130. await server?.close()
  131. })
  132. it('settles and flips to the three-column frame in one pass', async () => {
  133. onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
  134. await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
  135. // Loading page is gone; the grid carries the three tracks.
  136. expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
  137. const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
  138. expect(template.split(' ').length).toBe(3)
  139. })
  140. it('every plugin CSS landed with its ownership tag', async () => {
  141. const owners = await page.evaluate(() =>
  142. [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
  143. expect(owners).toContain(LAYOUT_ID)
  144. expect(owners).toContain(SIDEBAR_ID)
  145. })
  146. it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
  147. onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
  148. const frame = page.locator('[class*="frame"]')
  149. const firstTrack = async (): Promise<string> => (await frame.evaluate(
  150. el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
  151. // The tracks transition on the deepsuite curve; assert the animated
  152. // settle rather than an instant jump.
  153. const settledTrack = async (px: string): Promise<void> => {
  154. await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
  155. }
  156. // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome.
  157. const brand = () => page.locator('[class*="brand"]').count()
  158. await page.getByRole('button', { name: 'Collapse sidebar' }).click()
  159. // Mid-collapse the wide chrome is still mounted, fading — not swapped out.
  160. expect(await brand()).toBe(1)
  161. await settledTrack('56px')
  162. await expect.poll(brand, { timeout: 2000 }).toBe(0)
  163. for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
  164. await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
  165. }
  166. await page.getByRole('button', { name: 'Open sidebar' }).click()
  167. await settledTrack('280px')
  168. await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
  169. // Rail search: collapse again, the search control expands and lands in the box.
  170. await page.getByRole('button', { name: 'Collapse sidebar' }).click()
  171. await settledTrack('56px')
  172. await page.getByRole('button', { name: 'Search sessions' }).click()
  173. await settledTrack('280px')
  174. // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it.
  175. await expect.poll(() => page.evaluate(() =>
  176. (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search')
  177. })
  178. it('renders file tool rows and expands fixture reasoning from either click target', async () => {
  179. onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure'))
  180. await page.locator('[role="treeitem"]').first().click()
  181. await page.locator('[role="treeitem"][aria-selected]').first().click()
  182. const thinkRoot = page.locator('[data-variant="think"]').first()
  183. const think = thinkRoot.getByRole('button')
  184. await think.waitFor({ state: 'visible', timeout: 10_000 })
  185. expect(await think.getAttribute('aria-expanded')).toBe('false')
  186. await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click()
  187. expect(await think.getAttribute('aria-expanded')).toBe('true')
  188. expect(await thinkRoot.locator(':scope > div').count()).toBe(2)
  189. await think.getByText('Think', { exact: true }).click()
  190. expect(await think.getAttribute('aria-expanded')).toBe('false')
  191. const editRoot = page.locator('[data-variant="edit"]').first()
  192. await editRoot.waitFor({ state: 'visible', timeout: 10_000 })
  193. expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1)
  194. expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1)
  195. const writeRoot = page.locator('[data-variant="write"]').first()
  196. await writeRoot.waitFor({ state: 'visible', timeout: 10_000 })
  197. expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1)
  198. expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
  199. })
  200. it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
  201. onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
  202. await page.getByRole('button', { name: 'New session', exact: true }).click()
  203. const input = page.locator('textarea[placeholder]')
  204. await input.waitFor({ timeout: 15_000 })
  205. await input.fill('render markdown')
  206. await page.getByRole('button', { name: '发送' }).click()
  207. const streaming = page.locator('[data-streaming="true"]')
  208. await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
  209. await streaming.waitFor({ state: 'detached', timeout: 15_000 })
  210. const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
  211. expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
  212. expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
  213. const external = page.getByRole('link', { name: 'DeepSeek' })
  214. expect(await external.getAttribute('target')).toBe('_blank')
  215. expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
  216. })
  217. it('renders and completes the resident question through the composer slot', async () => {
  218. onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
  219. const sessionTree = page.getByRole('tree', { name: 'Sessions' })
  220. const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' })
  221. if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
  222. await sessionTree.getByText('Fixture 历史会话', { exact: true }).click()
  223. const composer = page.locator('[data-question-key]')
  224. await composer.waitFor({ timeout: 15_000 })
  225. expect({
  226. question: await composer.getByRole('heading').innerText(),
  227. progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
  228. options: await composer.getByRole('radio').allTextContents(),
  229. custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
  230. }).toMatchInlineSnapshot(`
  231. {
  232. "custom": "其他,请填写自定义答案",
  233. "options": [
  234. "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
  235. "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
  236. "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
  237. ],
  238. "progress": "1 / 3",
  239. "question": "你现在更想招哪类 Agent/Harness 候选人?",
  240. }
  241. `)
  242. await composer.getByRole('radio', { name: '工程落地型' }).click()
  243. await composer.getByText('2 / 3', { exact: true }).waitFor()
  244. await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
  245. await composer.getByRole('checkbox', { name: '系统设计' }).click()
  246. await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
  247. await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
  248. await composer.waitFor({ state: 'detached' })
  249. const restoredInput = page.locator('textarea[placeholder]')
  250. await restoredInput.waitFor()
  251. expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
  252. })
  253. it('stayed clean: no page errors across the whole load chain', () => {
  254. expect(pageErrors).toEqual([])
  255. })
  256. })