message-feedback-layout.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. // Web e2e scenario: with the feedback note editor open, the assistant IconActions
  2. // row stays one intact line (no wrapping, nothing pushed out), and the note
  3. // editor floats above the transcript in a popover that escapes the conversation
  4. // column's overflow clip and stays inside the viewport.
  5. //
  6. // The hazard this pins: a slot-contributed note editor (260px textarea plus
  7. // Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an
  8. // inline expansion made the row wider than the column — full-screen desktop
  9. // included — so the branch action and the clock were pushed out of view by later
  10. // flex items. The fix is to not mount the editor in the row at all: it is a
  11. // popover portaled to document.body and fixed-positioned from the note trigger's
  12. // rect, so the row keeps its single 28px line of icons and the trigger, and the
  13. // panel cannot be cropped by the column's overflow because it lives outside it.
  14. //
  15. // The sweep records, per viewport, whether the open editor keeps the actions row
  16. // on one line with zero overflow, whether the panel is outside the column (proof
  17. // it escapes the clip), whether the panel stays inside the viewport (proof the
  18. // clamp works), and whether it sits by its trigger. All relations, no absolute
  19. // pixels: the column width follows the viewport, the sidebar, and the platform's
  20. // scrollbar, so a golden carrying pixels would document the platform, not the
  21. // behavior.
  22. //
  23. // Zero model calls: a settled transcript is cold-seeded, so nothing streams.
  24. import { readFile } from 'node:fs/promises'
  25. import { fileURLToPath } from 'node:url'
  26. import { join } from 'node:path'
  27. import type { Browser, Page } from 'playwright'
  28. import { chromium } from 'playwright'
  29. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  30. import {
  31. compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
  32. type WebScaffold,
  33. } from './scaffold.ts'
  34. import { newEnglishPage, saveFailureShot } from './support.ts'
  35. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedback-layout', import.meta.url))
  36. /**
  37. * Committed golden of the popover relations at every stop. Booleans and counts
  38. * only, never absolute coordinates.
  39. */
  40. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  41. const MODE = webSnapshotMode()
  42. /** Borrowed read-only: this scenario needs any settled assistant message to rate. */
  43. const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
  44. const SEED_ID = 'message-feedback-layout-e2e'
  45. /** Viewport widths from full-screen desktop down to a narrow window. */
  46. const WIDTHS = [1680, 1280, 1024, 900, 700, 600]
  47. /** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */
  48. export interface PopoverMetrics {
  49. /** Viewport width the stop was measured at. */
  50. width: number
  51. /** The row's scrollable overflow with the note editor closed (natural row width). */
  52. rowOverflowClosed: number
  53. /** The row's scrollable overflow with the note editor open; must equal the closed value. */
  54. rowOverflowOpen: number
  55. /** Flex lines the row occupies with the note editor open; the editor must not reflow it. */
  56. rowLines: number
  57. /** Row items whose right edge escapes the column, editor closed. */
  58. itemsOutsideColumnClosed: number
  59. /** Row items whose right edge escapes the column, editor open; must equal the closed value. */
  60. itemsOutsideColumnOpen: number
  61. /** True when the portaled panel is NOT inside the column (escapes its overflow clip). */
  62. panelOutsideColumn: boolean
  63. /** True when the panel lies fully inside the viewport (the clamp holds). */
  64. panelWithinViewport: boolean
  65. /** Horizontal separation between the panel's left edge and the note trigger's, in px. */
  66. panelToTriggerGap: number
  67. }
  68. /**
  69. * Measure the feedback row (and the open popover, when present) at the current
  70. * viewport. The same reader serves the closed and open readings so the two
  71. * sides differ only by whether the editor is open.
  72. * @param page - the page under test.
  73. * @param width - the viewport width already applied, recorded with the reading.
  74. * @param editorOpen - true to also read the popover's relations; throws if it is absent.
  75. * @returns the stop's relations.
  76. */
  77. function measurePopover(page: Page, width: number, editorOpen: boolean): Promise<PopoverMetrics> {
  78. return page.evaluate(({ viewportWidth, open }) => {
  79. const rated = document.querySelector<HTMLElement>('button[aria-label="Remove rating"]')
  80. if (rated === null) throw new Error('no rated feedback control in the DOM')
  81. const row = rated.parentElement?.closest<HTMLElement>('div[class*="actions"]') ?? null
  82. if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control')
  83. const trigger = row.querySelector<HTMLElement>('button[aria-haspopup="dialog"]')
  84. if (trigger === null) throw new Error('the note trigger is not in the row')
  85. /**
  86. * The real flex items of the row. A slot contributor (the feedback strip)
  87. * arrives as a `display: contents` wrapper (the `assistant-actions` slot
  88. * renders inside a transparent `data-slot` div), which reports an all-zero
  89. * rect; a zero box would be miscounted as a phantom flex line. The actual
  90. * items are the boxes inside it.
  91. * @param element - the row whose items to read.
  92. * @returns the real flex-item boxes, in flex/DOM order.
  93. */
  94. const flexItemBoxes = (element: HTMLElement): DOMRect[] => {
  95. const boxes: DOMRect[] = []
  96. for (const child of Array.from(element.children)) {
  97. const el = child as HTMLElement
  98. const rect = el.getBoundingClientRect()
  99. if (el.style.display === 'contents') {
  100. boxes.push(...flexItemBoxes(el))
  101. } else if (rect.height > 0 && rect.width > 0) {
  102. boxes.push(rect)
  103. }
  104. }
  105. return boxes
  106. }
  107. /**
  108. * Group items into flex lines by overlapping vertical extent.
  109. * @param boxes - the row items' boxes, in DOM order.
  110. * @returns the number of distinct lines.
  111. */
  112. const countFlexLines = (boxes: DOMRect[]): number => {
  113. const centres: number[] = []
  114. for (const box of boxes) {
  115. const centre = box.top + box.height / 2
  116. if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre)
  117. }
  118. return centres.length
  119. }
  120. const column = row.closest<HTMLElement>('[data-conversation-scroll]')
  121. const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0)
  122. const itemRects = flexItemBoxes(row)
  123. // A half-pixel tolerance: subpixel layout puts a contained edge a fraction
  124. // over the boundary on some device scale factors.
  125. const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length
  126. // The editor is a portal, so the row measures identically whether the
  127. // editor is open or not; the closed/open fields differ by call so the sweep
  128. // can assert a zero delta on them.
  129. const overflow = row.scrollWidth - row.clientWidth
  130. let builder: {
  131. panelOutsideColumn: boolean
  132. panelWithinViewport: boolean
  133. panelToTriggerGap: number
  134. }
  135. if (!open) {
  136. builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 }
  137. } else {
  138. const panel = document.body.querySelector<HTMLElement>('[role="dialog"]')
  139. if (panel === null) throw new Error('the note popover is not open')
  140. const panelBox = panel.getBoundingClientRect()
  141. const triggerBox = trigger.getBoundingClientRect()
  142. const vw = window.innerWidth
  143. const vh = window.innerHeight
  144. builder = {
  145. // The panel portals out of the column, so the clip cannot reach it.
  146. panelOutsideColumn: column === null ? true : !column.contains(panel),
  147. panelWithinViewport:
  148. panelBox.left >= -0.5
  149. && panelBox.right <= vw + 0.5
  150. && panelBox.top >= -0.5
  151. && panelBox.bottom <= vh + 0.5,
  152. // The panel is fixed from the trigger's left, so a zero gap says it is
  153. // anchored; a clamp can only widen it.
  154. panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left),
  155. }
  156. }
  157. return {
  158. width: viewportWidth,
  159. rowOverflowClosed: overflow,
  160. rowOverflowOpen: overflow,
  161. rowLines: countFlexLines(itemRects),
  162. itemsOutsideColumnClosed: itemsOutsideColumn,
  163. itemsOutsideColumnOpen: itemsOutsideColumn,
  164. ...builder,
  165. }
  166. }, { viewportWidth: width, open: editorOpen })
  167. }
  168. /**
  169. * Render the golden body: one line per stop, relations and counts only. The
  170. * row-overflow and outside-column readings are deltas (open minus closed) so
  171. * the golden records that opening the editor leaves the row untouched, not an
  172. * absolute count that many unrelated controls could move.
  173. * @param stops - the measured stops, in sweep order.
  174. * @returns the golden body, without a trailing newline.
  175. */
  176. function renderGeometry(stops: PopoverMetrics[]): string {
  177. return [
  178. '# Assistant actions row with the feedback note popover open',
  179. '',
  180. '| viewport | row overflow delta | row lines | items-outside delta '
  181. + '| panel outside the column | panel within the viewport | panel-to-trigger gap |',
  182. '| --- | --- | --- | --- | --- | --- | --- |',
  183. ...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px `
  184. + `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} `
  185. + `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} `
  186. + `| ${String(stop.panelToTriggerGap)}px |`),
  187. ].join('\n')
  188. }
  189. describe('web e2e: the feedback note editor floats above the column', () => {
  190. let scaffold: WebScaffold
  191. let browser: Browser
  192. let page: Page
  193. let tripwire: ReturnType<typeof watchConsole>
  194. beforeAll(async () => {
  195. scaffold = await launchWebScaffold({})
  196. await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
  197. browser = await chromium.launch()
  198. page = await newEnglishPage(browser, 900)
  199. tripwire = watchConsole(page)
  200. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  201. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  202. }, 180_000)
  203. afterAll(async () => {
  204. await browser?.close()
  205. await scaffold?.close()
  206. })
  207. /**
  208. * Open the seeded transcript. The first treeitem is the collapsible group
  209. * row; the session itself is the row beneath it.
  210. * @returns nothing.
  211. */
  212. async function openSeededSession(): Promise<void> {
  213. const groupRow = page.locator('[role="treeitem"]').first()
  214. await groupRow.waitFor({ timeout: 15_000 })
  215. if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
  216. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  217. await sessionRow.waitFor({ timeout: 15_000 })
  218. await sessionRow.click()
  219. }
  220. /**
  221. * Resize to a viewport and read the row once its width stops moving. The
  222. * frame eases its column tracks, so reading straight after a resize can
  223. * report the previous viewport's relation.
  224. * @param width - viewport width to settle at.
  225. * @param editorOpen - whether the note editor is currently open; reads the popover relations when so.
  226. * @returns the row's (and popover's) readings at that width.
  227. */
  228. const settleAt = async (width: number, editorOpen: boolean): Promise<PopoverMetrics> => {
  229. await page.setViewportSize({ width, height: 900 })
  230. let previous = -1
  231. await expect.poll(async () => {
  232. const current = await page.evaluate(() =>
  233. document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1)
  234. const settled = current === previous
  235. previous = current
  236. return settled
  237. }, { timeout: 10_000 }).toBe(true)
  238. // The popover is JS-positioned from the trigger rect and re-places on
  239. // resize/scroll, so once the column width stops moving we nudge it to the
  240. // final layout; otherwise the panel can sit at a transient position from
  241. // mid-resize and the anchor reading would be off.
  242. await page.evaluate(() => window.dispatchEvent(new Event('resize')))
  243. return measurePopover(page, width, editorOpen)
  244. }
  245. /**
  246. * Rate a message, then for every stop read the row once with the note editor
  247. * closed and once with it open, handing the SAME measured readings to both
  248. * assertions so the golden and the assertions describe one measurement
  249. * rather than two runs that could disagree.
  250. * @returns the stops in {@link WIDTHS} order.
  251. */
  252. let swept: Promise<PopoverMetrics[]> | undefined
  253. const sweep = (): Promise<PopoverMetrics[]> => {
  254. swept ??= (async () => {
  255. await openSeededSession()
  256. await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
  257. // The controller defers its list read to the first hover or focus, so the
  258. // strip has to be touched before it can be rated.
  259. const like = page.getByRole('button', { name: 'Good response' }).first()
  260. await like.waitFor({ timeout: 30_000 })
  261. await like.scrollIntoViewIfNeeded()
  262. await like.hover()
  263. await like.click()
  264. await page.getByRole('button', { name: 'Remove rating' }).first()
  265. .waitFor({ timeout: 15_000 })
  266. const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first()
  267. const stops: PopoverMetrics[] = []
  268. for (const width of WIDTHS) {
  269. // Reset to the closed baseline at each stop before opening.
  270. if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click()
  271. const closed = await settleAt(width, false)
  272. await page.getByRole('button', { name: 'Add a note' }).first().click()
  273. await page.getByRole('dialog').waitFor({ timeout: 10_000 })
  274. const open = await settleAt(width, true)
  275. stops.push({
  276. width,
  277. rowOverflowClosed: closed.rowOverflowClosed,
  278. rowOverflowOpen: open.rowOverflowOpen,
  279. rowLines: open.rowLines,
  280. itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed,
  281. itemsOutsideColumnOpen: open.itemsOutsideColumnOpen,
  282. panelOutsideColumn: open.panelOutsideColumn,
  283. panelWithinViewport: open.panelWithinViewport,
  284. panelToTriggerGap: open.panelToTriggerGap,
  285. })
  286. }
  287. return stops
  288. })()
  289. return swept
  290. }
  291. it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => {
  292. onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout'))
  293. const stops = await sweep()
  294. for (const stop of stops) {
  295. // The popover lives outside the row, so opening it must not change the
  296. // row at all. This is the vacuity guard of the whole redesign: an inline
  297. // editor would widen or reflow the row, pushing the delta off zero.
  298. expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0)
  299. expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0)
  300. // The row is one 28px line; the editor never forces a reflow.
  301. expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1)
  302. // The panel escapes the column's overflow clip by living outside it.
  303. expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true)
  304. // The placement clamps the panel inside the viewport at every width.
  305. expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true)
  306. // The panel stays anchored to its trigger rather than drifting off.
  307. expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4)
  308. }
  309. expect(tripwire.pageErrors).toEqual([])
  310. }, 180_000)
  311. it('matches the committed geometry golden', async () => {
  312. onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden'))
  313. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
  314. }, 180_000)
  315. it('kept the console clean', () => {
  316. expect(tripwire.pageErrors).toEqual([])
  317. expect(tripwire.warnings).toEqual([])
  318. })
  319. })