session-timeline-shell-outline.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { expect, test, type Locator, type Page } from "@playwright/test"
  2. import {
  3. assistantMessage,
  4. setupTimeline,
  5. shell,
  6. textPart,
  7. toolPart,
  8. userMessage,
  9. } from "../performance/timeline-stability/fixture"
  10. for (const deviceScaleFactor of [1.25, 1.5]) {
  11. test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
  12. const shellID = "prt_shell_outline"
  13. const timeline = await setupTimeline(page, {
  14. messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
  15. settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
  16. reducedMotion: true,
  17. deviceScaleFactor,
  18. })
  19. const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
  20. const output = part.locator('[data-component="bash-output"]')
  21. const row = page.locator("[data-timeline-key]", { has: part })
  22. await expect(output).toBeVisible()
  23. await timeline.settle()
  24. const geometry = await row.evaluate((element) => {
  25. const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
  26. if (!output) throw new Error("Shell output is unavailable")
  27. const rowRect = element.getBoundingClientRect()
  28. const outputRect = output.getBoundingClientRect()
  29. // Match a rounded-down measurement at a fractional device-pixel phase.
  30. element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
  31. element.style.transform = "translateY(0.25px)"
  32. output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
  33. output.style.setProperty("background", "rgb(0, 0, 0)", "important")
  34. const style = getComputedStyle(output)
  35. return {
  36. outputWidth: outputRect.width,
  37. outputHeight: outputRect.height,
  38. borderColor: style.borderTopColor,
  39. boxShadow: style.boxShadow,
  40. clipMargin: getComputedStyle(element).overflowClipMargin,
  41. }
  42. })
  43. await timeline.settle()
  44. const clipped = await row.evaluate((element) => {
  45. const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
  46. return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
  47. })
  48. expect(clipped).toBeCloseTo(0.49, 1)
  49. expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
  50. const edges = await captureCardEdges(page, output)
  51. expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
  52. expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
  53. expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
  54. expect(geometry.boxShadow).toBe("none")
  55. expect(geometry.clipMargin).toBe("0.5px")
  56. expect(edges.magenta.top).toBeGreaterThan(0.75)
  57. expect(edges.magenta.bottom).toBeGreaterThan(0.75)
  58. expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
  59. })
  60. }
  61. test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
  62. const patchID = "prt_patch_outline"
  63. const file = {
  64. filePath: "src/outline.ts",
  65. relativePath: "src/outline.ts",
  66. type: "update",
  67. additions: 1,
  68. deletions: 1,
  69. before: "const outline = false\n",
  70. after: "const outline = true\n",
  71. }
  72. const timeline = await setupTimeline(page, {
  73. messages: [
  74. userMessage(),
  75. assistantMessage([
  76. toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
  77. ]),
  78. ],
  79. settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
  80. reducedMotion: true,
  81. })
  82. const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
  83. const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
  84. const row = page.locator("[data-timeline-key]", { has: part })
  85. await expect(card).toBeVisible()
  86. await timeline.settle()
  87. const geometry = await row.evaluate((element) => {
  88. const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
  89. if (!card) throw new Error("Patch card is unavailable")
  90. const rowRect = element.getBoundingClientRect()
  91. const cardRect = card.getBoundingClientRect()
  92. element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
  93. const clipMargin = getComputedStyle(element).overflowClipMargin
  94. const bottom = element.getBoundingClientRect().bottom
  95. return {
  96. overflow: card.getBoundingClientRect().bottom - bottom,
  97. paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
  98. clipMargin,
  99. cardWidth: cardRect.width,
  100. cardHeight: cardRect.height,
  101. }
  102. })
  103. await timeline.settle()
  104. expect(geometry.overflow).toBeCloseTo(0.49, 1)
  105. expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
  106. const edges = await captureCardEdges(page, card)
  107. expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
  108. expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
  109. expect(edges.luminance.top).toBeLessThan(245)
  110. expect(edges.luminance.bottom).toBeLessThan(245)
  111. expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
  112. expect(geometry.clipMargin).toBe("0.5px")
  113. })
  114. test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
  115. const secondUserID = "msg_outline_second_user"
  116. await setupTimeline(page, {
  117. messages: [
  118. userMessage(undefined, {
  119. summary: {
  120. diffs: [
  121. {
  122. file: "src/summary.ts",
  123. additions: 1,
  124. deletions: 1,
  125. patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
  126. },
  127. ],
  128. },
  129. }),
  130. assistantMessage([textPart("prt_outline_text", "Assistant text")]),
  131. userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
  132. assistantMessage([], {
  133. id: "msg_outline_second_assistant",
  134. parentID: secondUserID,
  135. created: 1700000011000,
  136. }),
  137. ],
  138. })
  139. await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
  140. await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
  141. const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
  142. elements.map((element) => ({
  143. tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
  144. clipMargin: getComputedStyle(element).overflowClipMargin,
  145. })),
  146. )
  147. expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
  148. expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
  149. })
  150. async function captureCardEdges(page: Page, card: Locator) {
  151. const box = await card.boundingBox()
  152. if (!box) throw new Error("Tool card bounds are unavailable")
  153. const viewport = page.viewportSize()
  154. if (!viewport) throw new Error("Viewport bounds are unavailable")
  155. const screenshot = await page.screenshot()
  156. return page.evaluate(
  157. async ({ source, box, viewport }) => {
  158. const image = new Image()
  159. image.src = source
  160. await image.decode()
  161. const canvas = document.createElement("canvas")
  162. canvas.width = image.naturalWidth
  163. canvas.height = image.naturalHeight
  164. const context = canvas.getContext("2d")
  165. if (!context) throw new Error("2D canvas is unavailable")
  166. context.drawImage(image, 0, 0)
  167. const scale = {
  168. x: image.naturalWidth / viewport.width,
  169. y: image.naturalHeight / viewport.height,
  170. }
  171. const rows = (candidates: number[]) => {
  172. const left = Math.floor((box.x + 8) * scale.x)
  173. const width = Math.floor((box.width - 16) * scale.x)
  174. return candidates.map((row) => {
  175. const pixels = context.getImageData(left, row, width, 1).data
  176. const indexes = Array.from({ length: width }, (_, index) => index * 4)
  177. return {
  178. luminance:
  179. indexes
  180. .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
  181. .reduce((sum, value) => sum + value, 0) / width,
  182. magenta:
  183. indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
  184. .length / width,
  185. }
  186. })
  187. }
  188. const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
  189. const columns = new Uint32Array(image.naturalWidth)
  190. for (let index = 0; index < pixels.length; index += 4) {
  191. if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
  192. columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
  193. }
  194. const top = box.y * scale.y
  195. const bottom = (box.y + box.height) * scale.y
  196. const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
  197. const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
  198. return {
  199. box,
  200. luminance: {
  201. top: Math.min(...topRows.map((row) => row.luminance)),
  202. bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
  203. },
  204. magenta: {
  205. top: Math.max(...topRows.map((row) => row.magenta)),
  206. bottom: Math.max(...bottomRows.map((row) => row.magenta)),
  207. vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
  208. },
  209. }
  210. },
  211. {
  212. source: `data:image/png;base64,${screenshot.toString("base64")}`,
  213. viewport,
  214. box,
  215. },
  216. )
  217. }