composer-tab-geometry.e2e.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. // Browser geometry for the input card across Chat and Trajectory. The browser
  2. // must expose layout-consuming scrollbars, and an uncompensated control keeps
  3. // equal rectangles from passing vacuously.
  4. import { fileURLToPath } from 'node:url'
  5. import { join } from 'node:path'
  6. import type { Browser, Page } from 'playwright'
  7. import { chromium } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  9. import { createChatScrollFixture } from './chat-scroll-fixture.ts'
  10. import {
  11. assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
  12. webSnapshotMode, type WebScaffold,
  13. } from './scaffold.ts'
  14. import { newEnglishPage, saveFailureShot } from './support.ts'
  15. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-tab-geometry', import.meta.url))
  16. /** Records platform-neutral distances between the two tabs' card rectangles. */
  17. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  18. const MODE = webSnapshotMode()
  19. /** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
  20. const FIXTURE = createChatScrollFixture({
  21. markerPrefix: 'TAB_GEOMETRY',
  22. title: 'COMPOSER_TAB_GEOMETRY long session',
  23. turns: 24,
  24. })
  25. const SEED_ID = 'composer-tab-geometry-web-e2e'
  26. /** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
  27. const WIDE_VIEWPORT = { width: 1680, height: 1000 }
  28. const NARROW_VIEWPORT = { width: 800, height: 1000 }
  29. /**
  30. * Resize to one measurement viewport after the responsive sidebar and center
  31. * column finish their track transition.
  32. * @param page - the page under test.
  33. * @param viewport - the viewport dimensions to apply.
  34. * @param sidebarCollapsed - the sidebar state expected at this width.
  35. */
  36. async function setMeasuredViewport(
  37. page: Page,
  38. viewport: { width: number; height: number },
  39. sidebarCollapsed: boolean,
  40. ): Promise<void> {
  41. await page.setViewportSize(viewport)
  42. await page.locator('[data-sidebar-collapsed="true"]').waitFor({
  43. state: sidebarCollapsed ? 'attached' : 'detached',
  44. timeout: 10_000,
  45. })
  46. await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
  47. const deadline = performance.now() + 5_000
  48. let previous = host.getBoundingClientRect().width
  49. let stableFrames = 0
  50. while (performance.now() < deadline) {
  51. await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
  52. const current = host.getBoundingClientRect().width
  53. stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
  54. if (stableFrames >= 3) return
  55. previous = current
  56. }
  57. throw new Error('conversation width did not settle after the viewport changed')
  58. })
  59. }
  60. /**
  61. * The uncompensated cascade, injected into the page: the overlay seat's `right`
  62. * compensation dropped to 0, so it measures the full padding box while Chat's
  63. * seat still rides the reserved content box. `!important` beats the module
  64. * rules without a rebuild, and the id lets the control be lifted again in the
  65. * same session.
  66. */
  67. const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
  68. const CONTROL_CSS = `
  69. [data-conversation-scroll]:has([data-conversation-composer-overlay]) > [data-composer-seat] { right: 0 !important; }
  70. `
  71. /** The column scroller and the input card as the browser lays them out, in one tab. */
  72. interface TabMetrics {
  73. gutter: string
  74. overflowX: string
  75. overflowY: string
  76. band: number
  77. scrolls: boolean
  78. cardLeft: number
  79. cardRight: number
  80. cardWidth: number
  81. }
  82. /** One tab's metrics beside the other's, plus the distances between them. */
  83. interface TabComparison {
  84. chat: TabMetrics
  85. trajectory: TabMetrics
  86. leftShift: number
  87. rightShift: number
  88. widthShift: number
  89. }
  90. /**
  91. * Measure the column scroller and the input card in the tab currently shown.
  92. * @param page - the page under test.
  93. * @returns the scroller's resolved overflow style and the card's rectangle.
  94. */
  95. function measureTab(page: Page): Promise<TabMetrics> {
  96. return page.evaluate(() => {
  97. const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
  98. if (host === null) throw new Error('conversation column scroller not in the DOM')
  99. const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
  100. if (card === null) throw new Error('no input card inside the composer seat')
  101. const style = getComputedStyle(host)
  102. const hostRect = host.getBoundingClientRect()
  103. const cardRect = card.getBoundingClientRect()
  104. return {
  105. gutter: style.scrollbarGutter,
  106. overflowX: style.overflowX,
  107. overflowY: style.overflowY,
  108. band: hostRect.width - host.clientWidth,
  109. scrolls: host.scrollHeight > host.clientHeight,
  110. cardLeft: cardRect.left,
  111. cardRight: cardRect.right,
  112. cardWidth: cardRect.width,
  113. }
  114. })
  115. }
  116. /**
  117. * Show one tab and wait for the view that owns it to be laid out.
  118. * @param page - the page under test.
  119. * @param tab - the tab to show.
  120. */
  121. async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
  122. await page.getByRole('tab', { name: tab, exact: true }).click()
  123. if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
  124. else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]:visible').first().waitFor({ timeout: 30_000 })
  125. // Both measurements are taken after a paint, so a rectangle read mid-transition
  126. // cannot be reported as a shift the cascade did not cause.
  127. await page.evaluate(() => new Promise<void>((settle) => {
  128. requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
  129. }))
  130. }
  131. /**
  132. * Measure both tabs and the distances between them, leaving Chat shown.
  133. * @param page - the page under test.
  134. * @returns each tab's metrics and the card's displacement between them.
  135. */
  136. async function compareTabs(page: Page): Promise<TabComparison> {
  137. await showTab(page, 'Chat')
  138. const chat = await measureTab(page)
  139. await showTab(page, 'Trajectory')
  140. const trajectory = await measureTab(page)
  141. await showTab(page, 'Chat')
  142. return {
  143. chat,
  144. trajectory,
  145. leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
  146. rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
  147. widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
  148. }
  149. }
  150. /**
  151. * Run the uncompensated cascade in the page for one measurement, then lift it:
  152. * the overlay seat's `right` compensation dropped to 0, so it measures the
  153. * full padding box while Chat's seat still rides the reserved content box.
  154. * @param page - the page under test.
  155. * @returns the comparison as the column lays out without the compensation.
  156. */
  157. async function compareTabsWithoutCompensation(page: Page): Promise<TabComparison> {
  158. await page.evaluate(({ id, css }) => {
  159. const style = document.createElement('style')
  160. style.id = id
  161. style.textContent = css
  162. document.head.append(style)
  163. }, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
  164. try {
  165. return await compareTabs(page)
  166. } finally {
  167. await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
  168. }
  169. }
  170. /**
  171. * Open the seeded session from the sidebar search.
  172. *
  173. * Cold summaries carry the temp workspace's basename, so the persisted first
  174. * message is the stable identity to search for, and the query itself drives the
  175. * lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
  176. * is test-scoped and this runs in `beforeAll`.
  177. * @param page - the page under test.
  178. */
  179. async function openSeededSession(page: Page): Promise<void> {
  180. // Search collapsed into a header action; expand it before filling.
  181. const searchButton = page.getByRole('button', { name: 'Search sessions' })
  182. if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
  183. const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
  184. await search.fill(FIXTURE.markers.user(1))
  185. const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  186. const deadline = Date.now() + 60_000
  187. for (;;) {
  188. if (await results.count() === 1) break
  189. if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
  190. await page.waitForTimeout(200)
  191. }
  192. await results.click()
  193. }
  194. /**
  195. * Render the golden body.
  196. * @param wide - comparison at the viewport where the card sits at its width cap.
  197. * @param narrow - comparison at the viewport where the card shrinks with the column.
  198. * @param control - comparison at the wide viewport with the compensation removed.
  199. * @returns the golden body, without a trailing newline.
  200. */
  201. function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
  202. const section = (name: string, comparison: TabComparison): string[] => [
  203. `## ${name}`,
  204. '',
  205. `- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
  206. `- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
  207. `- Chat reserved band: ${String(comparison.chat.band)}px`,
  208. `- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
  209. `- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
  210. `- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
  211. `- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
  212. `- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
  213. `- input card width changes between tabs: ${String(comparison.widthShift)}px`,
  214. '',
  215. ]
  216. return [
  217. '# Input card position across the Chat and Trajectory tabs',
  218. '',
  219. ...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
  220. ...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
  221. ...section('Wide viewport, seat compensation removed in the page (control)', control),
  222. ].join('\n').trimEnd()
  223. }
  224. describe('web e2e: input card position across view tabs', () => {
  225. let scaffold: WebScaffold
  226. let browser: Browser
  227. let page: Page
  228. let tripwire: ReturnType<typeof watchConsole>
  229. beforeAll(async () => {
  230. scaffold = await launchWebScaffold({})
  231. await seedSession(scaffold, FIXTURE.log, SEED_ID)
  232. // Scrollbars must take layout space here or the comparison is vacuous.
  233. browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
  234. page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
  235. tripwire = watchConsole(page)
  236. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  237. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  238. await openSeededSession(page)
  239. await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
  240. await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
  241. .waitFor({ timeout: 30_000 })
  242. }, 180_000)
  243. afterAll(async () => {
  244. await browser?.close()
  245. await scaffold?.close()
  246. })
  247. it('reserves the gutter in Chat and lets Trajectory own its width', async () => {
  248. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
  249. await setMeasuredViewport(page, WIDE_VIEWPORT, false)
  250. // Vacuity guard. The scenario must be able to fail: on an engine that
  251. // does not implement `scrollbar-gutter`, Chat reserves nothing and the
  252. // overlay seat's fixed compensation stands alone, manufacturing an 8px
  253. // deviation the equal-rectangle assertions would catch. `stable` reserves
  254. // even without overflow, so a short transcript is not a vacuous case; the
  255. // poll still pins the measurement to the overflowing state the product
  256. // ships.
  257. await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
  258. const comparison = await compareTabs(page)
  259. expect(comparison.chat.band).toBeGreaterThan(0)
  260. // Chat keeps the unconditional reservation so its seat's content box never
  261. // jumps as the transcript starts to scroll.
  262. expect(comparison.chat.gutter).toBe('stable')
  263. // The overlay branch does NOT reserve: the view owns its own scrollers, so
  264. // a reserved gutter would only narrow the view's content by the bar's
  265. // width. The seat compensates instead, which the next test asserts.
  266. expect(comparison.trajectory.gutter).toBe('auto')
  267. expect(comparison.trajectory.band).toBe(0)
  268. // Declared as a scroll container on both axes rather than left to compute:
  269. // `overflow: hidden` would drop any reservation in WebKit, and a `visible`
  270. // horizontal axis computes to `auto` beside a scrolling one.
  271. expect(comparison.trajectory.overflowY).toBe('auto')
  272. expect(comparison.trajectory.overflowX).toBe('hidden')
  273. // Only Chat scrolls this box; the Trajectory view owns its own scrollers.
  274. expect(comparison.trajectory.scrolls).toBe(false)
  275. expect(tripwire.pageErrors).toEqual([])
  276. }, 60_000)
  277. it('holds the input card in place when the tab changes', async () => {
  278. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
  279. await setMeasuredViewport(page, WIDE_VIEWPORT, false)
  280. const comparison = await compareTabs(page)
  281. // The reported symptom as a number. At this viewport the card sits at its
  282. // width cap, so the uncompensated cascade's shift shows up as a centring
  283. // difference — half the band on each edge — rather than as a width change.
  284. expect(comparison.leftShift).toBe(0)
  285. expect(comparison.rightShift).toBe(0)
  286. expect(comparison.widthShift).toBe(0)
  287. expect(tripwire.pageErrors).toEqual([])
  288. }, 60_000)
  289. it('holds the input card in place at a viewport where it shrinks with the column', async () => {
  290. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
  291. await setMeasuredViewport(page, WIDE_VIEWPORT, false)
  292. const capped = await measureTab(page)
  293. await setMeasuredViewport(page, NARROW_VIEWPORT, true)
  294. const comparison = await compareTabs(page)
  295. // The other geometry, and a different failure: below the cap the card takes
  296. // the column's width, so an unreserved gutter changes its WIDTH by the whole
  297. // band instead of shifting it by half. Asserted against the capped
  298. // measurement rather than against the cap's pixel value, which belongs to
  299. // the stylesheet.
  300. expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
  301. expect(comparison.leftShift).toBe(0)
  302. expect(comparison.rightShift).toBe(0)
  303. expect(comparison.widthShift).toBe(0)
  304. await setMeasuredViewport(page, WIDE_VIEWPORT, false)
  305. expect(tripwire.pageErrors).toEqual([])
  306. }, 60_000)
  307. it('moves the card again once the seat compensation is removed in the page', async () => {
  308. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
  309. await setMeasuredViewport(page, WIDE_VIEWPORT, false)
  310. // The control: without it, equal rectangles could also mean the tab switch
  311. // never reached the layout. Under the uncompensated cascade the overlay seat
  312. // loses its `right` compensation and measures the full padding box, so the
  313. // card moves by half the band on each edge. Chat's own reservation is
  314. // untouched — that is the side that must not change.
  315. const comparison = await compareTabsWithoutCompensation(page)
  316. expect(comparison.chat.gutter).toBe('stable')
  317. expect(comparison.chat.band).toBeGreaterThan(0)
  318. expect(comparison.trajectory.band).toBe(0)
  319. expect(comparison.leftShift).toBe(comparison.chat.band / 2)
  320. expect(comparison.rightShift).toBe(comparison.chat.band / 2)
  321. // Restoring the sheet restores the compensation, so the control cannot leak
  322. // into the remaining measurements.
  323. const restored = await compareTabs(page)
  324. expect(restored.leftShift).toBe(0)
  325. expect(tripwire.pageErrors).toEqual([])
  326. }, 60_000)
  327. it('matches the committed tab geometry golden', async () => {
  328. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
  329. await setMeasuredViewport(page, WIDE_VIEWPORT, false)
  330. const wide = await compareTabs(page)
  331. await setMeasuredViewport(page, NARROW_VIEWPORT, true)
  332. const narrow = await compareTabs(page)
  333. await setMeasuredViewport(page, WIDE_VIEWPORT, false)
  334. const control = await compareTabsWithoutCompensation(page)
  335. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
  336. expect(tripwire.pageErrors).toEqual([])
  337. }, 60_000)
  338. it('commits exactly the fixtures it reads', async () => {
  339. // The seeded session is generated in-process, so the geometry golden is the
  340. // whole inventory.
  341. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  342. })
  343. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  344. expect(tripwire.warnings).toEqual([])
  345. expect(tripwire.pageErrors).toEqual([])
  346. })
  347. })