clickable-links-gallery.e2e.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. // Web e2e gallery: every clickable link and artifact form the chat renders,
  2. // in one settled keyless turn — the regression anchor for unifying link
  3. // styles. One fixture turn produces:
  4. // - prose: Markdown link, reference-style link, mailto link, inline-code URL,
  5. // produced-file mention, a known-site link carrying that site's own leading
  6. // mark, plus inert contrasts (ambiguous basename, unwritten
  7. // file, command code, URL-with-flags code, javascript: destination,
  8. // footnote superscript, remote image, fenced code block with its copy chrome)
  9. // - artifacts: seven produced files (chips overflow into the "+N" remainder
  10. // and the show-in-folder affordance) with a failed write excluded
  11. // - tool rows: write/edit/str_replace_editor/read file links, a failure row
  12. // without one, and — expanded — the web-search source links (one non-http
  13. // source stays inert) and answer link, the web-fetch URL, the read card's
  14. // inert path label and fold toggle, the diff card's inert path header, the
  15. // grep card's fold-only file headers, a failing bash card's exit status, and
  16. // a generic tool card's IN/OUT surfaces.
  17. // Image thumbnails and subagent rows stay out: the first needs real attachment
  18. // bytes (image-display.expected.e2e.ts owns that route) and the second needs a
  19. // child session (subagent-conversation owns it).
  20. import { fileURLToPath } from 'node:url'
  21. import type { Browser, Page } from 'playwright'
  22. import { chromium } from 'playwright'
  23. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  24. import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  25. import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  26. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  27. import type {} from '@deepseek-ai/dsh-session-title'
  28. import {
  29. assertFixtureInventory,
  30. captureStableAria,
  31. compareOrRefreshGolden,
  32. launchWebScaffold,
  33. seedSession,
  34. watchConsole,
  35. webSnapshotMode,
  36. type WebScaffold,
  37. } from './scaffold.ts'
  38. import { newEnglishPage, saveFailureShot } from './support.ts'
  39. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery', import.meta.url))
  40. const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url))
  41. // The golden holds the show-in-folder affordance; pin the native-opener
  42. // capability so headless Linux CI and desktop developer hosts expose the same
  43. // UI branch.
  44. const OVERLAY = fileURLToPath(new URL('./clickable-links-gallery.overlay.yml', import.meta.url))
  45. const MODE = webSnapshotMode()
  46. const SEED_ID = 'clickable-links-gallery-web-e2e'
  47. const DONE = 'LINK_GALLERY_DONE'
  48. // Seeded events and browser time share a day independently of host timezones.
  49. const GALLERY_TIME = Date.UTC(2026, 0, 15, 12)
  50. const GUIDE_URL = 'https://docs.example.test/guide'
  51. const HTTP_URL = 'http://docs.example.test/plain'
  52. const API_URL = 'https://docs.example.test/api'
  53. const RELEASES_URL = 'https://docs.example.test/releases'
  54. const MAILTO_URL = 'mailto:owner@example.test'
  55. const SOURCE_URL = 'https://docs.example.test/links'
  56. const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec'
  57. const FETCH_URL = 'https://docs.example.test/tokens'
  58. // A mapped host, so the prose pins that the leading glyph is the site's mark
  59. // rather than the globe the unmapped docs host keeps.
  60. const REPO_URL = 'https://github.com/example/link-gallery'
  61. /** One-part text content for a built message. */
  62. function text(value: string): { type: 'text'; text: string }[] {
  63. return [{ type: 'text', text: value }]
  64. }
  65. /** A settled root tool call: its call event arguments plus result content/meta. */
  66. interface GalleryCall {
  67. name: string
  68. args: Record<string, unknown>
  69. result: string
  70. meta?: JsonValue
  71. isError?: true
  72. }
  73. /** The five successful writes; docs/press.md and src/tokens.css join via other tools. */
  74. const WRITES = ['site/report.html', 'a/style.css', 'b/style.css', 'site/index.html', 'site/app.js']
  75. /** The gallery turn's tool calls, in surface order. */
  76. const CALLS: GalleryCall[] = [
  77. ...WRITES.map(path => ({
  78. name: 'write',
  79. args: { file_path: path, content: `content of ${path}\n` },
  80. result: `Created ${path}`,
  81. })),
  82. {
  83. name: 'edit',
  84. args: {
  85. file_path: 'src/tokens.css',
  86. old_string: '--inline-code: #EBEEF2;',
  87. new_string: '--inline-code: #F5F5F5;',
  88. },
  89. result: 'Edited src/tokens.css',
  90. meta: {
  91. diffs: [{
  92. path: 'src/tokens.css',
  93. oldText: '--inline-code: #EBEEF2;\n',
  94. newText: '--inline-code: #F5F5F5;\n',
  95. }],
  96. },
  97. },
  98. {
  99. name: 'str_replace_editor',
  100. args: { command: 'create', path: 'docs/press.md', file_text: '# Press kit\n' },
  101. result: 'Created docs/press.md',
  102. meta: { diffs: [{ path: 'docs/press.md', oldText: null, newText: '# Press kit\n' }] },
  103. },
  104. {
  105. name: 'write',
  106. args: { file_path: 'c/broken.css', content: 'nope\n' },
  107. result: 'permission denied: c/broken.css',
  108. isError: true,
  109. },
  110. {
  111. name: 'read',
  112. args: { file_path: 'docs/guide.md' },
  113. // The read card validates the model-facing envelope, not just the meta:
  114. // without <path>/<type>/<content> the row falls back to the generic card.
  115. result: [
  116. '<path>docs/guide.md</path>',
  117. '<type>file</type>',
  118. '<content>',
  119. ...Array.from({ length: 12 }, (_, index) =>
  120. index === 0 ? '# Link style guide' : `guide line ${String(index + 1)}`),
  121. '</content>',
  122. ].join('\n'),
  123. meta: {
  124. path: 'docs/guide.md',
  125. offset: 1,
  126. lines: Array.from({ length: 12 }, (_, index) => ({
  127. number: index + 1,
  128. text: index === 0 ? '# Link style guide' : `guide line ${String(index + 1)}`,
  129. })),
  130. totalLines: 12,
  131. },
  132. },
  133. {
  134. name: 'grep',
  135. args: { pattern: 'linkColor' },
  136. result: 'nine matches across three files',
  137. meta: {
  138. shape: 'matches',
  139. files: ['a/style.css', 'b/style.css', 'src/tokens.css'].map(path => ({
  140. path,
  141. matches: [3, 7, 11].map(lineNumber => ({ lineNumber, line: ' color: var(--linkColor);' })),
  142. })),
  143. truncated: false,
  144. total: 9,
  145. },
  146. },
  147. {
  148. name: 'glob',
  149. args: { pattern: '**/*.css' },
  150. result: 'a/style.css\nb/style.css\nsrc/tokens.css',
  151. meta: { shape: 'paths', paths: ['a/style.css', 'b/style.css', 'src/tokens.css'], truncated: false, total: 3 },
  152. },
  153. {
  154. name: 'bash',
  155. args: { command: 'ls site', description: 'List the built site' },
  156. result: 'report.html\nindex.html\napp.js\n',
  157. },
  158. {
  159. name: 'bash',
  160. args: { command: 'pnpm run lint', description: 'Run the lint gate', workdir: 'site' },
  161. result: 'style.css: unexpected hex literal\n[exit code: 1]',
  162. },
  163. {
  164. name: 'web_search',
  165. args: { queries: ['clickable link styles', 'produced files ui'] },
  166. result: 'Two sources found.',
  167. meta: {
  168. truncated: false,
  169. answer: `Unify links per [the guide](${GUIDE_URL}).`,
  170. sources: [
  171. { url: SOURCE_URL, title: 'Link styles reference', snippet: 'One cursor token, one focus ring.' },
  172. { url: INERT_SOURCE_URL, title: 'Mirror spec (non-http)', snippet: 'A non-http source renders inert.' },
  173. ],
  174. },
  175. },
  176. {
  177. name: 'web_fetch',
  178. args: { url: FETCH_URL },
  179. result: 'Design token reference page.',
  180. meta: { url: FETCH_URL, statusCode: 200, truncated: false },
  181. },
  182. {
  183. name: 'design_tokens_sync',
  184. args: { source: 'design-platform.css', dryRun: false },
  185. result: '{"synced":true,"tokens":12}',
  186. },
  187. ]
  188. /**
  189. * Build the settled gallery turn: every call above plus a link-dense closing prose.
  190. * @param imageUrl - scaffold-hosted image the prose embeds (kept same-origin so
  191. * the tripwire stays clean; the varying origin is tokenized in the golden).
  192. */
  193. function galleryFixture(imageUrl: string): string {
  194. const session = Session.create(SessionId('clickable-links-gallery-source'))
  195. session.append('turn/start', { turn: 1 })
  196. const user = session.append('user/message', createUserMessage({
  197. content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'),
  198. source: { kind: 'user' },
  199. }), { surfaceOp: 'append' })
  200. session.append('session/title', {
  201. title: 'Clickable links gallery',
  202. messageSeqs: [user.seq],
  203. source: { kind: 'fallback' },
  204. })
  205. session.append('step/start', { turn: 1, step: 1 })
  206. const calls = CALLS.map((call, index) => ({
  207. ...call,
  208. callId: ToolCallId(`gallery-${String(index)}`),
  209. argsJson: JSON.stringify(call.args),
  210. }))
  211. session.append('assistant/message', {
  212. stream: [],
  213. turn: 1,
  214. step: 1,
  215. message: createAssistantMessage({
  216. content: calls.map(call => ({
  217. type: 'tool-call' as const,
  218. id: call.callId,
  219. name: call.name,
  220. arguments: call.argsJson,
  221. })),
  222. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  223. }),
  224. }, { surfaceOp: 'append' })
  225. for (const call of calls) {
  226. const source = session.append('tool/call', {
  227. turn: 1,
  228. step: 1,
  229. callId: call.callId,
  230. name: call.name,
  231. arguments: call.argsJson,
  232. })
  233. session.append('tool/result', {
  234. turn: 1,
  235. step: 1,
  236. message: createToolResultMessage({
  237. callId: call.callId,
  238. content: text(call.result),
  239. isError: call.isError === true,
  240. }),
  241. ...(call.meta === undefined ? {} : { meta: call.meta }),
  242. }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
  243. }
  244. session.append('step/end', { turn: 1, step: 1 })
  245. session.append('step/start', { turn: 1, step: 2 })
  246. session.append('assistant/message', {
  247. stream: [],
  248. turn: 1,
  249. step: 2,
  250. message: createAssistantMessage({
  251. content: text([
  252. '## Link gallery',
  253. '',
  254. `Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
  255. + `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
  256. '',
  257. `Preview: [plain HTTP](${HTTP_URL}).`,
  258. '',
  259. `Upstream: [the repository](${REPO_URL}).`,
  260. '',
  261. `Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
  262. '',
  263. 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
  264. '',
  265. `![Token preview](${imageUrl})`,
  266. '',
  267. '```css',
  268. '--inline-code: #F5F5F5;',
  269. '```',
  270. '',
  271. DONE,
  272. '',
  273. `[rel]: ${RELEASES_URL}`,
  274. '',
  275. '[^1]: Footnote references stay inert superscripts.',
  276. ].join('\n')),
  277. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  278. }),
  279. }, { surfaceOp: 'append' })
  280. session.append('step/end', { turn: 1, step: 2 })
  281. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  282. return [
  283. JSON.stringify({
  284. type: 'session',
  285. version: SESSION_FORMAT_VERSION,
  286. id: '{{sessionId}}',
  287. createdAt: 0,
  288. cwd: '{{cwd}}',
  289. isSeeded: false,
  290. delegationDepth: 0,
  291. }),
  292. ...session.snapshotEvents().map(event => JSON.stringify({
  293. ...event,
  294. time: GALLERY_TIME + event.seq * 1_000,
  295. })),
  296. '',
  297. ].join('\n')
  298. }
  299. describe('web e2e: clickable links gallery', () => {
  300. let scaffold: WebScaffold
  301. let browser: Browser
  302. let page: Page
  303. let imageUrl: string
  304. let tripwire: ReturnType<typeof watchConsole>
  305. beforeAll(async () => {
  306. scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
  307. imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString()
  308. await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME })
  309. browser = await chromium.launch()
  310. page = await newEnglishPage(browser)
  311. await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({
  312. contentType: 'text/html',
  313. body: `<h1>${new URL(route.request().url()).pathname}</h1>`,
  314. }))
  315. await page.clock.setFixedTime(GALLERY_TIME + 60_000)
  316. tripwire = watchConsole(page)
  317. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  318. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  319. }, 120_000)
  320. afterAll(async () => {
  321. await browser?.close()
  322. await scaffold?.close()
  323. })
  324. it.skipIf(MODE === 'record')('renders every clickable link and artifact form of the settled turn', async () => {
  325. onTestFailed(() => saveFailureShot(page, 'web-e2e-clickable-links-gallery'))
  326. const groupRow = page.locator('[role="treeitem"]').first()
  327. await groupRow.waitFor({ timeout: 15_000 })
  328. await groupRow.click()
  329. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  330. await sessionRow.waitFor({ timeout: 10_000 })
  331. await sessionRow.click()
  332. await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  333. // Markdown prose: plain, reference-style, and mailto links plus the
  334. // inline-code URL are anchors; the URL-with-flags code, the javascript:
  335. // destination, and plain command code stay inert.
  336. const markdown = page.locator('[class*="markdown"]')
  337. await expect.poll(() => markdown.locator(`a[href="${GUIDE_URL}"]`).count(), { timeout: 10_000 }).toBe(1)
  338. expect(await markdown.locator(`a[href="${RELEASES_URL}"]`).count()).toBe(1)
  339. expect(await markdown.locator(`a[href="${HTTP_URL}"]`).count()).toBe(1)
  340. expect(await markdown.locator(`a[href="${MAILTO_URL}"]`).count()).toBe(1)
  341. const inlineCodeLink = markdown.locator(`code a[href="${API_URL}"]`)
  342. expect(await inlineCodeLink.count()).toBe(1)
  343. expect(await inlineCodeLink.getAttribute('target')).toBe('_blank')
  344. expect(await page.getByText(`curl ${API_URL}`, { exact: true }).locator('a').count()).toBe(0)
  345. expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0)
  346. expect(await markdown.locator(`img[src="${imageUrl}"]`).count()).toBe(1)
  347. // Produced files: one unique-basename mention links; the shared basename
  348. // and the unwritten file stay inert code. The seeded session carries no
  349. // recorded change summary, so no changed-files card follows the prose.
  350. const mentions = markdown.locator('code button')
  351. expect(await mentions.count()).toBe(1)
  352. expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
  353. expect(await page.locator('[data-changed-files]').count()).toBe(0)
  354. expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0)
  355. // Tool rows: five writes, the edit, and the read carry the dotted file
  356. // link; the failed write row does not, and neither does str_replace_editor
  357. // — TOOL_VARIANTS has no entry for it, so it falls to the generic row with
  358. // no openable path even though its create still joins the produced chips.
  359. expect(await page.locator('button[class*="fileLink"]').count()).toBe(7)
  360. // Expanded cards. The turn-process group collapses a multi-call turn, so
  361. // it opens first. Rows expand via a right-edge click: the row center can
  362. // land on the nested fileLink button, which would hand the path to the
  363. // Host's opener.
  364. await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click()
  365. for (const row of [
  366. /^Search clickable link styles/,
  367. /^Fetch /,
  368. /^Read docs\/guide\.md/,
  369. /^Edit src\/tokens\.css/,
  370. /^Grep linkColor/,
  371. /Run the lint gate|pnpm run lint/,
  372. ]) {
  373. const toggle = page.getByRole('button', { name: row }).first()
  374. await toggle.waitFor({ timeout: 10_000 })
  375. const box = await toggle.boundingBox()
  376. await toggle.click(box === null ? {} : { position: { x: box.width - 8, y: box.height / 2 } })
  377. }
  378. // Both generic rows (the unclassified str_replace_editor and the unknown
  379. // design_tokens_sync) expand to their IN/OUT surfaces.
  380. const genericRows = page.getByRole('button', { name: /^Tool call/ })
  381. for (let index = 0; index < await genericRows.count(); index += 1) {
  382. const toggle = genericRows.nth(index)
  383. const box = await toggle.boundingBox()
  384. await toggle.click(box === null ? {} : { position: { x: box.width - 8, y: box.height / 2 } })
  385. }
  386. const sourceLink = page.locator(`a[href="${SOURCE_URL}"]`)
  387. await expect.poll(() => sourceLink.count(), { timeout: 10_000 }).toBe(1)
  388. expect(await page.locator('a[href^="ftp:"]').count()).toBe(0)
  389. expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(1)
  390. expect(await page.locator(`a[href="${GUIDE_URL}"]`).count()).toBe(2)
  391. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  392. .split(SEED_ID).join('{{seededId}}')
  393. .split(imageUrl).join('{{imageUrl}}')
  394. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  395. expect(tripwire.pageErrors).toEqual([])
  396. expect(tripwire.warnings).toEqual([])
  397. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
  398. // The link language itself — ARIA records none of it, so pin the computed
  399. // styles: link-blue 500-weight text, no underline at rest, dotted underline
  400. // on hover, and a leading currentColor glyph. Light theme, so the link
  401. // alias resolves to deepseek-500.
  402. const LINK_BLUE = 'rgb(65, 118, 230)'
  403. const styleOf = async (target: ReturnType<Page['locator']>, property: string): Promise<string> =>
  404. target.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), property)
  405. const guideLink = markdown.locator(`a[href="${GUIDE_URL}"]`).first()
  406. for (const [name, link] of [
  407. ['markdown anchor', guideLink],
  408. ['file mention', mentions.first()],
  409. ['search source', sourceLink.first()],
  410. ['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()],
  411. ] as const) {
  412. expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE)
  413. expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500')
  414. expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none')
  415. expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1)
  416. }
  417. // A mapped host leads with its own mark; the unmapped docs host keeps the
  418. // globe in the same seat.
  419. const repoLink = markdown.locator(`a[href="${REPO_URL}"]`)
  420. expect(await repoLink.count()).toBe(1)
  421. const repoMark = await repoLink.locator('svg path').getAttribute('d')
  422. const globeMark = await guideLink.locator('svg path').getAttribute('d')
  423. expect(repoMark).not.toBe(globeMark)
  424. await guideLink.hover()
  425. expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
  426. expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted')
  427. expect(await styleOf(guideLink, 'text-underline-offset')).toBe('3px')
  428. await mentions.first().hover()
  429. expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted')
  430. // The excluded grey affordance: tool-row file links keep their own color.
  431. expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE)
  432. // Ordinary message HTTP(S) links delegate to the right Sidebar Browser.
  433. await guideLink.click()
  434. const browserAddress = page.locator('[data-rightbar-col]').getByRole('textbox', { name: 'Enter an HTTP(S) address' })
  435. await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL)
  436. await markdown.locator(`a[href="${HTTP_URL}"]`).click()
  437. await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
  438. }, 90_000)
  439. })