lifecycle.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { expect, test } from "@playwright/test"
  2. import {
  3. defineVisualRegions,
  4. mapVisualRegions,
  5. reportVisualStability,
  6. startVisualProbe,
  7. stopVisualProbe,
  8. visualPlan,
  9. } from "../../utils/visual-stability"
  10. import {
  11. assistantMessage,
  12. completedAssistantInfo,
  13. messageUpdated,
  14. partDelta,
  15. partUpdated,
  16. reasoningPart,
  17. setupTimeline,
  18. shell,
  19. status,
  20. textPart,
  21. userMessage,
  22. waitForVisualSettle,
  23. } from "./fixture"
  24. test.describe("timeline visual lifecycle stability", () => {
  25. test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
  26. test.setTimeout(180_000)
  27. const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
  28. const initial = ids.map((id) => shell(id, "running"))
  29. const followingID = "prt_parallel_04_following"
  30. const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
  31. completed: false,
  32. })
  33. const timeline = await setupTimeline(page, {
  34. messages: [userMessage(), assistant],
  35. settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
  36. cpuRate: 4,
  37. eventRetry: 24,
  38. seedHistory: true,
  39. })
  40. await timeline.send(status("busy"), 150)
  41. for (const id of ids) await timeline.waitForPart(id)
  42. const scroller = page.locator(".scroll-view__viewport", {
  43. has: page.locator('[data-timeline-row="AssistantPart"]'),
  44. })
  45. await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
  46. const regions = defineVisualRegions({
  47. prt_shell_empty: shellRegion(ids[0]),
  48. prt_shell_short: shellRegion(ids[1]),
  49. prt_shell_long: shellRegion(ids[2]),
  50. following: shellRegion(followingID),
  51. })
  52. await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
  53. await startVisualProbe(page, regions)
  54. await timeline.sendAll([
  55. { event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
  56. { event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
  57. { event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
  58. { event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
  59. { event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
  60. { event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
  61. { event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
  62. { event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
  63. { event: status("idle"), delay: 700 },
  64. ])
  65. const trace = await stopVisualProbe<keyof typeof regions>(page)
  66. await reportVisualStability(
  67. testInfo,
  68. "parallel-shells",
  69. trace,
  70. visualPlan(
  71. regions,
  72. [
  73. { type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
  74. { type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
  75. { type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
  76. { type: "opacity", regions: "all" },
  77. { type: "continuity", regions: "all" },
  78. { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
  79. { type: "label-stability", regions: "all" },
  80. { type: "preserve-bottom-anchor" },
  81. { type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
  82. ],
  83. { perMarker: true },
  84. ),
  85. )
  86. await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
  87. const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
  88. await short.locator('[data-slot="collapsible-trigger"]').click()
  89. await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
  90. await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
  91. await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
  92. })
  93. test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
  94. page,
  95. }, testInfo) => {
  96. const reasoningID = "prt_reasoning_visible"
  97. const textID = "prt_streamed_text"
  98. const assistant = assistantMessage([], { completed: false })
  99. const timeline = await setupTimeline(page, {
  100. messages: [userMessage(), assistant],
  101. settings: { showReasoningSummaries: true },
  102. cpuRate: 4,
  103. })
  104. await timeline.send(status("busy"), 120)
  105. await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
  106. const regions = defineVisualRegions({
  107. thinking: { selector: '[data-timeline-row="Thinking"]' },
  108. reasoning: {
  109. selector: `[data-timeline-part-id="${reasoningID}"]`,
  110. closest: '[data-timeline-row="AssistantPart"]',
  111. },
  112. text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  113. })
  114. await startVisualProbe(page, regions)
  115. await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
  116. await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
  117. await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
  118. await timeline.waitForPart(reasoningID)
  119. await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
  120. await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
  121. await timeline.send(partDelta(textID, " **stable"), 90)
  122. await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
  123. await timeline.send(partDelta(textID, "](https://example.com)."), 220)
  124. await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
  125. await timeline.send(status("idle"), 500)
  126. const trace = await stopVisualProbe<keyof typeof regions>(page)
  127. await reportVisualStability(
  128. testInfo,
  129. "reasoning-text-handoff",
  130. trace,
  131. visualPlan(regions, [
  132. { type: "required", regions: ["reasoning", "text"] },
  133. { type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
  134. { type: "unique", regions: ["reasoning", "text"] },
  135. { type: "stable", regions: ["reasoning", "text"] },
  136. { type: "opacity", regions: "all" },
  137. { type: "continuity", regions: "all" },
  138. { type: "motion", regions: "all", maxReversals: 4 },
  139. { type: "label-stability", regions: "all" },
  140. { type: "flow", regions: ["reasoning", "text"] },
  141. ]),
  142. )
  143. await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
  144. })
  145. })
  146. function lines(count: number) {
  147. return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
  148. }
  149. function shellRegion(id: string) {
  150. return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
  151. }