markdown-wide-table.e2e.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  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 MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
  58. /** Golden-facing names, in {@link MARKERS} order. */
  59. const TABLE_NAMES = ['fill', 'wide', 'long-cell']
  60. /**
  61. * Viewport sweep. The wide stops leave the transcript far wider than the
  62. * 748px message column, so the breakout relation holds with a fat margin on
  63. * every platform; the narrow stop drops the transcript below the message
  64. * column, which must clamp the breakout to neutral. The sidebar is collapsed
  65. * for the whole sweep (see beforeAll), so the transcript width follows the
  66. * viewport identically on overlay- and classic-scrollbar platforms.
  67. */
  68. const WIDTHS = [1680, 1100, 640]
  69. /** A sentence long enough that three of them cannot sit unwrapped in the 748px column. */
  70. const SENTENCE = 'This cell carries one full sentence so the unwrapped table is far wider than the message column.'
  71. /** Unbroken path-like token (no scheme, so GFM does not autolink it and no anchor joins the tab order). */
  72. const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
  73. const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
  74. /** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */
  75. function tablesMarkdown(): string {
  76. const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
  77. const wideRow = (row: number): string[] =>
  78. Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
  79. return [
  80. 'Three markdown tables exercise the wide-table layout rules.',
  81. '',
  82. `| ${FILL_MARKER} | Current approach | Proposed approach |`,
  83. '| --- | --- | --- |',
  84. `| Rendering | ${SENTENCE} | ${SENTENCE} |`,
  85. `| Memory | ${SENTENCE} | ${SENTENCE} |`,
  86. '',
  87. `| ${wideHeader.join(' | ')} |`,
  88. `|${' --- |'.repeat(12)}`,
  89. `| ${wideRow(1).join(' | ')} |`,
  90. `| ${wideRow(2).join(' | ')} |`,
  91. '',
  92. `| ${LONG_CELL_MARKER} | Value |`,
  93. '| --- | --- |',
  94. `| path | ${LONG_TOKEN} |`,
  95. `| 说明 | ${CJK_SENTENCE} |`,
  96. '',
  97. TAIL_MARKER,
  98. ].join('\n')
  99. }
  100. /** Build one closed, invariant-checked session fixture carrying the three tables. */
  101. function wideTableFixture(): string {
  102. const session = Session.create(SessionId('markdown-wide-table-source'))
  103. const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
  104. session.append('turn/start', { turn: 1 })
  105. const user = session.append('user/message', createUserMessage({
  106. content: [{ type: 'text', text: 'Show the wide-table layout scenarios.' }],
  107. source: { kind: 'user' },
  108. }), { surfaceOp: 'append' })
  109. session.append('session/title', {
  110. title: 'Markdown wide tables',
  111. messageSeqs: [user.seq],
  112. source: { kind: 'fallback' },
  113. })
  114. session.append('step/start', { turn: 1, step: 1 })
  115. session.append('assistant/message', {
  116. turn: 1,
  117. step: 1,
  118. message: createMessage({
  119. role: 'assistant',
  120. content: [{ type: 'text', text: tablesMarkdown() }],
  121. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  122. }),
  123. }, { surfaceOp: 'append' })
  124. session.append('step/end', { turn: 1, step: 1 })
  125. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  126. const header = {
  127. type: 'session',
  128. version: SESSION_FORMAT_VERSION,
  129. id: '{{sessionId}}',
  130. createdAt: 0,
  131. cwd: '{{cwd}}',
  132. }
  133. return [
  134. JSON.stringify(header),
  135. // Spaced event times, as the sibling markdown fixtures pin them.
  136. ...session.events.map(event => JSON.stringify({
  137. ...event,
  138. time: eventTimeOrigin + event.seq * 1_000,
  139. })),
  140. '',
  141. ].join('\n')
  142. }
  143. /** One table's layout relations at the current viewport. */
  144. interface TableReading {
  145. /** Identifying first-header-cell marker. */
  146. marker: string
  147. /** `scrollWidth - clientWidth` of the wrapper: the residual horizontal scroll. */
  148. overflow: number
  149. /** Wrapper content width. */
  150. clientWidth: number
  151. /** Rendered wrapper height; wrapping shows up as growth when the column narrows. */
  152. height: number
  153. /** Renderer marked the table with the `md-table-wide` breakout hook. */
  154. wideHook: boolean
  155. /** Resolved lead padding (the breakout's alignment compensation). */
  156. paddingLeft: number
  157. /** The table's own left x, for the content-alignment relation. */
  158. tableLeft: number
  159. }
  160. /** Read all three tables' relations in one pass. */
  161. function readTables(page: Page): Promise<TableReading[]> {
  162. return page.evaluate((markers) => {
  163. const wrappers = [...document.querySelectorAll<HTMLElement>('[class*="tableScroll"]')]
  164. return markers.map((marker) => {
  165. const wrapper = wrappers.find(candidate => candidate.textContent?.includes(marker) ?? false)
  166. if (wrapper === undefined) throw new Error(`table wrapper ${marker} not rendered`)
  167. const table = wrapper.querySelector('table')
  168. if (table === null) throw new Error(`table ${marker} not rendered`)
  169. return {
  170. marker,
  171. overflow: wrapper.scrollWidth - wrapper.clientWidth,
  172. clientWidth: wrapper.clientWidth,
  173. height: wrapper.getBoundingClientRect().height,
  174. wideHook: wrapper.classList.contains('md-table-wide'),
  175. paddingLeft: Number.parseFloat(getComputedStyle(wrapper).paddingLeft),
  176. tableLeft: table.getBoundingClientRect().left,
  177. }
  178. })
  179. }, MARKERS)
  180. }
  181. /** A sweep stop: the three tables' readings at one viewport width. */
  182. interface TableStop {
  183. width: number
  184. tables: TableReading[]
  185. }
  186. /**
  187. * Close the details pane so the transcript spans the viewport. Open, it pins
  188. * the transcript to exactly the message column and every breakout relation
  189. * would go vacuous.
  190. * @param target - the page whose pane to close.
  191. */
  192. async function closeDetailsPane(target: Page): Promise<void> {
  193. await target.getByRole('button', { name: 'Close details', exact: true }).waitFor({ timeout: 10_000 })
  194. await target.evaluate(() => {
  195. document.querySelector<HTMLElement>('button[aria-label="Close details"]')?.click()
  196. })
  197. // Closed details resolve to zero width but never unmount (ui-layout
  198. // columns contract), so the settled signal is the frame's collapse marker,
  199. // not the button's detachment.
  200. await target.waitForSelector('[data-details-collapsed]', { timeout: 5_000 })
  201. }
  202. /**
  203. * Render the golden body: relations only. `fills` is the wrap-first claim
  204. * (the wrapper has no residual horizontal scroll), `scrolls` the many-column
  205. * fallback, and `breaks out` whether the wide wrapper spans past the message
  206. * column (compared against the fill table, which by construction is exactly
  207. * the message column's width).
  208. * @param stops - the measured stops, in sweep order.
  209. * @param wrapTighter - per table name, whether the block grew taller at the
  210. * narrowest stop than at the widest (the proof wrapping engaged).
  211. * @returns the golden body, without a trailing newline.
  212. */
  213. function renderGeometry(stops: TableStop[], wrapTighter: Map<string, boolean>): string {
  214. return [
  215. '# Markdown wide-table relations',
  216. '',
  217. '| viewport | table | fills the column | scrolls | breaks out past the column |',
  218. '| --- | --- | --- | --- | --- |',
  219. ...stops.flatMap((stop) => {
  220. const columnWidth = stop.tables[0]!.clientWidth
  221. return stop.tables.map((table, index) =>
  222. `| ${String(stop.width)}px | ${TABLE_NAMES[index]} | ${String(table.overflow <= 1)} `
  223. + `| ${String(table.overflow > 1)} | ${String(table.clientWidth > columnWidth + 8)} |`,
  224. )
  225. }),
  226. '',
  227. 'Wrap-first engagement (taller at the narrowest stop than at the widest):',
  228. '',
  229. ...[...wrapTighter.entries()].map(([name, tighter]) => `- ${name}: ${String(tighter)}`),
  230. ].join('\n')
  231. }
  232. describe('web e2e: markdown tables fill the column, wide ones break out and scroll', () => {
  233. let scaffold: WebScaffold
  234. let browser: Browser
  235. let page: Page
  236. let tripwire: ReturnType<typeof watchConsole>
  237. beforeAll(async () => {
  238. scaffold = await launchWebScaffold({})
  239. await seedSession(scaffold, wideTableFixture(), SEED_ID)
  240. browser = await chromium.launch()
  241. page = await newEnglishPage(browser)
  242. tripwire = watchConsole(page)
  243. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  244. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  245. const groupRow = page.locator('[role="treeitem"]').first()
  246. await groupRow.waitFor({ timeout: 15_000 })
  247. await groupRow.click()
  248. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  249. await sessionRow.waitFor({ timeout: 10_000 })
  250. await sessionRow.click()
  251. await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
  252. // Collapse the sidebar and close the details pane for the whole sweep:
  253. // classic-scrollbar platforms (Linux CI) lose ~15px of layout width,
  254. // which shifts how much of a narrow viewport the panes leave the
  255. // transcript and lands the narrow stop's readings far from the macOS
  256. // ones — and the details pane alone pins the transcript to exactly the
  257. // message column, which would make every breakout relation vacuous.
  258. // With both out of the equation the transcript follows the viewport
  259. // identically on every platform, which is what keeps one committed
  260. // golden true for all lanes.
  261. await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
  262. // JS click: after the transcript scrolled to its tail, the pane's close
  263. // button can sit under the sticky header where a pointer click is
  264. // intercepted; the pane itself is scaffolding, not the behavior under
  265. // test, so actionability adds nothing here.
  266. await closeDetailsPane(page)
  267. }, 180_000)
  268. afterAll(async () => {
  269. await browser?.close()
  270. await scaffold?.close()
  271. })
  272. /**
  273. * Resize to a viewport and read the tables once layout settles (the frame
  274. * eases its column tracks, so a read straight after a resize can catch a
  275. * mid-transition width).
  276. * @param width - viewport width to settle at.
  277. * @returns the three tables' readings at that width.
  278. */
  279. const settleAt = async (width: number): Promise<TableReading[]> => {
  280. await page.setViewportSize({ width, height: 900 })
  281. // The wide wrapper follows the transcript width (the fill wrapper caps
  282. // at the message column and would report "settled" mid-transition).
  283. let previousWidth = -1
  284. await expect.poll(async () => {
  285. const current = (await readTables(page))[1]!.clientWidth
  286. const settled = current === previousWidth
  287. previousWidth = current
  288. return settled
  289. }, { timeout: 10_000 }).toBe(true)
  290. return readTables(page)
  291. }
  292. /** Sweep once; every assertion reads the same measurement. */
  293. let swept: Promise<TableStop[]> | undefined
  294. const sweep = (): Promise<TableStop[]> => {
  295. swept ??= (async () => {
  296. const stops: TableStop[] = []
  297. for (const width of WIDTHS) stops.push({ width, tables: await settleAt(width) })
  298. return stops
  299. })()
  300. return swept
  301. }
  302. it('fills narrow tables, scrolls wide ones, and breaks them out where the transcript is wider', async () => {
  303. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table'))
  304. const stops = await sweep()
  305. for (const stop of stops) {
  306. const [fill, wide, longCell] = stop.tables
  307. const columnWidth = fill!.clientWidth
  308. // #520: an ordinary wide table fills the message column and wraps…
  309. expect(fill!.overflow, `fill viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
  310. // …a long unbroken token and long CJK prose wrap instead of forcing a scroll…
  311. expect(longCell!.overflow, `long-cell viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
  312. // …and a many-column table keeps its natural width behind the scroll fallback.
  313. expect(wide!.overflow, `wide viewport ${String(stop.width)}`).toBeGreaterThan(1)
  314. // The hook is column-count static, present at every stop.
  315. expect(wide!.wideHook).toBe(true)
  316. expect(fill!.wideHook).toBe(false)
  317. expect(longCell!.wideHook).toBe(false)
  318. if (stop.width > 748) {
  319. // Breakout: the wide wrapper spans past the message column, and its
  320. // lead padding keeps the table content starting at the message
  321. // column's left edge (compared to the fill table's content).
  322. expect(wide!.clientWidth, `wide breakout at ${String(stop.width)}`).toBeGreaterThan(columnWidth + 8)
  323. expect(wide!.paddingLeft, `lead at ${String(stop.width)}`).toBeGreaterThan(0)
  324. expect(Math.abs(wide!.tableLeft - fill!.tableLeft), `alignment at ${String(stop.width)}`).toBeLessThan(1.5)
  325. } else {
  326. // Below the message column there is no spare width: the breakout
  327. // clamps to neutral and the wrapper stays the column's width.
  328. expect(Math.abs(wide!.clientWidth - columnWidth), `neutral at ${String(stop.width)}`).toBeLessThan(1.5)
  329. expect(wide!.paddingLeft, `no lead at ${String(stop.width)}`).toBeLessThan(1.5)
  330. }
  331. }
  332. // Wrap-first engaged for real: the filling tables grow taller as the
  333. // column narrows (the wide table only scrolls, so it is exempt).
  334. const widest = stops[0]!
  335. const narrowest = stops[stops.length - 1]!
  336. expect(narrowest.tables[0]!.height).toBeGreaterThan(widest.tables[0]!.height)
  337. expect(narrowest.tables[2]!.height).toBeGreaterThan(widest.tables[2]!.height)
  338. expect(tripwire.pageErrors).toEqual([])
  339. }, 120_000)
  340. it('keeps the wide table keyboard-scrollable', async () => {
  341. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-keyboard'))
  342. await sweep()
  343. await settleAt(1680)
  344. const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
  345. // Chromium makes scrollable containers keyboard-focusable by default;
  346. // arrow keys then scroll the focused wrapper.
  347. await wide.focus()
  348. await page.keyboard.press('ArrowRight')
  349. await page.keyboard.press('ArrowRight')
  350. await expect.poll(() => wide.evaluate(element => element.scrollLeft), { timeout: 5_000 })
  351. .toBeGreaterThan(0)
  352. expect(tripwire.pageErrors).toEqual([])
  353. }, 120_000)
  354. it('reveals the wide table scrollbar on hover only', async () => {
  355. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
  356. await sweep()
  357. await settleAt(1680)
  358. const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
  359. // Chromium never repaints state-conditioned scrollbar STYLES, so the
  360. // hover reveal toggles overflow-x itself; the resting padding matches
  361. // the bar height so the swap does not move anything below. Both are
  362. // ordinary properties whose computed values follow :hover.
  363. const overflowState = () => wide.evaluate(element => [
  364. getComputedStyle(element).overflowX,
  365. getComputedStyle(element).paddingBottom,
  366. ].join(' '))
  367. // Park the pointer away and drop focus: the keyboard case above leaves
  368. // the wrapper focused, and focus-visible also reveals the bar.
  369. await page.mouse.move(4, 4)
  370. await wide.evaluate((element) => { element.blur() })
  371. await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
  372. // Resting hidden overflow keeps the scroll position reachable and intact.
  373. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
  374. await wide.hover()
  375. await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px')
  376. // Pointer leaves: the bar rests hidden again.
  377. await page.mouse.move(4, 4)
  378. await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
  379. expect(tripwire.pageErrors).toEqual([])
  380. }, 120_000)
  381. it('keeps the fill/scroll relations under page zoom', async () => {
  382. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
  383. await sweep()
  384. await settleAt(1100)
  385. try {
  386. await page.evaluate(() => { document.documentElement.style.zoom = '1.25' })
  387. await expect.poll(async () => {
  388. const [fill, wide, longCell] = await readTables(page)
  389. return fill!.overflow <= 1 && longCell!.overflow <= 1 && wide!.overflow > 1
  390. }, { timeout: 10_000 }).toBe(true)
  391. } finally {
  392. await page.evaluate(() => { document.documentElement.style.zoom = '' })
  393. }
  394. expect(tripwire.pageErrors).toEqual([])
  395. }, 120_000)
  396. it('reports the same relations on a high-DPI page', async () => {
  397. const hidpiPage = await browser.newPage({
  398. viewport: { width: 1100, height: 900 },
  399. deviceScaleFactor: 2,
  400. locale: 'en-US',
  401. })
  402. const hidpiTripwire = watchConsole(hidpiPage)
  403. try {
  404. onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi'))
  405. await hidpiPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
  406. await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  407. const groupRow = hidpiPage.locator('[role="treeitem"]').first()
  408. await groupRow.waitFor({ timeout: 15_000 })
  409. await groupRow.click()
  410. const sessionRow = hidpiPage.locator('[role="treeitem"]').nth(1)
  411. await sessionRow.waitFor({ timeout: 10_000 })
  412. await sessionRow.click()
  413. await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
  414. await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
  415. await closeDetailsPane(hidpiPage)
  416. // The pane collapses ease over the layout transition: compare only a
  417. // settled reading (two consecutive equal wide-wrapper widths).
  418. let readings: TableReading[] = []
  419. let previousWide = -1
  420. await expect.poll(async () => {
  421. readings = await readTables(hidpiPage)
  422. const settled = readings[1]!.clientWidth === previousWide
  423. previousWide = readings[1]!.clientWidth
  424. return settled
  425. }, { timeout: 10_000 }).toBe(true)
  426. const baseline = (await sweep()).find(stop => stop.width === 1100)!
  427. const relations = (tables: TableReading[]) => tables.map(table => ({
  428. marker: table.marker,
  429. fills: table.overflow <= 1,
  430. breaksOut: table.clientWidth > tables[0]!.clientWidth + 8,
  431. }))
  432. expect(relations(readings)).toEqual(relations(baseline.tables))
  433. expect(hidpiTripwire.pageErrors).toEqual([])
  434. } finally {
  435. await hidpiPage.close()
  436. }
  437. }, 120_000)
  438. it('matches the committed geometry golden', async () => {
  439. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-golden'))
  440. const stops = await sweep()
  441. const widest = stops[0]!
  442. const narrowest = stops[stops.length - 1]!
  443. const wrapTighter = new Map<string, boolean>([
  444. ['fill', narrowest.tables[0]!.height > widest.tables[0]!.height],
  445. ['long-cell', narrowest.tables[2]!.height > widest.tables[2]!.height],
  446. ])
  447. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(stops, wrapTighter), MODE)
  448. expect(tripwire.pageErrors).toEqual([])
  449. }, 120_000)
  450. it('commits exactly the fixtures it reads', async () => {
  451. // No model calls, so no replay log: the golden is the whole inventory.
  452. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  453. })
  454. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  455. expect(tripwire.warnings).toEqual([])
  456. expect(tripwire.pageErrors).toEqual([])
  457. })
  458. })