shell-matrix.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { test } from "@playwright/test"
  2. import {
  3. defineVisualRegions,
  4. reportVisualStability,
  5. startVisualProbe,
  6. stopVisualProbe,
  7. visualPlan,
  8. } from "../../utils/visual-stability"
  9. import {
  10. assistantMessage,
  11. partUpdated,
  12. setupTimeline,
  13. shell,
  14. textPart,
  15. userMessage,
  16. waitForVisualSettle,
  17. } from "./fixture"
  18. const profiles = [
  19. {
  20. name: "empty running to completed",
  21. updates: [{ state: "completed" as const, output: "", delay: 350 }],
  22. },
  23. {
  24. name: "50 lines arriving incrementally",
  25. updates: [
  26. { state: "running" as const, output: lines(1), delay: 100 },
  27. { state: "running" as const, output: lines(10), delay: 160 },
  28. { state: "running" as const, output: lines(25), delay: 90 },
  29. { state: "running" as const, output: lines(50), delay: 220 },
  30. { state: "completed" as const, output: lines(50), delay: 500 },
  31. ],
  32. },
  33. {
  34. name: "wide ANSI and CRLF output",
  35. updates: [
  36. {
  37. state: "running" as const,
  38. output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
  39. "\r\n",
  40. ),
  41. delay: 240,
  42. },
  43. {
  44. state: "completed" as const,
  45. output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
  46. delay: 500,
  47. },
  48. ],
  49. },
  50. ] as const
  51. for (const profile of profiles) {
  52. test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
  53. const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
  54. const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
  55. const timeline = await setupTimeline(page, {
  56. messages: [
  57. userMessage(),
  58. assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
  59. completed: false,
  60. }),
  61. ],
  62. settings: { shellToolPartsExpanded: true },
  63. cpuRate: 4,
  64. seedHistory: true,
  65. })
  66. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  67. await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
  68. await waitForVisualSettle(page, [
  69. `[data-timeline-part-id="${shellID}"]`,
  70. `[data-timeline-part-id="${followingID}"]`,
  71. ])
  72. const regions = defineVisualRegions({
  73. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  74. following: {
  75. selector: `[data-timeline-part-id="${followingID}"]`,
  76. closest: '[data-timeline-row="AssistantPart"]',
  77. },
  78. })
  79. await startVisualProbe(page, regions)
  80. for (const update of profile.updates) {
  81. await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
  82. }
  83. const trace = await stopVisualProbe<keyof typeof regions>(page)
  84. await reportVisualStability(
  85. testInfo,
  86. `shell-${profiles.indexOf(profile)}`,
  87. trace,
  88. visualPlan(
  89. regions,
  90. [
  91. { type: "required", regions: ["shell", "following"] },
  92. { type: "unique", regions: ["shell", "following"] },
  93. { type: "stable", regions: ["shell", "following"] },
  94. { type: "opacity", regions: "all" },
  95. { type: "continuity", regions: "all" },
  96. { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
  97. { type: "label-stability", regions: "all" },
  98. { type: "preserve-bottom-anchor" },
  99. { type: "flow", regions: ["shell", "following"] },
  100. ],
  101. { perMarker: true },
  102. ),
  103. )
  104. })
  105. }
  106. test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
  107. const shellID = "prt_matrix_collapsed_01_shell"
  108. const followingID = "prt_matrix_collapsed_02_following"
  109. const timeline = await setupTimeline(page, {
  110. messages: [
  111. userMessage(),
  112. assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
  113. completed: false,
  114. }),
  115. ],
  116. settings: { shellToolPartsExpanded: false },
  117. cpuRate: 4,
  118. seedHistory: true,
  119. })
  120. await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
  121. const regions = defineVisualRegions({
  122. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  123. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  124. })
  125. await startVisualProbe(page, regions)
  126. await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
  127. await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
  128. const trace = await stopVisualProbe<keyof typeof regions>(page)
  129. await reportVisualStability(
  130. testInfo,
  131. "collapsed-shell",
  132. trace,
  133. visualPlan(
  134. regions,
  135. [
  136. { type: "required", regions: ["shell", "following"] },
  137. { type: "unique", regions: ["shell", "following"] },
  138. { type: "stable", regions: ["shell", "following"] },
  139. { type: "opacity", regions: "all" },
  140. { type: "continuity", regions: "all" },
  141. { type: "motion", regions: ["following"], maxPositionReversals: 0 },
  142. { type: "label-stability", regions: "all" },
  143. { type: "flow", regions: ["shell", "following"] },
  144. ],
  145. { perMarker: true },
  146. ),
  147. )
  148. })
  149. test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
  150. const shellID = "prt_matrix_error_01_shell"
  151. const followingID = "prt_matrix_error_02_following"
  152. const timeline = await setupTimeline(page, {
  153. messages: [
  154. userMessage(),
  155. assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
  156. completed: false,
  157. }),
  158. ],
  159. settings: { shellToolPartsExpanded: true },
  160. cpuRate: 4,
  161. seedHistory: true,
  162. })
  163. await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
  164. const regions = defineVisualRegions({
  165. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  166. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  167. })
  168. await startVisualProbe(page, regions)
  169. await timeline.send(
  170. partUpdated({
  171. ...shell(shellID, "error"),
  172. state: {
  173. status: "error",
  174. input: { command: `echo ${shellID}` },
  175. error: "Command failed after output",
  176. metadata: {},
  177. time: { start: 1700000001000, end: 1700000002000 },
  178. },
  179. }),
  180. 500,
  181. )
  182. const trace = await stopVisualProbe<keyof typeof regions>(page)
  183. await reportVisualStability(
  184. testInfo,
  185. "shell-error",
  186. trace,
  187. visualPlan(
  188. regions,
  189. [
  190. { type: "required", regions: ["shell", "following"] },
  191. { type: "unique", regions: ["shell", "following"] },
  192. { type: "stable", regions: ["shell", "following"] },
  193. { type: "opacity", regions: "all" },
  194. { type: "continuity", regions: "all" },
  195. { type: "motion", regions: ["following"], maxPositionReversals: 0 },
  196. { type: "label-stability", regions: "all" },
  197. { type: "preserve-bottom-anchor" },
  198. { type: "flow", regions: ["shell", "following"] },
  199. ],
  200. { perMarker: true },
  201. ),
  202. )
  203. })
  204. test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
  205. const shellID = "prt_late_text_01_shell"
  206. const followingID = "prt_late_text_02_following"
  207. const timeline = await setupTimeline(page, {
  208. messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
  209. settings: { shellToolPartsExpanded: true },
  210. cpuRate: 4,
  211. seedHistory: true,
  212. })
  213. await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
  214. const regions = defineVisualRegions({
  215. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  216. following: {
  217. selector: `[data-timeline-part-id="${followingID}"]`,
  218. closest: '[data-timeline-row="AssistantPart"]',
  219. },
  220. })
  221. await startVisualProbe(page, regions)
  222. await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
  223. await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
  224. await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
  225. const trace = await stopVisualProbe<keyof typeof regions>(page)
  226. await reportVisualStability(
  227. testInfo,
  228. "late-text-before-shell-output",
  229. trace,
  230. visualPlan(
  231. regions,
  232. [
  233. { type: "required", regions: ["shell", "following"] },
  234. { type: "unique", regions: ["shell", "following"] },
  235. { type: "stable", regions: ["shell"] },
  236. { type: "opacity", regions: "all" },
  237. { type: "continuity", regions: "all" },
  238. { type: "motion", regions: "all", maxPositionReversals: 0 },
  239. { type: "label-stability", regions: "all" },
  240. { type: "preserve-bottom-anchor" },
  241. { type: "flow", regions: ["shell", "following"] },
  242. ],
  243. { perMarker: true },
  244. ),
  245. )
  246. })
  247. function lines(count: number) {
  248. return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
  249. }