markdown-wide-table.e2e.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  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. stream: [],
  117. turn: 1,
  118. step: 1,
  119. message: createMessage({
  120. role: 'assistant',
  121. content: [{ type: 'text', text: tablesMarkdown() }],
  122. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  123. }),
  124. }, { surfaceOp: 'append' })
  125. session.append('step/end', { turn: 1, step: 1 })
  126. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  127. const header = {
  128. type: 'session',
  129. version: SESSION_FORMAT_VERSION,
  130. id: '{{sessionId}}',
  131. createdAt: 0,
  132. cwd: '{{cwd}}',
  133. isSeeded: false,
  134. delegationDepth: 0,
  135. }
  136. return [
  137. JSON.stringify(header),
  138. // Spaced event times, as the sibling markdown fixtures pin them.
  139. ...session.snapshotEvents().map(event => JSON.stringify({
  140. ...event,
  141. time: eventTimeOrigin + event.seq * 1_000,
  142. })),
  143. '',
  144. ].join('\n')
  145. }
  146. /** One table's layout relations at the current viewport. */
  147. interface TableReading {
  148. /** Identifying first-header-cell marker. */
  149. marker: string
  150. /** `scrollWidth - clientWidth` of the wrapper: the residual horizontal scroll. */
  151. overflow: number
  152. /** Wrapper content width. */
  153. clientWidth: number
  154. /** Rendered wrapper height; wrapping shows up as growth when the column narrows. */
  155. height: number
  156. /** Renderer marked the table with the `md-table-wide` breakout hook. */
  157. wideHook: boolean
  158. /** Resolved lead padding (the breakout's alignment compensation). */
  159. paddingLeft: number
  160. /** The table's own left x, for the content-alignment relation. */
  161. tableLeft: number
  162. }
  163. /** Read all three tables' relations in one pass. */
  164. function readTables(page: Page): Promise<TableReading[]> {
  165. return page.evaluate((markers) => {
  166. const wrappers = [...document.querySelectorAll<HTMLElement>('[class*="tableScroll"]')]
  167. return markers.map((marker) => {
  168. const wrapper = wrappers.find(candidate => candidate.textContent?.includes(marker) ?? false)
  169. if (wrapper === undefined) throw new Error(`table wrapper ${marker} not rendered`)
  170. const table = wrapper.querySelector('table')
  171. if (table === null) throw new Error(`table ${marker} not rendered`)
  172. return {
  173. marker,
  174. overflow: wrapper.scrollWidth - wrapper.clientWidth,
  175. clientWidth: wrapper.clientWidth,
  176. height: wrapper.getBoundingClientRect().height,
  177. wideHook: wrapper.classList.contains('md-table-wide'),
  178. paddingLeft: Number.parseFloat(getComputedStyle(wrapper).paddingLeft),
  179. tableLeft: table.getBoundingClientRect().left,
  180. }
  181. })
  182. }, MARKERS)
  183. }
  184. /** A sweep stop: the three tables' readings at one viewport width. */
  185. interface TableStop {
  186. width: number
  187. tables: TableReading[]
  188. }
  189. /**
  190. * Wait for collapsed columns, completed grid transitions, and the
  191. * ConversationRoot ResizeObserver's width publication before measuring tables.
  192. * @param target - the page whose frame to read.
  193. */
  194. async function awaitTableLayout(target: Page): Promise<void> {
  195. await target.evaluate(async () => { await document.fonts.ready })
  196. await target.waitForFunction(() => {
  197. const element = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]')
  198. if (element === null) return false
  199. const tracks = getComputedStyle(element).gridTemplateColumns.split(' ').map(Number.parseFloat)
  200. const root = element.querySelector<HTMLElement>('div[data-phase]')
  201. // Mirrored from ui-layout's SIDEBAR_COLLAPSED; these tests use the Host compiler face.
  202. return tracks[0] === 56 && tracks.at(-1) === 0
  203. && element.getAnimations().every(animation =>
  204. animation.playState === 'finished' || animation.playState === 'idle')
  205. && root !== null
  206. && root.style.getPropertyValue('--dsh-conversation-column-width') === `${String(root.offsetWidth)}px`
  207. }, undefined, { timeout: 10_000 })
  208. }
  209. /**
  210. * Render the golden body: relations only. `fills` is the wrap-first claim
  211. * (the wrapper has no residual horizontal scroll), `scrolls` the many-column
  212. * fallback, and `breaks out` whether the wide wrapper spans past the message
  213. * column (compared against the fill table, which by construction is exactly
  214. * the message column's width).
  215. * @param stops - the measured stops, in sweep order.
  216. * @param wrapTighter - per table name, whether the block grew taller at the
  217. * narrowest stop than at the widest (the proof wrapping engaged).
  218. * @returns the golden body, without a trailing newline.
  219. */
  220. function renderGeometry(stops: TableStop[], wrapTighter: Map<string, boolean>): string {
  221. return [
  222. '# Markdown wide-table relations',
  223. '',
  224. '| viewport | table | fills the column | scrolls | breaks out past the column |',
  225. '| --- | --- | --- | --- | --- |',
  226. ...stops.flatMap((stop) => {
  227. const columnWidth = stop.tables[0]!.clientWidth
  228. return stop.tables.map((table, index) =>
  229. `| ${String(stop.width)}px | ${TABLE_NAMES[index]} | ${String(table.overflow <= 1)} `
  230. + `| ${String(table.overflow > 1)} | ${String(table.clientWidth > columnWidth + 8)} |`,
  231. )
  232. }),
  233. '',
  234. 'Wrap-first engagement (taller at the narrowest stop than at the widest):',
  235. '',
  236. ...[...wrapTighter.entries()].map(([name, tighter]) => `- ${name}: ${String(tighter)}`),
  237. ].join('\n')
  238. }
  239. describe('web e2e: markdown tables fill the column, wide ones break out and scroll', () => {
  240. let scaffold: WebScaffold
  241. let browser: Browser
  242. let page: Page
  243. let tripwire: ReturnType<typeof watchConsole>
  244. beforeAll(async () => {
  245. scaffold = await launchWebScaffold({})
  246. await seedSession(scaffold, wideTableFixture(), SEED_ID)
  247. browser = await chromium.launch()
  248. page = await newEnglishPage(browser)
  249. tripwire = watchConsole(page)
  250. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  251. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  252. const groupRow = page.locator('[role="treeitem"]').first()
  253. await groupRow.waitFor({ timeout: 15_000 })
  254. await groupRow.click()
  255. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  256. await sessionRow.waitFor({ timeout: 10_000 })
  257. await sessionRow.click()
  258. await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
  259. // Collapse the sidebar and keep the right column at its rail for the whole
  260. // sweep: classic-scrollbar platforms (Linux CI) lose ~15px of layout
  261. // width, which shifts how much of a narrow viewport the panes leave the
  262. // transcript and lands the narrow stop's readings far from the macOS
  263. // ones — and an expanded right column alone pins the transcript to
  264. // exactly the message column, which would make every breakout relation
  265. // vacuous. With both out of the equation the transcript follows the
  266. // viewport identically on every platform, which is what keeps one
  267. // committed golden true for all lanes.
  268. await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
  269. await awaitTableLayout(page)
  270. }, 180_000)
  271. afterAll(async () => {
  272. await browser?.close()
  273. await scaffold?.close()
  274. })
  275. /**
  276. * Resize to a viewport and read the tables once layout settles (the frame
  277. * eases its column tracks, so a read straight after a resize can catch a
  278. * mid-transition width).
  279. * @param width - viewport width to settle at.
  280. * @returns the three tables' readings at that width.
  281. */
  282. const settleAt = async (width: number): Promise<TableReading[]> => {
  283. await page.setViewportSize({ width, height: 900 })
  284. await awaitTableLayout(page)
  285. return readTables(page)
  286. }
  287. /** Sweep once; every assertion reads the same measurement. */
  288. let swept: Promise<TableStop[]> | undefined
  289. const sweep = (): Promise<TableStop[]> => {
  290. swept ??= (async () => {
  291. const stops: TableStop[] = []
  292. for (const width of WIDTHS) stops.push({ width, tables: await settleAt(width) })
  293. return stops
  294. })()
  295. return swept
  296. }
  297. it('fills narrow tables, scrolls wide ones, and breaks them out where the transcript is wider', async () => {
  298. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table'))
  299. const stops = await sweep()
  300. for (const stop of stops) {
  301. const [fill, wide, longCell] = stop.tables
  302. const columnWidth = fill!.clientWidth
  303. // #520: an ordinary wide table fills the message column and wraps…
  304. expect(fill!.overflow, `fill viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
  305. // …a long unbroken token and long CJK prose wrap instead of forcing a scroll…
  306. expect(longCell!.overflow, `long-cell viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
  307. // …and a many-column table keeps its natural width behind the scroll fallback.
  308. expect(wide!.overflow, `wide viewport ${String(stop.width)}`).toBeGreaterThan(1)
  309. // The hook is column-count static, present at every stop.
  310. expect(wide!.wideHook).toBe(true)
  311. expect(fill!.wideHook).toBe(false)
  312. expect(longCell!.wideHook).toBe(false)
  313. if (stop.width > 748) {
  314. // Breakout: the wide wrapper spans past the message column, and its
  315. // lead padding keeps the table content starting at the message
  316. // column's left edge (compared to the fill table's content).
  317. expect(wide!.clientWidth, `wide breakout at ${String(stop.width)}`).toBeGreaterThan(columnWidth + 8)
  318. expect(wide!.paddingLeft, `lead at ${String(stop.width)}`).toBeGreaterThan(0)
  319. expect(Math.abs(wide!.tableLeft - fill!.tableLeft), `alignment at ${String(stop.width)}`).toBeLessThan(1.5)
  320. } else {
  321. // Below the message column there is no spare width: the breakout
  322. // clamps to neutral and the wrapper stays the column's width.
  323. expect(Math.abs(wide!.clientWidth - columnWidth), `neutral at ${String(stop.width)}`).toBeLessThan(1.5)
  324. expect(wide!.paddingLeft, `no lead at ${String(stop.width)}`).toBeLessThan(1.5)
  325. }
  326. }
  327. // Wrap-first engaged for real: the filling tables grow taller as the
  328. // column narrows (the wide table only scrolls, so it is exempt).
  329. const widest = stops[0]!
  330. const narrowest = stops[stops.length - 1]!
  331. expect(narrowest.tables[0]!.height).toBeGreaterThan(widest.tables[0]!.height)
  332. expect(narrowest.tables[2]!.height).toBeGreaterThan(widest.tables[2]!.height)
  333. expect(tripwire.pageErrors).toEqual([])
  334. }, 120_000)
  335. it('keeps the wide table keyboard-scrollable', async () => {
  336. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-keyboard'))
  337. await sweep()
  338. await settleAt(1680)
  339. const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
  340. // Chromium makes scrollable containers keyboard-focusable by default;
  341. // arrow keys then scroll the focused wrapper.
  342. await wide.focus()
  343. await page.keyboard.press('ArrowRight')
  344. await page.keyboard.press('ArrowRight')
  345. await expect.poll(() => wide.evaluate(element => element.scrollLeft), { timeout: 5_000 })
  346. .toBeGreaterThan(0)
  347. expect(tripwire.pageErrors).toEqual([])
  348. }, 120_000)
  349. it('reveals the wide table scrollbar on hover only', async () => {
  350. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
  351. await sweep()
  352. await settleAt(1680)
  353. const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
  354. // Chromium never repaints state-conditioned scrollbar STYLES, so the
  355. // hover reveal toggles overflow-x itself; the resting padding matches
  356. // the bar height so the swap does not move anything below. Both are
  357. // ordinary properties whose computed values follow :hover.
  358. const overflowState = () => wide.evaluate(element => [
  359. getComputedStyle(element).overflowX,
  360. getComputedStyle(element).paddingBottom,
  361. ].join(' '))
  362. // Park the pointer away and drop focus: the keyboard case above leaves
  363. // the wrapper focused, and focus-visible also reveals the bar.
  364. await page.mouse.move(4, 4)
  365. await wide.evaluate((element) => { element.blur() })
  366. await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
  367. // Resting hidden overflow keeps the scroll position reachable and intact.
  368. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
  369. await wide.hover()
  370. await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px')
  371. // Pointer leaves: the bar rests hidden again.
  372. await page.mouse.move(4, 4)
  373. await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
  374. expect(tripwire.pageErrors).toEqual([])
  375. }, 120_000)
  376. it('keeps the fill/scroll relations under page zoom', async () => {
  377. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
  378. await sweep()
  379. await settleAt(1100)
  380. try {
  381. await page.evaluate(() => { document.documentElement.style.zoom = '1.25' })
  382. await expect.poll(async () => {
  383. const [fill, wide, longCell] = await readTables(page)
  384. return fill!.overflow <= 1 && longCell!.overflow <= 1 && wide!.overflow > 1
  385. }, { timeout: 10_000 }).toBe(true)
  386. } finally {
  387. await page.evaluate(() => { document.documentElement.style.zoom = '' })
  388. }
  389. expect(tripwire.pageErrors).toEqual([])
  390. }, 120_000)
  391. it('reports the same relations on a high-DPI page', async () => {
  392. const hidpiPage = await browser.newPage({
  393. viewport: { width: 1100, height: 900 },
  394. deviceScaleFactor: 2,
  395. locale: 'en-US',
  396. })
  397. const hidpiTripwire = watchConsole(hidpiPage)
  398. try {
  399. onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi'))
  400. await hidpiPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  401. await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  402. const groupRow = hidpiPage.locator('[role="treeitem"]').first()
  403. await groupRow.waitFor({ timeout: 15_000 })
  404. await groupRow.click()
  405. const sessionRow = hidpiPage.locator('[role="treeitem"]').nth(1)
  406. await sessionRow.waitFor({ timeout: 10_000 })
  407. await sessionRow.click()
  408. await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
  409. await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
  410. await awaitTableLayout(hidpiPage)
  411. const readings = await readTables(hidpiPage)
  412. const baseline = (await sweep()).find(stop => stop.width === 1100)!
  413. const relations = (tables: TableReading[]) => tables.map(table => ({
  414. marker: table.marker,
  415. fills: table.overflow <= 1,
  416. breaksOut: table.clientWidth > tables[0]!.clientWidth + 8,
  417. }))
  418. expect(relations(readings)).toEqual(relations(baseline.tables))
  419. expect(hidpiTripwire.pageErrors).toEqual([])
  420. } finally {
  421. await hidpiPage.close()
  422. }
  423. }, 120_000)
  424. it('matches the committed geometry golden', async () => {
  425. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-golden'))
  426. const stops = await sweep()
  427. const widest = stops[0]!
  428. const narrowest = stops[stops.length - 1]!
  429. const wrapTighter = new Map<string, boolean>([
  430. ['fill', narrowest.tables[0]!.height > widest.tables[0]!.height],
  431. ['long-cell', narrowest.tables[2]!.height > widest.tables[2]!.height],
  432. ])
  433. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(stops, wrapTighter), MODE)
  434. expect(tripwire.pageErrors).toEqual([])
  435. }, 120_000)
  436. it('commits exactly the fixtures it reads', async () => {
  437. // No model calls, so no replay log: the golden is the whole inventory.
  438. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  439. })
  440. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  441. expect(tripwire.warnings).toEqual([])
  442. expect(tripwire.pageErrors).toEqual([])
  443. })
  444. })