smoke-real.e2e.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826
  1. // Real-host smoke: spawn `dsh web` with a real key, walk the full flow
  2. // list in a real chromium, screenshot every screen into .artifacts/ for the
  3. // figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
  4. // convention); vitest.web.config.ts loads the repo-root .env before this file
  5. // runs (the CLI only auto-loads .env from its cwd — a temp dir here, so
  6. // sessions never land in the repo's .sessions).
  7. //
  8. // Selector convention: CSS Modules hash as [hash]_[local], so class-substring
  9. // selectors are unreliable — anchor on data-* attributes (data-variant /
  10. // data-sample) or visible text. The one [class*=] use below
  11. // (frame/handle) rides local names that survive hashing as suffixes; prefer
  12. // data-* for anything new.
  13. //
  14. // Flow order matters: chat rounds first (the bash round reuses the first
  15. // send's session), geometry and theme after, reload recovery last. Tests run
  16. // sequentially in-file.
  17. import type { ChildProcess } from 'node:child_process'
  18. import { spawn } from 'node:child_process'
  19. import { randomUUID } from 'node:crypto'
  20. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  21. import { createServer } from 'node:http'
  22. import { createRequire, SourceMap } from 'node:module'
  23. import { tmpdir } from 'node:os'
  24. import { join } from 'node:path'
  25. import { fileURLToPath, pathToFileURL } from 'node:url'
  26. import type { Browser, Page } from 'playwright'
  27. import { chromium } from 'playwright'
  28. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  29. import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
  30. const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
  31. const comboMapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
  32. function waitForReadyLine(child: ChildProcess): Promise<string> {
  33. return new Promise((resolveReady, reject) => {
  34. let out = ''
  35. const timer = setTimeout(() => { reject(new Error(`dsh web not ready in 90s; output:\n${out}`)) }, 90_000)
  36. const onData = (chunk: Buffer): void => {
  37. out += chunk.toString()
  38. const match = /dsh web: (http:\/\/[^\s]+)/.exec(out)
  39. if (match?.[1] !== undefined) {
  40. clearTimeout(timer)
  41. resolveReady(match[1])
  42. }
  43. }
  44. child.stdout?.on('data', onData)
  45. child.stderr?.on('data', onData)
  46. child.once('exit', (code) => {
  47. clearTimeout(timer)
  48. reject(new Error(`dsh web exited early (code ${code}); output:\n${out}`))
  49. })
  50. })
  51. }
  52. async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Promise<T> {
  53. const response = await fetch(`${baseUrl}/api/${endpoint}`, {
  54. method: 'POST',
  55. headers: { 'content-type': 'application/json' },
  56. body: JSON.stringify({
  57. type: 'client-request',
  58. rpcId: `smoke-${endpoint}`,
  59. method: endpoint,
  60. payload: { args },
  61. }),
  62. })
  63. if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`)
  64. const body = await response.json() as {
  65. result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
  66. }
  67. if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.code}: ${body.result.error.message}`)
  68. return body.result.value
  69. }
  70. /** Read the explicit page cut from a freshly opened Session follow stream. */
  71. async function sessionCursor(baseUrl: string, sessionId: string): Promise<number> {
  72. const socket = new WebSocket(`${baseUrl.replace(/^http/, 'ws')}/api/remote.mux`)
  73. const streamId = `smoke-history-${randomUUID()}`
  74. try {
  75. await new Promise<void>((resolve, reject) => {
  76. const cleanup = (): void => {
  77. socket.removeEventListener('open', opened)
  78. socket.removeEventListener('error', failed)
  79. socket.removeEventListener('close', closed)
  80. }
  81. const opened = (): void => {
  82. cleanup()
  83. resolve()
  84. }
  85. const failed = (): void => {
  86. cleanup()
  87. reject(new Error('session/follow carrier failed before opening'))
  88. }
  89. const closed = (): void => {
  90. cleanup()
  91. reject(new Error('session/follow carrier closed before opening'))
  92. }
  93. socket.addEventListener('open', opened)
  94. socket.addEventListener('error', failed)
  95. socket.addEventListener('close', closed)
  96. })
  97. return await new Promise<number>((resolve, reject) => {
  98. const timer = setTimeout(() => { finish(new Error('session/follow did not publish an opening cursor')) }, 10_000)
  99. const cleanup = (): void => {
  100. clearTimeout(timer)
  101. socket.removeEventListener('message', message)
  102. socket.removeEventListener('error', failed)
  103. socket.removeEventListener('close', closed)
  104. }
  105. const finish = (error: Error | undefined, cursor?: number): void => {
  106. cleanup()
  107. if (error !== undefined) reject(error)
  108. else resolve(cursor ?? -1)
  109. }
  110. const message = (event: MessageEvent<unknown>): void => {
  111. try {
  112. if (typeof event.data !== 'string') throw new Error('session/follow published a non-text frame')
  113. const frame: unknown = JSON.parse(event.data)
  114. if (!isRecord(frame) || frame.streamId !== streamId) return
  115. if (frame.type === 'error') {
  116. finish(new Error(`session/follow failed: ${JSON.stringify(frame.error)}`))
  117. return
  118. }
  119. if (frame.type === 'end') {
  120. finish(new Error('session/follow ended before its opening cursor'))
  121. return
  122. }
  123. const value = frame.value
  124. if (frame.type === 'item' && isRecord(value)
  125. && value.type === 'snapshot' && Number.isSafeInteger(value.cursor)) {
  126. finish(undefined, value.cursor as number)
  127. }
  128. } catch (error) {
  129. finish(error instanceof Error ? error : new Error(String(error)))
  130. }
  131. }
  132. const failed = (): void => { finish(new Error('session/follow carrier failed before its opening cursor')) }
  133. const closed = (): void => { finish(new Error('session/follow carrier closed before its opening cursor')) }
  134. socket.addEventListener('message', message)
  135. socket.addEventListener('error', failed)
  136. socket.addEventListener('close', closed)
  137. socket.send(JSON.stringify({
  138. type: 'open',
  139. streamId,
  140. endpoint: 'session/follow',
  141. payload: { args: { request: { address: { kind: 'session', sessionId } } } },
  142. }))
  143. })
  144. } finally {
  145. socket.close()
  146. }
  147. }
  148. interface HistoryPage {
  149. events: { event: { type: string; data: unknown } }[]
  150. hasMore: boolean
  151. }
  152. function isRecord(value: unknown): value is Record<string, unknown> {
  153. return typeof value === 'object' && value !== null
  154. }
  155. function providerTitle(page: HistoryPage): string | undefined {
  156. for (let index = page.events.length - 1; index >= 0; index--) {
  157. const event = page.events[index]!.event
  158. if (event.type !== 'session/title' || !isRecord(event.data)) continue
  159. const source = event.data.source
  160. if (typeof event.data.title === 'string' && isRecord(source) && source.kind === 'provider') {
  161. return event.data.title
  162. }
  163. }
  164. return undefined
  165. }
  166. function hasAssistantMarker(page: HistoryPage, marker: string): boolean {
  167. return page.events.some(({ event }) => {
  168. if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) return false
  169. const content = event.data.message.content
  170. if (!Array.isArray(content)) return false
  171. return content.some(block =>
  172. isRecord(block) && block.type === 'text' && typeof block.text === 'string' && block.text.includes(marker))
  173. })
  174. }
  175. async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
  176. const throughSeq = await sessionCursor(baseUrl, sessionId)
  177. return remoteRpc<HistoryPage>(baseUrl, 'session/page', {
  178. request: { address: { kind: 'session', sessionId }, throughSeq, maxMessages: 10 },
  179. })
  180. }
  181. async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise<string> {
  182. let observed: string | undefined
  183. await expect.poll(async () => {
  184. observed = providerTitle(await history(baseUrl, sessionId))
  185. return observed
  186. }, { timeout: 90_000 }).toEqual(expect.any(String))
  187. if (observed === undefined) throw new Error('provider-backed session title was not observed')
  188. return observed
  189. }
  190. async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker: string): Promise<void> {
  191. await expect.poll(async () => hasAssistantMarker(await history(baseUrl, sessionId), marker), {
  192. timeout: 120_000,
  193. }).toBe(true)
  194. }
  195. /** Find one real source location through a served indexed map. */
  196. function firstMappedSource(script: string, payload: ConstructorParameters<typeof SourceMap>[0]): string | undefined {
  197. const consumer = new SourceMap(payload)
  198. const lines = script.split('\n')
  199. for (let line = 0; line < lines.length; line++) {
  200. const lastColumn = Math.min(lines[line]!.length, 512)
  201. for (let column = 0; column <= lastColumn; column++) {
  202. const entry = consumer.findEntry(line, column)
  203. if (!('originalSource' in entry) || typeof entry.originalSource !== 'string') continue
  204. if (entry.originalSource.startsWith('/packages/') && entry.originalSource.includes('/src/')) {
  205. return entry.originalSource
  206. }
  207. }
  208. }
  209. return undefined
  210. }
  211. /** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
  212. async function screen(page: Page, name: string): Promise<void> {
  213. await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
  214. }
  215. /** First column track (px string) of the frame grid. */
  216. async function firstTrack(page: Page): Promise<string> {
  217. return (await page.locator('[class*="frame"]').evaluate(
  218. el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
  219. }
  220. /** Last column track (details) as a number of pixels. */
  221. async function detailsTrack(page: Page): Promise<number> {
  222. const cols = await page.locator('[class*="frame"]').evaluate(
  223. el => getComputedStyle(el).gridTemplateColumns)
  224. return Number(cols.split(' ').pop()!.replace('px', ''))
  225. }
  226. // Readiness gate: `dsh web` serves every production manifest plugin; until every UI
  227. // plugin's client bundle exists and exports apply, the loader fail-louds and
  228. // the frame never appears.
  229. const UI_PLUGIN_DIRS = [
  230. 'connection', 'ui-theme', 'locale', 'ui-layout', 'ui-renderer', 'ui-session', 'ui-sidebar',
  231. 'ui-settings', 'ui-settings-general', 'ui-settings-models', 'ui-conversation', 'ui-approval', 'ui-chat',
  232. 'ui-model-selection', 'ui-user-questions', 'ui-trajectory', '../session-query/session-log-export',
  233. ]
  234. const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
  235. const notReady = UI_PLUGIN_DIRS.filter((dir) => {
  236. const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
  237. return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
  238. })
  239. if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
  240. describe('dsh web keyless CLI smoke', () => {
  241. it('serves a usable app from two immutable plugin batches', async () => {
  242. requireDist()
  243. const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-'))
  244. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
  245. const child = spawn(
  246. process.execPath,
  247. ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'],
  248. {
  249. cwd: sessionsDir,
  250. env: {
  251. ...process.env,
  252. DEEPSEEK_API_KEY: 'keyless-web-no-call',
  253. DSH_HOME: join(sessionsDir, '.dsh'),
  254. DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
  255. TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
  256. },
  257. stdio: ['ignore', 'pipe', 'pipe'],
  258. },
  259. )
  260. let browser: Browser | undefined
  261. try {
  262. const readyUrl = await waitForReadyLine(child)
  263. expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
  264. expect((await fetch(readyUrl)).status).toBe(200)
  265. browser = await chromium.launch({ headless: true })
  266. const page = await newEnglishPage(browser)
  267. const pluginScripts: string[] = []
  268. const cacheHeaders = new Map<string, string | undefined>()
  269. // Chromium reports `preload as=script` as Script and reuses that same
  270. // request when the matching script node executes; this count pins both.
  271. page.on('request', (request) => {
  272. const url = new URL(request.url())
  273. const resource = `${url.pathname}${url.search}`
  274. if (request.resourceType() === 'script' && resource.startsWith('/plugins/??')) {
  275. pluginScripts.push(resource)
  276. }
  277. })
  278. page.on('response', (response) => {
  279. const url = new URL(response.url())
  280. const resource = `${url.pathname}${url.search}`
  281. if (resource.startsWith('/plugins/??')) {
  282. cacheHeaders.set(resource, response.headers()['cache-control'])
  283. }
  284. })
  285. await page.goto(readyUrl)
  286. await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 })
  287. const batchPaths = [...new Set(pluginScripts)].sort()
  288. expect(batchPaths).toHaveLength(2)
  289. expect(batchPaths).toContainEqual(expect.stringMatching(
  290. /^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/,
  291. ))
  292. expect(batchPaths).toContainEqual(expect.stringMatching(
  293. /^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/,
  294. ))
  295. expect([...cacheHeaders.values()]).toEqual([
  296. 'public, max-age=31536000, immutable',
  297. 'public, max-age=31536000, immutable',
  298. ])
  299. for (const path of batchPaths) {
  300. const [scriptResponse, mapResponse] = await Promise.all([
  301. fetch(`${readyUrl}${path}`),
  302. fetch(`${readyUrl}${comboMapUrl(path)}`),
  303. ])
  304. expect(scriptResponse.status).toBe(200)
  305. expect(mapResponse.status).toBe(200)
  306. const script = await scriptResponse.text()
  307. const payload = await mapResponse.json() as ConstructorParameters<typeof SourceMap>[0]
  308. const sections = (payload as unknown as {
  309. sections: { map: { sources?: unknown[]; sourcesContent?: unknown[] } }[]
  310. }).sections
  311. expect(sections.every(section => (
  312. Array.isArray(section.map.sources)
  313. && Array.isArray(section.map.sourcesContent)
  314. && section.map.sourcesContent.length === section.map.sources.length
  315. && section.map.sourcesContent.every(source => typeof source === 'string')
  316. ))).toBe(true)
  317. expect(firstMappedSource(script, payload)).toMatch(/^\/packages\/.+\/src\//)
  318. }
  319. } finally {
  320. await browser?.close()
  321. const closed = child.exitCode === null
  322. ? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
  323. : Promise.resolve()
  324. if (child.exitCode === null) child.kill('SIGTERM')
  325. await closed
  326. rmSync(sessionsDir, { recursive: true, force: true })
  327. }
  328. })
  329. it('routes web runtime context and workspace instructions through the real CLI request', async () => {
  330. requireDist()
  331. const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
  332. mkdirSync(join(workspace, '.git'))
  333. writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
  334. interface NativeProviderRequest {
  335. messages?: { role?: string; content?: string }[]
  336. tools?: { function?: { name?: string } }[]
  337. }
  338. let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void
  339. const requests: NativeProviderRequest[] = []
  340. const providerRequests = new Promise<NativeProviderRequest[]>((resolve) => {
  341. resolveProviderRequests = resolve
  342. })
  343. const provider = createServer((request, response) => {
  344. let body = ''
  345. request.setEncoding('utf8')
  346. request.on('data', (chunk: string) => { body += chunk })
  347. request.on('end', () => {
  348. const parsed = JSON.parse(body) as NativeProviderRequest
  349. if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed)
  350. if (requests.length === 1) resolveProviderRequests(requests)
  351. response.writeHead(200, { 'content-type': 'text/event-stream' })
  352. response.end([
  353. 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
  354. 'data: {"choices":[{"delta":{"content":"done"}}]}',
  355. 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
  356. 'data: [DONE]',
  357. '',
  358. ].join('\n\n'))
  359. })
  360. })
  361. await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
  362. const address = provider.address()
  363. if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
  364. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
  365. const child = spawn(
  366. process.execPath,
  367. ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'],
  368. {
  369. cwd: workspace,
  370. env: {
  371. ...process.env,
  372. DEEPSEEK_API_KEY: 'keyless-web-workspace',
  373. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  374. DSH_HOME: join(workspace, '.dsh'),
  375. DSH_AGENTS_HOME: join(workspace, '.agents'),
  376. TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
  377. },
  378. stdio: ['ignore', 'pipe', 'pipe'],
  379. },
  380. )
  381. try {
  382. const baseUrl = await waitForReadyLine(child)
  383. const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} })
  384. await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: {
  385. requestId: randomUUID(),
  386. sessionId: created.sessionId,
  387. mode: 'queue',
  388. content: [{ type: 'text', text: 'go' }],
  389. } })
  390. const capturedRequests = await Promise.race([
  391. providerRequests,
  392. new Promise<never>((_resolve, reject) => {
  393. setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
  394. }),
  395. ])
  396. const captured = capturedRequests[0]
  397. if (captured === undefined) {
  398. throw new Error('provider did not receive the workspace projection request')
  399. }
  400. const workspaceMessage = captured.messages?.find(message =>
  401. message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
  402. const systemMessage = captured.messages?.find(message => message.role === 'system')
  403. const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
  404. .replace('{{webUrl}}', baseUrl)
  405. expect(systemMessage?.content).toContain(expectedWebSection)
  406. expect(workspaceMessage).toMatchInlineSnapshot(`
  407. {
  408. "content": "<system-reminder>
  409. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
  410. Instructions from: AGENTS.md
  411. web-workspace-context-probe
  412. </system-reminder>",
  413. "role": "user",
  414. }
  415. `)
  416. expect(captured.tools?.map(tool => tool.function?.name)
  417. .filter(name => name === 'web_search' || name === 'web_fetch'))
  418. .toMatchInlineSnapshot(`
  419. [
  420. "web_search",
  421. ]
  422. `)
  423. } finally {
  424. const closed = child.exitCode === null
  425. ? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
  426. : Promise.resolve()
  427. if (child.exitCode === null) child.kill('SIGTERM')
  428. await closed
  429. await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
  430. rmSync(workspace, { recursive: true, force: true })
  431. }
  432. })
  433. it('retries a partial transport failure through the shipped Web composition', async () => {
  434. requireDist()
  435. const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
  436. const promptMarker = 'WEB_RETRY_REQUEST'
  437. const recoveredMarker = 'WEB_RETRY_RECOVERED'
  438. let mainAttempts = 0
  439. const provider = createServer((request, response) => {
  440. let body = ''
  441. request.setEncoding('utf8')
  442. request.on('data', (chunk: string) => { body += chunk })
  443. request.on('end', () => {
  444. const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
  445. const titleRequest = parsed.max_tokens === 64
  446. const mainRequest = !titleRequest && body.includes(promptMarker)
  447. response.writeHead(200, { 'content-type': 'text/event-stream' })
  448. if (!mainRequest) {
  449. response.end([
  450. 'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
  451. 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
  452. 'data: [DONE]',
  453. '',
  454. ].join('\n\n'))
  455. return
  456. }
  457. mainAttempts++
  458. if (mainAttempts === 1) {
  459. response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
  460. setTimeout(() => { response.destroy() }, 20)
  461. return
  462. }
  463. response.end([
  464. `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
  465. 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
  466. 'data: [DONE]',
  467. '',
  468. ].join('\n\n'))
  469. })
  470. })
  471. await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
  472. const address = provider.address()
  473. if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
  474. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
  475. const child = spawn(
  476. process.execPath,
  477. ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'],
  478. {
  479. cwd: workspace,
  480. env: {
  481. ...process.env,
  482. DEEPSEEK_API_KEY: 'keyless-web-retry',
  483. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  484. DSH_HOME: join(workspace, '.dsh'),
  485. TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
  486. },
  487. stdio: ['ignore', 'pipe', 'pipe'],
  488. },
  489. )
  490. try {
  491. const baseUrl = await waitForReadyLine(child)
  492. const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} })
  493. await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: {
  494. requestId: randomUUID(),
  495. sessionId: created.sessionId,
  496. mode: 'queue',
  497. content: [{ type: 'text', text: promptMarker }],
  498. } })
  499. let page: HistoryPage | undefined
  500. await expect.poll(async () => {
  501. page = await history(baseUrl, created.sessionId)
  502. return hasAssistantMarker(page, recoveredMarker)
  503. }, { timeout: 20_000 }).toBe(true)
  504. if (page === undefined) throw new Error('retry history was not observed')
  505. const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
  506. expect(mainAttempts).toBe(2)
  507. expect(retry?.data).toMatchObject({
  508. turn: 1,
  509. step: 1,
  510. retry: 1,
  511. maxRetries: 5,
  512. failure: { code: 'TRANSPORT' },
  513. })
  514. expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
  515. } finally {
  516. const closed = child.exitCode === null
  517. ? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
  518. : Promise.resolve()
  519. if (child.exitCode === null) child.kill('SIGTERM')
  520. await closed
  521. await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
  522. rmSync(workspace, { recursive: true, force: true })
  523. }
  524. }, 120_000)
  525. it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
  526. requireDist()
  527. const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
  528. interface CodeModeProviderRequest {
  529. messages?: { role?: string; content?: string }[]
  530. tools?: { function?: { name?: string } }[]
  531. }
  532. let resolveProviderRequest!: (request: CodeModeProviderRequest) => void
  533. const providerRequest = new Promise<CodeModeProviderRequest>((resolve) => {
  534. resolveProviderRequest = resolve
  535. })
  536. const provider = createServer((request, response) => {
  537. let body = ''
  538. request.setEncoding('utf8')
  539. request.on('data', (chunk: string) => { body += chunk })
  540. request.on('end', () => {
  541. resolveProviderRequest(JSON.parse(body) as CodeModeProviderRequest)
  542. response.writeHead(200, { 'content-type': 'text/event-stream' })
  543. response.end([
  544. 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
  545. 'data: {"choices":[{"delta":{"content":"done"}}]}',
  546. 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
  547. 'data: [DONE]',
  548. '',
  549. ].join('\n\n'))
  550. })
  551. })
  552. await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
  553. const address = provider.address()
  554. if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
  555. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
  556. const child = spawn(
  557. process.execPath,
  558. ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'],
  559. {
  560. cwd: workspace,
  561. env: {
  562. ...process.env,
  563. DEEPSEEK_API_KEY: 'keyless-web-code-mode',
  564. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  565. DSH_TOOLS_MODE: 'code',
  566. DSH_HOME: join(workspace, '.dsh'),
  567. DSH_AGENTS_HOME: join(workspace, '.agents'),
  568. TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
  569. },
  570. stdio: ['ignore', 'pipe', 'pipe'],
  571. },
  572. )
  573. try {
  574. const baseUrl = await waitForReadyLine(child)
  575. const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} })
  576. await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: {
  577. requestId: randomUUID(),
  578. sessionId: created.sessionId,
  579. mode: 'queue',
  580. content: [{ type: 'text', text: 'go' }],
  581. } })
  582. const captured = await Promise.race([
  583. providerRequest,
  584. new Promise<never>((_resolve, reject) => {
  585. setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
  586. }),
  587. ])
  588. expect(captured.tools?.map(tool => tool.function?.name)).toEqual(['run_code'])
  589. const system = captured.messages?.find(message => message.role === 'system')
  590. expect(system?.content).toContain('## Writing code for run_code')
  591. expect(system?.content).toContain('declare const tools')
  592. } finally {
  593. const closed = child.exitCode === null
  594. ? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
  595. : Promise.resolve()
  596. if (child.exitCode === null) child.kill('SIGTERM')
  597. await closed
  598. await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
  599. rmSync(workspace, { recursive: true, force: true })
  600. }
  601. })
  602. })
  603. describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key)', () => {
  604. let child: ChildProcess
  605. let sessionsDir: string
  606. let baseUrl: string
  607. let browser: Browser
  608. let page: Page
  609. const pageErrors: string[] = []
  610. beforeAll(async () => {
  611. requireDist()
  612. sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
  613. const port = await probeFreePort()
  614. // tsx boot mirrors the runtime half of the root dsh script. Isolate
  615. // the host-level Harness and shared-agent homes inside the temp world; tsx
  616. // also needs the repo's loader and tsconfig paths pointed at explicitly.
  617. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
  618. child = spawn(
  619. process.execPath,
  620. [
  621. '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web',
  622. // Launcher flags come first: the first token the launcher does not own
  623. // starts the web app's own arguments.
  624. // Pin the in-browser picker: the shipped `-auto` row would resolve to
  625. // the native OS chooser on this bind, and no page can drive that.
  626. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
  627. '--no-open',
  628. '--port', String(port),
  629. ],
  630. {
  631. cwd: sessionsDir,
  632. env: {
  633. ...process.env,
  634. DSH_HOME: join(sessionsDir, '.dsh'),
  635. DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
  636. TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
  637. },
  638. stdio: ['ignore', 'pipe', 'pipe'],
  639. },
  640. )
  641. baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
  642. browser = await chromium.launch()
  643. page = await newEnglishPage(browser)
  644. page.on('pageerror', e => pageErrors.push(String(e)))
  645. await page.goto(baseUrl, { waitUntil: 'load' })
  646. }, 120_000)
  647. afterAll(async () => {
  648. await browser?.close()
  649. if (child !== undefined && child.exitCode === null) {
  650. const gone = new Promise<void>(resolveExit => child.once('exit', () => { resolveExit() }))
  651. child.kill('SIGTERM')
  652. await Promise.race([gone, new Promise(r => setTimeout(r, 10_000).unref())])
  653. if (child.exitCode === null) child.kill('SIGKILL')
  654. }
  655. if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
  656. })
  657. it('cold start: loading page settles into the three-column frame', async () => {
  658. onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
  659. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  660. expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
  661. const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
  662. expect(template.split(' ').length).toBe(3)
  663. await screen(page, '01-cold-start')
  664. })
  665. it('empty-state first send completes a real model round', async () => {
  666. onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
  667. // This scenario spawns its own server against a fresh $DSH_HOME with the
  668. // DeepSeek credential inherited from the environment, so no onboarding
  669. // step mounts and the page is immediately interactive.
  670. // Fresh world: connect a Workspace so the composer starts live.
  671. await connectFreshWorkspace(page, sessionsDir)
  672. const input = page.locator('textarea').first()
  673. await input.waitFor({ timeout: 10_000 })
  674. const productTitle = await page.title()
  675. await screen(page, '02-empty-state')
  676. const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.`
  677. await input.fill(prompt)
  678. await input.press('Enter')
  679. // The first send must keep the session tree mounted; a near-empty body
  680. // reveals a duplicate runtime bundle with incompatible scope tags.
  681. await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
  682. expect(pageErrors).toEqual([])
  683. await page.waitForFunction(
  684. expected => document.title !== expected && document.title.endsWith(` — ${expected}`),
  685. productTitle,
  686. { timeout: 15_000 },
  687. )
  688. await expect.poll(async () => (await remoteRpc<{ items: { sessionId: string }[] }>(
  689. baseUrl,
  690. 'session/list',
  691. { _request: {} },
  692. )).items.length, {
  693. timeout: 15_000,
  694. }).toBe(1)
  695. const sessions = await remoteRpc<{ items: { sessionId: string }[] }>(
  696. baseUrl,
  697. 'session/list',
  698. { _request: {} },
  699. )
  700. const sessionId = sessions.items[0]?.sessionId
  701. if (sessionId === undefined) throw new Error('created Web session was not listed')
  702. const durableTitle = await waitForProviderTitle(baseUrl, sessionId)
  703. await page.waitForFunction(
  704. ({ expected, product }) => document.title === `${expected} — ${product}`,
  705. { expected: durableTitle, product: productTitle },
  706. { timeout: 15_000 },
  707. )
  708. const sessionTree = page.getByRole('tree', { name: 'Sessions' })
  709. const projectRow = sessionTree.getByRole('treeitem').first()
  710. if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
  711. await Promise.all([
  712. sessionTree.getByText(durableTitle, { exact: true }).waitFor({ timeout: 10_000 }),
  713. page.getByRole('navigation').getByText(durableTitle, { exact: true }).waitFor({ timeout: 10_000 }),
  714. ])
  715. await waitForAssistantMarker(baseUrl, sessionId, ROUND_DONE_MARKER)
  716. await page.locator('p').filter({ hasText: ROUND_DONE_MARKER }).waitFor({ timeout: 10_000 })
  717. await screen(page, '04-round-complete')
  718. }, 150_000)
  719. it('view tabs: Chat and Trajectory switch', async () => {
  720. onTestFailed(() => saveFailureShot(page, 'w5-tabs'))
  721. await page.locator('button', { hasText: /Trajectory/i }).first().click()
  722. await screen(page, '05-trajectory-tab')
  723. await page.getByLabel('Trajectory timeline').waitFor()
  724. await expect.poll(() => page.getByRole('tab', { name: 'Waterfall' }).count()).toBe(0)
  725. await page.locator('button', { hasText: /^Chat$/i }).first().click()
  726. await screen(page, '07-back-to-chat')
  727. })
  728. it('bash differential rendering: tool row click leaves the default details column closed', async () => {
  729. onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
  730. const input = page.locator('textarea').first()
  731. await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
  732. await input.press('Enter')
  733. // Wait for the tool ROW, not response text (the reply echoes any marker).
  734. // Bash renders through the third-party sample registration. Match that
  735. // exact row: other clickable variants (for example Think disclosure)
  736. // may precede the tool call in document order.
  737. const toolRow = page.locator('[data-sample="bash"]')
  738. await toolRow.waitFor({ timeout: 120_000 })
  739. await screen(page, '08-bash-round')
  740. expect(await detailsTrack(page)).toBe(0)
  741. await toolRow.click()
  742. // Tool rows do not drive layout.openDetails; the default column stays closed.
  743. expect(await detailsTrack(page)).toBe(0)
  744. await screen(page, '09-details-closed')
  745. }, 150_000)
  746. it('sidebar drag widens the column and resets across reload', async () => {
  747. onTestFailed(() => saveFailureShot(page, 'w5-drag'))
  748. const before = await firstTrack(page)
  749. const handle = page.locator('[class*="handle"]').first()
  750. const box = await handle.boundingBox()
  751. expect(box).not.toBeNull()
  752. await page.mouse.move(box!.x + box!.width / 2, box!.y + 300)
  753. await page.mouse.down()
  754. await page.mouse.move(box!.x + 70, box!.y + 300, { steps: 6 })
  755. await page.mouse.up()
  756. const after = await firstTrack(page)
  757. expect(after).not.toBe(before)
  758. await screen(page, '10-sidebar-dragged')
  759. await page.reload({ waitUntil: 'load' })
  760. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  761. expect(await firstTrack(page)).toBe(before)
  762. })
  763. it('dark mode: the body attribute cascades the token sheets', async () => {
  764. onTestFailed(() => saveFailureShot(page, 'w5-dark'))
  765. // The body attribute is the documented cascade mechanism; the Settings
  766. // gesture is owned by settings-chrome.e2e.ts — drive the attribute
  767. // directly here.
  768. const dark = await page.evaluate(() => {
  769. document.body.setAttribute('data-ds-dark-theme', '')
  770. return getComputedStyle(document.body).backgroundColor
  771. })
  772. await screen(page, '11-dark-mode')
  773. const light = await page.evaluate(() => {
  774. document.body.removeAttribute('data-ds-dark-theme')
  775. return getComputedStyle(document.body).backgroundColor
  776. })
  777. expect(dark).not.toBe(light)
  778. })
  779. it('reload recovery: history replays after a fresh boot', async () => {
  780. onTestFailed(() => saveFailureShot(page, 'w5-reload'))
  781. await page.reload({ waitUntil: 'load' })
  782. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  783. await page.locator('p').filter({ hasText: ROUND_DONE_MARKER }).waitFor({ timeout: 30_000 })
  784. await screen(page, '12-reload-recovery')
  785. })
  786. it('stayed clean: no page errors across every flow', () => {
  787. expect(pageErrors).toEqual([])
  788. })
  789. })