markdown-wide-table.e2e.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. // Web e2e scenario: markdown tables in the message column, deepsuite-chat
  2. // parity. Tables under four columns (and long-cell tables) fill the 748px
  3. // message column and wrap; four-or-more-column tables keep their natural
  4. // width, scroll horizontally inside their wrapper, and — through the
  5. // renderer's `md-table-wide` hook plus AssistantMarkdown's container-query
  6. // breakout — span the whole transcript width instead of clipping at the
  7. // message column, with the table content still starting at the message
  8. // column's left edge. When the transcript is narrower than the message
  9. // column the breakout clamps to neutral and the plain in-column scroll
  10. // remains.
  11. //
  12. // Only a real engine lays out CSS tables and resolves container-query
  13. // units, so the fill/scroll/breakout relations, the lead-padding alignment,
  14. // arrow-key scrolling, and the zoom/DPR arms are all measured in Chromium
  15. // across viewport stops. The golden records relations and booleans, never
  16. // pixels: absolute widths document the platform, not the behavior.
  17. //
  18. // Zero model calls: the transcript is a closed turn assembled through the
  19. // Session API and seeded cold; a stray stream would fail loud with
  20. // NO_ADAPTER.
  21. import { fileURLToPath } from 'node:url'
  22. import type { Browser, Page } from 'playwright'
  23. import { chromium } from 'playwright'
  24. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  25. import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  26. import {
  27. SESSION_FORMAT_VERSION,
  28. Session,
  29. SessionId,
  30. } from '@deepseek-ai/dsh-session'
  31. import type {} from '@deepseek-ai/dsh-session-title'
  32. import {
  33. assertFixtureInventory,
  34. compareOrRefreshGolden,
  35. launchWebScaffold,
  36. seedSession,
  37. watchConsole,
  38. webSnapshotMode,
  39. type WebScaffold,
  40. } from './scaffold.ts'
  41. import { newEnglishPage, saveFailureShot } from './support.ts'
  42. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-wide-table', import.meta.url))
  43. const GEOMETRY_EXPECTED = fileURLToPath(
  44. new URL('./expected/markdown-wide-table/geometry.expected.md', import.meta.url),
  45. )
  46. const MODE = webSnapshotMode()
  47. const SEED_ID = 'markdown-wide-table-web-e2e'
  48. /** Painted into the final paragraph; the open barrier waits for it. */
  49. const TAIL_MARKER = 'MWT_TABLES_DONE'
  50. /**
  51. * First-header-cell markers identify each table without depending on CSS
  52. * module hashes or DOM order.
  53. */
  54. const FILL_MARKER = 'MWT_FILL_C1'
  55. const WIDE_MARKER = 'MWT_WIDE_C01'
  56. const LONG_CELL_MARKER = 'MWT_LONGCELL_F1'
  57. const SHORT_MARKER = 'MWT_SHORT_C1'
  58. const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
  59. /** Golden-facing names, in {@link MARKERS} order. */
  60. const TABLE_NAMES = ['fill', 'wide', 'long-cell']
  61. /**
  62. * Viewport sweep. The wide stops leave the transcript far wider than the
  63. * 748px message column, so the breakout relation holds with a fat margin on
  64. * every platform; the narrow stop drops the transcript below the message
  65. * column, which must clamp the breakout to neutral. The sidebar is collapsed
  66. * for the whole sweep (see beforeAll), so the transcript width follows the
  67. * viewport identically on overlay- and classic-scrollbar platforms.
  68. */
  69. const WIDTHS = [1680, 1100, 640]
  70. /** A sentence long enough that three of them cannot sit unwrapped in the 748px column. */
  71. const SENTENCE = 'This cell carries one full sentence so the unwrapped table is far wider than the message column.'
  72. /** Unbroken path-like token (no scheme, so GFM does not autolink it and no anchor joins the tab order). */
  73. const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
  74. const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
  75. /** The assistant markdown includes fitting and overflowing wide tables. */
  76. function tablesMarkdown(): string {
  77. const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
  78. const wideRow = (row: number): string[] =>
  79. Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
  80. return [
  81. 'Markdown tables exercise the wide-table layout rules.',
  82. '',
  83. `| ${SHORT_MARKER} | C2 | C3 | C4 |`,
  84. '| --- | --- | --- | --- |',
  85. '| 1 | 2 | 3 | 4 |',
  86. '| 5 | 6 | 7 | 8 |',
  87. '',
  88. 'The paragraph after the short table stays in place.',
  89. '',
  90. `| ${FILL_MARKER} | Current approach | Proposed approach |`,
  91. '| --- | --- | --- |',
  92. `| Rendering | ${SENTENCE} | ${SENTENCE} |`,
  93. `| Memory | ${SENTENCE} | ${SENTENCE} |`,
  94. '',
  95. `| ${wideHeader.join(' | ')} |`,
  96. `|${' --- |'.repeat(12)}`,
  97. `| ${wideRow(1).join(' | ')} |`,
  98. `| ${wideRow(2).join(' | ')} |`,
  99. '',
  100. `| ${LONG_CELL_MARKER} | Value |`,
  101. '| --- | --- |',
  102. `| path | ${LONG_TOKEN} |`,
  103. `| 说明 | ${CJK_SENTENCE} |`,
  104. '',
  105. TAIL_MARKER,
  106. ].join('\n')
  107. }
  108. /** Build one closed, invariant-checked session fixture carrying the tables. */
  109. function wideTableFixture(): string {
  110. const session = Session.create(SessionId('markdown-wide-table-source'))
  111. const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
  112. session.append('turn/start', { turn: 1 })
  113. const user = session.append('user/message', createUserMessage({
  114. content: [{ type: 'text', text: 'Show the wide-table layout scenarios.' }],
  115. source: { kind: 'user' },
  116. }), { surfaceOp: 'append' })
  117. session.append('session/title', {
  118. title: 'Markdown wide tables',
  119. messageSeqs: [user.seq],
  120. source: { kind: 'fallback' },
  121. })
  122. session.append('step/start', { turn: 1, step: 1 })
  123. session.append('assistant/message', {
  124. stream: [],
  125. turn: 1,
  126. step: 1,
  127. message: createMessage({
  128. role: 'assistant',
  129. content: [{ type: 'text', text: tablesMarkdown() }],
  130. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  131. }),
  132. }, { surfaceOp: 'append' })
  133. session.append('step/end', { turn: 1, step: 1 })
  134. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  135. const header = {
  136. type: 'session',
  137. version: SESSION_FORMAT_VERSION,
  138. id: '{{sessionId}}',
  139. createdAt: 0,
  140. cwd: '{{cwd}}',
  141. isSeeded: false,
  142. delegationDepth: 0,
  143. }
  144. return [
  145. JSON.stringify(header),
  146. // Spaced event times, as the sibling markdown fixtures pin them.
  147. ...session.snapshotEvents().map(event => JSON.stringify({
  148. ...event,
  149. time: eventTimeOrigin + event.seq * 1_000,
  150. })),
  151. '',
  152. ].join('\n')
  153. }
  154. /** One table's layout relations at the current viewport. */
  155. interface TableReading {
  156. /** Identifying first-header-cell marker. */
  157. marker: string
  158. /** `scrollWidth - clientWidth` of the wrapper: the residual horizontal scroll. */
  159. overflow: number
  160. /** Wrapper content width. */
  161. clientWidth: number
  162. /** Rendered wrapper height; wrapping shows up as growth when the column narrows. */
  163. height: number
  164. /** Renderer marked the table with the `md-table-wide` breakout hook. */
  165. wideHook: boolean
  166. /** Resolved lead padding (the breakout's alignment compensation). */
  167. paddingLeft: number
  168. /** The table's own left x, for the content-alignment relation. */
  169. tableLeft: number
  170. }
  171. /** Read all three tables' relations in one pass. */
  172. function readTables(page: Page): Promise<TableReading[]> {
  173. return page.evaluate((markers) => {
  174. const wrappers = [...document.querySelectorAll<HTMLElement>('[class*="tableScroll"]')]
  175. return markers.map((marker) => {
  176. const wrapper = wrappers.find(candidate => candidate.textContent?.includes(marker) ?? false)
  177. if (wrapper === undefined) throw new Error(`table wrapper ${marker} not rendered`)
  178. const table = wrapper.querySelector('table')
  179. if (table === null) throw new Error(`table ${marker} not rendered`)
  180. return {
  181. marker,
  182. overflow: wrapper.scrollWidth - wrapper.clientWidth,
  183. clientWidth: wrapper.clientWidth,
  184. height: wrapper.getBoundingClientRect().height,
  185. wideHook: wrapper.classList.contains('md-table-wide'),
  186. paddingLeft: Number.parseFloat(getComputedStyle(wrapper).paddingLeft),
  187. tableLeft: table.getBoundingClientRect().left,
  188. }
  189. })
  190. }, MARKERS)
  191. }
  192. /** A sweep stop: the three tables' readings at one viewport width. */
  193. interface TableStop {
  194. width: number
  195. tables: TableReading[]
  196. }
  197. /**
  198. * Wait for collapsed columns, completed grid transitions, and the
  199. * ConversationRoot ResizeObserver's width publication before measuring tables.
  200. * @param target - the page whose frame to read.
  201. */
  202. async function awaitTableLayout(target: Page): Promise<void> {
  203. await target.evaluate(async () => { await document.fonts.ready })
  204. await target.waitForFunction(() => {
  205. const element = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]')
  206. if (element === null) return false
  207. const tracks = getComputedStyle(element).gridTemplateColumns.split(' ').map(Number.parseFloat)
  208. const root = element.querySelector<HTMLElement>('div[data-phase]')
  209. // Mirrored from ui-layout's SIDEBAR_COLLAPSED; these tests use the Host compiler face.
  210. return tracks[0] === 56 && tracks.at(-1) === 0
  211. && element.getAnimations().every(animation =>
  212. animation.playState === 'finished' || animation.playState === 'idle')
  213. && root !== null
  214. && root.style.getPropertyValue('--dsh-conversation-column-width') === `${String(root.offsetWidth)}px`
  215. }, undefined, { timeout: 10_000 })
  216. }
  217. /**
  218. * Render the golden body: relations only. `fills` is the wrap-first claim
  219. * (the wrapper has no residual horizontal scroll), `scrolls` the many-column
  220. * fallback, and `breaks out` whether the wide wrapper spans past the message
  221. * column (compared against the fill table, which by construction is exactly
  222. * the message column's width).
  223. * @param stops - the measured stops, in sweep order.
  224. * @param wrapTighter - per table name, whether the block grew taller at the
  225. * narrowest stop than at the widest (the proof wrapping engaged).
  226. * @returns the golden body, without a trailing newline.
  227. */
  228. function renderGeometry(stops: TableStop[], wrapTighter: Map<string, boolean>): string {
  229. return [
  230. '# Markdown wide-table relations',
  231. '',
  232. '| viewport | table | fills the column | scrolls | breaks out past the column |',
  233. '| --- | --- | --- | --- | --- |',
  234. ...stops.flatMap((stop) => {
  235. const columnWidth = stop.tables[0]!.clientWidth
  236. return stop.tables.map((table, index) =>
  237. `| ${String(stop.width)}px | ${TABLE_NAMES[index]} | ${String(table.overflow <= 1)} `
  238. + `| ${String(table.overflow > 1)} | ${String(table.clientWidth > columnWidth + 8)} |`,
  239. )
  240. }),
  241. '',
  242. 'Wrap-first engagement (taller at the narrowest stop than at the widest):',
  243. '',
  244. ...[...wrapTighter.entries()].map(([name, tighter]) => `- ${name}: ${String(tighter)}`),
  245. ].join('\n')
  246. }
  247. describe('web e2e: markdown tables fill the column, wide ones break out and scroll', () => {
  248. let scaffold: WebScaffold
  249. let browser: Browser
  250. let page: Page
  251. let tripwire: ReturnType<typeof watchConsole>
  252. beforeAll(async () => {
  253. scaffold = await launchWebScaffold({})
  254. await seedSession(scaffold, wideTableFixture(), SEED_ID)
  255. // The geometry assertions include the space occupied by native scrollbars.
  256. browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
  257. page = await newEnglishPage(browser)
  258. tripwire = watchConsole(page)
  259. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  260. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  261. const groupRow = page.locator('[role="treeitem"]').first()
  262. await groupRow.waitFor({ timeout: 15_000 })
  263. await groupRow.click()
  264. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  265. await sessionRow.waitFor({ timeout: 10_000 })
  266. await sessionRow.click()
  267. await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
  268. // Collapse the sidebar and keep the right column at its rail for the whole
  269. // sweep: classic-scrollbar platforms (Linux CI) lose ~15px of layout
  270. // width, which shifts how much of a narrow viewport the panes leave the
  271. // transcript and lands the narrow stop's readings far from the macOS
  272. // ones — and an expanded right column alone pins the transcript to
  273. // exactly the message column, which would make every breakout relation
  274. // vacuous. With both out of the equation the transcript follows the
  275. // viewport identically on every platform, which is what keeps one
  276. // committed golden true for all lanes.
  277. await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
  278. await awaitTableLayout(page)
  279. }, 180_000)
  280. afterAll(async () => {
  281. await browser?.close()
  282. await scaffold?.close()
  283. })
  284. /**
  285. * Resize to a viewport and read the tables once layout settles (the frame
  286. * eases its column tracks, so a read straight after a resize can catch a
  287. * mid-transition width).
  288. * @param width - viewport width to settle at.
  289. * @returns the three tables' readings at that width.
  290. */
  291. const settleAt = async (width: number): Promise<TableReading[]> => {
  292. await page.setViewportSize({ width, height: 900 })
  293. await awaitTableLayout(page)
  294. return readTables(page)
  295. }
  296. /** Sweep once; every assertion reads the same measurement. */
  297. let swept: Promise<TableStop[]> | undefined
  298. const sweep = (): Promise<TableStop[]> => {
  299. swept ??= (async () => {
  300. const stops: TableStop[] = []
  301. for (const width of WIDTHS) stops.push({ width, tables: await settleAt(width) })
  302. return stops
  303. })()
  304. return swept
  305. }
  306. it('fills narrow tables, scrolls wide ones, and breaks them out where the transcript is wider', async () => {
  307. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table'))
  308. const stops = await sweep()
  309. for (const stop of stops) {
  310. const [fill, wide, longCell] = stop.tables
  311. const columnWidth = fill!.clientWidth
  312. // #520: an ordinary wide table fills the message column and wraps…
  313. expect(fill!.overflow, `fill viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
  314. // …a long unbroken token and long CJK prose wrap instead of forcing a scroll…
  315. expect(longCell!.overflow, `long-cell viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
  316. // …and a many-column table keeps its natural width behind the scroll fallback.
  317. expect(wide!.overflow, `wide viewport ${String(stop.width)}`).toBeGreaterThan(1)
  318. // The hook is column-count static, present at every stop.
  319. expect(wide!.wideHook).toBe(true)
  320. expect(fill!.wideHook).toBe(false)
  321. expect(longCell!.wideHook).toBe(false)
  322. if (stop.width > 748) {
  323. // Breakout: the wide wrapper spans past the message column, and its
  324. // lead padding keeps the table content starting at the message
  325. // column's left edge (compared to the fill table's content).
  326. expect(wide!.clientWidth, `wide breakout at ${String(stop.width)}`).toBeGreaterThan(columnWidth + 8)
  327. expect(wide!.paddingLeft, `lead at ${String(stop.width)}`).toBeGreaterThan(0)
  328. expect(Math.abs(wide!.tableLeft - fill!.tableLeft), `alignment at ${String(stop.width)}`).toBeLessThan(1.5)
  329. } else {
  330. // Below the message column there is no spare width: the breakout
  331. // clamps to neutral and the wrapper stays the column's width.
  332. expect(Math.abs(wide!.clientWidth - columnWidth), `neutral at ${String(stop.width)}`).toBeLessThan(1.5)
  333. expect(wide!.paddingLeft, `no lead at ${String(stop.width)}`).toBeLessThan(1.5)
  334. }
  335. }
  336. // Wrap-first engaged for real: the filling tables grow taller as the
  337. // column narrows (the wide table only scrolls, so it is exempt).
  338. const widest = stops[0]!
  339. const narrowest = stops[stops.length - 1]!
  340. expect(narrowest.tables[0]!.height).toBeGreaterThan(widest.tables[0]!.height)
  341. expect(narrowest.tables[2]!.height).toBeGreaterThan(widest.tables[2]!.height)
  342. expect(tripwire.pageErrors).toEqual([])
  343. }, 120_000)
  344. it('keeps the wide table keyboard-scrollable', async () => {
  345. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-keyboard'))
  346. await sweep()
  347. await settleAt(1680)
  348. const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
  349. // Chromium makes scrollable containers keyboard-focusable by default;
  350. // arrow keys then scroll the focused wrapper.
  351. await wide.focus()
  352. await page.keyboard.press('ArrowRight')
  353. await page.keyboard.press('ArrowRight')
  354. await expect.poll(() => wide.evaluate(element => element.scrollLeft), { timeout: 5_000 })
  355. .toBeGreaterThan(0)
  356. expect(tripwire.pageErrors).toEqual([])
  357. }, 120_000)
  358. it('reveals the wide table scrollbar on hover only', async () => {
  359. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
  360. await sweep()
  361. await settleAt(1680)
  362. const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
  363. // Chromium never repaints state-conditioned scrollbar STYLES, so the
  364. // hover reveal toggles overflow-x itself; the resting padding matches
  365. // the bar height so the swap does not move anything below. Both are
  366. // ordinary properties whose computed values follow :hover.
  367. const overflowState = () => wide.evaluate(element => [
  368. getComputedStyle(element).overflowX,
  369. getComputedStyle(element).paddingBottom,
  370. ].join(' '))
  371. // Park the pointer away and drop focus: the keyboard case above leaves
  372. // the wrapper focused, and focus-visible also reveals the bar.
  373. await page.mouse.move(4, 4)
  374. await wide.evaluate((element) => { element.blur() })
  375. await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
  376. // Resting hidden overflow keeps the scroll position reachable and intact.
  377. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
  378. await wide.hover()
  379. await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px')
  380. // Pointer leaves: the bar rests hidden again.
  381. await page.mouse.move(4, 4)
  382. await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
  383. expect(tripwire.pageErrors).toEqual([])
  384. }, 120_000)
  385. it('keeps a fitting wide table and its following paragraph stationary during interaction', async () => {
  386. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height'))
  387. await settleAt(1680)
  388. const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER })
  389. await short.evaluate((element) => { element.scrollIntoView({ block: 'center', behavior: 'instant' }) })
  390. await page.mouse.move(4, 4)
  391. await short.evaluate((element) => { element.blur() })
  392. expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true)
  393. expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
  394. const position = () => short.evaluate((element) => {
  395. const following = element.nextElementSibling
  396. if (following === null) throw new Error('short table has no following paragraph')
  397. return {
  398. height: element.getBoundingClientRect().height,
  399. followingTop: following.getBoundingClientRect().top,
  400. }
  401. })
  402. const resting = await position()
  403. await short.hover()
  404. await expect.poll(position).toEqual(resting)
  405. await page.mouse.move(4, 4)
  406. await short.focus()
  407. expect(await short.evaluate(element => document.activeElement === element)).toBe(true)
  408. await expect.poll(position).toEqual(resting)
  409. await short.evaluate((element) => { element.blur() })
  410. await expect.poll(position).toEqual(resting)
  411. expect(tripwire.pageErrors).toEqual([])
  412. }, 120_000)
  413. it('gives the gutter to painted table content, not transparent breakout padding', async () => {
  414. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-width-handle-hit'))
  415. await settleAt(1680)
  416. const hitAtHandle = async (marker: string) => {
  417. const wrapper = page.locator('[class*="tableScroll"]', { hasText: marker })
  418. await wrapper.evaluate((element) => { element.scrollIntoView({ block: 'center', behavior: 'instant' }) })
  419. return await page.evaluate((tableMarker) => {
  420. const handle = document.querySelector<HTMLElement>('[data-width-handle="right"]')
  421. const wrapper = [...document.querySelectorAll<HTMLElement>('[class*="tableScroll"]')]
  422. .find(candidate => candidate.textContent?.includes(tableMarker) ?? false)
  423. const table = wrapper?.querySelector('table') ?? null
  424. if (handle === null || table === null) throw new Error(`missing hit-test geometry for ${tableMarker}`)
  425. const handleRect = handle.getBoundingClientRect()
  426. const tableRect = table.getBoundingClientRect()
  427. const x = handleRect.left + handleRect.width / 2
  428. const y = tableRect.top + tableRect.height / 2
  429. const hit = document.elementFromPoint(x, y)
  430. return {
  431. tableCoversHandle: tableRect.left <= x && tableRect.right >= x,
  432. hitTable: hit !== null && table.contains(hit),
  433. hitHandle: hit !== null && handle.contains(hit),
  434. }
  435. }, marker)
  436. }
  437. expect(await hitAtHandle(WIDE_MARKER)).toEqual({
  438. tableCoversHandle: true,
  439. hitTable: true,
  440. hitHandle: false,
  441. })
  442. expect(await hitAtHandle(SHORT_MARKER)).toEqual({
  443. tableCoversHandle: false,
  444. hitTable: false,
  445. hitHandle: true,
  446. })
  447. expect(tripwire.pageErrors).toEqual([])
  448. }, 120_000)
  449. it('keeps the fill/scroll relations under page zoom', async () => {
  450. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
  451. await sweep()
  452. await settleAt(1100)
  453. try {
  454. await page.evaluate(() => { document.documentElement.style.zoom = '1.25' })
  455. await expect.poll(async () => {
  456. const [fill, wide, longCell] = await readTables(page)
  457. return fill!.overflow <= 1 && longCell!.overflow <= 1 && wide!.overflow > 1
  458. }, { timeout: 10_000 }).toBe(true)
  459. } finally {
  460. await page.evaluate(() => { document.documentElement.style.zoom = '' })
  461. }
  462. expect(tripwire.pageErrors).toEqual([])
  463. }, 120_000)
  464. it('reports the same relations on a high-DPI page', async () => {
  465. const hidpiPage = await browser.newPage({
  466. viewport: { width: 1100, height: 900 },
  467. deviceScaleFactor: 2,
  468. locale: 'en-US',
  469. })
  470. const hidpiTripwire = watchConsole(hidpiPage)
  471. try {
  472. onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi'))
  473. await hidpiPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  474. await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  475. const groupRow = hidpiPage.locator('[role="treeitem"]').first()
  476. await groupRow.waitFor({ timeout: 15_000 })
  477. await groupRow.click()
  478. const sessionRow = hidpiPage.locator('[role="treeitem"]').nth(1)
  479. await sessionRow.waitFor({ timeout: 10_000 })
  480. await sessionRow.click()
  481. await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
  482. await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
  483. await awaitTableLayout(hidpiPage)
  484. const readings = await readTables(hidpiPage)
  485. const baseline = (await sweep()).find(stop => stop.width === 1100)!
  486. const relations = (tables: TableReading[]) => tables.map(table => ({
  487. marker: table.marker,
  488. fills: table.overflow <= 1,
  489. breaksOut: table.clientWidth > tables[0]!.clientWidth + 8,
  490. }))
  491. expect(relations(readings)).toEqual(relations(baseline.tables))
  492. expect(hidpiTripwire.pageErrors).toEqual([])
  493. } finally {
  494. await hidpiPage.close()
  495. }
  496. }, 120_000)
  497. it('matches the committed geometry golden', async () => {
  498. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-golden'))
  499. const stops = await sweep()
  500. const widest = stops[0]!
  501. const narrowest = stops[stops.length - 1]!
  502. const wrapTighter = new Map<string, boolean>([
  503. ['fill', narrowest.tables[0]!.height > widest.tables[0]!.height],
  504. ['long-cell', narrowest.tables[2]!.height > widest.tables[2]!.height],
  505. ])
  506. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(stops, wrapTighter), MODE)
  507. expect(tripwire.pageErrors).toEqual([])
  508. }, 120_000)
  509. it('commits exactly the fixtures it reads', async () => {
  510. // No model calls, so no replay log: the golden is the whole inventory.
  511. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  512. })
  513. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  514. expect(tripwire.warnings).toEqual([])
  515. expect(tripwire.pageErrors).toEqual([])
  516. })
  517. })