composer-tab-geometry.e2e.ts 21 KB

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