interaction.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { expect, test } from "@playwright/test"
  2. import {
  3. defineVisualRegions,
  4. reportVisualStability,
  5. startVisualProbe,
  6. stopVisualProbe,
  7. visualPlan,
  8. } from "../../utils/visual-stability"
  9. import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
  10. test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
  11. const shellID = "prt_interaction_01_shell"
  12. const followingID = "prt_interaction_02_following"
  13. await setupTimeline(page, {
  14. messages: [
  15. userMessage(),
  16. assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
  17. ],
  18. settings: { shellToolPartsExpanded: false },
  19. cpuRate: 4,
  20. seedHistory: true,
  21. })
  22. const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
  23. await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
  24. const regions = defineVisualRegions({
  25. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  26. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  27. })
  28. const plan = visualPlan(regions, [
  29. { type: "required", regions: ["shell", "following"] },
  30. { type: "unique", regions: ["shell", "following"] },
  31. { type: "stable", regions: ["shell", "following"] },
  32. { type: "opacity", regions: "all" },
  33. { type: "continuity", regions: "all" },
  34. { type: "motion", regions: "all", maxPositionReversals: 0 },
  35. { type: "label-stability", regions: "all" },
  36. { type: "preserve-bottom-anchor" },
  37. { type: "flow", regions: ["shell", "following"] },
  38. ])
  39. await startVisualProbe(page, regions)
  40. await trigger.click()
  41. await expect(trigger).toHaveAttribute("aria-expanded", "true")
  42. await page.waitForTimeout(500)
  43. const expanded = await stopVisualProbe<keyof typeof regions>(page)
  44. await reportVisualStability(testInfo, "shell-expand", expanded, plan)
  45. await startVisualProbe(page, regions)
  46. await trigger.click()
  47. await expect(trigger).toHaveAttribute("aria-expanded", "false")
  48. await page.waitForTimeout(500)
  49. const collapsed = await stopVisualProbe<keyof typeof regions>(page)
  50. await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
  51. })
  52. test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
  53. const ids = [
  54. "prt_interaction_01_read",
  55. "prt_interaction_02_glob",
  56. "prt_interaction_03_grep",
  57. "prt_interaction_04_list",
  58. ]
  59. const group = `[data-timeline-part-ids="${ids.join(",")}"]`
  60. const followingID = "prt_interaction_context_following"
  61. await setupTimeline(page, {
  62. messages: [
  63. userMessage(),
  64. assistantMessage([
  65. toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
  66. toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
  67. toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
  68. toolPart(ids[3]!, "list", "completed", { path: "src" }),
  69. textPart(followingID, "Following context expansion"),
  70. ]),
  71. ],
  72. cpuRate: 4,
  73. seedHistory: true,
  74. })
  75. const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
  76. await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
  77. for (const [name, expanded] of [
  78. ["context-expand", true],
  79. ["context-collapse", false],
  80. ["context-reexpand", true],
  81. ] as const) {
  82. const regions = defineVisualRegions({
  83. context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
  84. following: {
  85. selector: `[data-timeline-part-id="${followingID}"]`,
  86. closest: '[data-timeline-row="AssistantPart"]',
  87. },
  88. })
  89. await startVisualProbe(page, regions)
  90. await trigger.click()
  91. await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
  92. await page.waitForTimeout(500)
  93. const trace = await stopVisualProbe<keyof typeof regions>(page)
  94. await reportVisualStability(
  95. testInfo,
  96. name,
  97. trace,
  98. visualPlan(regions, [
  99. { type: "required", regions: ["context", "following"] },
  100. { type: "unique", regions: ["context", "following"] },
  101. { type: "stable", regions: ["context", "following"] },
  102. { type: "opacity", regions: "all" },
  103. { type: "continuity", regions: "all" },
  104. { type: "motion", regions: "all", maxPositionReversals: 0 },
  105. { type: "label-stability", regions: "all" },
  106. { type: "preserve-bottom-anchor" },
  107. { type: "flow", regions: ["context", "following"] },
  108. ]),
  109. )
  110. }
  111. })
  112. test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
  113. const editID = "prt_interaction_edit"
  114. const followingID = "prt_interaction_edit_following"
  115. await setupTimeline(page, {
  116. messages: [
  117. userMessage(),
  118. assistantMessage([
  119. toolPart(
  120. editID,
  121. "edit",
  122. "completed",
  123. { filePath: "src/edit.ts" },
  124. {
  125. metadata: {
  126. filediff: {
  127. file: "src/edit.ts",
  128. additions: 40,
  129. deletions: 40,
  130. before: source(40, false),
  131. after: source(40, true),
  132. },
  133. },
  134. },
  135. ),
  136. textPart(followingID, "Following edit expansion"),
  137. ]),
  138. ],
  139. settings: { editToolPartsExpanded: false },
  140. cpuRate: 4,
  141. seedHistory: true,
  142. })
  143. const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
  144. await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
  145. const regions = defineVisualRegions({
  146. edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  147. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  148. })
  149. await startVisualProbe(page, regions)
  150. await trigger.click()
  151. await expect(trigger).toHaveAttribute("aria-expanded", "true")
  152. await page.waitForTimeout(900)
  153. const trace = await stopVisualProbe<keyof typeof regions>(page)
  154. await reportVisualStability(
  155. testInfo,
  156. "edit-expand",
  157. trace,
  158. visualPlan(regions, [
  159. { type: "required", regions: ["edit", "following"] },
  160. { type: "unique", regions: ["edit", "following"] },
  161. { type: "stable", regions: ["edit", "following"] },
  162. { type: "opacity", regions: "all" },
  163. { type: "continuity", regions: "all" },
  164. { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
  165. { type: "label-stability", regions: "all" },
  166. { type: "preserve-bottom-anchor" },
  167. { type: "flow", regions: ["edit", "following"] },
  168. ]),
  169. )
  170. })
  171. test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
  172. const firstUser = userMessage(undefined, {
  173. summary: {
  174. diffs: Array.from({ length: 12 }, (_, index) => ({
  175. file: `src/diff-${index}.ts`,
  176. additions: 1,
  177. deletions: 1,
  178. patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
  179. })),
  180. },
  181. })
  182. const nextUserID = "msg_2000_diff_interaction_user"
  183. await setupTimeline(page, {
  184. messages: [
  185. firstUser,
  186. assistantMessage(),
  187. userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
  188. assistantMessage([], {
  189. id: "msg_2001_diff_interaction_assistant",
  190. parentID: nextUserID,
  191. created: 1700000011000,
  192. }),
  193. ],
  194. cpuRate: 4,
  195. })
  196. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  197. await scroller.evaluate((element) => (element.scrollTop = 0))
  198. const diff = page.locator('[data-timeline-row="DiffSummary"]')
  199. const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
  200. await expect(diff).toBeVisible()
  201. const regions = defineVisualRegions({
  202. diff: { selector: '[data-timeline-row="DiffSummary"]' },
  203. following: { selector: `[data-message-id="${nextUserID}"]` },
  204. })
  205. await startVisualProbe(page, regions)
  206. await page.getByText(/show all/i).click()
  207. await page.waitForTimeout(500)
  208. await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
  209. await page.waitForTimeout(900)
  210. const trace = await stopVisualProbe<keyof typeof regions>(page)
  211. await reportVisualStability(
  212. testInfo,
  213. "diff-summary-expand",
  214. trace,
  215. visualPlan(regions, [
  216. { type: "required", regions: ["diff", "following"] },
  217. { type: "unique", regions: ["diff", "following"] },
  218. { type: "stable", regions: ["diff", "following"] },
  219. { type: "opacity", regions: "all" },
  220. { type: "continuity", regions: "all" },
  221. { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
  222. { type: "label-stability", regions: "all" },
  223. { type: "flow", regions: ["diff", "following"] },
  224. ]),
  225. )
  226. })
  227. function lines(count: number) {
  228. return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
  229. }
  230. function source(count: number, changed: boolean) {
  231. return Array.from(
  232. { length: count },
  233. (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
  234. ).join("")
  235. }