Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- packages/app/e2e/AGENTS.md +16 -0
- packages/app/e2e/performance/timeline-stability/README.md +61 -0
- packages/app/e2e/performance/timeline-stability/adverse.spec.ts +250 -0
- packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts +192 -0
- packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts +113 -0
- packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +121 -0
- packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +113 -0
- packages/app/e2e/performance/timeline-stability/fixture.test.ts +68 -0
- packages/app/e2e/performance/timeline-stability/fixture.ts +568 -0
- packages/app/e2e/performance/timeline-stability/interaction.spec.ts +242 -0
- packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts +157 -0
- packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts +75 -0
- packages/app/e2e/performance/timeline-stability/playwright.config.ts +17 -0
- packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +353 -0
- packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts +255 -0
- packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts +106 -0
- packages/app/e2e/performance/timeline-stability/tools.spec.ts +198 -0
- packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts +272 -0
- packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts +87 -0
- packages/app/e2e/performance/timeline/first-navigation-metrics.ts +32 -0
- packages/app/e2e/performance/timeline/first-navigation-probe.ts +86 -0
- packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts +114 -0
- packages/app/e2e/performance/timeline/navigation-milestones.ts +128 -0
- packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts +312 -0
- packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +172 -0
- packages/app/e2e/performance/timeline/session-tab-flash.spec.ts +67 -0
- packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts +251 -0
- packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +144 -0
- packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts +59 -0
- packages/app/e2e/performance/timeline/session-tab-switch-probe.ts +226 -0
- packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +504 -0
- packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts +306 -0
- packages/app/e2e/performance/timeline/session-timeline-profile.ts +40 -0
- packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts +547 -0
- packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +369 -0
- packages/app/e2e/performance/timeline/timeline-test-helpers.ts +134 -0
- packages/app/e2e/performance/unit/chrome-trace-write.test.ts +15 -0
- packages/app/e2e/performance/unit/first-navigation-metrics.test.ts +32 -0
- packages/app/e2e/performance/unit/mock-server.test.ts +46 -0
- packages/app/e2e/performance/unit/navigation-milestones.test.ts +34 -0
- packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts +42 -0
- packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts +86 -0
- packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts +55 -0
- packages/app/e2e/performance/unit/timeline-test-helpers.test.ts +10 -0
- packages/app/e2e/performance/unit/visual-stability.test.ts +392 -0
- packages/app/e2e/reproduction/timeline-suspense/index.html +34 -0
- packages/app/e2e/reproduction/timeline-suspense/main.tsx +315 -0
- packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts +34 -0
- packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +179 -0
- packages/app/e2e/reproduction/timeline-suspense/vite.config.ts +7 -0
packages/app/e2e/AGENTS.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Required Reading
|
| 2 |
+
|
| 3 |
+
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
|
| 4 |
+
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
|
| 5 |
+
|
| 6 |
+
## Test Hygiene
|
| 7 |
+
|
| 8 |
+
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
|
| 9 |
+
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
|
| 10 |
+
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
|
| 11 |
+
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
|
| 12 |
+
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
|
| 13 |
+
- Register event and network waits before the action that triggers them.
|
| 14 |
+
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
|
| 15 |
+
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
|
| 16 |
+
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
|
packages/app/e2e/performance/timeline-stability/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Timeline Layout Continuity
|
| 2 |
+
|
| 3 |
+
Run from `packages/app`:
|
| 4 |
+
|
| 5 |
+
```sh
|
| 6 |
+
bun run test:stability
|
| 7 |
+
```
|
| 8 |
+
|
| 9 |
+
The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device.
|
| 10 |
+
|
| 11 |
+
## What It Proves
|
| 12 |
+
|
| 13 |
+
The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as:
|
| 14 |
+
|
| 15 |
+
- Preserve a visible semantic anchor while the user is away from the bottom.
|
| 16 |
+
- Preserve end anchoring while active content grows or new content appears.
|
| 17 |
+
- Keep adjacent visible rows ordered without material overlap.
|
| 18 |
+
- Keep user-selected disclosure state through updates and virtualization.
|
| 19 |
+
- Avoid a sampled blank interval while one visible surface replaces another.
|
| 20 |
+
- Preserve logical row and control identity where local state or focus depends on it.
|
| 21 |
+
- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement.
|
| 22 |
+
|
| 23 |
+
The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures.
|
| 24 |
+
|
| 25 |
+
## What It Does Not Prove
|
| 26 |
+
|
| 27 |
+
The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled.
|
| 28 |
+
|
| 29 |
+
The suite does not provide complete coverage for:
|
| 30 |
+
|
| 31 |
+
- Compositor-only or raster-only glitches.
|
| 32 |
+
- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion.
|
| 33 |
+
- Physical display refresh rates, native OS scaling, or a named low-end device.
|
| 34 |
+
- TCP packetization, proxy buffering, or the complete real server/provider pipeline.
|
| 35 |
+
|
| 36 |
+
Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions.
|
| 37 |
+
|
| 38 |
+
For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing.
|
| 39 |
+
|
| 40 |
+
## Test Layers
|
| 41 |
+
|
| 42 |
+
- **Projection:** admitted rows, grouping, labels, and final visible states.
|
| 43 |
+
- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration.
|
| 44 |
+
- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior.
|
| 45 |
+
- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs.
|
| 46 |
+
- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events.
|
| 47 |
+
- **Oracle contract:** pure analyzer and browser sampler calibration tests.
|
| 48 |
+
|
| 49 |
+
Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior.
|
| 50 |
+
|
| 51 |
+
## Diagnostics
|
| 52 |
+
|
| 53 |
+
Failures retain:
|
| 54 |
+
|
| 55 |
+
- `video.webm`
|
| 56 |
+
- `trace.zip`
|
| 57 |
+
- failure screenshot
|
| 58 |
+
- sampled DOM/layout trace JSON
|
| 59 |
+
- event markers and summarized violations
|
| 60 |
+
|
| 61 |
+
The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does.
|
packages/app/e2e/performance/timeline-stability/adverse.spec.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 10 |
+
assistantMessage,
|
| 11 |
+
partUpdated,
|
| 12 |
+
setupTimeline,
|
| 13 |
+
shell,
|
| 14 |
+
textPart,
|
| 15 |
+
toolPart,
|
| 16 |
+
userMessage,
|
| 17 |
+
waitForVisualSettle,
|
| 18 |
+
type TimelineMessage,
|
| 19 |
+
} from "./fixture"
|
| 20 |
+
|
| 21 |
+
test.describe("timeline adverse visual stability", () => {
|
| 22 |
+
test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
|
| 23 |
+
const activeShellID = "prt_adverse_01_shell"
|
| 24 |
+
const messages = [
|
| 25 |
+
...history(24),
|
| 26 |
+
userMessage(),
|
| 27 |
+
assistantMessage([shell(activeShellID, "running")], { completed: false }),
|
| 28 |
+
]
|
| 29 |
+
const timeline = await setupTimeline(page, {
|
| 30 |
+
messages,
|
| 31 |
+
settings: { shellToolPartsExpanded: true },
|
| 32 |
+
cpuRate: 4,
|
| 33 |
+
eventRetry: 30,
|
| 34 |
+
})
|
| 35 |
+
const scroller = page.locator(".scroll-view__viewport", {
|
| 36 |
+
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
| 37 |
+
})
|
| 38 |
+
await scroller.evaluate((element) => {
|
| 39 |
+
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
|
| 40 |
+
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
|
| 41 |
+
})
|
| 42 |
+
await page.waitForTimeout(150)
|
| 43 |
+
await expect
|
| 44 |
+
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 45 |
+
.toBeGreaterThan(100)
|
| 46 |
+
const anchor = await scroller.evaluate((element) => {
|
| 47 |
+
const view = element.getBoundingClientRect()
|
| 48 |
+
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
| 49 |
+
const rect = row.getBoundingClientRect()
|
| 50 |
+
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
| 51 |
+
})?.dataset.timelineKey
|
| 52 |
+
})
|
| 53 |
+
expect(anchor).toBeTruthy()
|
| 54 |
+
await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
|
| 55 |
+
|
| 56 |
+
const regions = defineVisualRegions({
|
| 57 |
+
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
| 58 |
+
})
|
| 59 |
+
await startVisualProbe(page, regions)
|
| 60 |
+
await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
|
| 61 |
+
await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
|
| 62 |
+
await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
|
| 63 |
+
await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
|
| 64 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 65 |
+
await reportVisualStability(
|
| 66 |
+
testInfo,
|
| 67 |
+
"scrolled-away-shell",
|
| 68 |
+
trace,
|
| 69 |
+
visualPlan(regions, [
|
| 70 |
+
{ type: "required", regions: ["anchor"] },
|
| 71 |
+
{ type: "unique", regions: ["anchor"] },
|
| 72 |
+
{ type: "stable", regions: ["anchor"] },
|
| 73 |
+
{ type: "fixed", regions: ["anchor"] },
|
| 74 |
+
{ type: "opacity", regions: "all" },
|
| 75 |
+
{ type: "continuity", regions: "all" },
|
| 76 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 77 |
+
{ type: "label-stability", regions: "all" },
|
| 78 |
+
]),
|
| 79 |
+
)
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
test("preserves an explicit shell state across virtualization", async ({ page }) => {
|
| 83 |
+
const targetID = "prt_virtual_shell"
|
| 84 |
+
const messages = [
|
| 85 |
+
userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
|
| 86 |
+
assistantMessage([shell(targetID, "completed", lines(20))], {
|
| 87 |
+
id: "msg_0001_virtual_assistant",
|
| 88 |
+
parentID: "msg_0000_virtual_user",
|
| 89 |
+
created: 1700000001000,
|
| 90 |
+
}),
|
| 91 |
+
...history(35, 10),
|
| 92 |
+
]
|
| 93 |
+
await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
|
| 94 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 95 |
+
await scroller.evaluate((element) => {
|
| 96 |
+
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
|
| 97 |
+
element.scrollTop = 0
|
| 98 |
+
})
|
| 99 |
+
await page.waitForTimeout(300)
|
| 100 |
+
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
|
| 101 |
+
await expect(trigger).toBeVisible()
|
| 102 |
+
await trigger.click()
|
| 103 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 104 |
+
|
| 105 |
+
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
| 106 |
+
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
|
| 107 |
+
await scroller.evaluate((element) => (element.scrollTop = 0))
|
| 108 |
+
await expect(trigger).toBeVisible()
|
| 109 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
|
| 113 |
+
const shellID = "prt_narrow_01_shell"
|
| 114 |
+
const followingID = "prt_narrow_02_following"
|
| 115 |
+
const timeline = await setupTimeline(page, {
|
| 116 |
+
messages: [
|
| 117 |
+
userMessage(),
|
| 118 |
+
assistantMessage(
|
| 119 |
+
[shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
|
| 120 |
+
{
|
| 121 |
+
completed: false,
|
| 122 |
+
},
|
| 123 |
+
),
|
| 124 |
+
],
|
| 125 |
+
settings: { shellToolPartsExpanded: true },
|
| 126 |
+
viewport: { width: 430, height: 800 },
|
| 127 |
+
cpuRate: 4,
|
| 128 |
+
})
|
| 129 |
+
await waitForVisualSettle(page, [
|
| 130 |
+
`[data-timeline-part-id="${shellID}"]`,
|
| 131 |
+
`[data-timeline-part-id="${followingID}"]`,
|
| 132 |
+
])
|
| 133 |
+
const regions = defineVisualRegions({
|
| 134 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 135 |
+
following: {
|
| 136 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 137 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 138 |
+
},
|
| 139 |
+
})
|
| 140 |
+
await startVisualProbe(page, regions)
|
| 141 |
+
await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
|
| 142 |
+
await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
|
| 143 |
+
await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
|
| 144 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 145 |
+
await reportVisualStability(
|
| 146 |
+
testInfo,
|
| 147 |
+
"narrow-shell",
|
| 148 |
+
trace,
|
| 149 |
+
visualPlan(
|
| 150 |
+
regions,
|
| 151 |
+
[
|
| 152 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 153 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 154 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 155 |
+
{ type: "opacity", regions: "all" },
|
| 156 |
+
{ type: "continuity", regions: "all" },
|
| 157 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 158 |
+
{ type: "label-stability", regions: "all" },
|
| 159 |
+
{ type: "preserve-bottom-anchor" },
|
| 160 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 161 |
+
],
|
| 162 |
+
{ perMarker: true },
|
| 163 |
+
),
|
| 164 |
+
)
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
|
| 168 |
+
const shellID = "prt_resize_01_shell"
|
| 169 |
+
const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
|
| 170 |
+
const followingID = "prt_resize_04_following"
|
| 171 |
+
await setupTimeline(page, {
|
| 172 |
+
messages: [
|
| 173 |
+
userMessage(),
|
| 174 |
+
assistantMessage([
|
| 175 |
+
shell(shellID, "completed", wideLines(15)),
|
| 176 |
+
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
| 177 |
+
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
| 178 |
+
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
|
| 179 |
+
]),
|
| 180 |
+
],
|
| 181 |
+
settings: { shellToolPartsExpanded: true },
|
| 182 |
+
cpuRate: 4,
|
| 183 |
+
seedHistory: true,
|
| 184 |
+
})
|
| 185 |
+
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
| 186 |
+
const regions = defineVisualRegions({
|
| 187 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 188 |
+
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
| 189 |
+
following: {
|
| 190 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 191 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 192 |
+
},
|
| 193 |
+
})
|
| 194 |
+
await startVisualProbe(page, regions)
|
| 195 |
+
await page.setViewportSize({ width: 430, height: 800 })
|
| 196 |
+
await page.waitForTimeout(500)
|
| 197 |
+
await page.setViewportSize({ width: 900, height: 800 })
|
| 198 |
+
await page.waitForTimeout(500)
|
| 199 |
+
await page.setViewportSize({ width: 1400, height: 900 })
|
| 200 |
+
await page.waitForTimeout(500)
|
| 201 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 202 |
+
await reportVisualStability(
|
| 203 |
+
testInfo,
|
| 204 |
+
"responsive-resize",
|
| 205 |
+
trace,
|
| 206 |
+
visualPlan(regions, [
|
| 207 |
+
{ type: "required", regions: ["shell", "context", "following"] },
|
| 208 |
+
{ type: "unique", regions: ["shell", "context", "following"] },
|
| 209 |
+
{ type: "stable", regions: ["shell", "context", "following"] },
|
| 210 |
+
{ type: "opacity", regions: "all" },
|
| 211 |
+
{ type: "continuity", regions: "all" },
|
| 212 |
+
{ type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
|
| 213 |
+
{ type: "label-stability", regions: "all" },
|
| 214 |
+
{ type: "flow", regions: ["shell", "context", "following"] },
|
| 215 |
+
]),
|
| 216 |
+
)
|
| 217 |
+
})
|
| 218 |
+
})
|
| 219 |
+
|
| 220 |
+
function history(count: number, offset = 0): TimelineMessage[] {
|
| 221 |
+
return Array.from({ length: count }, (_, index) => {
|
| 222 |
+
const value = index + offset
|
| 223 |
+
const prefix = `msg_0${String(value).padStart(3, "0")}_history`
|
| 224 |
+
const userID = `${prefix}_a_user`
|
| 225 |
+
return [
|
| 226 |
+
userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
|
| 227 |
+
assistantMessage(
|
| 228 |
+
[
|
| 229 |
+
textPart(
|
| 230 |
+
`prt_history_${String(value).padStart(3, "0")}`,
|
| 231 |
+
`Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
|
| 232 |
+
),
|
| 233 |
+
],
|
| 234 |
+
{
|
| 235 |
+
id: `${prefix}_b_assistant`,
|
| 236 |
+
parentID: userID,
|
| 237 |
+
created: 1699990001000 + value * 10_000,
|
| 238 |
+
},
|
| 239 |
+
),
|
| 240 |
+
]
|
| 241 |
+
}).flat()
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function lines(count: number) {
|
| 245 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function wideLines(count: number) {
|
| 249 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
|
| 250 |
+
}
|
packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 10 |
+
assistantID,
|
| 11 |
+
assistantMessage,
|
| 12 |
+
event,
|
| 13 |
+
partUpdated,
|
| 14 |
+
setupTimeline,
|
| 15 |
+
textPart,
|
| 16 |
+
toolPart,
|
| 17 |
+
userMessage,
|
| 18 |
+
waitForVisualSettle,
|
| 19 |
+
} from "./fixture"
|
| 20 |
+
|
| 21 |
+
const inputs = {
|
| 22 |
+
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
| 23 |
+
glob: { path: ".", pattern: "**/*.ts" },
|
| 24 |
+
grep: { path: ".", pattern: "stable", include: "*.ts" },
|
| 25 |
+
list: { path: "src" },
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
|
| 29 |
+
const firstID = "prt_append_01_read"
|
| 30 |
+
const followingID = "prt_append_99_following"
|
| 31 |
+
const timeline = await setupTimeline(page, {
|
| 32 |
+
messages: [
|
| 33 |
+
userMessage(),
|
| 34 |
+
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
|
| 35 |
+
completed: false,
|
| 36 |
+
}),
|
| 37 |
+
],
|
| 38 |
+
cpuRate: 4,
|
| 39 |
+
})
|
| 40 |
+
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
|
| 41 |
+
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
|
| 42 |
+
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
|
| 43 |
+
const regions = defineVisualRegions({
|
| 44 |
+
context: {
|
| 45 |
+
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
|
| 46 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 47 |
+
},
|
| 48 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 49 |
+
})
|
| 50 |
+
await startVisualProbe(page, regions)
|
| 51 |
+
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
|
| 52 |
+
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
|
| 53 |
+
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
|
| 54 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 55 |
+
await reportVisualStability(
|
| 56 |
+
testInfo,
|
| 57 |
+
"context-append",
|
| 58 |
+
trace,
|
| 59 |
+
visualPlan(
|
| 60 |
+
regions,
|
| 61 |
+
[
|
| 62 |
+
{ type: "required", regions: ["context", "following"] },
|
| 63 |
+
{ type: "unique", regions: ["context", "following"] },
|
| 64 |
+
{ type: "stable", regions: ["context", "following"] },
|
| 65 |
+
{ type: "opacity", regions: "all" },
|
| 66 |
+
{ type: "continuity", regions: "all" },
|
| 67 |
+
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
| 68 |
+
{ type: "label-stability", regions: "all" },
|
| 69 |
+
{ type: "preserve-bottom-anchor" },
|
| 70 |
+
{ type: "flow", regions: ["context", "following"] },
|
| 71 |
+
],
|
| 72 |
+
{ perMarker: true },
|
| 73 |
+
),
|
| 74 |
+
)
|
| 75 |
+
await expect(
|
| 76 |
+
page.locator(
|
| 77 |
+
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
|
| 78 |
+
),
|
| 79 |
+
).toBeVisible()
|
| 80 |
+
await expect(
|
| 81 |
+
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
|
| 82 |
+
).toHaveAttribute("aria-expanded", "true")
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
|
| 86 |
+
const textID = "prt_split_02_text"
|
| 87 |
+
const followingID = "prt_split_99_following"
|
| 88 |
+
const timeline = await setupTimeline(page, {
|
| 89 |
+
messages: [
|
| 90 |
+
userMessage(),
|
| 91 |
+
assistantMessage([
|
| 92 |
+
toolPart("prt_split_01_read", "read", "completed", inputs.read),
|
| 93 |
+
textPart(textID, "Boundary"),
|
| 94 |
+
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
|
| 95 |
+
textPart(followingID, "Following split groups"),
|
| 96 |
+
]),
|
| 97 |
+
],
|
| 98 |
+
cpuRate: 4,
|
| 99 |
+
})
|
| 100 |
+
const regions = defineVisualRegions({
|
| 101 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 102 |
+
})
|
| 103 |
+
await startVisualProbe(page, regions)
|
| 104 |
+
await timeline.send(
|
| 105 |
+
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
|
| 106 |
+
500,
|
| 107 |
+
)
|
| 108 |
+
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
|
| 109 |
+
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
|
| 110 |
+
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
|
| 111 |
+
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
|
| 112 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 113 |
+
await reportVisualStability(
|
| 114 |
+
testInfo,
|
| 115 |
+
"context-split-merge",
|
| 116 |
+
trace,
|
| 117 |
+
visualPlan(
|
| 118 |
+
regions,
|
| 119 |
+
[
|
| 120 |
+
{ type: "required", regions: ["following"] },
|
| 121 |
+
{ type: "unique", regions: ["following"] },
|
| 122 |
+
{ type: "stable", regions: ["following"] },
|
| 123 |
+
{ type: "opacity", regions: "all" },
|
| 124 |
+
{ type: "continuity", regions: "all" },
|
| 125 |
+
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
| 126 |
+
{ type: "label-stability", regions: "all" },
|
| 127 |
+
],
|
| 128 |
+
{ perMarker: true },
|
| 129 |
+
),
|
| 130 |
+
)
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
test("removing the first context member replaces the group once without overlapping following content", async ({
|
| 134 |
+
page,
|
| 135 |
+
}, testInfo) => {
|
| 136 |
+
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
|
| 137 |
+
const followingID = "prt_key_99_following"
|
| 138 |
+
const timeline = await setupTimeline(page, {
|
| 139 |
+
messages: [
|
| 140 |
+
userMessage(),
|
| 141 |
+
assistantMessage([
|
| 142 |
+
toolPart(ids[0]!, "read", "completed", inputs.read),
|
| 143 |
+
toolPart(ids[1]!, "glob", "completed", inputs.glob),
|
| 144 |
+
toolPart(ids[2]!, "grep", "completed", inputs.grep),
|
| 145 |
+
textPart(followingID, "Following replaced group"),
|
| 146 |
+
]),
|
| 147 |
+
],
|
| 148 |
+
cpuRate: 4,
|
| 149 |
+
})
|
| 150 |
+
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
| 151 |
+
const originalRowKey = await original.evaluate((element) =>
|
| 152 |
+
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
|
| 153 |
+
)
|
| 154 |
+
await original.locator('[data-slot="collapsible-trigger"]').click()
|
| 155 |
+
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
| 156 |
+
const regions = defineVisualRegions({
|
| 157 |
+
context: {
|
| 158 |
+
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
|
| 159 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 160 |
+
},
|
| 161 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 162 |
+
})
|
| 163 |
+
await startVisualProbe(page, regions)
|
| 164 |
+
await timeline.send(
|
| 165 |
+
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
|
| 166 |
+
500,
|
| 167 |
+
)
|
| 168 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 169 |
+
await reportVisualStability(
|
| 170 |
+
testInfo,
|
| 171 |
+
"context-first-remove",
|
| 172 |
+
trace,
|
| 173 |
+
visualPlan(regions, [
|
| 174 |
+
{ type: "required", regions: ["context", "following"] },
|
| 175 |
+
{ type: "unique", regions: ["context", "following"] },
|
| 176 |
+
{ type: "opacity", regions: "all" },
|
| 177 |
+
{ type: "continuity", regions: "all" },
|
| 178 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 179 |
+
{ type: "label-stability", regions: "all" },
|
| 180 |
+
{ type: "flow", regions: ["context", "following"] },
|
| 181 |
+
]),
|
| 182 |
+
)
|
| 183 |
+
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
|
| 184 |
+
expect(
|
| 185 |
+
await page
|
| 186 |
+
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
|
| 187 |
+
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
|
| 188 |
+
).toBe(originalRowKey)
|
| 189 |
+
await expect(
|
| 190 |
+
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
|
| 191 |
+
).toHaveAttribute("aria-expanded", "true")
|
| 192 |
+
})
|
packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 19 |
+
// Fractional scaling exercises different browser rounding than the baseline.
|
| 20 |
+
for (const deviceScaleFactor of [1, 1.25]) {
|
| 21 |
+
test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => {
|
| 22 |
+
const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell`
|
| 23 |
+
const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following`
|
| 24 |
+
const timeline = await setupTimeline(page, {
|
| 25 |
+
messages: [
|
| 26 |
+
userMessage(),
|
| 27 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], {
|
| 28 |
+
completed: false,
|
| 29 |
+
}),
|
| 30 |
+
],
|
| 31 |
+
settings: { shellToolPartsExpanded: true },
|
| 32 |
+
cpuRate: 4,
|
| 33 |
+
deviceScaleFactor,
|
| 34 |
+
seedHistory: true,
|
| 35 |
+
})
|
| 36 |
+
await waitForVisualSettle(page, [
|
| 37 |
+
`[data-timeline-part-id="${shellID}"]`,
|
| 38 |
+
`[data-timeline-part-id="${followingID}"]`,
|
| 39 |
+
])
|
| 40 |
+
const regions = defineVisualRegions({
|
| 41 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 42 |
+
following: {
|
| 43 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 44 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 45 |
+
},
|
| 46 |
+
})
|
| 47 |
+
await startVisualProbe(page, regions)
|
| 48 |
+
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180)
|
| 49 |
+
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500)
|
| 50 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 51 |
+
await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions))
|
| 52 |
+
})
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
for (const reducedMotion of [true]) {
|
| 56 |
+
test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({
|
| 57 |
+
page,
|
| 58 |
+
}, testInfo) => {
|
| 59 |
+
const shellID = `prt_motion_${reducedMotion}_01_shell`
|
| 60 |
+
const followingID = `prt_motion_${reducedMotion}_02_following`
|
| 61 |
+
const timeline = await setupTimeline(page, {
|
| 62 |
+
messages: [
|
| 63 |
+
userMessage(),
|
| 64 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], {
|
| 65 |
+
completed: false,
|
| 66 |
+
}),
|
| 67 |
+
],
|
| 68 |
+
settings: { shellToolPartsExpanded: true },
|
| 69 |
+
reducedMotion,
|
| 70 |
+
cpuRate: 4,
|
| 71 |
+
seedHistory: true,
|
| 72 |
+
})
|
| 73 |
+
await waitForVisualSettle(page, [
|
| 74 |
+
`[data-timeline-part-id="${shellID}"]`,
|
| 75 |
+
`[data-timeline-part-id="${followingID}"]`,
|
| 76 |
+
])
|
| 77 |
+
const regions = defineVisualRegions({
|
| 78 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 79 |
+
following: {
|
| 80 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 81 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 82 |
+
},
|
| 83 |
+
})
|
| 84 |
+
await startVisualProbe(page, regions)
|
| 85 |
+
await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500)
|
| 86 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 87 |
+
await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions))
|
| 88 |
+
})
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function lines(count: number) {
|
| 92 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function shellPlan<Regions extends ReturnType<typeof defineVisualRegions>>(
|
| 96 |
+
regions: Regions & Record<"shell" | "following", { selector: string }>,
|
| 97 |
+
) {
|
| 98 |
+
return visualPlan(
|
| 99 |
+
regions,
|
| 100 |
+
[
|
| 101 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 102 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 103 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 104 |
+
{ type: "opacity", regions: "all" },
|
| 105 |
+
{ type: "continuity", regions: "all" },
|
| 106 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 107 |
+
{ type: "label-stability", regions: "all" },
|
| 108 |
+
{ type: "preserve-bottom-anchor" },
|
| 109 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 110 |
+
],
|
| 111 |
+
{ perMarker: true },
|
| 112 |
+
)
|
| 113 |
+
}
|
packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
textPart,
|
| 14 |
+
toolPart,
|
| 15 |
+
userMessage,
|
| 16 |
+
waitForVisualSettle,
|
| 17 |
+
} from "./fixture"
|
| 18 |
+
|
| 19 |
+
const profiles = [
|
| 20 |
+
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
| 21 |
+
{
|
| 22 |
+
name: "multi patch",
|
| 23 |
+
tool: "apply_patch",
|
| 24 |
+
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
| 25 |
+
},
|
| 26 |
+
] as const
|
| 27 |
+
|
| 28 |
+
for (const profile of profiles) {
|
| 29 |
+
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
|
| 30 |
+
const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
|
| 31 |
+
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
|
| 32 |
+
const timeline = await setupTimeline(page, {
|
| 33 |
+
messages: [
|
| 34 |
+
userMessage(),
|
| 35 |
+
assistantMessage(
|
| 36 |
+
[
|
| 37 |
+
toolPart(partID, profile.tool, "pending", profile.input),
|
| 38 |
+
textPart(followingID, `Following ${profile.name}`),
|
| 39 |
+
],
|
| 40 |
+
{ completed: false },
|
| 41 |
+
),
|
| 42 |
+
],
|
| 43 |
+
settings: { editToolPartsExpanded: true },
|
| 44 |
+
cpuRate: 4,
|
| 45 |
+
})
|
| 46 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
| 47 |
+
const regions = defineVisualRegions({
|
| 48 |
+
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 49 |
+
following: {
|
| 50 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 51 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 52 |
+
},
|
| 53 |
+
})
|
| 54 |
+
await startVisualProbe(page, regions)
|
| 55 |
+
await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180)
|
| 56 |
+
await timeline.send(partUpdated(completedPart(partID, profile)), 900)
|
| 57 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 58 |
+
await reportVisualStability(
|
| 59 |
+
testInfo,
|
| 60 |
+
`file-${profile.name}`,
|
| 61 |
+
trace,
|
| 62 |
+
visualPlan(
|
| 63 |
+
regions,
|
| 64 |
+
[
|
| 65 |
+
{ type: "required", regions: ["tool", "following"] },
|
| 66 |
+
{ type: "unique", regions: ["tool", "following"] },
|
| 67 |
+
{ type: "stable", regions: ["tool", "following"] },
|
| 68 |
+
{ type: "opacity", regions: "all" },
|
| 69 |
+
{ type: "continuity", regions: "all" },
|
| 70 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
| 71 |
+
{ type: "label-stability", regions: "all" },
|
| 72 |
+
{ type: "preserve-bottom-anchor" },
|
| 73 |
+
{ type: "flow", regions: ["tool", "following"] },
|
| 74 |
+
],
|
| 75 |
+
{ perMarker: true },
|
| 76 |
+
),
|
| 77 |
+
)
|
| 78 |
+
})
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function completedPart(partID: string, profile: (typeof profiles)[number]) {
|
| 82 |
+
if (profile.tool === "edit") {
|
| 83 |
+
return toolPart(partID, profile.tool, "completed", profile.input, {
|
| 84 |
+
metadata: {
|
| 85 |
+
filediff: {
|
| 86 |
+
file: "src/edit.ts",
|
| 87 |
+
additions: 50,
|
| 88 |
+
deletions: 50,
|
| 89 |
+
before: source(50, false),
|
| 90 |
+
after: source(50, true),
|
| 91 |
+
},
|
| 92 |
+
},
|
| 93 |
+
})
|
| 94 |
+
}
|
| 95 |
+
const files = [
|
| 96 |
+
patchFile("src/a.ts", "update"),
|
| 97 |
+
patchFile("src/b.ts", "add"),
|
| 98 |
+
patchFile("src/old.ts", "delete"),
|
| 99 |
+
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
| 100 |
+
]
|
| 101 |
+
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
| 105 |
+
return {
|
| 106 |
+
filePath,
|
| 107 |
+
relativePath: filePath,
|
| 108 |
+
type,
|
| 109 |
+
additions: type === "delete" ? 0 : 20,
|
| 110 |
+
deletions: type === "add" ? 0 : 20,
|
| 111 |
+
before: type === "add" ? undefined : source(20, false),
|
| 112 |
+
after: type === "delete" ? undefined : source(20, true),
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function source(count: number, changed: boolean) {
|
| 117 |
+
return Array.from(
|
| 118 |
+
{ length: count },
|
| 119 |
+
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
| 120 |
+
).join("")
|
| 121 |
+
}
|
packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 10 |
+
assistantMessage,
|
| 11 |
+
partUpdated,
|
| 12 |
+
setupTimeline,
|
| 13 |
+
textPart,
|
| 14 |
+
toolPart,
|
| 15 |
+
userMessage,
|
| 16 |
+
waitForVisualSettle,
|
| 17 |
+
} from "./fixture"
|
| 18 |
+
|
| 19 |
+
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
|
| 20 |
+
const patchID = "prt_incremental_01_patch"
|
| 21 |
+
const followingID = "prt_incremental_02_following"
|
| 22 |
+
const first = patchFile("src/a.ts", "update")
|
| 23 |
+
const timeline = await setupTimeline(page, {
|
| 24 |
+
messages: [
|
| 25 |
+
userMessage(),
|
| 26 |
+
assistantMessage(
|
| 27 |
+
[
|
| 28 |
+
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
| 29 |
+
textPart(followingID, "Following incremental patch"),
|
| 30 |
+
],
|
| 31 |
+
{ completed: false },
|
| 32 |
+
),
|
| 33 |
+
],
|
| 34 |
+
settings: { editToolPartsExpanded: true },
|
| 35 |
+
cpuRate: 4,
|
| 36 |
+
seedHistory: true,
|
| 37 |
+
})
|
| 38 |
+
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
|
| 39 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 40 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
| 41 |
+
const regions = defineVisualRegions({
|
| 42 |
+
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 43 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 44 |
+
})
|
| 45 |
+
await startVisualProbe(page, regions)
|
| 46 |
+
const second = patchFile("src/b.ts", "add")
|
| 47 |
+
const third = patchFile("src/old.ts", "delete")
|
| 48 |
+
await timeline.send(
|
| 49 |
+
partUpdated(
|
| 50 |
+
toolPart(
|
| 51 |
+
patchID,
|
| 52 |
+
"apply_patch",
|
| 53 |
+
"running",
|
| 54 |
+
{ files: [first.filePath, second.filePath] },
|
| 55 |
+
{ metadata: { files: [first, second] } },
|
| 56 |
+
),
|
| 57 |
+
),
|
| 58 |
+
240,
|
| 59 |
+
)
|
| 60 |
+
await timeline.send(
|
| 61 |
+
partUpdated(
|
| 62 |
+
toolPart(
|
| 63 |
+
patchID,
|
| 64 |
+
"apply_patch",
|
| 65 |
+
"completed",
|
| 66 |
+
{ files: [first.filePath, second.filePath, third.filePath] },
|
| 67 |
+
{ metadata: { files: [first, second, third] } },
|
| 68 |
+
),
|
| 69 |
+
),
|
| 70 |
+
800,
|
| 71 |
+
)
|
| 72 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 73 |
+
await reportVisualStability(
|
| 74 |
+
testInfo,
|
| 75 |
+
"incremental-patch",
|
| 76 |
+
trace,
|
| 77 |
+
visualPlan(
|
| 78 |
+
regions,
|
| 79 |
+
[
|
| 80 |
+
{ type: "required", regions: ["patch", "following"] },
|
| 81 |
+
{ type: "unique", regions: ["patch", "following"] },
|
| 82 |
+
{ type: "stable", regions: ["patch", "following"] },
|
| 83 |
+
{ type: "opacity", regions: "all" },
|
| 84 |
+
{ type: "continuity", regions: "all" },
|
| 85 |
+
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
| 86 |
+
{ type: "label-stability", regions: "all" },
|
| 87 |
+
{ type: "preserve-bottom-anchor" },
|
| 88 |
+
{ type: "flow", regions: ["patch", "following"] },
|
| 89 |
+
],
|
| 90 |
+
{ perMarker: true },
|
| 91 |
+
),
|
| 92 |
+
)
|
| 93 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 94 |
+
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
| 98 |
+
return {
|
| 99 |
+
filePath,
|
| 100 |
+
relativePath: filePath,
|
| 101 |
+
type,
|
| 102 |
+
additions: type === "delete" ? 0 : 4,
|
| 103 |
+
deletions: type === "add" ? 0 : 3,
|
| 104 |
+
before: type === "add" ? undefined : source(false),
|
| 105 |
+
after: type === "delete" ? undefined : source(true),
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function source(changed: boolean) {
|
| 110 |
+
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
| 111 |
+
"",
|
| 112 |
+
)
|
| 113 |
+
}
|
packages/app/e2e/performance/timeline-stability/fixture.test.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
event,
|
| 5 |
+
toolPart,
|
| 6 |
+
userMessage,
|
| 7 |
+
validateTimelineEvent,
|
| 8 |
+
validateTimelineMessages,
|
| 9 |
+
type PartSeed,
|
| 10 |
+
} from "./fixture"
|
| 11 |
+
|
| 12 |
+
describe("timeline fixture validation", () => {
|
| 13 |
+
test("accepts a valid timeline", () => {
|
| 14 |
+
expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2)
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
test("rejects malformed SDK values at runtime", () => {
|
| 18 |
+
expect(() =>
|
| 19 |
+
assistantMessage([], {
|
| 20 |
+
error: { name: "APIError", data: { message: "failed" } } as never,
|
| 21 |
+
}),
|
| 22 |
+
).toThrow()
|
| 23 |
+
expect(() =>
|
| 24 |
+
validateTimelineEvent({
|
| 25 |
+
directory: "C:/OpenCode/TimelineStability",
|
| 26 |
+
payload: {
|
| 27 |
+
id: "evt_invalid_status",
|
| 28 |
+
type: "session.status",
|
| 29 |
+
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
| 30 |
+
},
|
| 31 |
+
}),
|
| 32 |
+
).toThrow()
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
test("rejects duplicate IDs and orphan assistants", () => {
|
| 36 |
+
expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/)
|
| 37 |
+
expect(() =>
|
| 38 |
+
validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]),
|
| 39 |
+
).toThrow(/parent user/)
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
test("assigns deterministic event IDs", () => {
|
| 43 |
+
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
| 44 |
+
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
| 45 |
+
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
| 46 |
+
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
| 47 |
+
})
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
if (false) {
|
| 51 |
+
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
|
| 52 |
+
userMessage([userSeed])
|
| 53 |
+
|
| 54 |
+
// @ts-expect-error Tool completion fields are not valid while pending.
|
| 55 |
+
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
|
| 56 |
+
// @ts-expect-error Tool completion fields are not valid while running.
|
| 57 |
+
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
|
| 58 |
+
// @ts-expect-error Tool error fields are not valid after completion.
|
| 59 |
+
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
|
| 60 |
+
|
| 61 |
+
assistantMessage([
|
| 62 |
+
// @ts-expect-error Agent references belong to user messages, not assistant messages.
|
| 63 |
+
{ id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } },
|
| 64 |
+
])
|
| 65 |
+
|
| 66 |
+
// @ts-expect-error Retry status events require message and next.
|
| 67 |
+
event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } })
|
| 68 |
+
}
|
packages/app/e2e/performance/timeline-stability/fixture.ts
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { Event } from "@opencode-ai/schema/event"
|
| 3 |
+
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
| 4 |
+
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
| 5 |
+
import type {
|
| 6 |
+
AssistantMessage,
|
| 7 |
+
GlobalEvent,
|
| 8 |
+
Message,
|
| 9 |
+
Part,
|
| 10 |
+
Session,
|
| 11 |
+
SessionStatus,
|
| 12 |
+
ToolPart,
|
| 13 |
+
ToolState,
|
| 14 |
+
UserMessage,
|
| 15 |
+
} from "@opencode-ai/sdk/v2/client"
|
| 16 |
+
import { expect, type Page } from "@playwright/test"
|
| 17 |
+
import { Schema } from "effect"
|
| 18 |
+
import { mockOpenCodeServer } from "../../utils/mock-server"
|
| 19 |
+
import { installSseTransport } from "../../utils/sse-transport"
|
| 20 |
+
import { expectSessionTitle } from "../../utils/waits"
|
| 21 |
+
|
| 22 |
+
export const directory = "C:/OpenCode/TimelineStability"
|
| 23 |
+
export const projectID = "proj_timeline_stability"
|
| 24 |
+
export const sessionID = "ses_timeline_stability"
|
| 25 |
+
export const userID = "msg_1000_timeline_user"
|
| 26 |
+
export const assistantID = "msg_1001_timeline_assistant"
|
| 27 |
+
export const title = "Timeline visual stability"
|
| 28 |
+
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
| 29 |
+
|
| 30 |
+
type TimelinePayload = Extract<
|
| 31 |
+
GlobalEvent["payload"],
|
| 32 |
+
{
|
| 33 |
+
type:
|
| 34 |
+
| "message.updated"
|
| 35 |
+
| "message.removed"
|
| 36 |
+
| "message.part.updated"
|
| 37 |
+
| "message.part.removed"
|
| 38 |
+
| "message.part.delta"
|
| 39 |
+
| "session.status"
|
| 40 |
+
}
|
| 41 |
+
>
|
| 42 |
+
|
| 43 |
+
type DeepReadonly<Value> = Value extends readonly unknown[]
|
| 44 |
+
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
| 45 |
+
: Value extends object
|
| 46 |
+
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
| 47 |
+
: Value
|
| 48 |
+
|
| 49 |
+
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
|
| 50 |
+
export type EventPayload = TimelineEvent
|
| 51 |
+
export type ToolStatus = ToolState["status"]
|
| 52 |
+
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
|
| 53 |
+
|
| 54 |
+
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
|
| 55 |
+
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
|
| 56 |
+
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
|
| 57 |
+
export type PartSeed<Owner extends Message["role"]> =
|
| 58 |
+
OwnedPart<Owner> extends infer Candidate
|
| 59 |
+
? Candidate extends Part
|
| 60 |
+
? Omit<Candidate, "sessionID" | "messageID">
|
| 61 |
+
: never
|
| 62 |
+
: never
|
| 63 |
+
|
| 64 |
+
type ToolOptions<State extends ToolStatus> = State extends "pending"
|
| 65 |
+
? { output?: never; title?: never; metadata?: never; error?: never }
|
| 66 |
+
: State extends "running"
|
| 67 |
+
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
|
| 68 |
+
: State extends "error"
|
| 69 |
+
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
| 70 |
+
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
| 71 |
+
|
| 72 |
+
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
|
| 73 |
+
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
|
| 74 |
+
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
| 75 |
+
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
|
| 76 |
+
const timelineEventSchema = Schema.Union([
|
| 77 |
+
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
|
| 78 |
+
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
|
| 79 |
+
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
|
| 80 |
+
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
|
| 81 |
+
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
|
| 82 |
+
eventSchema("session.status", SessionStatusEvent.Status.data),
|
| 83 |
+
])
|
| 84 |
+
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
|
| 85 |
+
let eventSequence = 0
|
| 86 |
+
|
| 87 |
+
export async function setupTimeline(
|
| 88 |
+
page: Page,
|
| 89 |
+
input: {
|
| 90 |
+
messages?: TimelineMessage[]
|
| 91 |
+
settings?: Record<string, boolean>
|
| 92 |
+
sessions?: Session[]
|
| 93 |
+
cpuRate?: number
|
| 94 |
+
viewport?: { width: number; height: number }
|
| 95 |
+
eventRetry?: number
|
| 96 |
+
reducedMotion?: boolean
|
| 97 |
+
locale?: string
|
| 98 |
+
deviceScaleFactor?: number
|
| 99 |
+
seedHistory?: boolean
|
| 100 |
+
protocol?: "v1" | "v2"
|
| 101 |
+
} = {},
|
| 102 |
+
) {
|
| 103 |
+
const sessions = input.sessions ?? [session()]
|
| 104 |
+
const messages = validateTimelineMessages([
|
| 105 |
+
...(input.seedHistory ? historyMessages(18) : []),
|
| 106 |
+
...(input.messages ?? [userMessage(), assistantMessage()]),
|
| 107 |
+
])
|
| 108 |
+
const active = messages.findLast((message) => message.info.role === "assistant")
|
| 109 |
+
const initialStatus = decodeStatus(
|
| 110 |
+
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
|
| 111 |
+
decodeOptions,
|
| 112 |
+
)
|
| 113 |
+
const transport = await installSseTransport<EventPayload>(page, {
|
| 114 |
+
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
| 115 |
+
retry: input.eventRetry ?? 20,
|
| 116 |
+
})
|
| 117 |
+
await mockOpenCodeServer(page, {
|
| 118 |
+
protocol: input.protocol,
|
| 119 |
+
directory,
|
| 120 |
+
project: project(),
|
| 121 |
+
provider: provider(),
|
| 122 |
+
sessions,
|
| 123 |
+
sessionStatus: { [sessionID]: initialStatus },
|
| 124 |
+
pageMessages: () => ({
|
| 125 |
+
items: messages,
|
| 126 |
+
}),
|
| 127 |
+
})
|
| 128 |
+
await page.addInitScript((settings) => {
|
| 129 |
+
localStorage.setItem(
|
| 130 |
+
"settings.v3",
|
| 131 |
+
JSON.stringify({
|
| 132 |
+
general: {
|
| 133 |
+
editToolPartsExpanded: false,
|
| 134 |
+
shellToolPartsExpanded: false,
|
| 135 |
+
showReasoningSummaries: false,
|
| 136 |
+
showSessionProgressBar: true,
|
| 137 |
+
...settings,
|
| 138 |
+
},
|
| 139 |
+
}),
|
| 140 |
+
)
|
| 141 |
+
if (settings.newLayoutDesigns === false) {
|
| 142 |
+
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
| 143 |
+
}
|
| 144 |
+
}, input.settings ?? {})
|
| 145 |
+
if (input.locale) {
|
| 146 |
+
await page.addInitScript((locale) => {
|
| 147 |
+
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
|
| 148 |
+
}, input.locale)
|
| 149 |
+
}
|
| 150 |
+
if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
|
| 151 |
+
await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
|
| 152 |
+
if (input.deviceScaleFactor) {
|
| 153 |
+
const devtools = await page.context().newCDPSession(page)
|
| 154 |
+
const viewport = input.viewport ?? { width: 1400, height: 900 }
|
| 155 |
+
await devtools.send("Emulation.setDeviceMetricsOverride", {
|
| 156 |
+
width: viewport.width,
|
| 157 |
+
height: viewport.height,
|
| 158 |
+
deviceScaleFactor: input.deviceScaleFactor,
|
| 159 |
+
mobile: false,
|
| 160 |
+
})
|
| 161 |
+
}
|
| 162 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 163 |
+
await transport.waitForConnection()
|
| 164 |
+
await expectSessionTitle(page, title)
|
| 165 |
+
if (input.cpuRate && input.cpuRate > 1) {
|
| 166 |
+
const devtools = await page.context().newCDPSession(page)
|
| 167 |
+
await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
transport,
|
| 172 |
+
async send(event: TimelineEvent, delay = 0) {
|
| 173 |
+
const valid = validateTimelineEvent(event)
|
| 174 |
+
await transport.send(valid, { marker: describeEvent(valid) })
|
| 175 |
+
if (delay) await page.waitForTimeout(delay)
|
| 176 |
+
},
|
| 177 |
+
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
| 178 |
+
for (const item of sequence) {
|
| 179 |
+
const valid = validateTimelineEvent(item.event)
|
| 180 |
+
await transport.send(valid, { marker: describeEvent(valid) })
|
| 181 |
+
await page.waitForTimeout(item.delay)
|
| 182 |
+
}
|
| 183 |
+
},
|
| 184 |
+
async settle(frames = 3) {
|
| 185 |
+
await page.evaluate(
|
| 186 |
+
(frames) =>
|
| 187 |
+
new Promise<void>((resolve) => {
|
| 188 |
+
let remaining = frames
|
| 189 |
+
const tick = () => {
|
| 190 |
+
remaining--
|
| 191 |
+
if (remaining <= 0) return resolve()
|
| 192 |
+
requestAnimationFrame(tick)
|
| 193 |
+
}
|
| 194 |
+
requestAnimationFrame(tick)
|
| 195 |
+
}),
|
| 196 |
+
frames,
|
| 197 |
+
)
|
| 198 |
+
},
|
| 199 |
+
async waitForPart(partID: string) {
|
| 200 |
+
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
| 201 |
+
await expect(part).toHaveCount(1)
|
| 202 |
+
await expect(part).toBeVisible()
|
| 203 |
+
},
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function describeEvent(event: EventPayload) {
|
| 208 |
+
if (event.payload.type === "message.part.updated") {
|
| 209 |
+
const part = event.payload.properties.part
|
| 210 |
+
return [
|
| 211 |
+
event.payload.type,
|
| 212 |
+
part.id,
|
| 213 |
+
part.type === "tool" ? part.tool : part.type,
|
| 214 |
+
part.type === "tool" ? part.state.status : undefined,
|
| 215 |
+
]
|
| 216 |
+
.filter(Boolean)
|
| 217 |
+
.join(":")
|
| 218 |
+
}
|
| 219 |
+
if (event.payload.type === "session.status") {
|
| 220 |
+
const status = event.payload.properties.status
|
| 221 |
+
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
|
| 222 |
+
.filter((value) => value !== undefined)
|
| 223 |
+
.join(":")
|
| 224 |
+
}
|
| 225 |
+
return event.payload.type
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export function event<const Type extends TimelinePayload["type"]>(
|
| 229 |
+
type: Type,
|
| 230 |
+
properties: Extract<TimelinePayload, { type: Type }>["properties"],
|
| 231 |
+
): TimelineEvent
|
| 232 |
+
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
|
| 233 |
+
return validateTimelineEvent({
|
| 234 |
+
directory,
|
| 235 |
+
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
|
| 236 |
+
})
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
| 240 |
+
return decodeEvent(input, decodeOptions)
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
| 244 |
+
input.forEach((message) => decodeMessage(message, decodeOptions))
|
| 245 |
+
const messages = [...input]
|
| 246 |
+
const messageIDs = new Set<string>()
|
| 247 |
+
const partIDs = new Set<string>()
|
| 248 |
+
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
|
| 249 |
+
|
| 250 |
+
messages.forEach((message) => {
|
| 251 |
+
if (messageIDs.has(message.info.id))
|
| 252 |
+
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
|
| 253 |
+
messageIDs.add(message.info.id)
|
| 254 |
+
if (message.info.role === "assistant" && !users.has(message.info.parentID))
|
| 255 |
+
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
|
| 256 |
+
message.parts.forEach((part) => {
|
| 257 |
+
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
|
| 258 |
+
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
|
| 259 |
+
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
|
| 260 |
+
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
| 261 |
+
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
|
| 262 |
+
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
| 263 |
+
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
| 264 |
+
partIDs.add(part.id)
|
| 265 |
+
})
|
| 266 |
+
})
|
| 267 |
+
return messages
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
|
| 271 |
+
await page.waitForFunction(
|
| 272 |
+
({ selectors, stableFrames }) => {
|
| 273 |
+
const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
|
| 274 |
+
if (elements.some((element) => !element)) return false
|
| 275 |
+
return new Promise<boolean>((resolve) => {
|
| 276 |
+
let stable = 0
|
| 277 |
+
let previous = ""
|
| 278 |
+
const sample = () => {
|
| 279 |
+
const signature = JSON.stringify(
|
| 280 |
+
elements.map((element) => {
|
| 281 |
+
const rect = element!.getBoundingClientRect()
|
| 282 |
+
return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
|
| 283 |
+
}),
|
| 284 |
+
)
|
| 285 |
+
stable = signature === previous ? stable + 1 : 0
|
| 286 |
+
previous = signature
|
| 287 |
+
const ordered = elements
|
| 288 |
+
.slice(1)
|
| 289 |
+
.every(
|
| 290 |
+
(element, index) =>
|
| 291 |
+
elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
|
| 292 |
+
)
|
| 293 |
+
if (stable >= stableFrames && ordered) return resolve(true)
|
| 294 |
+
requestAnimationFrame(sample)
|
| 295 |
+
}
|
| 296 |
+
requestAnimationFrame(sample)
|
| 297 |
+
})
|
| 298 |
+
},
|
| 299 |
+
{ selectors, stableFrames },
|
| 300 |
+
)
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
export function historyMessages(count: number): TimelineMessage[] {
|
| 304 |
+
return Array.from({ length: count }, (_, index) => {
|
| 305 |
+
const value = String(index).padStart(4, "0")
|
| 306 |
+
const historyUserID = `msg_0${value}_history_a_user`
|
| 307 |
+
return [
|
| 308 |
+
userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
|
| 309 |
+
assistantMessage(
|
| 310 |
+
[
|
| 311 |
+
{
|
| 312 |
+
id: `prt_0${value}_history_text`,
|
| 313 |
+
type: "text",
|
| 314 |
+
text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
|
| 315 |
+
},
|
| 316 |
+
],
|
| 317 |
+
{
|
| 318 |
+
id: `msg_0${value}_history_b_assistant`,
|
| 319 |
+
parentID: historyUserID,
|
| 320 |
+
created: 1690000001000 + index * 10_000,
|
| 321 |
+
},
|
| 322 |
+
),
|
| 323 |
+
]
|
| 324 |
+
}).flat()
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
export function partUpdated(part: Part | PartSeed<"assistant">) {
|
| 328 |
+
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
|
| 329 |
+
decodePart(owned, decodeOptions)
|
| 330 |
+
return event("message.part.updated", {
|
| 331 |
+
sessionID,
|
| 332 |
+
part: owned,
|
| 333 |
+
time: 1700000002000,
|
| 334 |
+
})
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
| 338 |
+
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
export function messageUpdated(info: Message) {
|
| 342 |
+
return event("message.updated", { sessionID, info })
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
export function status(type: SessionStatus["type"], attempt = 1) {
|
| 346 |
+
return event("session.status", {
|
| 347 |
+
sessionID,
|
| 348 |
+
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
|
| 349 |
+
})
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
export function userMessage(
|
| 353 |
+
parts?: PartSeed<"user">[],
|
| 354 |
+
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
|
| 355 |
+
): Extract<TimelineMessage, { info: { role: "user" } }> {
|
| 356 |
+
const id = input.id ?? userID
|
| 357 |
+
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
| 358 |
+
const message = {
|
| 359 |
+
info: {
|
| 360 |
+
id,
|
| 361 |
+
sessionID,
|
| 362 |
+
role: "user",
|
| 363 |
+
time: { created: input.created ?? 1700000000000 },
|
| 364 |
+
summary: input.summary ?? { diffs: [] },
|
| 365 |
+
agent: "build",
|
| 366 |
+
model,
|
| 367 |
+
},
|
| 368 |
+
parts: seeds.map((part) => ({
|
| 369 |
+
...part,
|
| 370 |
+
sessionID,
|
| 371 |
+
messageID: id,
|
| 372 |
+
})),
|
| 373 |
+
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
|
| 374 |
+
decodeMessage(message, decodeOptions)
|
| 375 |
+
return message
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
export function assistantMessage(
|
| 379 |
+
parts: PartSeed<"assistant">[] = [],
|
| 380 |
+
input: {
|
| 381 |
+
id?: string
|
| 382 |
+
parentID?: string
|
| 383 |
+
completed?: boolean
|
| 384 |
+
error?: AssistantMessage["error"]
|
| 385 |
+
created?: number
|
| 386 |
+
} = {},
|
| 387 |
+
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
|
| 388 |
+
const id = input.id ?? assistantID
|
| 389 |
+
const message = {
|
| 390 |
+
info: {
|
| 391 |
+
id,
|
| 392 |
+
sessionID,
|
| 393 |
+
role: "assistant",
|
| 394 |
+
time: {
|
| 395 |
+
created: input.created ?? 1700000001000,
|
| 396 |
+
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
|
| 397 |
+
},
|
| 398 |
+
parentID: input.parentID ?? userID,
|
| 399 |
+
modelID: model.modelID,
|
| 400 |
+
providerID: model.providerID,
|
| 401 |
+
mode: "build",
|
| 402 |
+
agent: "build",
|
| 403 |
+
path: { cwd: directory, root: directory },
|
| 404 |
+
cost: 0.01,
|
| 405 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 406 |
+
variant: "max",
|
| 407 |
+
...(input.error ? { error: input.error } : {}),
|
| 408 |
+
},
|
| 409 |
+
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
|
| 410 |
+
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
|
| 411 |
+
decodeMessage(message, decodeOptions)
|
| 412 |
+
return message
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
export function userText(
|
| 416 |
+
text: string,
|
| 417 |
+
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
|
| 418 |
+
): Extract<PartSeed<"user">, { type: "text" }> {
|
| 419 |
+
return { id: "prt_user_text", type: "text", text, ...input }
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
|
| 423 |
+
return { id, type: "text", text }
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
|
| 427 |
+
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
export function toolPart(
|
| 431 |
+
id: string,
|
| 432 |
+
tool: string,
|
| 433 |
+
state: "pending",
|
| 434 |
+
input: Record<string, unknown>,
|
| 435 |
+
options?: ToolOptions<"pending">,
|
| 436 |
+
): Omit<ToolPart, "sessionID" | "messageID">
|
| 437 |
+
export function toolPart(
|
| 438 |
+
id: string,
|
| 439 |
+
tool: string,
|
| 440 |
+
state: "running",
|
| 441 |
+
input: Record<string, unknown>,
|
| 442 |
+
options?: ToolOptions<"running">,
|
| 443 |
+
): Omit<ToolPart, "sessionID" | "messageID">
|
| 444 |
+
export function toolPart(
|
| 445 |
+
id: string,
|
| 446 |
+
tool: string,
|
| 447 |
+
state: "completed",
|
| 448 |
+
input: Record<string, unknown>,
|
| 449 |
+
options?: ToolOptions<"completed">,
|
| 450 |
+
): Omit<ToolPart, "sessionID" | "messageID">
|
| 451 |
+
export function toolPart(
|
| 452 |
+
id: string,
|
| 453 |
+
tool: string,
|
| 454 |
+
state: "error",
|
| 455 |
+
input: Record<string, unknown>,
|
| 456 |
+
options?: ToolOptions<"error">,
|
| 457 |
+
): Omit<ToolPart, "sessionID" | "messageID">
|
| 458 |
+
export function toolPart(
|
| 459 |
+
id: string,
|
| 460 |
+
tool: string,
|
| 461 |
+
state: ToolStatus,
|
| 462 |
+
input: Record<string, unknown>,
|
| 463 |
+
options: ToolOptions<ToolStatus> = {},
|
| 464 |
+
): Omit<ToolPart, "sessionID" | "messageID"> {
|
| 465 |
+
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
|
| 466 |
+
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
| 467 |
+
if (state === "running")
|
| 468 |
+
return {
|
| 469 |
+
...base,
|
| 470 |
+
state: {
|
| 471 |
+
status: state,
|
| 472 |
+
input,
|
| 473 |
+
title: options.title,
|
| 474 |
+
metadata: options.metadata ?? {},
|
| 475 |
+
time: { start: 1700000001000 },
|
| 476 |
+
},
|
| 477 |
+
}
|
| 478 |
+
if (state === "error")
|
| 479 |
+
return {
|
| 480 |
+
...base,
|
| 481 |
+
state: {
|
| 482 |
+
status: state,
|
| 483 |
+
input,
|
| 484 |
+
error: options.error ?? "Tool failed",
|
| 485 |
+
metadata: options.metadata ?? {},
|
| 486 |
+
time: { start: 1700000001000, end: 1700000002000 },
|
| 487 |
+
},
|
| 488 |
+
}
|
| 489 |
+
return {
|
| 490 |
+
...base,
|
| 491 |
+
state: {
|
| 492 |
+
status: state,
|
| 493 |
+
input,
|
| 494 |
+
output: options.output ?? "Completed",
|
| 495 |
+
title: options.title ?? tool,
|
| 496 |
+
metadata: options.metadata ?? {},
|
| 497 |
+
time: { start: 1700000001000, end: 1700000002000 },
|
| 498 |
+
},
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
export function shell(
|
| 503 |
+
id: string,
|
| 504 |
+
state: ToolStatus,
|
| 505 |
+
output = "",
|
| 506 |
+
command = `echo ${id}`,
|
| 507 |
+
): Omit<ToolPart, "sessionID" | "messageID"> {
|
| 508 |
+
if (state === "pending") return toolPart(id, "bash", state, { command })
|
| 509 |
+
if (state === "running")
|
| 510 |
+
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
| 511 |
+
if (state === "error")
|
| 512 |
+
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
|
| 513 |
+
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
|
| 517 |
+
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
export function project() {
|
| 521 |
+
return {
|
| 522 |
+
id: projectID,
|
| 523 |
+
worktree: directory,
|
| 524 |
+
vcs: "git",
|
| 525 |
+
name: "timeline-stability",
|
| 526 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 527 |
+
sandboxes: [],
|
| 528 |
+
}
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
export function session(input: Partial<Session> = {}): Session {
|
| 532 |
+
return {
|
| 533 |
+
id: sessionID,
|
| 534 |
+
slug: "timeline-stability",
|
| 535 |
+
projectID,
|
| 536 |
+
directory,
|
| 537 |
+
title,
|
| 538 |
+
version: "dev",
|
| 539 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 540 |
+
...input,
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
function eventSchema<
|
| 545 |
+
const Type extends TimelinePayload["type"],
|
| 546 |
+
const Properties extends Schema.Codec<unknown, unknown>,
|
| 547 |
+
>(type: Type, properties: Properties) {
|
| 548 |
+
return Schema.Struct({
|
| 549 |
+
directory: Schema.String,
|
| 550 |
+
project: Schema.optional(Schema.String),
|
| 551 |
+
workspace: Schema.optional(Schema.String),
|
| 552 |
+
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
|
| 553 |
+
})
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
function provider() {
|
| 557 |
+
return {
|
| 558 |
+
all: [
|
| 559 |
+
{
|
| 560 |
+
id: "opencode",
|
| 561 |
+
name: "OpenCode",
|
| 562 |
+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
| 563 |
+
},
|
| 564 |
+
],
|
| 565 |
+
connected: ["opencode"],
|
| 566 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 567 |
+
}
|
| 568 |
+
}
|
packages/app/e2e/performance/timeline-stability/interaction.spec.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 11 |
+
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
| 12 |
+
const shellID = "prt_interaction_01_shell"
|
| 13 |
+
const followingID = "prt_interaction_02_following"
|
| 14 |
+
await setupTimeline(page, {
|
| 15 |
+
messages: [
|
| 16 |
+
userMessage(),
|
| 17 |
+
assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
|
| 18 |
+
],
|
| 19 |
+
settings: { shellToolPartsExpanded: false },
|
| 20 |
+
cpuRate: 4,
|
| 21 |
+
seedHistory: true,
|
| 22 |
+
})
|
| 23 |
+
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
| 24 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
| 25 |
+
const regions = defineVisualRegions({
|
| 26 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 27 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 28 |
+
})
|
| 29 |
+
const plan = visualPlan(regions, [
|
| 30 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 31 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 32 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 33 |
+
{ type: "opacity", regions: "all" },
|
| 34 |
+
{ type: "continuity", regions: "all" },
|
| 35 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 36 |
+
{ type: "label-stability", regions: "all" },
|
| 37 |
+
{ type: "preserve-bottom-anchor" },
|
| 38 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 39 |
+
])
|
| 40 |
+
await startVisualProbe(page, regions)
|
| 41 |
+
await trigger.click()
|
| 42 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 43 |
+
await page.waitForTimeout(500)
|
| 44 |
+
const expanded = await stopVisualProbe<keyof typeof regions>(page)
|
| 45 |
+
await reportVisualStability(testInfo, "shell-expand", expanded, plan)
|
| 46 |
+
|
| 47 |
+
await startVisualProbe(page, regions)
|
| 48 |
+
await trigger.click()
|
| 49 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
| 50 |
+
await page.waitForTimeout(500)
|
| 51 |
+
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
|
| 52 |
+
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
|
| 56 |
+
const ids = [
|
| 57 |
+
"prt_interaction_01_read",
|
| 58 |
+
"prt_interaction_02_glob",
|
| 59 |
+
"prt_interaction_03_grep",
|
| 60 |
+
"prt_interaction_04_list",
|
| 61 |
+
]
|
| 62 |
+
const group = `[data-timeline-part-ids="${ids.join(",")}"]`
|
| 63 |
+
const followingID = "prt_interaction_context_following"
|
| 64 |
+
await setupTimeline(page, {
|
| 65 |
+
messages: [
|
| 66 |
+
userMessage(),
|
| 67 |
+
assistantMessage([
|
| 68 |
+
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
| 69 |
+
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
| 70 |
+
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
|
| 71 |
+
toolPart(ids[3]!, "list", "completed", { path: "src" }),
|
| 72 |
+
textPart(followingID, "Following context expansion"),
|
| 73 |
+
]),
|
| 74 |
+
],
|
| 75 |
+
cpuRate: 4,
|
| 76 |
+
seedHistory: true,
|
| 77 |
+
})
|
| 78 |
+
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
| 79 |
+
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
|
| 80 |
+
for (const [name, expanded] of [
|
| 81 |
+
["context-expand", true],
|
| 82 |
+
["context-collapse", false],
|
| 83 |
+
["context-reexpand", true],
|
| 84 |
+
] as const) {
|
| 85 |
+
const regions = defineVisualRegions({
|
| 86 |
+
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
| 87 |
+
following: {
|
| 88 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 89 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 90 |
+
},
|
| 91 |
+
})
|
| 92 |
+
await startVisualProbe(page, regions)
|
| 93 |
+
await trigger.click()
|
| 94 |
+
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
| 95 |
+
await page.waitForTimeout(500)
|
| 96 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 97 |
+
await reportVisualStability(
|
| 98 |
+
testInfo,
|
| 99 |
+
name,
|
| 100 |
+
trace,
|
| 101 |
+
visualPlan(regions, [
|
| 102 |
+
{ type: "required", regions: ["context", "following"] },
|
| 103 |
+
{ type: "unique", regions: ["context", "following"] },
|
| 104 |
+
{ type: "stable", regions: ["context", "following"] },
|
| 105 |
+
{ type: "opacity", regions: "all" },
|
| 106 |
+
{ type: "continuity", regions: "all" },
|
| 107 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 108 |
+
{ type: "label-stability", regions: "all" },
|
| 109 |
+
{ type: "preserve-bottom-anchor" },
|
| 110 |
+
{ type: "flow", regions: ["context", "following"] },
|
| 111 |
+
]),
|
| 112 |
+
)
|
| 113 |
+
}
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
|
| 117 |
+
const editID = "prt_interaction_edit"
|
| 118 |
+
const followingID = "prt_interaction_edit_following"
|
| 119 |
+
await setupTimeline(page, {
|
| 120 |
+
messages: [
|
| 121 |
+
userMessage(),
|
| 122 |
+
assistantMessage([
|
| 123 |
+
toolPart(
|
| 124 |
+
editID,
|
| 125 |
+
"edit",
|
| 126 |
+
"completed",
|
| 127 |
+
{ filePath: "src/edit.ts" },
|
| 128 |
+
{
|
| 129 |
+
metadata: {
|
| 130 |
+
filediff: {
|
| 131 |
+
file: "src/edit.ts",
|
| 132 |
+
additions: 40,
|
| 133 |
+
deletions: 40,
|
| 134 |
+
before: source(40, false),
|
| 135 |
+
after: source(40, true),
|
| 136 |
+
},
|
| 137 |
+
},
|
| 138 |
+
},
|
| 139 |
+
),
|
| 140 |
+
textPart(followingID, "Following edit expansion"),
|
| 141 |
+
]),
|
| 142 |
+
],
|
| 143 |
+
settings: { editToolPartsExpanded: false },
|
| 144 |
+
cpuRate: 4,
|
| 145 |
+
seedHistory: true,
|
| 146 |
+
})
|
| 147 |
+
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
| 148 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
| 149 |
+
const regions = defineVisualRegions({
|
| 150 |
+
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 151 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 152 |
+
})
|
| 153 |
+
await startVisualProbe(page, regions)
|
| 154 |
+
await trigger.click()
|
| 155 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 156 |
+
await page.waitForTimeout(900)
|
| 157 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 158 |
+
await reportVisualStability(
|
| 159 |
+
testInfo,
|
| 160 |
+
"edit-expand",
|
| 161 |
+
trace,
|
| 162 |
+
visualPlan(regions, [
|
| 163 |
+
{ type: "required", regions: ["edit", "following"] },
|
| 164 |
+
{ type: "unique", regions: ["edit", "following"] },
|
| 165 |
+
{ type: "stable", regions: ["edit", "following"] },
|
| 166 |
+
{ type: "opacity", regions: "all" },
|
| 167 |
+
{ type: "continuity", regions: "all" },
|
| 168 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
| 169 |
+
{ type: "label-stability", regions: "all" },
|
| 170 |
+
{ type: "preserve-bottom-anchor" },
|
| 171 |
+
{ type: "flow", regions: ["edit", "following"] },
|
| 172 |
+
]),
|
| 173 |
+
)
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
|
| 177 |
+
const firstUser = userMessage(undefined, {
|
| 178 |
+
summary: {
|
| 179 |
+
diffs: Array.from({ length: 12 }, (_, index) => ({
|
| 180 |
+
file: `src/diff-${index}.ts`,
|
| 181 |
+
additions: 1,
|
| 182 |
+
deletions: 1,
|
| 183 |
+
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
| 184 |
+
})),
|
| 185 |
+
},
|
| 186 |
+
})
|
| 187 |
+
const nextUserID = "msg_2000_diff_interaction_user"
|
| 188 |
+
await setupTimeline(page, {
|
| 189 |
+
messages: [
|
| 190 |
+
firstUser,
|
| 191 |
+
assistantMessage(),
|
| 192 |
+
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
|
| 193 |
+
assistantMessage([], {
|
| 194 |
+
id: "msg_2001_diff_interaction_assistant",
|
| 195 |
+
parentID: nextUserID,
|
| 196 |
+
created: 1700000011000,
|
| 197 |
+
}),
|
| 198 |
+
],
|
| 199 |
+
cpuRate: 4,
|
| 200 |
+
})
|
| 201 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 202 |
+
await scroller.evaluate((element) => (element.scrollTop = 0))
|
| 203 |
+
const diff = page.locator('[data-timeline-row="DiffSummary"]')
|
| 204 |
+
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
|
| 205 |
+
await expect(diff).toBeVisible()
|
| 206 |
+
const regions = defineVisualRegions({
|
| 207 |
+
diff: { selector: '[data-timeline-row="DiffSummary"]' },
|
| 208 |
+
following: { selector: `[data-message-id="${nextUserID}"]` },
|
| 209 |
+
})
|
| 210 |
+
await startVisualProbe(page, regions)
|
| 211 |
+
await page.getByText(/show all/i).click()
|
| 212 |
+
await page.waitForTimeout(500)
|
| 213 |
+
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
|
| 214 |
+
await page.waitForTimeout(900)
|
| 215 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 216 |
+
await reportVisualStability(
|
| 217 |
+
testInfo,
|
| 218 |
+
"diff-summary-expand",
|
| 219 |
+
trace,
|
| 220 |
+
visualPlan(regions, [
|
| 221 |
+
{ type: "required", regions: ["diff", "following"] },
|
| 222 |
+
{ type: "unique", regions: ["diff", "following"] },
|
| 223 |
+
{ type: "stable", regions: ["diff", "following"] },
|
| 224 |
+
{ type: "opacity", regions: "all" },
|
| 225 |
+
{ type: "continuity", regions: "all" },
|
| 226 |
+
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
| 227 |
+
{ type: "label-stability", regions: "all" },
|
| 228 |
+
{ type: "flow", regions: ["diff", "following"] },
|
| 229 |
+
]),
|
| 230 |
+
)
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
function lines(count: number) {
|
| 234 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function source(count: number, changed: boolean) {
|
| 238 |
+
return Array.from(
|
| 239 |
+
{ length: count },
|
| 240 |
+
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
| 241 |
+
).join("")
|
| 242 |
+
}
|
packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 25 |
+
test.describe("timeline visual lifecycle stability", () => {
|
| 26 |
+
test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
|
| 27 |
+
test.setTimeout(180_000)
|
| 28 |
+
const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
|
| 29 |
+
const initial = ids.map((id) => shell(id, "running"))
|
| 30 |
+
const followingID = "prt_parallel_04_following"
|
| 31 |
+
const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
|
| 32 |
+
completed: false,
|
| 33 |
+
})
|
| 34 |
+
const timeline = await setupTimeline(page, {
|
| 35 |
+
messages: [userMessage(), assistant],
|
| 36 |
+
settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
|
| 37 |
+
cpuRate: 4,
|
| 38 |
+
eventRetry: 24,
|
| 39 |
+
seedHistory: true,
|
| 40 |
+
})
|
| 41 |
+
await timeline.send(status("busy"), 150)
|
| 42 |
+
for (const id of ids) await timeline.waitForPart(id)
|
| 43 |
+
const scroller = page.locator(".scroll-view__viewport", {
|
| 44 |
+
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
| 45 |
+
})
|
| 46 |
+
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
| 47 |
+
const regions = defineVisualRegions({
|
| 48 |
+
prt_shell_empty: shellRegion(ids[0]),
|
| 49 |
+
prt_shell_short: shellRegion(ids[1]),
|
| 50 |
+
prt_shell_long: shellRegion(ids[2]),
|
| 51 |
+
following: shellRegion(followingID),
|
| 52 |
+
})
|
| 53 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
|
| 54 |
+
await startVisualProbe(page, regions)
|
| 55 |
+
await timeline.sendAll([
|
| 56 |
+
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
| 57 |
+
{ event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
|
| 58 |
+
{ event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
|
| 59 |
+
{ event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
|
| 60 |
+
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
|
| 61 |
+
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
|
| 62 |
+
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
| 63 |
+
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
|
| 64 |
+
{ event: status("idle"), delay: 700 },
|
| 65 |
+
])
|
| 66 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 67 |
+
await reportVisualStability(
|
| 68 |
+
testInfo,
|
| 69 |
+
"parallel-shells",
|
| 70 |
+
trace,
|
| 71 |
+
visualPlan(
|
| 72 |
+
regions,
|
| 73 |
+
[
|
| 74 |
+
{ type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
| 75 |
+
{ type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
|
| 76 |
+
{ type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
| 77 |
+
{ type: "opacity", regions: "all" },
|
| 78 |
+
{ type: "continuity", regions: "all" },
|
| 79 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
|
| 80 |
+
{ type: "label-stability", regions: "all" },
|
| 81 |
+
{ type: "preserve-bottom-anchor" },
|
| 82 |
+
{ type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
| 83 |
+
],
|
| 84 |
+
{ perMarker: true },
|
| 85 |
+
),
|
| 86 |
+
)
|
| 87 |
+
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
|
| 88 |
+
|
| 89 |
+
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
|
| 90 |
+
await short.locator('[data-slot="collapsible-trigger"]').click()
|
| 91 |
+
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
| 92 |
+
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
|
| 93 |
+
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
|
| 97 |
+
page,
|
| 98 |
+
}, testInfo) => {
|
| 99 |
+
const reasoningID = "prt_reasoning_visible"
|
| 100 |
+
const textID = "prt_streamed_text"
|
| 101 |
+
const assistant = assistantMessage([], { completed: false })
|
| 102 |
+
const timeline = await setupTimeline(page, {
|
| 103 |
+
messages: [userMessage(), assistant],
|
| 104 |
+
settings: { showReasoningSummaries: true },
|
| 105 |
+
cpuRate: 4,
|
| 106 |
+
})
|
| 107 |
+
await timeline.send(status("busy"), 120)
|
| 108 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
| 109 |
+
|
| 110 |
+
const regions = defineVisualRegions({
|
| 111 |
+
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
| 112 |
+
reasoning: {
|
| 113 |
+
selector: `[data-timeline-part-id="${reasoningID}"]`,
|
| 114 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 115 |
+
},
|
| 116 |
+
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 117 |
+
})
|
| 118 |
+
await startVisualProbe(page, regions)
|
| 119 |
+
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
|
| 120 |
+
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
| 121 |
+
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
| 122 |
+
await timeline.waitForPart(reasoningID)
|
| 123 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
| 124 |
+
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
|
| 125 |
+
await timeline.send(partDelta(textID, " **stable"), 90)
|
| 126 |
+
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
| 127 |
+
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
| 128 |
+
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
|
| 129 |
+
await timeline.send(status("idle"), 500)
|
| 130 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 131 |
+
await reportVisualStability(
|
| 132 |
+
testInfo,
|
| 133 |
+
"reasoning-text-handoff",
|
| 134 |
+
trace,
|
| 135 |
+
visualPlan(regions, [
|
| 136 |
+
{ type: "required", regions: ["reasoning", "text"] },
|
| 137 |
+
{ type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
|
| 138 |
+
{ type: "unique", regions: ["reasoning", "text"] },
|
| 139 |
+
{ type: "stable", regions: ["reasoning", "text"] },
|
| 140 |
+
{ type: "opacity", regions: "all" },
|
| 141 |
+
{ type: "continuity", regions: "all" },
|
| 142 |
+
{ type: "motion", regions: "all", maxReversals: 4 },
|
| 143 |
+
{ type: "label-stability", regions: "all" },
|
| 144 |
+
{ type: "flow", regions: ["reasoning", "text"] },
|
| 145 |
+
]),
|
| 146 |
+
)
|
| 147 |
+
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
|
| 148 |
+
})
|
| 149 |
+
})
|
| 150 |
+
|
| 151 |
+
function lines(count: number) {
|
| 152 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function shellRegion(id: string) {
|
| 156 |
+
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
| 157 |
+
}
|
packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
analyzeVisualObservations,
|
| 4 |
+
defineVisualRegions,
|
| 5 |
+
startVisualProbe,
|
| 6 |
+
stopVisualProbe,
|
| 7 |
+
visualPlan,
|
| 8 |
+
} from "../../utils/visual-stability"
|
| 9 |
+
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
|
| 10 |
+
|
| 11 |
+
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
| 12 |
+
const partID = "prt_oracle_ancestor_opacity"
|
| 13 |
+
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
| 14 |
+
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
| 15 |
+
const regions = defineVisualRegions({
|
| 16 |
+
content: { selector: `[data-timeline-part-id="${partID}"]` },
|
| 17 |
+
})
|
| 18 |
+
await startVisualProbe(page, regions)
|
| 19 |
+
await row.evaluate((element) => {
|
| 20 |
+
element.parentElement!.style.opacity = "0"
|
| 21 |
+
})
|
| 22 |
+
await page.waitForTimeout(50)
|
| 23 |
+
await row.evaluate((element) => {
|
| 24 |
+
element.parentElement!.style.opacity = "1"
|
| 25 |
+
})
|
| 26 |
+
await page.waitForTimeout(50)
|
| 27 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 28 |
+
const issues = analyzeVisualObservations(
|
| 29 |
+
trace.samples,
|
| 30 |
+
visualPlan(regions, [
|
| 31 |
+
{ type: "opacity", regions: "all" },
|
| 32 |
+
{ type: "continuity", regions: "all" },
|
| 33 |
+
{ type: "motion", regions: "all" },
|
| 34 |
+
{ type: "label-stability", regions: "all" },
|
| 35 |
+
]),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
| 42 |
+
const partID = "prt_oracle_descendant_opacity"
|
| 43 |
+
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
| 44 |
+
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
| 45 |
+
await row.evaluate((element) => {
|
| 46 |
+
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
| 47 |
+
})
|
| 48 |
+
const regions = defineVisualRegions({
|
| 49 |
+
content: {
|
| 50 |
+
selector: `[data-timeline-part-id="${partID}"]`,
|
| 51 |
+
opacitySelectors: ['[data-probe-opacity="true"]'],
|
| 52 |
+
},
|
| 53 |
+
})
|
| 54 |
+
await startVisualProbe(page, regions)
|
| 55 |
+
await row.evaluate((element) => {
|
| 56 |
+
;(element as HTMLElement).style.opacity = "0"
|
| 57 |
+
})
|
| 58 |
+
await page.waitForTimeout(50)
|
| 59 |
+
await row.evaluate((element) => {
|
| 60 |
+
;(element as HTMLElement).style.opacity = "1"
|
| 61 |
+
})
|
| 62 |
+
await page.waitForTimeout(50)
|
| 63 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 64 |
+
const issues = analyzeVisualObservations(
|
| 65 |
+
trace.samples,
|
| 66 |
+
visualPlan(regions, [
|
| 67 |
+
{ type: "opacity", regions: "all" },
|
| 68 |
+
{ type: "continuity", regions: "all" },
|
| 69 |
+
{ type: "motion", regions: "all" },
|
| 70 |
+
{ type: "label-stability", regions: "all" },
|
| 71 |
+
]),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
| 75 |
+
})
|
packages/app/e2e/performance/timeline-stability/playwright.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import config from "../playwright.config"
|
| 2 |
+
|
| 3 |
+
export default {
|
| 4 |
+
...config,
|
| 5 |
+
testDir: ".",
|
| 6 |
+
testMatch: "**/*.spec.ts",
|
| 7 |
+
outputDir: "../../test-results/timeline-stability",
|
| 8 |
+
reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]],
|
| 9 |
+
retries: 0,
|
| 10 |
+
workers: 1,
|
| 11 |
+
use: {
|
| 12 |
+
...config.use,
|
| 13 |
+
trace: "retain-on-failure",
|
| 14 |
+
screenshot: "only-on-failure",
|
| 15 |
+
video: "retain-on-failure",
|
| 16 |
+
},
|
| 17 |
+
}
|
packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 10 |
+
assistantMessage,
|
| 11 |
+
partUpdated,
|
| 12 |
+
setupTimeline,
|
| 13 |
+
shell,
|
| 14 |
+
textPart,
|
| 15 |
+
userMessage,
|
| 16 |
+
type TimelineMessage,
|
| 17 |
+
} from "./fixture"
|
| 18 |
+
|
| 19 |
+
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
|
| 20 |
+
const shellID = "prt_wheel_01_shell"
|
| 21 |
+
const followingID = "prt_wheel_02_following"
|
| 22 |
+
const timeline = await setupTimeline(page, {
|
| 23 |
+
messages: [
|
| 24 |
+
...history(12),
|
| 25 |
+
userMessage(),
|
| 26 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
|
| 27 |
+
completed: false,
|
| 28 |
+
}),
|
| 29 |
+
],
|
| 30 |
+
settings: { shellToolPartsExpanded: true },
|
| 31 |
+
cpuRate: 4,
|
| 32 |
+
reducedMotion: true,
|
| 33 |
+
seedHistory: true,
|
| 34 |
+
})
|
| 35 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 36 |
+
const regions = defineVisualRegions({
|
| 37 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 38 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 39 |
+
})
|
| 40 |
+
await startVisualProbe(page, regions)
|
| 41 |
+
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
| 42 |
+
await scroller.evaluate((element) =>
|
| 43 |
+
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
|
| 44 |
+
)
|
| 45 |
+
await scroller.evaluate((element) => (element.scrollTop -= 180))
|
| 46 |
+
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
|
| 47 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 48 |
+
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
|
| 52 |
+
await setupTimeline(page, {
|
| 53 |
+
messages: history(80),
|
| 54 |
+
viewport: { width: 1400, height: 700 },
|
| 55 |
+
reducedMotion: true,
|
| 56 |
+
})
|
| 57 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 58 |
+
const text = page.getByText("History 79.", { exact: false })
|
| 59 |
+
await expect(text).toBeVisible()
|
| 60 |
+
await scroller.evaluate((element) => {
|
| 61 |
+
element.dataset.selectionLength = "0"
|
| 62 |
+
document.addEventListener("selectionchange", () => {
|
| 63 |
+
element.dataset.selectionLength = String(
|
| 64 |
+
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
|
| 65 |
+
)
|
| 66 |
+
})
|
| 67 |
+
})
|
| 68 |
+
const textBox = await text.boundingBox()
|
| 69 |
+
const scrollBox = await scroller.boundingBox()
|
| 70 |
+
expect(textBox).not.toBeNull()
|
| 71 |
+
expect(scrollBox).not.toBeNull()
|
| 72 |
+
if (!textBox || !scrollBox) return
|
| 73 |
+
|
| 74 |
+
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
|
| 75 |
+
await page.mouse.down()
|
| 76 |
+
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
|
| 77 |
+
|
| 78 |
+
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
|
| 79 |
+
await expect
|
| 80 |
+
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 81 |
+
.toBeGreaterThan(500)
|
| 82 |
+
await page.mouse.up()
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
| 86 |
+
const shellID = "prt_keyboard_01_shell"
|
| 87 |
+
const followingID = "prt_keyboard_02_following"
|
| 88 |
+
const timeline = await setupTimeline(page, {
|
| 89 |
+
messages: [
|
| 90 |
+
...history(12),
|
| 91 |
+
userMessage(),
|
| 92 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
|
| 93 |
+
completed: false,
|
| 94 |
+
}),
|
| 95 |
+
],
|
| 96 |
+
settings: { shellToolPartsExpanded: true },
|
| 97 |
+
cpuRate: 4,
|
| 98 |
+
})
|
| 99 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 100 |
+
await scroller.focus()
|
| 101 |
+
for (let index = 0; index < 3; index++) {
|
| 102 |
+
await scroller.press("PageUp")
|
| 103 |
+
await page.waitForTimeout(250)
|
| 104 |
+
}
|
| 105 |
+
await expect
|
| 106 |
+
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
|
| 107 |
+
timeout: 20_000,
|
| 108 |
+
})
|
| 109 |
+
.toBeGreaterThan(80)
|
| 110 |
+
await page.waitForFunction(() => {
|
| 111 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 112 |
+
element.querySelector("[data-timeline-row]"),
|
| 113 |
+
)
|
| 114 |
+
if (!root) return false
|
| 115 |
+
return new Promise<boolean>((resolve) => {
|
| 116 |
+
const top = root.scrollTop
|
| 117 |
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
| 118 |
+
})
|
| 119 |
+
})
|
| 120 |
+
const anchor = await scroller.evaluate((element) => {
|
| 121 |
+
const view = element.getBoundingClientRect()
|
| 122 |
+
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
| 123 |
+
const rect = row.getBoundingClientRect()
|
| 124 |
+
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
| 125 |
+
})?.dataset.timelineKey
|
| 126 |
+
})
|
| 127 |
+
expect(anchor).toBeTruthy()
|
| 128 |
+
const regions = defineVisualRegions({
|
| 129 |
+
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
| 130 |
+
})
|
| 131 |
+
await startVisualProbe(page, regions)
|
| 132 |
+
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
|
| 133 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 134 |
+
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
| 138 |
+
const shellID = "prt_descendant_keyboard_01_shell"
|
| 139 |
+
const timeline = await setupTimeline(page, {
|
| 140 |
+
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
| 141 |
+
settings: { shellToolPartsExpanded: false },
|
| 142 |
+
cpuRate: 4,
|
| 143 |
+
reducedMotion: true,
|
| 144 |
+
})
|
| 145 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 146 |
+
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
| 147 |
+
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
| 148 |
+
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
| 149 |
+
await row.focus()
|
| 150 |
+
for (let index = 0; index < 3; index++) {
|
| 151 |
+
await row.press("PageUp")
|
| 152 |
+
await page.waitForTimeout(250)
|
| 153 |
+
}
|
| 154 |
+
await expect
|
| 155 |
+
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 156 |
+
.toBeGreaterThan(5)
|
| 157 |
+
const anchor = await scroller.evaluate((element) => {
|
| 158 |
+
const view = element.getBoundingClientRect()
|
| 159 |
+
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
| 160 |
+
const rect = row.getBoundingClientRect()
|
| 161 |
+
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
| 162 |
+
})?.dataset.timelineKey
|
| 163 |
+
})
|
| 164 |
+
expect(anchor).toBeTruthy()
|
| 165 |
+
const regions = defineVisualRegions({
|
| 166 |
+
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
| 167 |
+
})
|
| 168 |
+
await startVisualProbe(page, regions)
|
| 169 |
+
await trigger.click()
|
| 170 |
+
await page.waitForTimeout(300)
|
| 171 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 172 |
+
await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
|
| 173 |
+
})
|
| 174 |
+
|
| 175 |
+
test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
|
| 176 |
+
const shellID = "prt_nested_keyboard_shell"
|
| 177 |
+
await setupTimeline(page, {
|
| 178 |
+
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
|
| 179 |
+
settings: { shellToolPartsExpanded: true },
|
| 180 |
+
cpuRate: 4,
|
| 181 |
+
reducedMotion: true,
|
| 182 |
+
seedHistory: true,
|
| 183 |
+
})
|
| 184 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 185 |
+
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
|
| 186 |
+
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
| 187 |
+
await nested.focus()
|
| 188 |
+
await page.waitForFunction(() => {
|
| 189 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 190 |
+
element.querySelector("[data-timeline-row]"),
|
| 191 |
+
)
|
| 192 |
+
if (!root) return false
|
| 193 |
+
return new Promise<boolean>((resolve) => {
|
| 194 |
+
const top = root.scrollTop
|
| 195 |
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
| 196 |
+
})
|
| 197 |
+
})
|
| 198 |
+
const before = await scroller.evaluate((element) => element.scrollTop)
|
| 199 |
+
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
|
| 200 |
+
await nested.press("PageUp")
|
| 201 |
+
await page.waitForTimeout(300)
|
| 202 |
+
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
| 203 |
+
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
|
| 204 |
+
|
| 205 |
+
await nested.evaluate((element) => (element.scrollTop = 0))
|
| 206 |
+
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
|
| 207 |
+
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
|
| 208 |
+
expect(boundaryBefore).toBeGreaterThan(0)
|
| 209 |
+
await nested.press("PageUp")
|
| 210 |
+
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
| 211 |
+
|
| 212 |
+
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
| 213 |
+
await nonOverflowing.evaluate((element) => {
|
| 214 |
+
element.setAttribute("data-scrollable", "")
|
| 215 |
+
element.setAttribute("tabindex", "0")
|
| 216 |
+
})
|
| 217 |
+
await nonOverflowing.focus()
|
| 218 |
+
const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
|
| 219 |
+
await nonOverflowing.press("PageUp")
|
| 220 |
+
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
|
| 224 |
+
const shellID = "prt_jump_01_shell"
|
| 225 |
+
const followingID = "prt_jump_02_following"
|
| 226 |
+
const timeline = await setupTimeline(page, {
|
| 227 |
+
messages: [
|
| 228 |
+
...history(20),
|
| 229 |
+
userMessage(),
|
| 230 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
|
| 231 |
+
],
|
| 232 |
+
settings: { shellToolPartsExpanded: true },
|
| 233 |
+
cpuRate: 4,
|
| 234 |
+
})
|
| 235 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 236 |
+
await scroller.evaluate(
|
| 237 |
+
(element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
|
| 238 |
+
)
|
| 239 |
+
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
| 240 |
+
const regions = defineVisualRegions({
|
| 241 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 242 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 243 |
+
})
|
| 244 |
+
await startVisualProbe(page, regions)
|
| 245 |
+
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
| 246 |
+
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
|
| 247 |
+
await page.waitForTimeout(600)
|
| 248 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 249 |
+
await reportVisualStability(
|
| 250 |
+
testInfo,
|
| 251 |
+
"jump-latest",
|
| 252 |
+
trace,
|
| 253 |
+
visualPlan(regions, [
|
| 254 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 255 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 256 |
+
{ type: "opacity", regions: "all" },
|
| 257 |
+
{ type: "continuity", regions: "all" },
|
| 258 |
+
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
| 259 |
+
{ type: "label-stability", regions: "all" },
|
| 260 |
+
{ type: "acquire-bottom-anchor" },
|
| 261 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 262 |
+
]),
|
| 263 |
+
)
|
| 264 |
+
})
|
| 265 |
+
|
| 266 |
+
test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
|
| 267 |
+
const shellID = "prt_tall_01_shell"
|
| 268 |
+
const followingID = "prt_tall_02_following"
|
| 269 |
+
const timeline = await setupTimeline(page, {
|
| 270 |
+
messages: [
|
| 271 |
+
userMessage(),
|
| 272 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
|
| 273 |
+
],
|
| 274 |
+
settings: { shellToolPartsExpanded: true },
|
| 275 |
+
viewport: { width: 900, height: 360 },
|
| 276 |
+
cpuRate: 4,
|
| 277 |
+
seedHistory: true,
|
| 278 |
+
})
|
| 279 |
+
const regions = defineVisualRegions({
|
| 280 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 281 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 282 |
+
})
|
| 283 |
+
await startVisualProbe(page, regions)
|
| 284 |
+
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
| 285 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 286 |
+
await reportVisualStability(
|
| 287 |
+
testInfo,
|
| 288 |
+
"taller-than-viewport",
|
| 289 |
+
trace,
|
| 290 |
+
visualPlan(regions, [
|
| 291 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 292 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 293 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 294 |
+
{ type: "opacity", regions: "all" },
|
| 295 |
+
{ type: "continuity", regions: "all" },
|
| 296 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 297 |
+
{ type: "label-stability", regions: "all" },
|
| 298 |
+
{ type: "preserve-bottom-anchor" },
|
| 299 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 300 |
+
]),
|
| 301 |
+
)
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
function history(count: number): TimelineMessage[] {
|
| 305 |
+
return Array.from({ length: count }, (_, index) => {
|
| 306 |
+
const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
|
| 307 |
+
const userID = `${prefix}_a_user`
|
| 308 |
+
return [
|
| 309 |
+
userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
|
| 310 |
+
assistantMessage(
|
| 311 |
+
[textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
|
| 312 |
+
{
|
| 313 |
+
id: `${prefix}_b_assistant`,
|
| 314 |
+
parentID: userID,
|
| 315 |
+
created: 1690000001000 + index * 10_000,
|
| 316 |
+
},
|
| 317 |
+
),
|
| 318 |
+
]
|
| 319 |
+
}).flat()
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
function lines(count: number) {
|
| 323 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function rowPairPlan(
|
| 327 |
+
regions: Record<"shell" | "following", { selector: string; closest?: string }>,
|
| 328 |
+
maxPositionReversals: number,
|
| 329 |
+
) {
|
| 330 |
+
return visualPlan(regions, [
|
| 331 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 332 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 333 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 334 |
+
{ type: "opacity", regions: "all" },
|
| 335 |
+
{ type: "continuity", regions: "all" },
|
| 336 |
+
{ type: "motion", regions: "all", maxPositionReversals },
|
| 337 |
+
{ type: "label-stability", regions: "all" },
|
| 338 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 339 |
+
])
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
|
| 343 |
+
return visualPlan(regions, [
|
| 344 |
+
{ type: "required", regions: ["anchor"] },
|
| 345 |
+
{ type: "unique", regions: ["anchor"] },
|
| 346 |
+
{ type: "stable", regions: ["anchor"] },
|
| 347 |
+
{ type: "fixed", regions: ["anchor"] },
|
| 348 |
+
{ type: "opacity", regions: "all" },
|
| 349 |
+
{ type: "continuity", regions: "all" },
|
| 350 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 351 |
+
{ type: "label-stability", regions: "all" },
|
| 352 |
+
])
|
| 353 |
+
}
|
packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 19 |
+
const profiles = [
|
| 20 |
+
{
|
| 21 |
+
name: "empty running to completed",
|
| 22 |
+
updates: [{ state: "completed" as const, output: "", delay: 350 }],
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
name: "50 lines arriving incrementally",
|
| 26 |
+
updates: [
|
| 27 |
+
{ state: "running" as const, output: lines(1), delay: 100 },
|
| 28 |
+
{ state: "running" as const, output: lines(10), delay: 160 },
|
| 29 |
+
{ state: "running" as const, output: lines(25), delay: 90 },
|
| 30 |
+
{ state: "running" as const, output: lines(50), delay: 220 },
|
| 31 |
+
{ state: "completed" as const, output: lines(50), delay: 500 },
|
| 32 |
+
],
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
name: "wide ANSI and CRLF output",
|
| 36 |
+
updates: [
|
| 37 |
+
{
|
| 38 |
+
state: "running" as const,
|
| 39 |
+
output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
|
| 40 |
+
"\r\n",
|
| 41 |
+
),
|
| 42 |
+
delay: 240,
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
state: "completed" as const,
|
| 46 |
+
output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
|
| 47 |
+
delay: 500,
|
| 48 |
+
},
|
| 49 |
+
],
|
| 50 |
+
},
|
| 51 |
+
] as const
|
| 52 |
+
|
| 53 |
+
for (const profile of profiles) {
|
| 54 |
+
test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
|
| 55 |
+
const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
|
| 56 |
+
const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
|
| 57 |
+
const timeline = await setupTimeline(page, {
|
| 58 |
+
messages: [
|
| 59 |
+
userMessage(),
|
| 60 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
|
| 61 |
+
completed: false,
|
| 62 |
+
}),
|
| 63 |
+
],
|
| 64 |
+
settings: { shellToolPartsExpanded: true },
|
| 65 |
+
cpuRate: 4,
|
| 66 |
+
seedHistory: true,
|
| 67 |
+
})
|
| 68 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 69 |
+
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
| 70 |
+
await waitForVisualSettle(page, [
|
| 71 |
+
`[data-timeline-part-id="${shellID}"]`,
|
| 72 |
+
`[data-timeline-part-id="${followingID}"]`,
|
| 73 |
+
])
|
| 74 |
+
const regions = defineVisualRegions({
|
| 75 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 76 |
+
following: {
|
| 77 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 78 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 79 |
+
},
|
| 80 |
+
})
|
| 81 |
+
await startVisualProbe(page, regions)
|
| 82 |
+
for (const update of profile.updates) {
|
| 83 |
+
await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
|
| 84 |
+
}
|
| 85 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 86 |
+
await reportVisualStability(
|
| 87 |
+
testInfo,
|
| 88 |
+
`shell-${profiles.indexOf(profile)}`,
|
| 89 |
+
trace,
|
| 90 |
+
visualPlan(
|
| 91 |
+
regions,
|
| 92 |
+
[
|
| 93 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 94 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 95 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 96 |
+
{ type: "opacity", regions: "all" },
|
| 97 |
+
{ type: "continuity", regions: "all" },
|
| 98 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
| 99 |
+
{ type: "label-stability", regions: "all" },
|
| 100 |
+
{ type: "preserve-bottom-anchor" },
|
| 101 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 102 |
+
],
|
| 103 |
+
{ perMarker: true },
|
| 104 |
+
),
|
| 105 |
+
)
|
| 106 |
+
})
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
|
| 110 |
+
const shellID = "prt_matrix_collapsed_01_shell"
|
| 111 |
+
const followingID = "prt_matrix_collapsed_02_following"
|
| 112 |
+
const timeline = await setupTimeline(page, {
|
| 113 |
+
messages: [
|
| 114 |
+
userMessage(),
|
| 115 |
+
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
|
| 116 |
+
completed: false,
|
| 117 |
+
}),
|
| 118 |
+
],
|
| 119 |
+
settings: { shellToolPartsExpanded: false },
|
| 120 |
+
cpuRate: 4,
|
| 121 |
+
seedHistory: true,
|
| 122 |
+
})
|
| 123 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
| 124 |
+
const regions = defineVisualRegions({
|
| 125 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 126 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 127 |
+
})
|
| 128 |
+
await startVisualProbe(page, regions)
|
| 129 |
+
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
| 130 |
+
await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
|
| 131 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 132 |
+
await reportVisualStability(
|
| 133 |
+
testInfo,
|
| 134 |
+
"collapsed-shell",
|
| 135 |
+
trace,
|
| 136 |
+
visualPlan(
|
| 137 |
+
regions,
|
| 138 |
+
[
|
| 139 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 140 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 141 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 142 |
+
{ type: "opacity", regions: "all" },
|
| 143 |
+
{ type: "continuity", regions: "all" },
|
| 144 |
+
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
| 145 |
+
{ type: "label-stability", regions: "all" },
|
| 146 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 147 |
+
],
|
| 148 |
+
{ perMarker: true },
|
| 149 |
+
),
|
| 150 |
+
)
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
|
| 154 |
+
const shellID = "prt_matrix_error_01_shell"
|
| 155 |
+
const followingID = "prt_matrix_error_02_following"
|
| 156 |
+
const timeline = await setupTimeline(page, {
|
| 157 |
+
messages: [
|
| 158 |
+
userMessage(),
|
| 159 |
+
assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
|
| 160 |
+
completed: false,
|
| 161 |
+
}),
|
| 162 |
+
],
|
| 163 |
+
settings: { shellToolPartsExpanded: true },
|
| 164 |
+
cpuRate: 4,
|
| 165 |
+
seedHistory: true,
|
| 166 |
+
})
|
| 167 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
| 168 |
+
const regions = defineVisualRegions({
|
| 169 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 170 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 171 |
+
})
|
| 172 |
+
await startVisualProbe(page, regions)
|
| 173 |
+
await timeline.send(
|
| 174 |
+
partUpdated({
|
| 175 |
+
...shell(shellID, "error"),
|
| 176 |
+
state: {
|
| 177 |
+
status: "error",
|
| 178 |
+
input: { command: `echo ${shellID}` },
|
| 179 |
+
error: "Command failed after output",
|
| 180 |
+
metadata: {},
|
| 181 |
+
time: { start: 1700000001000, end: 1700000002000 },
|
| 182 |
+
},
|
| 183 |
+
}),
|
| 184 |
+
500,
|
| 185 |
+
)
|
| 186 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 187 |
+
await reportVisualStability(
|
| 188 |
+
testInfo,
|
| 189 |
+
"shell-error",
|
| 190 |
+
trace,
|
| 191 |
+
visualPlan(
|
| 192 |
+
regions,
|
| 193 |
+
[
|
| 194 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 195 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 196 |
+
{ type: "stable", regions: ["shell", "following"] },
|
| 197 |
+
{ type: "opacity", regions: "all" },
|
| 198 |
+
{ type: "continuity", regions: "all" },
|
| 199 |
+
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
| 200 |
+
{ type: "label-stability", regions: "all" },
|
| 201 |
+
{ type: "preserve-bottom-anchor" },
|
| 202 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 203 |
+
],
|
| 204 |
+
{ perMarker: true },
|
| 205 |
+
),
|
| 206 |
+
)
|
| 207 |
+
})
|
| 208 |
+
|
| 209 |
+
test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
|
| 210 |
+
const shellID = "prt_late_text_01_shell"
|
| 211 |
+
const followingID = "prt_late_text_02_following"
|
| 212 |
+
const timeline = await setupTimeline(page, {
|
| 213 |
+
messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
|
| 214 |
+
settings: { shellToolPartsExpanded: true },
|
| 215 |
+
cpuRate: 4,
|
| 216 |
+
seedHistory: true,
|
| 217 |
+
})
|
| 218 |
+
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
|
| 219 |
+
const regions = defineVisualRegions({
|
| 220 |
+
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 221 |
+
following: {
|
| 222 |
+
selector: `[data-timeline-part-id="${followingID}"]`,
|
| 223 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 224 |
+
},
|
| 225 |
+
})
|
| 226 |
+
await startVisualProbe(page, regions)
|
| 227 |
+
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
|
| 228 |
+
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
| 229 |
+
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
| 230 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 231 |
+
await reportVisualStability(
|
| 232 |
+
testInfo,
|
| 233 |
+
"late-text-before-shell-output",
|
| 234 |
+
trace,
|
| 235 |
+
visualPlan(
|
| 236 |
+
regions,
|
| 237 |
+
[
|
| 238 |
+
{ type: "required", regions: ["shell", "following"] },
|
| 239 |
+
{ type: "unique", regions: ["shell", "following"] },
|
| 240 |
+
{ type: "stable", regions: ["shell"] },
|
| 241 |
+
{ type: "opacity", regions: "all" },
|
| 242 |
+
{ type: "continuity", regions: "all" },
|
| 243 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 244 |
+
{ type: "label-stability", regions: "all" },
|
| 245 |
+
{ type: "preserve-bottom-anchor" },
|
| 246 |
+
{ type: "flow", regions: ["shell", "following"] },
|
| 247 |
+
],
|
| 248 |
+
{ perMarker: true },
|
| 249 |
+
),
|
| 250 |
+
)
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
function lines(count: number) {
|
| 254 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 255 |
+
}
|
packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 10 |
+
assistantMessage,
|
| 11 |
+
partUpdated,
|
| 12 |
+
session,
|
| 13 |
+
sessionID,
|
| 14 |
+
setupTimeline,
|
| 15 |
+
textPart,
|
| 16 |
+
toolPart,
|
| 17 |
+
userMessage,
|
| 18 |
+
} from "./fixture"
|
| 19 |
+
|
| 20 |
+
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
|
| 21 |
+
const taskID = "prt_task_link"
|
| 22 |
+
const childID = "ses_task_child"
|
| 23 |
+
const input = { description: "Inspect child", subagent_type: "explore" }
|
| 24 |
+
const timeline = await setupTimeline(page, {
|
| 25 |
+
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
|
| 26 |
+
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
|
| 27 |
+
cpuRate: 4,
|
| 28 |
+
})
|
| 29 |
+
const regions = defineVisualRegions({
|
| 30 |
+
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
|
| 31 |
+
})
|
| 32 |
+
await startVisualProbe(page, regions)
|
| 33 |
+
await timeline.send(
|
| 34 |
+
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
|
| 35 |
+
500,
|
| 36 |
+
)
|
| 37 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 38 |
+
await reportVisualStability(
|
| 39 |
+
testInfo,
|
| 40 |
+
"task-link",
|
| 41 |
+
trace,
|
| 42 |
+
visualPlan(regions, [
|
| 43 |
+
{ type: "required", regions: ["task"] },
|
| 44 |
+
{ type: "unique", regions: ["task"] },
|
| 45 |
+
{ type: "stable", regions: ["task"] },
|
| 46 |
+
{ type: "opacity", regions: "all" },
|
| 47 |
+
{ type: "continuity", regions: "all" },
|
| 48 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 49 |
+
{ type: "label-stability", regions: "all" },
|
| 50 |
+
]),
|
| 51 |
+
)
|
| 52 |
+
await expect(
|
| 53 |
+
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
| 54 |
+
).toBeVisible()
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
|
| 58 |
+
const toolID = "prt_generic_mutation"
|
| 59 |
+
const followingID = "prt_generic_mutation_following"
|
| 60 |
+
const timeline = await setupTimeline(page, {
|
| 61 |
+
messages: [
|
| 62 |
+
userMessage(),
|
| 63 |
+
assistantMessage(
|
| 64 |
+
[
|
| 65 |
+
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
|
| 66 |
+
textPart(followingID, "Following generic tool"),
|
| 67 |
+
],
|
| 68 |
+
{ completed: false },
|
| 69 |
+
),
|
| 70 |
+
],
|
| 71 |
+
cpuRate: 4,
|
| 72 |
+
})
|
| 73 |
+
const regions = defineVisualRegions({
|
| 74 |
+
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 75 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 76 |
+
})
|
| 77 |
+
await startVisualProbe(page, regions)
|
| 78 |
+
await timeline.send(
|
| 79 |
+
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
|
| 80 |
+
200,
|
| 81 |
+
)
|
| 82 |
+
await timeline.send(
|
| 83 |
+
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
|
| 84 |
+
400,
|
| 85 |
+
)
|
| 86 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 87 |
+
await reportVisualStability(
|
| 88 |
+
testInfo,
|
| 89 |
+
"generic-mutation",
|
| 90 |
+
trace,
|
| 91 |
+
visualPlan(
|
| 92 |
+
regions,
|
| 93 |
+
[
|
| 94 |
+
{ type: "required", regions: ["tool", "following"] },
|
| 95 |
+
{ type: "unique", regions: ["tool", "following"] },
|
| 96 |
+
{ type: "stable", regions: ["tool", "following"] },
|
| 97 |
+
{ type: "opacity", regions: "all" },
|
| 98 |
+
{ type: "continuity", regions: "all" },
|
| 99 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 100 |
+
{ type: "label-stability", regions: "all" },
|
| 101 |
+
{ type: "flow", regions: ["tool", "following"] },
|
| 102 |
+
],
|
| 103 |
+
{ perMarker: true },
|
| 104 |
+
),
|
| 105 |
+
)
|
| 106 |
+
})
|
packages/app/e2e/performance/timeline-stability/tools.spec.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 10 |
+
assistantMessage,
|
| 11 |
+
directory,
|
| 12 |
+
partUpdated,
|
| 13 |
+
session,
|
| 14 |
+
sessionID,
|
| 15 |
+
setupTimeline,
|
| 16 |
+
status,
|
| 17 |
+
textPart,
|
| 18 |
+
toolPart,
|
| 19 |
+
userMessage,
|
| 20 |
+
} from "./fixture"
|
| 21 |
+
|
| 22 |
+
test.describe("timeline tool state stability", () => {
|
| 23 |
+
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
|
| 24 |
+
page,
|
| 25 |
+
}, testInfo) => {
|
| 26 |
+
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
|
| 27 |
+
const inputs = {
|
| 28 |
+
webfetch: { url: "https://example.com/docs" },
|
| 29 |
+
websearch: { query: "timeline stability" },
|
| 30 |
+
task: { description: "Inspect timeline", subagent_type: "explore" },
|
| 31 |
+
skill: { name: "stability" },
|
| 32 |
+
custom: { target: "timeline", depth: 2 },
|
| 33 |
+
}
|
| 34 |
+
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
| 35 |
+
const questionID = "prt_state_question"
|
| 36 |
+
const todoID = "prt_state_todo"
|
| 37 |
+
const initial = [
|
| 38 |
+
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
| 39 |
+
toolPart(questionID, "question", "pending", questionInput()),
|
| 40 |
+
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
| 41 |
+
textPart("prt_state_following", "Following lightweight tools"),
|
| 42 |
+
]
|
| 43 |
+
const childID = "ses_timeline_child"
|
| 44 |
+
const timeline = await setupTimeline(page, {
|
| 45 |
+
messages: [userMessage(), assistantMessage(initial, { completed: false })],
|
| 46 |
+
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
|
| 47 |
+
cpuRate: 4,
|
| 48 |
+
})
|
| 49 |
+
await timeline.send(status("busy"), 120)
|
| 50 |
+
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
| 51 |
+
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
| 52 |
+
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
| 53 |
+
|
| 54 |
+
const regionIDs = [
|
| 55 |
+
"prt_state_webfetch",
|
| 56 |
+
"prt_state_websearch",
|
| 57 |
+
"prt_state_task",
|
| 58 |
+
"prt_state_skill",
|
| 59 |
+
"prt_state_custom",
|
| 60 |
+
] as const
|
| 61 |
+
const regions = defineVisualRegions({
|
| 62 |
+
prt_state_webfetch: toolRegion(regionIDs[0]),
|
| 63 |
+
prt_state_websearch: toolRegion(regionIDs[1]),
|
| 64 |
+
prt_state_task: toolRegion(regionIDs[2]),
|
| 65 |
+
prt_state_skill: toolRegion(regionIDs[3]),
|
| 66 |
+
prt_state_custom: toolRegion(regionIDs[4]),
|
| 67 |
+
})
|
| 68 |
+
await startVisualProbe(page, regions)
|
| 69 |
+
for (const [index, id] of ids.entries()) {
|
| 70 |
+
await timeline.send(
|
| 71 |
+
partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
|
| 72 |
+
[80, 240, 100, 360, 140][index],
|
| 73 |
+
)
|
| 74 |
+
}
|
| 75 |
+
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
|
| 76 |
+
const key = id as (typeof ids)[number]
|
| 77 |
+
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
|
| 78 |
+
const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
|
| 79 |
+
await timeline.send(
|
| 80 |
+
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
|
| 81 |
+
[110, 70, 280, 130, 420][index],
|
| 82 |
+
)
|
| 83 |
+
}
|
| 84 |
+
await timeline.send(
|
| 85 |
+
partUpdated(
|
| 86 |
+
toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
|
| 87 |
+
),
|
| 88 |
+
350,
|
| 89 |
+
)
|
| 90 |
+
await timeline.waitForPart(questionID)
|
| 91 |
+
await timeline.send(status("idle"), 500)
|
| 92 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 93 |
+
await reportVisualStability(
|
| 94 |
+
testInfo,
|
| 95 |
+
"lightweight-tools",
|
| 96 |
+
trace,
|
| 97 |
+
visualPlan(regions, [
|
| 98 |
+
{ type: "required", regions: regionIDs },
|
| 99 |
+
{ type: "unique", regions: regionIDs },
|
| 100 |
+
{ type: "stable", regions: regionIDs },
|
| 101 |
+
{ type: "opacity", regions: "all" },
|
| 102 |
+
{ type: "continuity", regions: "all" },
|
| 103 |
+
{ type: "motion", regions: "all", maxReversals: 4 },
|
| 104 |
+
{ type: "label-stability", regions: "all" },
|
| 105 |
+
]),
|
| 106 |
+
)
|
| 107 |
+
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
| 108 |
+
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
| 109 |
+
await expect(
|
| 110 |
+
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
| 111 |
+
).toBeVisible()
|
| 112 |
+
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
test("keeps an expanded mixed context group stable through staggered completion and error", async ({
|
| 116 |
+
page,
|
| 117 |
+
}, testInfo) => {
|
| 118 |
+
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
|
| 119 |
+
const tools = ["read", "glob", "grep", "list"]
|
| 120 |
+
const inputs = [
|
| 121 |
+
{ filePath: "src/a.ts", offset: 0, limit: 120 },
|
| 122 |
+
{ path: directory, pattern: "**/*.ts" },
|
| 123 |
+
{ path: directory, pattern: "stability", include: "*.ts" },
|
| 124 |
+
{ path: "src" },
|
| 125 |
+
]
|
| 126 |
+
const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
|
| 127 |
+
const timeline = await setupTimeline(page, {
|
| 128 |
+
messages: [
|
| 129 |
+
userMessage(),
|
| 130 |
+
assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
|
| 131 |
+
],
|
| 132 |
+
cpuRate: 4,
|
| 133 |
+
})
|
| 134 |
+
await timeline.send(status("busy"), 100)
|
| 135 |
+
const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
|
| 136 |
+
const group = page.locator(groupSelector)
|
| 137 |
+
await expect(group).toBeVisible()
|
| 138 |
+
await group.locator('[data-slot="collapsible-trigger"]').click()
|
| 139 |
+
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
| 140 |
+
|
| 141 |
+
const regions = defineVisualRegions({
|
| 142 |
+
status: {
|
| 143 |
+
selector: `${groupSelector} [data-component="tool-status-title"]`,
|
| 144 |
+
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
| 145 |
+
},
|
| 146 |
+
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
| 147 |
+
following: {
|
| 148 |
+
selector: '[data-timeline-part-id="prt_ctx_following"]',
|
| 149 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 150 |
+
},
|
| 151 |
+
})
|
| 152 |
+
await startVisualProbe(page, regions)
|
| 153 |
+
for (const [index, delay] of [90, 260, 70, 380].entries()) {
|
| 154 |
+
await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
|
| 155 |
+
}
|
| 156 |
+
await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
|
| 157 |
+
await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
|
| 158 |
+
await timeline.send(
|
| 159 |
+
partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
|
| 160 |
+
110,
|
| 161 |
+
)
|
| 162 |
+
await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
|
| 163 |
+
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
| 164 |
+
await timeline.send(status("idle"), 700)
|
| 165 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 166 |
+
await reportVisualStability(
|
| 167 |
+
testInfo,
|
| 168 |
+
"mixed-context",
|
| 169 |
+
trace,
|
| 170 |
+
visualPlan(regions, [
|
| 171 |
+
{ type: "required", regions: ["context", "following"] },
|
| 172 |
+
{ type: "unique", regions: ["context"] },
|
| 173 |
+
{ type: "stable", regions: ["context"] },
|
| 174 |
+
{ type: "opacity", regions: "all" },
|
| 175 |
+
{ type: "continuity", regions: "all" },
|
| 176 |
+
{ type: "motion", regions: "all", maxReversals: 4 },
|
| 177 |
+
{ type: "label-stability", regions: "all" },
|
| 178 |
+
{ type: "flow", regions: ["context", "following"] },
|
| 179 |
+
]),
|
| 180 |
+
)
|
| 181 |
+
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
| 182 |
+
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
| 183 |
+
await group.locator('[data-slot="collapsible-trigger"]').click()
|
| 184 |
+
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
| 185 |
+
await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
|
| 186 |
+
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
| 187 |
+
await group.locator('[data-slot="collapsible-trigger"]').click()
|
| 188 |
+
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
| 189 |
+
})
|
| 190 |
+
})
|
| 191 |
+
|
| 192 |
+
function questionInput() {
|
| 193 |
+
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function toolRegion(id: string) {
|
| 197 |
+
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
| 198 |
+
}
|
packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 10 |
+
assistantID,
|
| 11 |
+
assistantMessage,
|
| 12 |
+
completedAssistantInfo,
|
| 13 |
+
event,
|
| 14 |
+
messageUpdated,
|
| 15 |
+
partDelta,
|
| 16 |
+
partUpdated,
|
| 17 |
+
setupTimeline,
|
| 18 |
+
shell,
|
| 19 |
+
status,
|
| 20 |
+
textPart,
|
| 21 |
+
toolPart,
|
| 22 |
+
userMessage,
|
| 23 |
+
} from "./fixture"
|
| 24 |
+
|
| 25 |
+
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
|
| 26 |
+
const firstID = "prt_mutation_01_first"
|
| 27 |
+
const middleID = "prt_mutation_02_middle"
|
| 28 |
+
const lastID = "prt_mutation_03_last"
|
| 29 |
+
const timeline = await setupTimeline(page, {
|
| 30 |
+
messages: [
|
| 31 |
+
userMessage(),
|
| 32 |
+
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
|
| 33 |
+
completed: false,
|
| 34 |
+
}),
|
| 35 |
+
],
|
| 36 |
+
cpuRate: 4,
|
| 37 |
+
})
|
| 38 |
+
const regions = defineVisualRegions({
|
| 39 |
+
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 40 |
+
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 41 |
+
})
|
| 42 |
+
await startVisualProbe(page, regions)
|
| 43 |
+
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
|
| 44 |
+
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
|
| 45 |
+
await timeline.send(
|
| 46 |
+
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
|
| 47 |
+
500,
|
| 48 |
+
)
|
| 49 |
+
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
|
| 50 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 51 |
+
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
| 55 |
+
const textID = "prt_text_reconcile"
|
| 56 |
+
const followingID = "prt_text_reconcile_following"
|
| 57 |
+
const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
|
| 58 |
+
completed: false,
|
| 59 |
+
})
|
| 60 |
+
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
| 61 |
+
const regions = defineVisualRegions({
|
| 62 |
+
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 63 |
+
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 64 |
+
})
|
| 65 |
+
await startVisualProbe(page, regions)
|
| 66 |
+
await timeline.send(partDelta(textID, " streamed content"), 100)
|
| 67 |
+
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
|
| 68 |
+
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
|
| 69 |
+
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
|
| 70 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 71 |
+
await reportVisualStability(
|
| 72 |
+
testInfo,
|
| 73 |
+
"text-reconcile",
|
| 74 |
+
trace,
|
| 75 |
+
visualPlan(regions, [
|
| 76 |
+
{ type: "required", regions: ["text", "following"] },
|
| 77 |
+
{ type: "unique", regions: ["text", "following"] },
|
| 78 |
+
{ type: "stable", regions: ["text", "following"] },
|
| 79 |
+
{ type: "opacity", regions: "all" },
|
| 80 |
+
{ type: "continuity", regions: "all" },
|
| 81 |
+
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
| 82 |
+
{ type: "label-stability", regions: "all" },
|
| 83 |
+
{ type: "preserve-bottom-anchor" },
|
| 84 |
+
{ type: "flow", regions: ["text", "following"] },
|
| 85 |
+
]),
|
| 86 |
+
)
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
|
| 90 |
+
const firstID = "prt_question_01_first"
|
| 91 |
+
const questionID = "prt_question_02_hidden"
|
| 92 |
+
const lastID = "prt_question_03_last"
|
| 93 |
+
const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
|
| 94 |
+
const timeline = await setupTimeline(page, {
|
| 95 |
+
messages: [
|
| 96 |
+
userMessage(),
|
| 97 |
+
assistantMessage(
|
| 98 |
+
[
|
| 99 |
+
textPart(firstID, "Before question"),
|
| 100 |
+
toolPart(questionID, "question", "running", input),
|
| 101 |
+
textPart(lastID, "After question"),
|
| 102 |
+
],
|
| 103 |
+
{ completed: false },
|
| 104 |
+
),
|
| 105 |
+
],
|
| 106 |
+
cpuRate: 4,
|
| 107 |
+
})
|
| 108 |
+
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
| 109 |
+
const regions = defineVisualRegions({
|
| 110 |
+
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 111 |
+
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
| 112 |
+
})
|
| 113 |
+
await startVisualProbe(page, regions)
|
| 114 |
+
await timeline.send(
|
| 115 |
+
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
| 116 |
+
600,
|
| 117 |
+
)
|
| 118 |
+
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
|
| 119 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 120 |
+
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
|
| 124 |
+
const assistant = assistantMessage([], { completed: false })
|
| 125 |
+
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
| 126 |
+
await timeline.send(status("busy"), 150)
|
| 127 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
| 128 |
+
const regions = defineVisualRegions({
|
| 129 |
+
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
| 130 |
+
error: { selector: '[data-timeline-row="Error"]' },
|
| 131 |
+
})
|
| 132 |
+
await startVisualProbe(page, regions)
|
| 133 |
+
await timeline.send(
|
| 134 |
+
messageUpdated({
|
| 135 |
+
...assistant.info,
|
| 136 |
+
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
|
| 137 |
+
}),
|
| 138 |
+
500,
|
| 139 |
+
)
|
| 140 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
| 141 |
+
await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
|
| 142 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 143 |
+
await reportVisualStability(
|
| 144 |
+
testInfo,
|
| 145 |
+
"thinking-error",
|
| 146 |
+
trace,
|
| 147 |
+
visualPlan(regions, [
|
| 148 |
+
{ type: "required", regions: ["thinking", "error"] },
|
| 149 |
+
{ type: "continuous-any", regions: ["thinking", "error"] },
|
| 150 |
+
{ type: "unique", regions: ["thinking", "error"] },
|
| 151 |
+
{ type: "opacity", regions: "all" },
|
| 152 |
+
{ type: "continuity", regions: "all" },
|
| 153 |
+
{ type: "motion", regions: "all" },
|
| 154 |
+
{ type: "label-stability", regions: "all" },
|
| 155 |
+
]),
|
| 156 |
+
)
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
test("updates retry attempts and long provider messages without remounting the retry row", async ({
|
| 160 |
+
page,
|
| 161 |
+
}, testInfo) => {
|
| 162 |
+
const timeline = await setupTimeline(page, {
|
| 163 |
+
messages: [userMessage(), assistantMessage([], { completed: false })],
|
| 164 |
+
cpuRate: 4,
|
| 165 |
+
})
|
| 166 |
+
await timeline.send(status("retry", 1), 120)
|
| 167 |
+
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
| 168 |
+
const regions = defineVisualRegions({
|
| 169 |
+
retry: { selector: '[data-timeline-row="Retry"]' },
|
| 170 |
+
})
|
| 171 |
+
await startVisualProbe(page, regions)
|
| 172 |
+
await timeline.send(
|
| 173 |
+
event("session.status", {
|
| 174 |
+
sessionID: "ses_timeline_stability",
|
| 175 |
+
status: {
|
| 176 |
+
type: "retry",
|
| 177 |
+
attempt: 2,
|
| 178 |
+
message: "A very long provider retry message ".repeat(8),
|
| 179 |
+
next: Date.now() + 10_000,
|
| 180 |
+
},
|
| 181 |
+
}),
|
| 182 |
+
300,
|
| 183 |
+
)
|
| 184 |
+
await timeline.send(status("retry", 3), 300)
|
| 185 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 186 |
+
await reportVisualStability(
|
| 187 |
+
testInfo,
|
| 188 |
+
"retry-evolution",
|
| 189 |
+
trace,
|
| 190 |
+
visualPlan(
|
| 191 |
+
regions,
|
| 192 |
+
[
|
| 193 |
+
{ type: "required", regions: ["retry"] },
|
| 194 |
+
{ type: "unique", regions: ["retry"] },
|
| 195 |
+
{ type: "stable", regions: ["retry"] },
|
| 196 |
+
{ type: "opacity", regions: "all" },
|
| 197 |
+
{ type: "continuity", regions: "all" },
|
| 198 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 199 |
+
{ type: "label-stability", regions: "all" },
|
| 200 |
+
],
|
| 201 |
+
{ perMarker: true },
|
| 202 |
+
),
|
| 203 |
+
)
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
|
| 207 |
+
page,
|
| 208 |
+
}, testInfo) => {
|
| 209 |
+
const removeUserID = "msg_0500_remove_user"
|
| 210 |
+
const removeAssistantID = "msg_0501_remove_assistant"
|
| 211 |
+
const anchorUserID = "msg_2000_anchor_user"
|
| 212 |
+
const timeline = await setupTimeline(page, {
|
| 213 |
+
messages: [
|
| 214 |
+
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
|
| 215 |
+
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
|
| 216 |
+
id: removeAssistantID,
|
| 217 |
+
parentID: removeUserID,
|
| 218 |
+
created: 1690000001000,
|
| 219 |
+
}),
|
| 220 |
+
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
|
| 221 |
+
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
|
| 222 |
+
id: "msg_2001_anchor_assistant",
|
| 223 |
+
parentID: anchorUserID,
|
| 224 |
+
created: 1700000001000,
|
| 225 |
+
}),
|
| 226 |
+
],
|
| 227 |
+
cpuRate: 4,
|
| 228 |
+
})
|
| 229 |
+
const regions = defineVisualRegions({
|
| 230 |
+
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
|
| 231 |
+
})
|
| 232 |
+
await startVisualProbe(page, regions)
|
| 233 |
+
await timeline.send(
|
| 234 |
+
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
|
| 235 |
+
200,
|
| 236 |
+
)
|
| 237 |
+
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
|
| 238 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 239 |
+
await reportVisualStability(
|
| 240 |
+
testInfo,
|
| 241 |
+
"historical-turn-remove",
|
| 242 |
+
trace,
|
| 243 |
+
visualPlan(
|
| 244 |
+
regions,
|
| 245 |
+
[
|
| 246 |
+
{ type: "required", regions: ["anchor"] },
|
| 247 |
+
{ type: "unique", regions: ["anchor"] },
|
| 248 |
+
{ type: "stable", regions: ["anchor"] },
|
| 249 |
+
{ type: "opacity", regions: "all" },
|
| 250 |
+
{ type: "continuity", regions: "all" },
|
| 251 |
+
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
| 252 |
+
{ type: "label-stability", regions: "all" },
|
| 253 |
+
],
|
| 254 |
+
{ perMarker: true },
|
| 255 |
+
),
|
| 256 |
+
)
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
+
function stablePairPlan(
|
| 260 |
+
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
| 261 |
+
maxPositionReversals: number,
|
| 262 |
+
) {
|
| 263 |
+
return visualPlan(regions, [
|
| 264 |
+
{ type: "required", regions: ["first", "last"] },
|
| 265 |
+
{ type: "unique", regions: ["first", "last"] },
|
| 266 |
+
{ type: "stable", regions: ["first", "last"] },
|
| 267 |
+
{ type: "opacity", regions: "all" },
|
| 268 |
+
{ type: "continuity", regions: "all" },
|
| 269 |
+
{ type: "motion", regions: "all", maxPositionReversals },
|
| 270 |
+
{ type: "label-stability", regions: "all" },
|
| 271 |
+
])
|
| 272 |
+
}
|
packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expectSessionTitle } from "../../utils/waits"
|
| 2 |
+
import { benchmark, expect } from "../benchmark"
|
| 3 |
+
import { measureFirstNavigation } from "./first-navigation-probe"
|
| 4 |
+
import { fixture } from "./session-timeline-stress.fixture"
|
| 5 |
+
import {
|
| 6 |
+
installStressSessionTabs,
|
| 7 |
+
installTimelineSettings,
|
| 8 |
+
mockStressTimeline,
|
| 9 |
+
stressDraftHref,
|
| 10 |
+
stressSessionHref,
|
| 11 |
+
} from "./timeline-test-helpers"
|
| 12 |
+
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
| 13 |
+
|
| 14 |
+
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
|
| 15 |
+
const draftID = "draft_first_navigation"
|
| 16 |
+
|
| 17 |
+
benchmark.describe("performance: first navigation paint", () => {
|
| 18 |
+
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
|
| 19 |
+
await setup(page)
|
| 20 |
+
const href = stressSessionHref(fixture.targetID)
|
| 21 |
+
const result = await measureFirstNavigation(page, {
|
| 22 |
+
href,
|
| 23 |
+
destinationPath: href,
|
| 24 |
+
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
| 25 |
+
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
| 26 |
+
contentSelector,
|
| 27 |
+
navigate: async () => {
|
| 28 |
+
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
| 29 |
+
await expectSessionTitle(page, fixture.expected.targetTitle)
|
| 30 |
+
},
|
| 31 |
+
})
|
| 32 |
+
report(result)
|
| 33 |
+
expect(result.summary.blankSamples).toBe(0)
|
| 34 |
+
expect(result.summary.unknownSamples).toBe(0)
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
|
| 38 |
+
await setup(page, draftID)
|
| 39 |
+
const href = stressDraftHref(draftID)
|
| 40 |
+
const result = await measureFirstNavigation(page, {
|
| 41 |
+
href,
|
| 42 |
+
destinationPath: href,
|
| 43 |
+
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
| 44 |
+
destinationSelector: '[data-component="prompt-input"]',
|
| 45 |
+
contentSelector,
|
| 46 |
+
navigate: async () => {
|
| 47 |
+
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
| 48 |
+
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
| 49 |
+
},
|
| 50 |
+
})
|
| 51 |
+
report(result)
|
| 52 |
+
expect(result.summary.blankSamples).toBe(0)
|
| 53 |
+
expect(result.summary.unknownSamples).toBe(0)
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
| 57 |
+
await setup(page)
|
| 58 |
+
const href = stressSessionHref(fixture.childID)
|
| 59 |
+
const result = await measureFirstNavigation(page, {
|
| 60 |
+
href,
|
| 61 |
+
destinationPath: href,
|
| 62 |
+
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
| 63 |
+
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
|
| 64 |
+
contentSelector,
|
| 65 |
+
navigate: async () => {
|
| 66 |
+
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
|
| 67 |
+
await expectSessionTitle(page, fixture.expected.childTitle)
|
| 68 |
+
},
|
| 69 |
+
})
|
| 70 |
+
report(result)
|
| 71 |
+
expect(result.summary.blankSamples).toBe(0)
|
| 72 |
+
expect(result.summary.unknownSamples).toBe(0)
|
| 73 |
+
})
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
|
| 77 |
+
await mockStressTimeline(page)
|
| 78 |
+
await installTimelineSettings(page)
|
| 79 |
+
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
|
| 80 |
+
await page.goto(stressSessionHref(fixture.sourceID))
|
| 81 |
+
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
| 82 |
+
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function messageSelector(id: string) {
|
| 86 |
+
return `[data-message-id="${id}"]`
|
| 87 |
+
}
|
packages/app/e2e/performance/timeline/first-navigation-metrics.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type FirstNavigationSample = {
|
| 2 |
+
observedAtMs: number
|
| 3 |
+
source: boolean
|
| 4 |
+
destination: boolean
|
| 5 |
+
content: boolean
|
| 6 |
+
pathname?: string
|
| 7 |
+
center?: string
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function category(sample: FirstNavigationSample) {
|
| 11 |
+
if (sample.destination && !sample.source) return "destination"
|
| 12 |
+
if (sample.source && !sample.destination) return "source"
|
| 13 |
+
if (!sample.content) return "blank"
|
| 14 |
+
return "unknown"
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
|
| 18 |
+
const categories = samples.map(category)
|
| 19 |
+
const stable = categories.findIndex(
|
| 20 |
+
(value, index) =>
|
| 21 |
+
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
|
| 22 |
+
)
|
| 23 |
+
return {
|
| 24 |
+
samples: samples.length,
|
| 25 |
+
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
|
| 26 |
+
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
| 27 |
+
sourceSamples: categories.filter((value) => value === "source").length,
|
| 28 |
+
blankSamples: categories.filter((value) => value === "blank").length,
|
| 29 |
+
unknownSamples: categories.filter((value) => value === "unknown").length,
|
| 30 |
+
destinationSamples: categories.filter((value) => value === "destination").length,
|
| 31 |
+
}
|
| 32 |
+
}
|
packages/app/e2e/performance/timeline/first-navigation-probe.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
|
| 3 |
+
|
| 4 |
+
type FirstNavigationProbe = {
|
| 5 |
+
samples: FirstNavigationSample[]
|
| 6 |
+
stop: () => void
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export async function measureFirstNavigation(
|
| 10 |
+
page: Page,
|
| 11 |
+
input: {
|
| 12 |
+
href: string
|
| 13 |
+
destinationPath: string
|
| 14 |
+
sourceSelector: string
|
| 15 |
+
destinationSelector: string
|
| 16 |
+
contentSelector: string
|
| 17 |
+
navigate: () => Promise<void>
|
| 18 |
+
},
|
| 19 |
+
) {
|
| 20 |
+
await page.evaluate(
|
| 21 |
+
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
|
| 22 |
+
const samples: FirstNavigationSample[] = []
|
| 23 |
+
let started: number | undefined
|
| 24 |
+
let running = true
|
| 25 |
+
const visible = (selector: string) =>
|
| 26 |
+
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
| 27 |
+
const rect = element.getBoundingClientRect()
|
| 28 |
+
const style = getComputedStyle(element)
|
| 29 |
+
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
| 30 |
+
})
|
| 31 |
+
const sample = () => {
|
| 32 |
+
if (!running || started === undefined) return
|
| 33 |
+
requestAnimationFrame(() => {
|
| 34 |
+
setTimeout(() => {
|
| 35 |
+
if (!running || started === undefined) return
|
| 36 |
+
samples.push({
|
| 37 |
+
observedAtMs: performance.now() - started,
|
| 38 |
+
source: visible(sourceSelector),
|
| 39 |
+
destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
|
| 40 |
+
content: visible(contentSelector),
|
| 41 |
+
pathname: `${location.pathname}${location.search}`,
|
| 42 |
+
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
|
| 43 |
+
})
|
| 44 |
+
sample()
|
| 45 |
+
}, 0)
|
| 46 |
+
})
|
| 47 |
+
}
|
| 48 |
+
document.addEventListener(
|
| 49 |
+
"click",
|
| 50 |
+
(event) => {
|
| 51 |
+
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
| 52 |
+
if (link?.getAttribute("href") !== href) return
|
| 53 |
+
started = performance.now()
|
| 54 |
+
sample()
|
| 55 |
+
},
|
| 56 |
+
{ capture: true, once: true },
|
| 57 |
+
)
|
| 58 |
+
;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
|
| 59 |
+
samples,
|
| 60 |
+
stop: () => {
|
| 61 |
+
running = false
|
| 62 |
+
},
|
| 63 |
+
}
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
href: input.href,
|
| 67 |
+
destinationPath: input.destinationPath,
|
| 68 |
+
sourceSelector: input.sourceSelector,
|
| 69 |
+
destinationSelector: input.destinationSelector,
|
| 70 |
+
contentSelector: input.contentSelector,
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
await input.navigate()
|
| 74 |
+
await page.waitForFunction(() => {
|
| 75 |
+
const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe
|
| 76 |
+
?.samples
|
| 77 |
+
if (!samples) return false
|
| 78 |
+
return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
|
| 79 |
+
})
|
| 80 |
+
const samples = await page.evaluate(() => {
|
| 81 |
+
const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
|
| 82 |
+
probe.stop()
|
| 83 |
+
return probe.samples
|
| 84 |
+
})
|
| 85 |
+
return { summary: summarizeFirstNavigation(samples), samples }
|
| 86 |
+
}
|
packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { benchmark, expect } from "../benchmark"
|
| 2 |
+
import { expectSessionTitle } from "../../utils/waits"
|
| 3 |
+
import { measureNavigationMilestones } from "./navigation-milestones"
|
| 4 |
+
import { fixture } from "./session-timeline-stress.fixture"
|
| 5 |
+
import {
|
| 6 |
+
installStressSessionTabs,
|
| 7 |
+
installTimelineSettings,
|
| 8 |
+
mockStressTimeline,
|
| 9 |
+
stressSessionHref,
|
| 10 |
+
} from "./timeline-test-helpers"
|
| 11 |
+
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
| 12 |
+
|
| 13 |
+
const homeRow = '[data-component="home-session-row"]'
|
| 14 |
+
const homeShell = '[data-component="home-session-search"]'
|
| 15 |
+
|
| 16 |
+
benchmark.describe("performance: home and tab navigation", () => {
|
| 17 |
+
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
|
| 18 |
+
await setup(page, [])
|
| 19 |
+
await page.goto("/")
|
| 20 |
+
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
| 21 |
+
await expect(row).toBeVisible()
|
| 22 |
+
const href = stressSessionHref(fixture.targetID)
|
| 23 |
+
const result = await measureNavigationMilestones(page, {
|
| 24 |
+
triggerSelector: homeRow,
|
| 25 |
+
milestones: {
|
| 26 |
+
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
|
| 27 |
+
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
|
| 28 |
+
},
|
| 29 |
+
navigate: async () => {
|
| 30 |
+
await row.click()
|
| 31 |
+
await expectSessionTitle(page, fixture.expected.targetTitle)
|
| 32 |
+
},
|
| 33 |
+
})
|
| 34 |
+
report(result)
|
| 35 |
+
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
|
| 36 |
+
fixture.expected.targetTitle,
|
| 37 |
+
)
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
benchmark("stages the review body after cold session content", async ({ page, report }) => {
|
| 41 |
+
await setup(page, [])
|
| 42 |
+
await page.goto("/")
|
| 43 |
+
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
| 44 |
+
await expect(row).toBeVisible()
|
| 45 |
+
const result = await page.evaluate(
|
| 46 |
+
({ rowSelector, title, contentSelector }) =>
|
| 47 |
+
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
|
| 48 |
+
let samples = 0
|
| 49 |
+
const sample = () => {
|
| 50 |
+
samples++
|
| 51 |
+
const content = !!document.querySelector(contentSelector)
|
| 52 |
+
const review = !!document.querySelector('[data-component="session-review"]')
|
| 53 |
+
if (content && !review) {
|
| 54 |
+
resolve({ contentBeforeReview: true, samples })
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
if (content && review) {
|
| 58 |
+
resolve({ contentBeforeReview: false, samples })
|
| 59 |
+
return
|
| 60 |
+
}
|
| 61 |
+
requestAnimationFrame(sample)
|
| 62 |
+
}
|
| 63 |
+
const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
|
| 64 |
+
item.textContent?.includes(title),
|
| 65 |
+
)
|
| 66 |
+
if (!target) throw new Error(`Home session row not found: ${title}`)
|
| 67 |
+
target.click()
|
| 68 |
+
requestAnimationFrame(sample)
|
| 69 |
+
}),
|
| 70 |
+
{
|
| 71 |
+
rowSelector: homeRow,
|
| 72 |
+
title: fixture.expected.targetTitle,
|
| 73 |
+
contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
| 74 |
+
},
|
| 75 |
+
)
|
| 76 |
+
report(result)
|
| 77 |
+
expect(result.contentBeforeReview).toBe(true)
|
| 78 |
+
await expect(page.locator('[data-component="session-review"]')).toBeVisible()
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
|
| 82 |
+
await setup(page, [fixture.sourceID])
|
| 83 |
+
const href = stressSessionHref(fixture.sourceID)
|
| 84 |
+
await page.goto(href)
|
| 85 |
+
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
| 86 |
+
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
| 87 |
+
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
| 88 |
+
const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
|
| 89 |
+
await expect(close).toBeVisible()
|
| 90 |
+
const result = await measureNavigationMilestones(page, {
|
| 91 |
+
triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
|
| 92 |
+
milestones: {
|
| 93 |
+
home: { selector: homeShell },
|
| 94 |
+
row: { selector: homeRow },
|
| 95 |
+
tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
|
| 96 |
+
},
|
| 97 |
+
navigate: async () => {
|
| 98 |
+
await close.click()
|
| 99 |
+
await expect(page).toHaveURL("/")
|
| 100 |
+
},
|
| 101 |
+
})
|
| 102 |
+
report(result)
|
| 103 |
+
})
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
|
| 107 |
+
await mockStressTimeline(page)
|
| 108 |
+
await installTimelineSettings(page)
|
| 109 |
+
await installStressSessionTabs(page, { sessionIDs })
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function messageSelector(id: string) {
|
| 113 |
+
return `[data-message-id="${id}"]`
|
| 114 |
+
}
|
packages/app/e2e/performance/timeline/navigation-milestones.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
|
| 3 |
+
export type NavigationMilestoneSample = {
|
| 4 |
+
observedAtMs: number
|
| 5 |
+
milestones: Record<string, boolean>
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
|
| 9 |
+
const names = Object.keys(samples[0]?.milestones ?? {})
|
| 10 |
+
const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
|
| 11 |
+
const first = samples.find(matches)
|
| 12 |
+
const stable = samples.findIndex(
|
| 13 |
+
(sample, index) =>
|
| 14 |
+
index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
|
| 15 |
+
)
|
| 16 |
+
return {
|
| 17 |
+
firstObservedMs: first?.observedAtMs ?? null,
|
| 18 |
+
stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
return {
|
| 22 |
+
samples: samples.length,
|
| 23 |
+
milestones: Object.fromEntries(
|
| 24 |
+
names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
|
| 25 |
+
),
|
| 26 |
+
all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
type NavigationMilestoneProbe = {
|
| 31 |
+
samples: NavigationMilestoneSample[]
|
| 32 |
+
stop: () => void
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export async function measureNavigationMilestones(
|
| 36 |
+
page: Page,
|
| 37 |
+
input: {
|
| 38 |
+
triggerSelector: string
|
| 39 |
+
milestones: Record<string, { selector: string; visible?: boolean }>
|
| 40 |
+
navigate: () => Promise<void>
|
| 41 |
+
},
|
| 42 |
+
) {
|
| 43 |
+
await page.evaluate(
|
| 44 |
+
({ triggerSelector, milestones }) => {
|
| 45 |
+
const samples: NavigationMilestoneSample[] = []
|
| 46 |
+
const streaks = new Map<string, number>()
|
| 47 |
+
const marked = new Set<string>()
|
| 48 |
+
let started: number | undefined
|
| 49 |
+
let running = true
|
| 50 |
+
const visible = (selector: string) =>
|
| 51 |
+
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
| 52 |
+
const rect = element.getBoundingClientRect()
|
| 53 |
+
const style = getComputedStyle(element)
|
| 54 |
+
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
| 55 |
+
})
|
| 56 |
+
const sample = () => {
|
| 57 |
+
if (!running || started === undefined) return
|
| 58 |
+
requestAnimationFrame(() => {
|
| 59 |
+
setTimeout(() => {
|
| 60 |
+
if (!running || started === undefined) return
|
| 61 |
+
const current = Object.fromEntries(
|
| 62 |
+
Object.entries(milestones).map(([name, milestone]) => [
|
| 63 |
+
name,
|
| 64 |
+
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
|
| 65 |
+
]),
|
| 66 |
+
)
|
| 67 |
+
samples.push({
|
| 68 |
+
observedAtMs: performance.now() - started,
|
| 69 |
+
milestones: current,
|
| 70 |
+
})
|
| 71 |
+
Object.entries(current).forEach(([name, value]) => {
|
| 72 |
+
if (!value) {
|
| 73 |
+
streaks.set(name, 0)
|
| 74 |
+
return
|
| 75 |
+
}
|
| 76 |
+
if (!marked.has(`${name}.first`)) {
|
| 77 |
+
performance.mark(`opencode.navigation.${name}.first`)
|
| 78 |
+
marked.add(`${name}.first`)
|
| 79 |
+
}
|
| 80 |
+
const streak = (streaks.get(name) ?? 0) + 1
|
| 81 |
+
streaks.set(name, streak)
|
| 82 |
+
if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
|
| 83 |
+
})
|
| 84 |
+
const all = Object.values(current).every(Boolean)
|
| 85 |
+
const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
|
| 86 |
+
streaks.set("all", allStreak)
|
| 87 |
+
if (all && !marked.has("all.first")) {
|
| 88 |
+
performance.mark("opencode.navigation.all.first")
|
| 89 |
+
marked.add("all.first")
|
| 90 |
+
}
|
| 91 |
+
if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
|
| 92 |
+
sample()
|
| 93 |
+
}, 0)
|
| 94 |
+
})
|
| 95 |
+
}
|
| 96 |
+
document.addEventListener(
|
| 97 |
+
"click",
|
| 98 |
+
(event) => {
|
| 99 |
+
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
|
| 100 |
+
started = performance.now()
|
| 101 |
+
performance.mark("opencode.navigation.click")
|
| 102 |
+
sample()
|
| 103 |
+
},
|
| 104 |
+
{ capture: true, once: true },
|
| 105 |
+
)
|
| 106 |
+
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
|
| 107 |
+
samples,
|
| 108 |
+
stop: () => {
|
| 109 |
+
running = false
|
| 110 |
+
},
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
|
| 114 |
+
)
|
| 115 |
+
await input.navigate()
|
| 116 |
+
await page.waitForFunction(() => {
|
| 117 |
+
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
|
| 118 |
+
?.samples
|
| 119 |
+
if (!samples || samples.length < 3) return false
|
| 120 |
+
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
|
| 121 |
+
})
|
| 122 |
+
const samples = await page.evaluate(() => {
|
| 123 |
+
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
|
| 124 |
+
probe.stop()
|
| 125 |
+
return probe.samples
|
| 126 |
+
})
|
| 127 |
+
return { summary: summarizeNavigationMilestones(samples), samples }
|
| 128 |
+
}
|
packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
import { benchmark, expect } from "../benchmark"
|
| 3 |
+
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
|
| 4 |
+
|
| 5 |
+
const changedLinesPerFile = 100
|
| 6 |
+
const linesPerSide = changedLinesPerFile / 2
|
| 7 |
+
const fileCounts = [1, 10, 100, 1_000, 10_000]
|
| 8 |
+
const filesPerDirectory = 100
|
| 9 |
+
const readyFrames = 3
|
| 10 |
+
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
|
| 11 |
+
|
| 12 |
+
type ReviewPaneScalingSample = {
|
| 13 |
+
observedAtMs: number
|
| 14 |
+
logicalRows: number
|
| 15 |
+
treeRows: number
|
| 16 |
+
fileRows: number
|
| 17 |
+
diffLines: number
|
| 18 |
+
header: string
|
| 19 |
+
ready: boolean
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type ReviewPaneScalingProbe = {
|
| 23 |
+
startedAt?: number
|
| 24 |
+
firstTreeRowMs?: number
|
| 25 |
+
logicalTreeReadyMs?: number
|
| 26 |
+
firstDiffRenderMs?: number
|
| 27 |
+
stableReadyMs?: number
|
| 28 |
+
samples: ReviewPaneScalingSample[]
|
| 29 |
+
frameTimesMs: number[]
|
| 30 |
+
longTasks: { startTime: number; duration: number }[]
|
| 31 |
+
stop: () => void
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
benchmark.describe("performance: review pane scaling", () => {
|
| 35 |
+
for (const fileCount of fileCounts) {
|
| 36 |
+
const changedLines = fileCount * changedLinesPerFile
|
| 37 |
+
|
| 38 |
+
benchmark(
|
| 39 |
+
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
|
| 40 |
+
async ({ page, report }) => {
|
| 41 |
+
benchmark.setTimeout(1_200_000)
|
| 42 |
+
await page.emulateMedia({ reducedMotion: "reduce" })
|
| 43 |
+
|
| 44 |
+
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
|
| 45 |
+
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
|
| 46 |
+
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
|
| 47 |
+
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
|
| 48 |
+
await setupTimelineBenchmark(page, {
|
| 49 |
+
historyTurns: 0,
|
| 50 |
+
eventBatch: 1,
|
| 51 |
+
newLayoutDesigns: true,
|
| 52 |
+
})
|
| 53 |
+
await page.route("**/vcs/diff**", (route) =>
|
| 54 |
+
route.fulfill({
|
| 55 |
+
status: 200,
|
| 56 |
+
contentType: "application/json",
|
| 57 |
+
headers: { "access-control-allow-origin": "*" },
|
| 58 |
+
body: responseBody,
|
| 59 |
+
}),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
|
| 63 |
+
const metrics = await measureReviewPaneLoad(page, {
|
| 64 |
+
expectedFile: reviewFile(0),
|
| 65 |
+
expectedRows,
|
| 66 |
+
})
|
| 67 |
+
const search = await measureBroadReviewSearch(page, fileCount)
|
| 68 |
+
|
| 69 |
+
expect(metrics.logicalRows).toBe(expectedRows)
|
| 70 |
+
expect(metrics.fileRows).toBeGreaterThan(0)
|
| 71 |
+
expect(metrics.treeRows).toBeGreaterThan(0)
|
| 72 |
+
expect(metrics.diffLines).toBeGreaterThan(0)
|
| 73 |
+
expect(search.logicalRows).toBe(fileCount)
|
| 74 |
+
expect(search.renderedRows).toBeGreaterThan(0)
|
| 75 |
+
report(
|
| 76 |
+
{ ...metrics, search },
|
| 77 |
+
{
|
| 78 |
+
fileCount,
|
| 79 |
+
changedLinesPerFile,
|
| 80 |
+
changedLines,
|
| 81 |
+
additions: changedLines / 2,
|
| 82 |
+
deletions: changedLines / 2,
|
| 83 |
+
patchLines: changedLines,
|
| 84 |
+
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
|
| 85 |
+
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
|
| 86 |
+
expectedRows,
|
| 87 |
+
},
|
| 88 |
+
)
|
| 89 |
+
},
|
| 90 |
+
)
|
| 91 |
+
}
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
|
| 95 |
+
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
| 96 |
+
await filter.evaluate((element) => {
|
| 97 |
+
element.addEventListener(
|
| 98 |
+
"input",
|
| 99 |
+
() => {
|
| 100 |
+
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
|
| 101 |
+
},
|
| 102 |
+
{ once: true, capture: true },
|
| 103 |
+
)
|
| 104 |
+
})
|
| 105 |
+
await filter.fill("file-")
|
| 106 |
+
|
| 107 |
+
return page.evaluate((expectedRows) => {
|
| 108 |
+
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
|
| 109 |
+
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
|
| 110 |
+
let previous = -1
|
| 111 |
+
let streak = 0
|
| 112 |
+
const sample = () => {
|
| 113 |
+
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
|
| 114 |
+
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
|
| 115 |
+
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
| 116 |
+
const ready =
|
| 117 |
+
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
|
| 118 |
+
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
|
| 119 |
+
previous = rows.length
|
| 120 |
+
if (streak >= 3) {
|
| 121 |
+
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
|
| 122 |
+
return
|
| 123 |
+
}
|
| 124 |
+
requestAnimationFrame(sample)
|
| 125 |
+
}
|
| 126 |
+
requestAnimationFrame(sample)
|
| 127 |
+
})
|
| 128 |
+
}, expectedRows)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
|
| 132 |
+
const changes = Array.from({ length: linesPerSide }, (_, index) => {
|
| 133 |
+
const line = String(index).padStart(3, "0")
|
| 134 |
+
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
|
| 135 |
+
}).join("\n")
|
| 136 |
+
let patchBytes = 0
|
| 137 |
+
let capped = false
|
| 138 |
+
|
| 139 |
+
return Array.from({ length: fileCount }, (_, index) => {
|
| 140 |
+
const file = reviewFile(index)
|
| 141 |
+
const fullPatch = [
|
| 142 |
+
`diff --git a/${file} b/${file}`,
|
| 143 |
+
`--- a/${file}`,
|
| 144 |
+
`+++ b/${file}`,
|
| 145 |
+
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
|
| 146 |
+
changes,
|
| 147 |
+
].join("\n")
|
| 148 |
+
if (index === 0 && fullPatch.length > patchByteLimit)
|
| 149 |
+
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
|
| 150 |
+
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
|
| 151 |
+
if (patch === fullPatch) patchBytes += fullPatch.length
|
| 152 |
+
else capped = true
|
| 153 |
+
return {
|
| 154 |
+
file,
|
| 155 |
+
patch,
|
| 156 |
+
additions: linesPerSide,
|
| 157 |
+
deletions: linesPerSide,
|
| 158 |
+
status: "modified" as const,
|
| 159 |
+
}
|
| 160 |
+
})
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function emptyReviewPatch(file: string) {
|
| 164 |
+
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function reviewFile(index: number) {
|
| 168 |
+
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
| 172 |
+
const toggle = page.getByRole("button", { name: "Toggle review" })
|
| 173 |
+
await expect(toggle).toBeVisible()
|
| 174 |
+
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
|
| 175 |
+
await installReviewPaneScalingProbe(page, input)
|
| 176 |
+
await toggle.click()
|
| 177 |
+
await page.waitForFunction(
|
| 178 |
+
() =>
|
| 179 |
+
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
|
| 180 |
+
?.stableReadyMs !== undefined,
|
| 181 |
+
undefined,
|
| 182 |
+
{ timeout: completionTimeoutMs },
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
return page.evaluate(() => {
|
| 186 |
+
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
|
| 187 |
+
probe.stop()
|
| 188 |
+
const startedAt = probe.startedAt!
|
| 189 |
+
const final = probe.samples.at(-1)!
|
| 190 |
+
const resources = performance
|
| 191 |
+
.getEntriesByType("resource")
|
| 192 |
+
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
|
| 193 |
+
const resource = resources.at(-1)
|
| 194 |
+
const longTasks = probe.longTasks.filter(
|
| 195 |
+
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
|
| 196 |
+
)
|
| 197 |
+
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
firstTreeRowMs: probe.firstTreeRowMs ?? null,
|
| 201 |
+
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
|
| 202 |
+
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
|
| 203 |
+
stableReadyMs: probe.stableReadyMs ?? null,
|
| 204 |
+
responseStartMs: resource ? resource.responseStart - startedAt : null,
|
| 205 |
+
responseEndMs: resource ? resource.responseEnd - startedAt : null,
|
| 206 |
+
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
|
| 207 |
+
treeRows: final.treeRows,
|
| 208 |
+
logicalRows: final.logicalRows,
|
| 209 |
+
fileRows: final.fileRows,
|
| 210 |
+
diffLines: final.diffLines,
|
| 211 |
+
samples: probe.samples.length,
|
| 212 |
+
maxFrameGapMs: Math.max(0, ...frameGaps),
|
| 213 |
+
longTaskCount: longTasks.length,
|
| 214 |
+
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
| 215 |
+
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
|
| 216 |
+
}
|
| 217 |
+
})
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
| 221 |
+
await page.evaluate(
|
| 222 |
+
({ expectedFile, expectedRows, stableFrames }) => {
|
| 223 |
+
let running = true
|
| 224 |
+
let readyStreak = 0
|
| 225 |
+
const basename = expectedFile.split("/").at(-1)!
|
| 226 |
+
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
|
| 227 |
+
? new PerformanceObserver((list) => {
|
| 228 |
+
probe.longTasks.push(
|
| 229 |
+
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
|
| 230 |
+
)
|
| 231 |
+
})
|
| 232 |
+
: undefined
|
| 233 |
+
const probe: ReviewPaneScalingProbe = {
|
| 234 |
+
samples: [],
|
| 235 |
+
frameTimesMs: [],
|
| 236 |
+
longTasks: [],
|
| 237 |
+
stop: () => {
|
| 238 |
+
running = false
|
| 239 |
+
longTaskObserver?.disconnect()
|
| 240 |
+
},
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
const sample = (time: number) => {
|
| 244 |
+
if (!running || probe.startedAt === undefined) return
|
| 245 |
+
const panel = document.querySelector<HTMLElement>("#review-panel")
|
| 246 |
+
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
|
| 247 |
+
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
|
| 248 |
+
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
|
| 249 |
+
const header =
|
| 250 |
+
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
|
| 251 |
+
const viewers = panel
|
| 252 |
+
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
|
| 253 |
+
: []
|
| 254 |
+
const diffLines = viewers.reduce(
|
| 255 |
+
(sum, viewer) =>
|
| 256 |
+
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
|
| 257 |
+
0,
|
| 258 |
+
)
|
| 259 |
+
const observedAtMs = time - probe.startedAt
|
| 260 |
+
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
| 261 |
+
const ready =
|
| 262 |
+
logicalRows === expectedRows &&
|
| 263 |
+
fileRows.length > 0 &&
|
| 264 |
+
header.includes(basename) &&
|
| 265 |
+
viewers.length === 1 &&
|
| 266 |
+
diffLines > 0
|
| 267 |
+
const previous = probe.samples.at(-1)
|
| 268 |
+
const stable =
|
| 269 |
+
ready &&
|
| 270 |
+
previous?.ready === true &&
|
| 271 |
+
previous.logicalRows === logicalRows &&
|
| 272 |
+
previous.treeRows === rows.length &&
|
| 273 |
+
previous.fileRows === fileRows.length &&
|
| 274 |
+
previous.diffLines === diffLines &&
|
| 275 |
+
previous.header === header
|
| 276 |
+
|
| 277 |
+
probe.frameTimesMs.push(observedAtMs)
|
| 278 |
+
probe.samples.push({
|
| 279 |
+
observedAtMs,
|
| 280 |
+
logicalRows,
|
| 281 |
+
treeRows: rows.length,
|
| 282 |
+
fileRows: fileRows.length,
|
| 283 |
+
diffLines,
|
| 284 |
+
header,
|
| 285 |
+
ready,
|
| 286 |
+
})
|
| 287 |
+
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
|
| 288 |
+
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
|
| 289 |
+
probe.logicalTreeReadyMs = observedAtMs
|
| 290 |
+
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
|
| 291 |
+
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
|
| 292 |
+
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
|
| 293 |
+
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
longTaskObserver?.observe({ type: "longtask", buffered: true })
|
| 297 |
+
document.addEventListener(
|
| 298 |
+
"click",
|
| 299 |
+
(event) => {
|
| 300 |
+
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
|
| 301 |
+
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
|
| 302 |
+
probe.startedAt = performance.now()
|
| 303 |
+
performance.mark("opencode.review-pane-scaling.click")
|
| 304 |
+
requestAnimationFrame(sample)
|
| 305 |
+
},
|
| 306 |
+
{ capture: true, once: true },
|
| 307 |
+
)
|
| 308 |
+
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
|
| 309 |
+
},
|
| 310 |
+
{ ...input, stableFrames: readyFrames },
|
| 311 |
+
)
|
| 312 |
+
}
|
packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
import { expectSessionTitle } from "../../utils/waits"
|
| 3 |
+
import { mockOpenCodeServer } from "../../utils/mock-server"
|
| 4 |
+
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
| 5 |
+
import { fixture } from "./session-timeline-stress.fixture"
|
| 6 |
+
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
| 7 |
+
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
| 8 |
+
|
| 9 |
+
type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
| 10 |
+
|
| 11 |
+
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
| 12 |
+
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
| 13 |
+
const userID = "msg_parent_hydration_user"
|
| 14 |
+
const user = {
|
| 15 |
+
...fixture.messages[fixture.targetID][0]!,
|
| 16 |
+
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
| 17 |
+
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
| 18 |
+
...part,
|
| 19 |
+
id: `prt_parent_hydration_user_${index}`,
|
| 20 |
+
messageID: userID,
|
| 21 |
+
})),
|
| 22 |
+
}
|
| 23 |
+
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
| 24 |
+
const assistants = Array.from({ length: 14 }, (_, index) => {
|
| 25 |
+
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
| 26 |
+
return {
|
| 27 |
+
...assistantSeed,
|
| 28 |
+
info: {
|
| 29 |
+
...assistantSeed.info,
|
| 30 |
+
id: messageID,
|
| 31 |
+
parentID: userID,
|
| 32 |
+
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
| 33 |
+
},
|
| 34 |
+
parts: assistantSeed.parts.map((part, partIndex) => ({
|
| 35 |
+
...part,
|
| 36 |
+
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
| 37 |
+
messageID,
|
| 38 |
+
})),
|
| 39 |
+
}
|
| 40 |
+
})
|
| 41 |
+
const messages = [user, ...assistants]
|
| 42 |
+
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
| 43 |
+
const lastID = userID
|
| 44 |
+
const lastAssistant = assistants.at(-1)!
|
| 45 |
+
const lastPart = lastAssistant.parts.at(-1)!
|
| 46 |
+
const lastPartID =
|
| 47 |
+
lastPart.type === "tool"
|
| 48 |
+
? lastPart.id
|
| 49 |
+
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
| 50 |
+
|
| 51 |
+
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
| 52 |
+
benchmark.setTimeout(180_000)
|
| 53 |
+
const results = [] as Awaited<ReturnType<typeof trial>>[]
|
| 54 |
+
for (let run = 0; run < 5; run++) {
|
| 55 |
+
results.push(
|
| 56 |
+
await withBenchmarkPage(
|
| 57 |
+
browser,
|
| 58 |
+
`session-parent-hydration-${mode}-${run}`,
|
| 59 |
+
(page) => trial(page, mode),
|
| 60 |
+
testInfo,
|
| 61 |
+
),
|
| 62 |
+
)
|
| 63 |
+
}
|
| 64 |
+
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
|
| 65 |
+
report(
|
| 66 |
+
{
|
| 67 |
+
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
|
| 68 |
+
summary: {
|
| 69 |
+
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
|
| 70 |
+
blankSamples: results.map((result) => result.metrics.blankSamples),
|
| 71 |
+
requestCounts: {
|
| 72 |
+
list: results.map((result) => result.requestCounts.list),
|
| 73 |
+
parent: results.map((result) => result.requestCounts.parent),
|
| 74 |
+
},
|
| 75 |
+
historyGateCount: results.map((result) => result.historyGateCount),
|
| 76 |
+
},
|
| 77 |
+
},
|
| 78 |
+
{ mode },
|
| 79 |
+
)
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
| 83 |
+
const requests: { type: "list" | "parent"; before?: string }[] = []
|
| 84 |
+
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
|
| 85 |
+
let historyGates = 0
|
| 86 |
+
await mockOpenCodeServer(page, {
|
| 87 |
+
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
|
| 88 |
+
provider: fixture.provider,
|
| 89 |
+
directory: fixture.directory,
|
| 90 |
+
project: fixture.project,
|
| 91 |
+
messageDelay: 50,
|
| 92 |
+
onMessages: (request) => {
|
| 93 |
+
if (request.sessionID === fixture.targetID && request.phase === "start")
|
| 94 |
+
requests.push({ type: "list", before: request.before })
|
| 95 |
+
},
|
| 96 |
+
beforeMessagesResponse: (request) => {
|
| 97 |
+
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
|
| 98 |
+
historyGates++
|
| 99 |
+
return history!.promise
|
| 100 |
+
},
|
| 101 |
+
onMessage: (request) => {
|
| 102 |
+
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
|
| 103 |
+
},
|
| 104 |
+
message: (sessionID, messageID) => {
|
| 105 |
+
if (sessionID !== fixture.targetID || messageID !== userID) return
|
| 106 |
+
return user
|
| 107 |
+
},
|
| 108 |
+
pageMessages: (sessionID, limit, before) => {
|
| 109 |
+
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
| 110 |
+
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
| 111 |
+
const start = Math.max(0, end - limit)
|
| 112 |
+
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
| 113 |
+
},
|
| 114 |
+
})
|
| 115 |
+
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
| 116 |
+
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
| 117 |
+
return route.fulfill({
|
| 118 |
+
status: 200,
|
| 119 |
+
contentType: "application/json",
|
| 120 |
+
body: JSON.stringify(
|
| 121 |
+
current
|
| 122 |
+
? {
|
| 123 |
+
data: {
|
| 124 |
+
...target,
|
| 125 |
+
cost: 0,
|
| 126 |
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 127 |
+
location: { directory: target.directory },
|
| 128 |
+
},
|
| 129 |
+
}
|
| 130 |
+
: target,
|
| 131 |
+
),
|
| 132 |
+
})
|
| 133 |
+
})
|
| 134 |
+
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
| 135 |
+
await page.goto(stressSessionHref(fixture.sourceID))
|
| 136 |
+
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
| 137 |
+
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
| 138 |
+
|
| 139 |
+
const href = stressSessionHref(fixture.targetID)
|
| 140 |
+
await page.evaluate(
|
| 141 |
+
({ href, title }) => {
|
| 142 |
+
const link = document.createElement("a")
|
| 143 |
+
link.id = "parent-hydration-target"
|
| 144 |
+
link.href = href
|
| 145 |
+
link.textContent = title
|
| 146 |
+
document.body.append(link)
|
| 147 |
+
},
|
| 148 |
+
{ href, title: target.title },
|
| 149 |
+
)
|
| 150 |
+
const metrics = await measureSessionSwitch(page, {
|
| 151 |
+
destinationIDs: messages.map((message) => message.info.id),
|
| 152 |
+
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
| 153 |
+
lastID,
|
| 154 |
+
requiredPartID: lastPartID,
|
| 155 |
+
requireBottomAnchor: false,
|
| 156 |
+
href,
|
| 157 |
+
switch: async () => {
|
| 158 |
+
await page.locator("#parent-hydration-target").click()
|
| 159 |
+
await expectSessionTitle(page, target.title)
|
| 160 |
+
},
|
| 161 |
+
}).finally(() => history?.resolve())
|
| 162 |
+
expect(metrics.firstCorrectObservedMs).not.toBeNull()
|
| 163 |
+
const requestCounts = {
|
| 164 |
+
list: requests.filter((request) => request.type === "list").length,
|
| 165 |
+
parent: requests.filter((request) => request.type === "parent").length,
|
| 166 |
+
}
|
| 167 |
+
if (mode === "candidate") {
|
| 168 |
+
expect(requestCounts.parent).toBe(0)
|
| 169 |
+
expect(historyGates).toBe(0)
|
| 170 |
+
}
|
| 171 |
+
return { metrics, requestCounts, historyGateCount: historyGates }
|
| 172 |
+
}
|
packages/app/e2e/performance/timeline/session-tab-flash.spec.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { benchmark, expect } from "../benchmark"
|
| 2 |
+
import { expectSessionTitle } from "../../utils/waits"
|
| 3 |
+
import { fixture } from "./session-timeline-stress.fixture"
|
| 4 |
+
import {
|
| 5 |
+
collectCachedRepaintTrace,
|
| 6 |
+
compressCachedRepaintTrace,
|
| 7 |
+
installCachedRepaintProbe,
|
| 8 |
+
waitForCachedRepaintWindow,
|
| 9 |
+
} from "./session-tab-repaint-probe"
|
| 10 |
+
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
| 11 |
+
import {
|
| 12 |
+
installStressSessionTabs,
|
| 13 |
+
installTimelineSettings,
|
| 14 |
+
mockStressTimeline,
|
| 15 |
+
stressSessionHref,
|
| 16 |
+
} from "./timeline-test-helpers"
|
| 17 |
+
|
| 18 |
+
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
|
| 19 |
+
benchmark.setTimeout(120_000)
|
| 20 |
+
await mockStressTimeline(page)
|
| 21 |
+
await installStressSessionTabs(page)
|
| 22 |
+
await installTimelineSettings(page)
|
| 23 |
+
await page.goto(stressSessionHref(fixture.targetID))
|
| 24 |
+
await expectSessionTitle(page, fixture.expected.targetTitle)
|
| 25 |
+
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
| 26 |
+
await page
|
| 27 |
+
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
|
| 28 |
+
.first()
|
| 29 |
+
.click()
|
| 30 |
+
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
| 31 |
+
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
| 32 |
+
|
| 33 |
+
await installCachedRepaintProbe(page, {
|
| 34 |
+
targetHref: stressSessionHref(fixture.targetID),
|
| 35 |
+
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
|
| 36 |
+
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
| 37 |
+
last: fixture.expected.targetMessageIDs.at(-1)!,
|
| 38 |
+
windowMs: 1_000,
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
await page
|
| 42 |
+
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
|
| 43 |
+
.first()
|
| 44 |
+
.click()
|
| 45 |
+
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
|
| 46 |
+
const result = await collectCachedRepaintTrace(page)
|
| 47 |
+
report(compressCachedRepaintTrace(result))
|
| 48 |
+
expect(result.samples.length).toBeGreaterThan(0)
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
benchmark("prefetches every open session tab", async ({ page, report }) => {
|
| 52 |
+
const prefetched = new Set<string>()
|
| 53 |
+
await mockStressTimeline(page, {
|
| 54 |
+
onMessages: (input) => {
|
| 55 |
+
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
|
| 56 |
+
},
|
| 57 |
+
})
|
| 58 |
+
await installStressSessionTabs(page, {
|
| 59 |
+
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
|
| 60 |
+
})
|
| 61 |
+
await installTimelineSettings(page)
|
| 62 |
+
await page.goto(stressSessionHref(fixture.sourceID))
|
| 63 |
+
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
| 64 |
+
|
| 65 |
+
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
|
| 66 |
+
report({ prefetched: [...prefetched] })
|
| 67 |
+
})
|
packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
|
| 3 |
+
type CachedRepaintTrace = {
|
| 4 |
+
timeOriginEpochMs: number
|
| 5 |
+
startedAtPerformanceMs: number
|
| 6 |
+
samples: {
|
| 7 |
+
observedAtMs: number
|
| 8 |
+
root: number | undefined
|
| 9 |
+
scrollTop: number
|
| 10 |
+
scrollHeight: number
|
| 11 |
+
bottomErrorPx: number | undefined
|
| 12 |
+
last: boolean
|
| 13 |
+
rows: { key: string | undefined; node: number; top: number; bottom: number }[]
|
| 14 |
+
mounted: number
|
| 15 |
+
center: string | undefined
|
| 16 |
+
destination: string[]
|
| 17 |
+
source: string[]
|
| 18 |
+
}[]
|
| 19 |
+
mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
|
| 20 |
+
shifts: { occurredAtMs: number; value: number }[]
|
| 21 |
+
windowMs: number
|
| 22 |
+
running: boolean
|
| 23 |
+
stop: () => void
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export async function installCachedRepaintProbe(
|
| 27 |
+
page: Page,
|
| 28 |
+
input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
|
| 29 |
+
) {
|
| 30 |
+
await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
|
| 31 |
+
const destinationIDs = new Set(destination)
|
| 32 |
+
const sourceIDs = new Set(source)
|
| 33 |
+
const nodeIDs = new WeakMap<Node, number>()
|
| 34 |
+
let nextNodeID = 1
|
| 35 |
+
const id = (node: Node) => {
|
| 36 |
+
const current = nodeIDs.get(node)
|
| 37 |
+
if (current) return current
|
| 38 |
+
nodeIDs.set(node, nextNodeID)
|
| 39 |
+
return nextNodeID++
|
| 40 |
+
}
|
| 41 |
+
const state: CachedRepaintTrace = {
|
| 42 |
+
timeOriginEpochMs: performance.timeOrigin,
|
| 43 |
+
startedAtPerformanceMs: 0,
|
| 44 |
+
samples: [],
|
| 45 |
+
mutations: [],
|
| 46 |
+
shifts: [],
|
| 47 |
+
windowMs,
|
| 48 |
+
running: false,
|
| 49 |
+
stop: () => {},
|
| 50 |
+
}
|
| 51 |
+
const recordShifts = (entries: PerformanceEntry[]) => {
|
| 52 |
+
if (!state.running) return
|
| 53 |
+
state.shifts.push(
|
| 54 |
+
...entries
|
| 55 |
+
.map((entry) => {
|
| 56 |
+
if (
|
| 57 |
+
entry.startTime < state.startedAtPerformanceMs ||
|
| 58 |
+
entry.startTime > state.startedAtPerformanceMs + state.windowMs
|
| 59 |
+
)
|
| 60 |
+
return
|
| 61 |
+
return {
|
| 62 |
+
occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
|
| 63 |
+
value: (entry as PerformanceEntry & { value: number }).value,
|
| 64 |
+
}
|
| 65 |
+
})
|
| 66 |
+
.filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
|
| 67 |
+
)
|
| 68 |
+
}
|
| 69 |
+
const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
|
| 70 |
+
shiftObserver.observe({ type: "layout-shift" })
|
| 71 |
+
const recordMutations = (entries: MutationRecord[]) => {
|
| 72 |
+
if (!state.running) return
|
| 73 |
+
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
| 74 |
+
if (observedAtMs > state.windowMs) return
|
| 75 |
+
const changed = entries.flatMap((entry) => [
|
| 76 |
+
...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
|
| 77 |
+
...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
|
| 78 |
+
])
|
| 79 |
+
if (changed.length) state.mutations.push({ observedAtMs, changed })
|
| 80 |
+
}
|
| 81 |
+
const mutationObserver = new MutationObserver(recordMutations)
|
| 82 |
+
mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
|
| 83 |
+
state.stop = () => {
|
| 84 |
+
recordShifts(shiftObserver.takeRecords())
|
| 85 |
+
recordMutations(mutationObserver.takeRecords())
|
| 86 |
+
state.running = false
|
| 87 |
+
shiftObserver.disconnect()
|
| 88 |
+
mutationObserver.disconnect()
|
| 89 |
+
}
|
| 90 |
+
const sample = () => {
|
| 91 |
+
if (!state.running) return
|
| 92 |
+
setTimeout(() => {
|
| 93 |
+
if (!state.running) return
|
| 94 |
+
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
| 95 |
+
if (observedAtMs > state.windowMs) return
|
| 96 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 97 |
+
element.querySelector("[data-timeline-row]"),
|
| 98 |
+
)
|
| 99 |
+
if (root) {
|
| 100 |
+
const view = root.getBoundingClientRect()
|
| 101 |
+
const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
| 102 |
+
.map((element) => ({
|
| 103 |
+
key: element.dataset.timelineKey,
|
| 104 |
+
node: id(element),
|
| 105 |
+
rect: element.getBoundingClientRect(),
|
| 106 |
+
}))
|
| 107 |
+
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
| 108 |
+
.map((item) => ({
|
| 109 |
+
key: item.key,
|
| 110 |
+
node: item.node,
|
| 111 |
+
top: item.rect.top - view.top,
|
| 112 |
+
bottom: item.rect.bottom - view.top,
|
| 113 |
+
}))
|
| 114 |
+
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
| 115 |
+
.filter((element) => {
|
| 116 |
+
const rect = element.getBoundingClientRect()
|
| 117 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 118 |
+
})
|
| 119 |
+
.map((element) => element.dataset.messageId!)
|
| 120 |
+
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
| 121 |
+
state.samples.push({
|
| 122 |
+
observedAtMs,
|
| 123 |
+
root: id(root),
|
| 124 |
+
scrollTop: root.scrollTop,
|
| 125 |
+
scrollHeight: root.scrollHeight,
|
| 126 |
+
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
| 127 |
+
last: messages.includes(last),
|
| 128 |
+
rows,
|
| 129 |
+
mounted: root.querySelectorAll("[data-timeline-key]").length,
|
| 130 |
+
center: document
|
| 131 |
+
.elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
|
| 132 |
+
?.textContent?.slice(0, 80),
|
| 133 |
+
destination: messages.filter((messageID) => destinationIDs.has(messageID)),
|
| 134 |
+
source: messages.filter((messageID) => sourceIDs.has(messageID)),
|
| 135 |
+
})
|
| 136 |
+
} else {
|
| 137 |
+
state.samples.push({
|
| 138 |
+
observedAtMs,
|
| 139 |
+
root: undefined,
|
| 140 |
+
scrollTop: 0,
|
| 141 |
+
scrollHeight: 0,
|
| 142 |
+
bottomErrorPx: undefined,
|
| 143 |
+
last: false,
|
| 144 |
+
rows: [],
|
| 145 |
+
mounted: 0,
|
| 146 |
+
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
|
| 147 |
+
destination: [],
|
| 148 |
+
source: [],
|
| 149 |
+
})
|
| 150 |
+
}
|
| 151 |
+
requestAnimationFrame(sample)
|
| 152 |
+
}, 0)
|
| 153 |
+
}
|
| 154 |
+
document.addEventListener(
|
| 155 |
+
"click",
|
| 156 |
+
(event) => {
|
| 157 |
+
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
| 158 |
+
if (link?.getAttribute("href") !== targetHref) return
|
| 159 |
+
state.startedAtPerformanceMs = performance.now()
|
| 160 |
+
state.running = true
|
| 161 |
+
requestAnimationFrame(sample)
|
| 162 |
+
},
|
| 163 |
+
{ capture: true, once: true },
|
| 164 |
+
)
|
| 165 |
+
;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
|
| 166 |
+
}, input)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
|
| 170 |
+
if (entry.startTime < started) return
|
| 171 |
+
return { occurredAtMs: entry.startTime - started, value: entry.value }
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
|
| 175 |
+
await page.waitForFunction((durationMs) => {
|
| 176 |
+
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
|
| 177 |
+
return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
|
| 178 |
+
}, durationMs)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
export async function collectCachedRepaintTrace(page: Page) {
|
| 182 |
+
return page.evaluate(() => {
|
| 183 |
+
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
|
| 184 |
+
state.stop()
|
| 185 |
+
return state
|
| 186 |
+
})
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
|
| 190 |
+
const roots = trace.samples.map((sample) => sample.root)
|
| 191 |
+
const bottomErrors = trace.samples.flatMap((sample) =>
|
| 192 |
+
sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
|
| 193 |
+
)
|
| 194 |
+
const category = (sample: CachedRepaintTrace["samples"][number]) => {
|
| 195 |
+
if (sample.source.length) return "source"
|
| 196 |
+
if (sample.root === undefined || sample.rows.length === 0) return "blank"
|
| 197 |
+
if (!sample.destination.length) return "unknown"
|
| 198 |
+
if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
|
| 199 |
+
return "wrongDestination"
|
| 200 |
+
}
|
| 201 |
+
return {
|
| 202 |
+
samples: trace.samples.length,
|
| 203 |
+
durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
|
| 204 |
+
firstSampleObservedMs: trace.samples[0]?.observedAtMs,
|
| 205 |
+
firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
|
| 206 |
+
blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
|
| 207 |
+
sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
|
| 208 |
+
wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
|
| 209 |
+
unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
|
| 210 |
+
rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
|
| 211 |
+
mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
|
| 212 |
+
mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
|
| 213 |
+
maxBottomErrorPx: Math.max(0, ...bottomErrors),
|
| 214 |
+
mutationBatches: trace.mutations.length,
|
| 215 |
+
addedNodes: trace.mutations.reduce(
|
| 216 |
+
(sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
|
| 217 |
+
0,
|
| 218 |
+
),
|
| 219 |
+
removedNodes: trace.mutations.reduce(
|
| 220 |
+
(sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
|
| 221 |
+
0,
|
| 222 |
+
),
|
| 223 |
+
layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
|
| 224 |
+
maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
|
| 229 |
+
const samples: {
|
| 230 |
+
observedAtMs: number[]
|
| 231 |
+
state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
|
| 232 |
+
}[] = []
|
| 233 |
+
for (const sample of trace.samples) {
|
| 234 |
+
const { observedAtMs, ...state } = sample
|
| 235 |
+
const previous = samples.at(-1)
|
| 236 |
+
if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
|
| 237 |
+
previous.observedAtMs.push(observedAtMs)
|
| 238 |
+
continue
|
| 239 |
+
}
|
| 240 |
+
samples.push({ observedAtMs: [observedAtMs], state })
|
| 241 |
+
}
|
| 242 |
+
return {
|
| 243 |
+
timeOriginEpochMs: trace.timeOriginEpochMs,
|
| 244 |
+
startedAtPerformanceMs: trace.startedAtPerformanceMs,
|
| 245 |
+
windowMs: trace.windowMs,
|
| 246 |
+
summary: summarizeCachedRepaintTrace(trace),
|
| 247 |
+
samples,
|
| 248 |
+
mutations: trace.mutations,
|
| 249 |
+
shifts: trace.shifts,
|
| 250 |
+
}
|
| 251 |
+
}
|
packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
import { expectSessionTitle } from "../../utils/waits"
|
| 3 |
+
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
| 4 |
+
import { fixture } from "./session-timeline-stress.fixture"
|
| 5 |
+
import {
|
| 6 |
+
createReviewDiffs,
|
| 7 |
+
installStressSessionTabs,
|
| 8 |
+
installTimelineSettings,
|
| 9 |
+
mockStressTimeline,
|
| 10 |
+
stressSessionHref,
|
| 11 |
+
} from "./timeline-test-helpers"
|
| 12 |
+
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
| 13 |
+
|
| 14 |
+
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
| 15 |
+
|
| 16 |
+
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
|
| 17 |
+
benchmark.setTimeout(180_000)
|
| 18 |
+
const results = { cold: [] as Result[], hot: [] as Result[] }
|
| 19 |
+
for (const mode of ["cold", "hot"] as const) {
|
| 20 |
+
for (let run = 0; run < 5; run++) {
|
| 21 |
+
results[mode].push(
|
| 22 |
+
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
report({ results, summary: summarize(results) })
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
benchmark(
|
| 30 |
+
"benchmarks v2 session tab switching with and without the review pane",
|
| 31 |
+
async ({ browser, report }, testInfo) => {
|
| 32 |
+
benchmark.setTimeout(360_000)
|
| 33 |
+
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
|
| 34 |
+
const results = {
|
| 35 |
+
closed: { cold: [] as Result[], hot: [] as Result[] },
|
| 36 |
+
open: { cold: [] as Result[], hot: [] as Result[] },
|
| 37 |
+
}
|
| 38 |
+
for (const reviewPane of ["closed", "open"] as const) {
|
| 39 |
+
for (const mode of ["cold", "hot"] as const) {
|
| 40 |
+
for (let run = 0; run < runs; run++) {
|
| 41 |
+
results[reviewPane][mode].push(
|
| 42 |
+
await withBenchmarkPage(
|
| 43 |
+
browser,
|
| 44 |
+
`session-tab-switch-v2-${reviewPane}-${mode}-${run}`,
|
| 45 |
+
(page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }),
|
| 46 |
+
testInfo,
|
| 47 |
+
),
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
|
| 53 |
+
},
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
async function trial(
|
| 57 |
+
page: Page,
|
| 58 |
+
mode: "cold" | "hot",
|
| 59 |
+
options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" },
|
| 60 |
+
) {
|
| 61 |
+
const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined
|
| 62 |
+
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
|
| 63 |
+
if (options?.newLayoutDesigns) await installTimelineSettings(page)
|
| 64 |
+
await installStressSessionTabs(page)
|
| 65 |
+
if (mode === "hot") {
|
| 66 |
+
await page.goto(stressSessionHref(fixture.targetID))
|
| 67 |
+
await expectSessionTitle(page, fixture.expected.targetTitle)
|
| 68 |
+
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
| 69 |
+
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
| 70 |
+
} else {
|
| 71 |
+
await page.goto(stressSessionHref(fixture.sourceID))
|
| 72 |
+
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
| 73 |
+
}
|
| 74 |
+
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
| 75 |
+
if (options?.reviewPane === "open") {
|
| 76 |
+
await openReviewPane(page)
|
| 77 |
+
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
| 81 |
+
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
| 82 |
+
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
| 83 |
+
const href = stressSessionHref(fixture.targetID)
|
| 84 |
+
const result = await measureSessionSwitch(page, {
|
| 85 |
+
destinationIDs,
|
| 86 |
+
sourceIDs,
|
| 87 |
+
lastID,
|
| 88 |
+
href,
|
| 89 |
+
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
| 90 |
+
})
|
| 91 |
+
return result
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function summarize(results: Record<"cold" | "hot", Result[]>) {
|
| 95 |
+
const stats = (values: (number | null)[]) => {
|
| 96 |
+
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
|
| 97 |
+
return {
|
| 98 |
+
min: sorted[0] ?? null,
|
| 99 |
+
median: sorted[Math.floor(sorted.length / 2)] ?? null,
|
| 100 |
+
max: sorted.at(-1) ?? null,
|
| 101 |
+
missing: values.length - sorted.length,
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
return Object.fromEntries(
|
| 105 |
+
Object.entries(results).map(([mode, values]) => [
|
| 106 |
+
mode,
|
| 107 |
+
{
|
| 108 |
+
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
|
| 109 |
+
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
|
| 110 |
+
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
|
| 111 |
+
},
|
| 112 |
+
]),
|
| 113 |
+
)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
|
| 117 |
+
return Object.fromEntries(
|
| 118 |
+
Object.entries(results).map(([reviewPane, values]) => [
|
| 119 |
+
reviewPane,
|
| 120 |
+
summarize(values as Record<"cold" | "hot", Result[]>),
|
| 121 |
+
]),
|
| 122 |
+
)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
async function switchSession(page: Page, sessionID: string, title: string) {
|
| 126 |
+
const href = stressSessionHref(sessionID)
|
| 127 |
+
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
| 128 |
+
await expect(tab).toBeVisible()
|
| 129 |
+
await tab.click()
|
| 130 |
+
await expectSessionTitle(page, title)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
async function openReviewPane(page: Page) {
|
| 134 |
+
await page.getByRole("button", { name: "Toggle review" }).click()
|
| 135 |
+
const panel = page.locator("#review-panel")
|
| 136 |
+
await expect(panel).toBeVisible()
|
| 137 |
+
// Text-based readiness works across review implementations; the legacy list mounts
|
| 138 |
+
// diff viewers lazily while V2 mounts the active preview eagerly.
|
| 139 |
+
await page.waitForFunction(() => {
|
| 140 |
+
const panel = document.querySelector<HTMLElement>("#review-panel")
|
| 141 |
+
const text = panel?.textContent ?? ""
|
| 142 |
+
return text.includes("generated-000.ts") && text.includes("+3")
|
| 143 |
+
})
|
| 144 |
+
}
|
packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type SessionSwitchSample = {
|
| 2 |
+
observedAtMs: number
|
| 3 |
+
destination: string[]
|
| 4 |
+
source: string[]
|
| 5 |
+
hasVisibleRows: boolean
|
| 6 |
+
last: boolean
|
| 7 |
+
requiredPartVisible?: boolean
|
| 8 |
+
bottomAnchorRequired?: boolean
|
| 9 |
+
bottomErrorPx?: number
|
| 10 |
+
review?: {
|
| 11 |
+
fileHost: boolean
|
| 12 |
+
fileHostReplaced: boolean
|
| 13 |
+
header: string
|
| 14 |
+
replacedLevels: string[]
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
| 19 |
+
const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
|
| 20 |
+
const firstCorrect = samples.findIndex(isCorrectDestination)
|
| 21 |
+
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
|
| 22 |
+
return {
|
| 23 |
+
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
|
| 24 |
+
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
|
| 25 |
+
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
|
| 26 |
+
wrongDestinationSamples: samples
|
| 27 |
+
.slice(firstDestination)
|
| 28 |
+
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
|
| 29 |
+
blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
|
| 30 |
+
unknownSamples: samples.filter(
|
| 31 |
+
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
|
| 32 |
+
).length,
|
| 33 |
+
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
|
| 34 |
+
reviewFileHostMissingSamples: samples.filter((sample) => sample.review && !sample.review.fileHost).length,
|
| 35 |
+
reviewFileHostReplacedSamples: samples.filter((sample) => sample.review?.fileHostReplaced).length,
|
| 36 |
+
reviewHeaders: [...new Set(samples.flatMap((sample) => (sample.review ? [sample.review.header] : [])))],
|
| 37 |
+
reviewReplacedLevels: [...new Set(samples.flatMap((sample) => sample.review?.replacedLevels ?? []))],
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function isCorrectDestination(sample: SessionSwitchSample) {
|
| 42 |
+
return (
|
| 43 |
+
sample.destination.length > 0 &&
|
| 44 |
+
sample.source.length === 0 &&
|
| 45 |
+
sample.last &&
|
| 46 |
+
sample.requiredPartVisible !== false &&
|
| 47 |
+
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
|
| 52 |
+
return samples.length === 3 && samples.every(isCorrectDestination)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
|
| 56 |
+
return (
|
| 57 |
+
samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
| 58 |
+
)
|
| 59 |
+
}
|
packages/app/e2e/performance/timeline/session-tab-switch-probe.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, type Page } from "@playwright/test"
|
| 2 |
+
import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
|
| 3 |
+
|
| 4 |
+
type SessionSwitchProbe = {
|
| 5 |
+
samples: SessionSwitchSample[]
|
| 6 |
+
stop: () => void
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
async function installSessionSwitchProbe(
|
| 10 |
+
page: Page,
|
| 11 |
+
input: {
|
| 12 |
+
destinationIDs: string[]
|
| 13 |
+
sourceIDs: string[]
|
| 14 |
+
lastID: string
|
| 15 |
+
requiredPartID?: string
|
| 16 |
+
requireBottomAnchor?: boolean
|
| 17 |
+
href: string
|
| 18 |
+
},
|
| 19 |
+
) {
|
| 20 |
+
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
|
| 21 |
+
const destination = new Set(destinationIDs)
|
| 22 |
+
const source = new Set(sourceIDs)
|
| 23 |
+
const samples: SessionSwitchSample[] = []
|
| 24 |
+
let started: number | undefined
|
| 25 |
+
let running = true
|
| 26 |
+
const reviewLevels: Record<string, string> = {
|
| 27 |
+
panel: "#review-panel",
|
| 28 |
+
tabs: '#review-panel [data-component="tabs"]',
|
| 29 |
+
body: '#review-panel [data-slot="session-review-v2-body"]',
|
| 30 |
+
review: '#review-panel [data-component="session-review-v2"]',
|
| 31 |
+
preview: '#review-panel [data-slot="session-review-v2-preview"]',
|
| 32 |
+
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
|
| 33 |
+
file: '#review-panel [data-component="file"][data-mode="diff"]',
|
| 34 |
+
}
|
| 35 |
+
const initialReviewNodes: Record<string, Element | null> = {}
|
| 36 |
+
const sample = () => {
|
| 37 |
+
if (!running || started === undefined) return
|
| 38 |
+
setTimeout(() => {
|
| 39 |
+
if (!running || started === undefined) return
|
| 40 |
+
const observedAtMs = performance.now() - started
|
| 41 |
+
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
|
| 42 |
+
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
|
| 43 |
+
const initialReviewFile = initialReviewNodes.file
|
| 44 |
+
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
|
| 45 |
+
const initial = initialReviewNodes[name]
|
| 46 |
+
if (!initial) return []
|
| 47 |
+
const current = document.querySelector(selector)
|
| 48 |
+
return current && current !== initial ? [name] : []
|
| 49 |
+
})
|
| 50 |
+
const review = reviewPanel
|
| 51 |
+
? {
|
| 52 |
+
fileHost: !!reviewFile,
|
| 53 |
+
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
|
| 54 |
+
header:
|
| 55 |
+
reviewPanel
|
| 56 |
+
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
|
| 57 |
+
?.textContent?.trim() ?? "",
|
| 58 |
+
replacedLevels,
|
| 59 |
+
}
|
| 60 |
+
: undefined
|
| 61 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 62 |
+
element.querySelector("[data-timeline-row]"),
|
| 63 |
+
)
|
| 64 |
+
if (root) {
|
| 65 |
+
const view = root.getBoundingClientRect()
|
| 66 |
+
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
| 67 |
+
.filter((element) => {
|
| 68 |
+
const rect = element.getBoundingClientRect()
|
| 69 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 70 |
+
})
|
| 71 |
+
.map((element) => element.dataset.messageId!)
|
| 72 |
+
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
|
| 73 |
+
const rect = element.getBoundingClientRect()
|
| 74 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 75 |
+
})
|
| 76 |
+
const requiredPartVisible = requiredPartID
|
| 77 |
+
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
|
| 78 |
+
if (element.dataset.timelinePartId !== requiredPartID) return false
|
| 79 |
+
const rect = element.getBoundingClientRect()
|
| 80 |
+
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
| 81 |
+
})
|
| 82 |
+
: undefined
|
| 83 |
+
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
| 84 |
+
samples.push({
|
| 85 |
+
observedAtMs,
|
| 86 |
+
destination: visible.filter((id) => destination.has(id)),
|
| 87 |
+
source: visible.filter((id) => source.has(id)),
|
| 88 |
+
hasVisibleRows,
|
| 89 |
+
last: visible.includes(lastID),
|
| 90 |
+
requiredPartVisible,
|
| 91 |
+
bottomAnchorRequired: requireBottomAnchor !== false,
|
| 92 |
+
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
| 93 |
+
review,
|
| 94 |
+
})
|
| 95 |
+
} else {
|
| 96 |
+
samples.push({
|
| 97 |
+
observedAtMs,
|
| 98 |
+
destination: [],
|
| 99 |
+
source: [],
|
| 100 |
+
hasVisibleRows: false,
|
| 101 |
+
last: false,
|
| 102 |
+
requiredPartVisible: requiredPartID ? false : undefined,
|
| 103 |
+
bottomAnchorRequired: requireBottomAnchor !== false,
|
| 104 |
+
review,
|
| 105 |
+
})
|
| 106 |
+
}
|
| 107 |
+
requestAnimationFrame(sample)
|
| 108 |
+
}, 0)
|
| 109 |
+
}
|
| 110 |
+
document.addEventListener(
|
| 111 |
+
"click",
|
| 112 |
+
(event) => {
|
| 113 |
+
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
| 114 |
+
if (link?.getAttribute("href") !== href) return
|
| 115 |
+
started = performance.now()
|
| 116 |
+
for (const [name, selector] of Object.entries(reviewLevels)) {
|
| 117 |
+
initialReviewNodes[name] = document.querySelector(selector)
|
| 118 |
+
}
|
| 119 |
+
requestAnimationFrame(sample)
|
| 120 |
+
},
|
| 121 |
+
{ capture: true, once: true },
|
| 122 |
+
)
|
| 123 |
+
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
| 124 |
+
samples,
|
| 125 |
+
stop: () => {
|
| 126 |
+
running = false
|
| 127 |
+
},
|
| 128 |
+
}
|
| 129 |
+
}, input)
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
async function waitForStableSessionSwitch(page: Page) {
|
| 133 |
+
await page.waitForFunction(() => {
|
| 134 |
+
const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
|
| 135 |
+
if (!samples) return false
|
| 136 |
+
return samples.some((_, index) => {
|
| 137 |
+
const stable = samples.slice(index, index + 3)
|
| 138 |
+
return (
|
| 139 |
+
stable.length === 3 &&
|
| 140 |
+
stable.every(
|
| 141 |
+
(sample) =>
|
| 142 |
+
sample.destination.length > 0 &&
|
| 143 |
+
sample.source.length === 0 &&
|
| 144 |
+
sample.last &&
|
| 145 |
+
sample.requiredPartVisible !== false &&
|
| 146 |
+
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
|
| 147 |
+
)
|
| 148 |
+
)
|
| 149 |
+
})
|
| 150 |
+
})
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function collectSessionSwitchResult(page: Page) {
|
| 154 |
+
const samples = await page.evaluate(() => {
|
| 155 |
+
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
|
| 156 |
+
probe.stop()
|
| 157 |
+
return probe.samples
|
| 158 |
+
})
|
| 159 |
+
return classifySessionSwitch(samples)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
export async function measureSessionSwitch(
|
| 163 |
+
page: Page,
|
| 164 |
+
input: {
|
| 165 |
+
destinationIDs: string[]
|
| 166 |
+
sourceIDs: string[]
|
| 167 |
+
lastID: string
|
| 168 |
+
requiredPartID?: string
|
| 169 |
+
requireBottomAnchor?: boolean
|
| 170 |
+
href: string
|
| 171 |
+
switch: () => Promise<void>
|
| 172 |
+
},
|
| 173 |
+
) {
|
| 174 |
+
const { switch: run, ...probe } = input
|
| 175 |
+
await installSessionSwitchProbe(page, probe)
|
| 176 |
+
try {
|
| 177 |
+
await run()
|
| 178 |
+
await waitForStableSessionSwitch(page)
|
| 179 |
+
return await collectSessionSwitchResult(page)
|
| 180 |
+
} finally {
|
| 181 |
+
await page.evaluate(() => {
|
| 182 |
+
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
| 183 |
+
})
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
export async function waitForStableTimeline(page: Page, lastID: string) {
|
| 188 |
+
const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
|
| 189 |
+
await expect
|
| 190 |
+
.poll(
|
| 191 |
+
async () => {
|
| 192 |
+
samples.push(
|
| 193 |
+
await page.evaluate(
|
| 194 |
+
(lastID) =>
|
| 195 |
+
new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
|
| 196 |
+
requestAnimationFrame(() =>
|
| 197 |
+
setTimeout(() => {
|
| 198 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 199 |
+
element.querySelector("[data-timeline-row]"),
|
| 200 |
+
)
|
| 201 |
+
if (!root) {
|
| 202 |
+
resolve({ last: false })
|
| 203 |
+
return
|
| 204 |
+
}
|
| 205 |
+
const view = root.getBoundingClientRect()
|
| 206 |
+
const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
|
| 207 |
+
if (element.dataset.messageId !== lastID) return false
|
| 208 |
+
const rect = element.getBoundingClientRect()
|
| 209 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 210 |
+
})
|
| 211 |
+
const spacer = root
|
| 212 |
+
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
| 213 |
+
?.getBoundingClientRect()
|
| 214 |
+
resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
|
| 215 |
+
}, 0),
|
| 216 |
+
)
|
| 217 |
+
}),
|
| 218 |
+
lastID,
|
| 219 |
+
),
|
| 220 |
+
)
|
| 221 |
+
return isStableDestination(samples.slice(-3))
|
| 222 |
+
},
|
| 223 |
+
{ timeout: 30_000, intervals: [0] },
|
| 224 |
+
)
|
| 225 |
+
.toBe(true)
|
| 226 |
+
}
|
packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import type { Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../../utils/mock-server"
|
| 4 |
+
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
| 5 |
+
import { expect } from "../benchmark"
|
| 6 |
+
|
| 7 |
+
const directory = "C:/OpenCode/TimelineStateRegression"
|
| 8 |
+
const projectID = "proj_timeline_state_regression"
|
| 9 |
+
const sessionID = "ses_timeline_state_regression"
|
| 10 |
+
const userMessageID = "msg_user_regression"
|
| 11 |
+
const assistantMessageID = "msg_assistant_regression"
|
| 12 |
+
const editPartID = "prt_0001_edit"
|
| 13 |
+
export const textPartID = "prt_9999_text"
|
| 14 |
+
const title = "Timeline collapse state regression"
|
| 15 |
+
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
| 16 |
+
|
| 17 |
+
type EventPayload = {
|
| 18 |
+
directory: string
|
| 19 |
+
payload: Record<string, unknown>
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const userMessage = {
|
| 23 |
+
info: {
|
| 24 |
+
id: userMessageID,
|
| 25 |
+
sessionID,
|
| 26 |
+
role: "user",
|
| 27 |
+
time: { created: 1700000000000 },
|
| 28 |
+
summary: { diffs: [] },
|
| 29 |
+
agent: "build",
|
| 30 |
+
model,
|
| 31 |
+
},
|
| 32 |
+
parts: [
|
| 33 |
+
{
|
| 34 |
+
id: "prt_user_text",
|
| 35 |
+
sessionID,
|
| 36 |
+
messageID: userMessageID,
|
| 37 |
+
type: "text",
|
| 38 |
+
text: "Please edit the file.",
|
| 39 |
+
},
|
| 40 |
+
],
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const editPart = {
|
| 44 |
+
id: editPartID,
|
| 45 |
+
sessionID,
|
| 46 |
+
messageID: assistantMessageID,
|
| 47 |
+
type: "tool",
|
| 48 |
+
callID: "call_edit_regression",
|
| 49 |
+
tool: "edit",
|
| 50 |
+
state: {
|
| 51 |
+
status: "completed",
|
| 52 |
+
input: { filePath: "src/regression.ts" },
|
| 53 |
+
output: "Edited src/regression.ts",
|
| 54 |
+
title: "src/regression.ts",
|
| 55 |
+
metadata: {
|
| 56 |
+
filediff: {
|
| 57 |
+
file: "src/regression.ts",
|
| 58 |
+
additions: 1,
|
| 59 |
+
deletions: 1,
|
| 60 |
+
before: "export const value = 'before'\n",
|
| 61 |
+
after: "export const value = 'after'\n",
|
| 62 |
+
},
|
| 63 |
+
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
|
| 64 |
+
},
|
| 65 |
+
time: { start: 1700000001000, end: 1700000002000 },
|
| 66 |
+
},
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const streamedTextPart = {
|
| 70 |
+
id: textPartID,
|
| 71 |
+
sessionID,
|
| 72 |
+
messageID: assistantMessageID,
|
| 73 |
+
type: "text",
|
| 74 |
+
text: "Streaming added a later assistant text part.",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const assistantMessage = {
|
| 78 |
+
info: {
|
| 79 |
+
id: assistantMessageID,
|
| 80 |
+
sessionID,
|
| 81 |
+
role: "assistant",
|
| 82 |
+
time: { created: 1700000001000 },
|
| 83 |
+
parentID: userMessageID,
|
| 84 |
+
modelID: model.modelID,
|
| 85 |
+
providerID: model.providerID,
|
| 86 |
+
mode: "build",
|
| 87 |
+
agent: "build",
|
| 88 |
+
path: { cwd: directory, root: directory },
|
| 89 |
+
cost: 0.01,
|
| 90 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 91 |
+
variant: "max",
|
| 92 |
+
},
|
| 93 |
+
parts: [editPart],
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export async function setupTimelineBenchmark(
|
| 97 |
+
page: Page,
|
| 98 |
+
options: {
|
| 99 |
+
historyTurns: number
|
| 100 |
+
eventBatch: number
|
| 101 |
+
newLayoutDesigns?: boolean
|
| 102 |
+
vcsDiff?: unknown[]
|
| 103 |
+
turnDiffs?: unknown[]
|
| 104 |
+
},
|
| 105 |
+
) {
|
| 106 |
+
const events: EventPayload[] = []
|
| 107 |
+
let eventBatch = options.eventBatch
|
| 108 |
+
const currentUserMessage = options.turnDiffs
|
| 109 |
+
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
|
| 110 |
+
: userMessage
|
| 111 |
+
await mockOpenCodeServer(page, {
|
| 112 |
+
directory,
|
| 113 |
+
project: project(),
|
| 114 |
+
provider: provider(),
|
| 115 |
+
sessions: [session()],
|
| 116 |
+
vcsDiff: options.vcsDiff,
|
| 117 |
+
pageMessages: () => ({
|
| 118 |
+
items: [
|
| 119 |
+
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
|
| 120 |
+
currentUserMessage,
|
| 121 |
+
assistantMessage,
|
| 122 |
+
],
|
| 123 |
+
}),
|
| 124 |
+
events: () => events.splice(0, eventBatch),
|
| 125 |
+
eventRetry: 16,
|
| 126 |
+
})
|
| 127 |
+
await page.addInitScript(
|
| 128 |
+
(input) => {
|
| 129 |
+
localStorage.setItem(
|
| 130 |
+
"settings.v3",
|
| 131 |
+
JSON.stringify({
|
| 132 |
+
general: {
|
| 133 |
+
newLayoutDesigns: input.newLayoutDesigns,
|
| 134 |
+
editToolPartsExpanded: true,
|
| 135 |
+
shellToolPartsExpanded: true,
|
| 136 |
+
showReasoningSummaries: true,
|
| 137 |
+
},
|
| 138 |
+
}),
|
| 139 |
+
)
|
| 140 |
+
},
|
| 141 |
+
{ newLayoutDesigns: options.newLayoutDesigns ?? false },
|
| 142 |
+
)
|
| 143 |
+
await page.setViewportSize({ width: 1366, height: 768 })
|
| 144 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 145 |
+
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
| 146 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 147 |
+
await expectSessionTitle(page, title)
|
| 148 |
+
await expectAppVisible(scroller)
|
| 149 |
+
return {
|
| 150 |
+
scroller,
|
| 151 |
+
text,
|
| 152 |
+
transport: {
|
| 153 |
+
enqueue(payload: EventPayload | EventPayload[]) {
|
| 154 |
+
events.push(...(Array.isArray(payload) ? payload : [payload]))
|
| 155 |
+
},
|
| 156 |
+
pendingCount() {
|
| 157 |
+
return events.length
|
| 158 |
+
},
|
| 159 |
+
releaseAll() {
|
| 160 |
+
eventBatch = events.length
|
| 161 |
+
},
|
| 162 |
+
},
|
| 163 |
+
async scrollToBottom() {
|
| 164 |
+
await scroller.evaluate((element) => {
|
| 165 |
+
element.scrollTop = element.scrollHeight
|
| 166 |
+
})
|
| 167 |
+
},
|
| 168 |
+
async waitForStableGeometry() {
|
| 169 |
+
await expect
|
| 170 |
+
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 171 |
+
.toBeLessThanOrEqual(1)
|
| 172 |
+
await page.waitForFunction((partID) => {
|
| 173 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 174 |
+
element.querySelector(`[data-timeline-part-id="${partID}"]`),
|
| 175 |
+
)
|
| 176 |
+
if (!root) return false
|
| 177 |
+
return new Promise<boolean>((resolve) => {
|
| 178 |
+
const height = root.scrollHeight
|
| 179 |
+
requestAnimationFrame(() =>
|
| 180 |
+
requestAnimationFrame(() =>
|
| 181 |
+
resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
|
| 182 |
+
),
|
| 183 |
+
)
|
| 184 |
+
})
|
| 185 |
+
}, textPartID)
|
| 186 |
+
},
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
|
| 191 |
+
return {
|
| 192 |
+
directory,
|
| 193 |
+
payload: {
|
| 194 |
+
type: "message.part.updated",
|
| 195 |
+
properties: {
|
| 196 |
+
part: {
|
| 197 |
+
...streamedTextPart,
|
| 198 |
+
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
| 199 |
+
},
|
| 200 |
+
},
|
| 201 |
+
},
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
| 206 |
+
return Array.from({ length: deltaCount }, (_, index) => ({
|
| 207 |
+
directory,
|
| 208 |
+
payload: {
|
| 209 |
+
type: "message.part.delta",
|
| 210 |
+
properties: {
|
| 211 |
+
messageID: assistantMessageID,
|
| 212 |
+
partID: textPartID,
|
| 213 |
+
field: "text",
|
| 214 |
+
delta: streamChunk(index + 1, deltaCount + 1),
|
| 215 |
+
},
|
| 216 |
+
},
|
| 217 |
+
}))
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function performanceTurn(index: number) {
|
| 221 |
+
const suffix = String(index).padStart(4, "0")
|
| 222 |
+
const userID = `msg_0000_${suffix}_a_user`
|
| 223 |
+
const assistantID = `msg_0000_${suffix}_b_assistant`
|
| 224 |
+
const before = historicalSource(index, false)
|
| 225 |
+
const after = historicalSource(index, true)
|
| 226 |
+
const parts = [
|
| 227 |
+
...(index % 5 === 0
|
| 228 |
+
? [
|
| 229 |
+
{
|
| 230 |
+
id: `prt_0000_${suffix}_reasoning`,
|
| 231 |
+
sessionID,
|
| 232 |
+
messageID: assistantID,
|
| 233 |
+
type: "reasoning",
|
| 234 |
+
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
|
| 235 |
+
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
|
| 236 |
+
},
|
| 237 |
+
]
|
| 238 |
+
: []),
|
| 239 |
+
{
|
| 240 |
+
id: `prt_0000_${suffix}_assistant`,
|
| 241 |
+
sessionID,
|
| 242 |
+
messageID: assistantID,
|
| 243 |
+
type: "text",
|
| 244 |
+
text: historicalMarkdown(index),
|
| 245 |
+
},
|
| 246 |
+
...(index % 8 === 0
|
| 247 |
+
? [
|
| 248 |
+
{
|
| 249 |
+
id: `prt_0000_${suffix}_edit`,
|
| 250 |
+
sessionID,
|
| 251 |
+
messageID: assistantID,
|
| 252 |
+
type: "tool",
|
| 253 |
+
callID: `call_0000_${suffix}_edit`,
|
| 254 |
+
tool: "edit",
|
| 255 |
+
state: {
|
| 256 |
+
status: "completed",
|
| 257 |
+
input: { filePath: `src/history-${index}.ts` },
|
| 258 |
+
output: `Edited src/history-${index}.ts`,
|
| 259 |
+
title: `src/history-${index}.ts`,
|
| 260 |
+
metadata: {
|
| 261 |
+
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
|
| 262 |
+
},
|
| 263 |
+
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
|
| 264 |
+
},
|
| 265 |
+
},
|
| 266 |
+
]
|
| 267 |
+
: []),
|
| 268 |
+
...(index % 12 === 0
|
| 269 |
+
? [
|
| 270 |
+
{
|
| 271 |
+
id: `prt_0000_${suffix}_write`,
|
| 272 |
+
sessionID,
|
| 273 |
+
messageID: assistantID,
|
| 274 |
+
type: "tool",
|
| 275 |
+
callID: `call_0000_${suffix}_write`,
|
| 276 |
+
tool: "write",
|
| 277 |
+
state: {
|
| 278 |
+
status: "completed",
|
| 279 |
+
input: { filePath: `src/generated-${index}.tsx`, content: after },
|
| 280 |
+
output: `Wrote src/generated-${index}.tsx`,
|
| 281 |
+
title: `src/generated-${index}.tsx`,
|
| 282 |
+
metadata: {
|
| 283 |
+
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
|
| 284 |
+
},
|
| 285 |
+
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
|
| 286 |
+
},
|
| 287 |
+
},
|
| 288 |
+
]
|
| 289 |
+
: []),
|
| 290 |
+
...(index % 16 === 0
|
| 291 |
+
? [
|
| 292 |
+
{
|
| 293 |
+
id: `prt_0000_${suffix}_patch`,
|
| 294 |
+
sessionID,
|
| 295 |
+
messageID: assistantID,
|
| 296 |
+
type: "tool",
|
| 297 |
+
callID: `call_0000_${suffix}_patch`,
|
| 298 |
+
tool: "apply_patch",
|
| 299 |
+
state: {
|
| 300 |
+
status: "completed",
|
| 301 |
+
input: { patchText: realisticPatch(index) },
|
| 302 |
+
output: "Success. Updated src/components/SessionCard.tsx",
|
| 303 |
+
title: "src/components/SessionCard.tsx",
|
| 304 |
+
metadata: {
|
| 305 |
+
files: [
|
| 306 |
+
{
|
| 307 |
+
filePath: "src/components/SessionCard.tsx",
|
| 308 |
+
relativePath: "src/components/SessionCard.tsx",
|
| 309 |
+
type: "update",
|
| 310 |
+
additions: 8,
|
| 311 |
+
deletions: 3,
|
| 312 |
+
patch: realisticPatch(index),
|
| 313 |
+
before,
|
| 314 |
+
after,
|
| 315 |
+
},
|
| 316 |
+
],
|
| 317 |
+
},
|
| 318 |
+
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
|
| 319 |
+
},
|
| 320 |
+
},
|
| 321 |
+
]
|
| 322 |
+
: []),
|
| 323 |
+
]
|
| 324 |
+
return [
|
| 325 |
+
{
|
| 326 |
+
info: {
|
| 327 |
+
id: userID,
|
| 328 |
+
sessionID,
|
| 329 |
+
role: "user",
|
| 330 |
+
time: { created: 1690000000000 + index * 2_000 },
|
| 331 |
+
summary: { diffs: [] },
|
| 332 |
+
agent: "build",
|
| 333 |
+
model,
|
| 334 |
+
},
|
| 335 |
+
parts: [
|
| 336 |
+
{
|
| 337 |
+
id: `prt_0000_${suffix}_user`,
|
| 338 |
+
sessionID,
|
| 339 |
+
messageID: userID,
|
| 340 |
+
type: "text",
|
| 341 |
+
text: `Historical prompt ${index}`,
|
| 342 |
+
},
|
| 343 |
+
],
|
| 344 |
+
},
|
| 345 |
+
{
|
| 346 |
+
info: {
|
| 347 |
+
id: assistantID,
|
| 348 |
+
sessionID,
|
| 349 |
+
role: "assistant",
|
| 350 |
+
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
| 351 |
+
parentID: userID,
|
| 352 |
+
modelID: model.modelID,
|
| 353 |
+
providerID: model.providerID,
|
| 354 |
+
mode: "build",
|
| 355 |
+
agent: "build",
|
| 356 |
+
path: { cwd: directory, root: directory },
|
| 357 |
+
cost: 0.01,
|
| 358 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 359 |
+
variant: "max",
|
| 360 |
+
finish: "stop",
|
| 361 |
+
},
|
| 362 |
+
parts,
|
| 363 |
+
},
|
| 364 |
+
]
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
function historicalMarkdown(index: number) {
|
| 368 |
+
const code = `import { For, Show, createSignal } from "solid-js"
|
| 369 |
+
|
| 370 |
+
type SessionRow = { id: string; title: string; active: boolean }
|
| 371 |
+
|
| 372 |
+
export function SessionList(props: { rows: SessionRow[] }) {
|
| 373 |
+
const [selected, setSelected] = createSignal<string>()
|
| 374 |
+
return (
|
| 375 |
+
<section aria-label="Sessions">
|
| 376 |
+
<For each={props.rows}>{(row) => (
|
| 377 |
+
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
|
| 378 |
+
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
|
| 379 |
+
</button>
|
| 380 |
+
)}</For>
|
| 381 |
+
</section>
|
| 382 |
+
)
|
| 383 |
+
}`
|
| 384 |
+
return `## Session renderer review ${index}
|
| 385 |
+
|
| 386 |
+
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
|
| 387 |
+
|
| 388 |
+
| Concern | Current behavior | Verification |
|
| 389 |
+
| --- | --- | --- |
|
| 390 |
+
| streaming | appends Markdown blocks | painted frames |
|
| 391 |
+
| geometry | anchors visible rows | DOM coordinates |
|
| 392 |
+
| tools | preserves expanded state | keyed remount probe |
|
| 393 |
+
|
| 394 |
+
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
|
| 395 |
+
|
| 396 |
+
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function historicalSource(index: number, updated: boolean) {
|
| 400 |
+
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
|
| 401 |
+
const limit = updated ? 24 : 20
|
| 402 |
+
return `import { createMemo, For } from "solid-js"
|
| 403 |
+
|
| 404 |
+
type Message = {
|
| 405 |
+
id: string
|
| 406 |
+
role: "user" | "assistant"
|
| 407 |
+
text: string
|
| 408 |
+
tokens: { input: number; output: number }
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
export function MessageSummary(props: { messages: Message[]; locale: string }) {
|
| 412 |
+
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
|
| 413 |
+
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
|
| 414 |
+
return (
|
| 415 |
+
<article data-session-index="${index}">
|
| 416 |
+
<header>{total().toLocaleString(props.locale)} output tokens</header>
|
| 417 |
+
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
|
| 418 |
+
</article>
|
| 419 |
+
)
|
| 420 |
+
}
|
| 421 |
+
`
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
function realisticPatch(index: number) {
|
| 425 |
+
return `*** Begin Patch
|
| 426 |
+
*** Update File: src/components/SessionCard.tsx
|
| 427 |
+
@@
|
| 428 |
+
-const title = props.session.title.toUpperCase()
|
| 429 |
+
-const messages = props.messages.slice(-20)
|
| 430 |
+
+const title = props.session.title.toLocaleUpperCase(props.locale)
|
| 431 |
+
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
|
| 432 |
+
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
|
| 433 |
+
@@
|
| 434 |
+
- <h2>{title}</h2>
|
| 435 |
+
+ <h2 data-session-index="${index}">{title}</h2>
|
| 436 |
+
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
|
| 437 |
+
*** End Patch`
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
export function streamChunk(index: number, count: number) {
|
| 441 |
+
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
|
| 442 |
+
if (index === count - 1)
|
| 443 |
+
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
|
| 444 |
+
|
| 445 |
+
const section = Math.floor(index / 18) + 1
|
| 446 |
+
const fragments = [
|
| 447 |
+
` continues across three`,
|
| 448 |
+
` or four word`,
|
| 449 |
+
` provider deltas and`,
|
| 450 |
+
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
|
| 451 |
+
`| Concern | State`,
|
| 452 |
+
` | Verification |\n|`,
|
| 453 |
+
` --- | ---`,
|
| 454 |
+
` | --- |\n|`,
|
| 455 |
+
` markdown | incremental |`,
|
| 456 |
+
` painted frames | <!-- stream-${index} -->\n\n`,
|
| 457 |
+
`\`\`\`tsx\nconst row: SessionRow`,
|
| 458 |
+
` = rows[index] ??`,
|
| 459 |
+
` fallback\nconst title =`,
|
| 460 |
+
` row.title.toLocaleUpperCase(locale)\n`,
|
| 461 |
+
`const selected = createMemo(()`,
|
| 462 |
+
` => row.id ===`,
|
| 463 |
+
` activeID()) // stream-${index}\n`,
|
| 464 |
+
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
|
| 465 |
+
]
|
| 466 |
+
return fragments[(index - 1) % fragments.length]!
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
function project() {
|
| 470 |
+
return {
|
| 471 |
+
id: projectID,
|
| 472 |
+
worktree: directory,
|
| 473 |
+
vcs: "git",
|
| 474 |
+
name: "timeline-state-regression",
|
| 475 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 476 |
+
sandboxes: [],
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
function session() {
|
| 481 |
+
return {
|
| 482 |
+
id: sessionID,
|
| 483 |
+
slug: "timeline-state-regression",
|
| 484 |
+
projectID,
|
| 485 |
+
directory,
|
| 486 |
+
title,
|
| 487 |
+
version: "dev",
|
| 488 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function provider() {
|
| 493 |
+
return {
|
| 494 |
+
all: [
|
| 495 |
+
{
|
| 496 |
+
id: "opencode",
|
| 497 |
+
name: "OpenCode",
|
| 498 |
+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
| 499 |
+
},
|
| 500 |
+
],
|
| 501 |
+
connected: ["opencode"],
|
| 502 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 503 |
+
}
|
| 504 |
+
}
|
packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
| 3 |
+
import {
|
| 4 |
+
buildInitialStreamEvent,
|
| 5 |
+
buildStreamDeltaEvents,
|
| 6 |
+
setupTimelineBenchmark,
|
| 7 |
+
textPartID,
|
| 8 |
+
} from "./session-timeline-benchmark.fixture"
|
| 9 |
+
import { startTimelineProfile } from "./session-timeline-profile"
|
| 10 |
+
import { createReviewDiffs } from "./timeline-test-helpers"
|
| 11 |
+
import {
|
| 12 |
+
collectTimelineStreamMetrics,
|
| 13 |
+
installTimelineStreamProbe,
|
| 14 |
+
startTimelineStreamProbe,
|
| 15 |
+
} from "./session-timeline-stream-probe"
|
| 16 |
+
|
| 17 |
+
type TimelineStreamOptions = {
|
| 18 |
+
newLayoutDesigns?: boolean
|
| 19 |
+
reviewDiffs?: boolean
|
| 20 |
+
reviewPane?: boolean
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
type ReviewPaneSample = {
|
| 24 |
+
observedAtMs: number
|
| 25 |
+
panelVisible: boolean
|
| 26 |
+
header: string
|
| 27 |
+
diffViewers: number
|
| 28 |
+
diffLines: number
|
| 29 |
+
codeBlocks: number
|
| 30 |
+
ready: boolean
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
type ReviewPaneProbe = {
|
| 34 |
+
samples: ReviewPaneSample[]
|
| 35 |
+
start: () => void
|
| 36 |
+
stop: () => void
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const reviewReadyStreak = 3
|
| 40 |
+
|
| 41 |
+
benchmark.describe("performance: session timeline streaming", () => {
|
| 42 |
+
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
|
| 43 |
+
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
| 44 |
+
const result = await runTimelineStreamBenchmark(page, {})
|
| 45 |
+
report(result.metrics, result.context)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => {
|
| 49 |
+
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
| 50 |
+
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true })
|
| 51 |
+
report(result.metrics, result.context)
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => {
|
| 55 |
+
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
| 56 |
+
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true })
|
| 57 |
+
report(result.metrics, result.context)
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
benchmark("streams assistant text in v2 with review pane open", async ({ page, report }) => {
|
| 61 |
+
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
| 62 |
+
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewPane: true })
|
| 63 |
+
report(result.metrics, result.context)
|
| 64 |
+
})
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
benchmark.describe("performance: review pane", () => {
|
| 68 |
+
benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => {
|
| 69 |
+
benchmark.setTimeout(240_000)
|
| 70 |
+
const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72)
|
| 71 |
+
const diffs = createReviewDiffs()
|
| 72 |
+
const fixture = await setupTimelineBenchmark(page, {
|
| 73 |
+
historyTurns,
|
| 74 |
+
eventBatch: 1,
|
| 75 |
+
newLayoutDesigns: true,
|
| 76 |
+
vcsDiff: diffs,
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
fixture.transport.enqueue(buildInitialStreamEvent(1))
|
| 80 |
+
await expect(fixture.text).toBeVisible()
|
| 81 |
+
await expect(fixture.text).toContainText("Implementation plan")
|
| 82 |
+
await fixture.scrollToBottom()
|
| 83 |
+
await fixture.waitForStableGeometry()
|
| 84 |
+
|
| 85 |
+
const open = await measureReviewPaneLoad(page, diffs[0]!.file)
|
| 86 |
+
const switches = []
|
| 87 |
+
for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file))
|
| 88 |
+
|
| 89 |
+
report(
|
| 90 |
+
{
|
| 91 |
+
open,
|
| 92 |
+
switches,
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
historyTurns,
|
| 96 |
+
reviewDiffs: diffs.length,
|
| 97 |
+
},
|
| 98 |
+
)
|
| 99 |
+
})
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOptions) {
|
| 103 |
+
const completionTimeoutMs = Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000)
|
| 104 |
+
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
|
| 105 |
+
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
|
| 106 |
+
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
|
| 107 |
+
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
|
| 108 |
+
const minimal = process.env.TIMELINE_MINIMAL === "1"
|
| 109 |
+
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
|
| 110 |
+
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
|
| 111 |
+
const diffs = options.reviewDiffs || options.reviewPane ? createReviewDiffs() : undefined
|
| 112 |
+
const fixture = await setupTimelineBenchmark(page, {
|
| 113 |
+
historyTurns,
|
| 114 |
+
eventBatch,
|
| 115 |
+
newLayoutDesigns: options.newLayoutDesigns,
|
| 116 |
+
// Turn diffs exercise timeline data cost; the pane-open scenario serves the same
|
| 117 |
+
// diffs through the default git mode so it works across review implementations.
|
| 118 |
+
turnDiffs: options.reviewDiffs ? diffs : undefined,
|
| 119 |
+
vcsDiff: options.reviewPane ? diffs : undefined,
|
| 120 |
+
})
|
| 121 |
+
|
| 122 |
+
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
|
| 123 |
+
const contentStart = performance.now()
|
| 124 |
+
await expect(fixture.text).toBeVisible()
|
| 125 |
+
await expect(fixture.text).toContainText("Implementation plan")
|
| 126 |
+
const initialContentObservedMs = performance.now() - contentStart
|
| 127 |
+
await fixture.scrollToBottom()
|
| 128 |
+
await fixture.waitForStableGeometry()
|
| 129 |
+
|
| 130 |
+
const reviewPane = options.reviewPane && diffs ? await measureReviewPaneLoad(page, diffs[0]!.file) : undefined
|
| 131 |
+
if (reviewPane) await fixture.waitForStableGeometry()
|
| 132 |
+
|
| 133 |
+
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
|
| 134 |
+
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
|
| 135 |
+
const deltas = buildStreamDeltaEvents(deltaCount)
|
| 136 |
+
await startTimelineStreamProbe(page)
|
| 137 |
+
fixture.transport.enqueue(deltas)
|
| 138 |
+
|
| 139 |
+
await page.waitForFunction(
|
| 140 |
+
(finalIndex) =>
|
| 141 |
+
(
|
| 142 |
+
window as Window & {
|
| 143 |
+
__timelineStreamBenchmark?: { applied: { index: number }[] }
|
| 144 |
+
}
|
| 145 |
+
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
|
| 146 |
+
deltaCount,
|
| 147 |
+
{ timeout: completionTimeoutMs },
|
| 148 |
+
)
|
| 149 |
+
await expect(fixture.text).toContainText("benchmark-complete")
|
| 150 |
+
await expect(fixture.text).toContainText("Streaming")
|
| 151 |
+
await fixture.waitForStableGeometry()
|
| 152 |
+
const metrics = await collectTimelineStreamMetrics(page, {
|
| 153 |
+
textPartID,
|
| 154 |
+
finalIndex: deltaCount,
|
| 155 |
+
navigations: benchmarkDiagnostics(page).navigations,
|
| 156 |
+
})
|
| 157 |
+
const delivered = deltas.length - fixture.transport.pendingCount()
|
| 158 |
+
await profile.stop()
|
| 159 |
+
|
| 160 |
+
const result = {
|
| 161 |
+
metrics: {
|
| 162 |
+
endToEndInitialContentObservedMs: initialContentObservedMs,
|
| 163 |
+
...metrics,
|
| 164 |
+
deliveredDeltas: delivered,
|
| 165 |
+
pendingDeltas: fixture.transport.pendingCount(),
|
| 166 |
+
reviewPane: reviewPane ?? null,
|
| 167 |
+
},
|
| 168 |
+
context: {
|
| 169 |
+
cpuThrottle,
|
| 170 |
+
profileCPU,
|
| 171 |
+
profileVisual,
|
| 172 |
+
minimal,
|
| 173 |
+
queuedDeltas: deltas.length,
|
| 174 |
+
historyTurns,
|
| 175 |
+
eventBatch,
|
| 176 |
+
newLayoutDesigns: options.newLayoutDesigns === true,
|
| 177 |
+
reviewPane: options.reviewPane === true ? "open" : "closed",
|
| 178 |
+
reviewDiffs: diffs?.length ?? 0,
|
| 179 |
+
},
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
await profile.reset()
|
| 183 |
+
return result
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
async function measureReviewPaneLoad(page: Page, file: string) {
|
| 187 |
+
// Default git mode reads the mocked /vcs/diff data, so opening the pane is enough
|
| 188 |
+
// and the flow works across review pane implementations.
|
| 189 |
+
await installReviewPaneProbe(page, { file })
|
| 190 |
+
await startReviewPaneProbe(page)
|
| 191 |
+
await page.getByRole("button", { name: "Toggle review" }).click()
|
| 192 |
+
await expect(page.locator("#review-panel")).toBeVisible()
|
| 193 |
+
return collectReviewPaneProbe(page)
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
async function measureReviewNextFile(page: Page, file: string) {
|
| 197 |
+
await installReviewPaneProbe(page, { file })
|
| 198 |
+
await startReviewPaneProbe(page)
|
| 199 |
+
await page.getByRole("button", { name: "Next file" }).click()
|
| 200 |
+
return collectReviewPaneProbe(page)
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
async function installReviewPaneProbe(page: Page, input: { file: string }) {
|
| 204 |
+
await page.evaluate((input) => {
|
| 205 |
+
const samples: ReviewPaneSample[] = []
|
| 206 |
+
const basename = input.file.split(/[\\/]/).at(-1) ?? input.file
|
| 207 |
+
let started: number | undefined
|
| 208 |
+
let running = true
|
| 209 |
+
|
| 210 |
+
const paneState = () => {
|
| 211 |
+
const panel = document.querySelector<HTMLElement>("#review-panel")
|
| 212 |
+
const review = panel?.querySelector<HTMLElement>('[data-component="session-review-v2"]')
|
| 213 |
+
const rect = (review ?? panel)?.getBoundingClientRect()
|
| 214 |
+
const text = panel?.textContent ?? ""
|
| 215 |
+
const previewHeader = panel?.querySelector<HTMLElement>(
|
| 216 |
+
'[data-slot="session-review-v2-file-header"]',
|
| 217 |
+
)?.textContent
|
| 218 |
+
const header = previewHeader ?? text
|
| 219 |
+
const viewers = panel ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')] : []
|
| 220 |
+
const codeBlocks = panel?.querySelectorAll("code").length ?? 0
|
| 221 |
+
const diffLines = viewers.reduce(
|
| 222 |
+
(sum, viewer) =>
|
| 223 |
+
sum +
|
| 224 |
+
(viewer.shadowRoot?.querySelectorAll("[data-line]").length ?? viewer.querySelectorAll("[data-line]").length),
|
| 225 |
+
0,
|
| 226 |
+
)
|
| 227 |
+
const panelVisible =
|
| 228 |
+
!!panel && panel.getAttribute("aria-hidden") !== "true" && !!rect && rect.width > 0 && rect.height > 0
|
| 229 |
+
return {
|
| 230 |
+
panelVisible,
|
| 231 |
+
header: header.slice(0, 500),
|
| 232 |
+
diffViewers: viewers.length,
|
| 233 |
+
diffLines,
|
| 234 |
+
codeBlocks,
|
| 235 |
+
ready:
|
| 236 |
+
panelVisible &&
|
| 237 |
+
header.includes(basename) &&
|
| 238 |
+
(viewers.length > 0 || text.includes("+3") || diffLines > 0 || codeBlocks > 0),
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const sample = () => {
|
| 243 |
+
if (!running || started === undefined) return
|
| 244 |
+
requestAnimationFrame(() => {
|
| 245 |
+
setTimeout(() => {
|
| 246 |
+
if (!running || started === undefined) return
|
| 247 |
+
samples.push({ observedAtMs: performance.now() - started, ...paneState() })
|
| 248 |
+
if (performance.now() - started < 10_000) sample()
|
| 249 |
+
}, 0)
|
| 250 |
+
})
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe = {
|
| 254 |
+
samples,
|
| 255 |
+
start: () => {
|
| 256 |
+
started = performance.now()
|
| 257 |
+
performance.mark("opencode.review-pane.click")
|
| 258 |
+
sample()
|
| 259 |
+
},
|
| 260 |
+
stop: () => {
|
| 261 |
+
running = false
|
| 262 |
+
},
|
| 263 |
+
}
|
| 264 |
+
}, input)
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
async function startReviewPaneProbe(page: Page) {
|
| 268 |
+
await page.evaluate(() => {
|
| 269 |
+
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!.start()
|
| 270 |
+
})
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
async function collectReviewPaneProbe(page: Page) {
|
| 274 |
+
await page.waitForFunction((streak) => {
|
| 275 |
+
const samples = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe?.samples
|
| 276 |
+
if (!samples) return false
|
| 277 |
+
return samples.some((_, index) => {
|
| 278 |
+
const stable = samples.slice(index, index + streak)
|
| 279 |
+
return stable.length === streak && stable.every((sample) => sample.ready)
|
| 280 |
+
})
|
| 281 |
+
}, reviewReadyStreak)
|
| 282 |
+
|
| 283 |
+
const samples = await page.evaluate(() => {
|
| 284 |
+
const probe = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!
|
| 285 |
+
probe.stop()
|
| 286 |
+
return probe.samples
|
| 287 |
+
})
|
| 288 |
+
return { summary: summarizeReviewPaneSamples(samples), samples }
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function summarizeReviewPaneSamples(samples: ReviewPaneSample[]) {
|
| 292 |
+
const firstReady = samples.find((sample) => sample.ready)
|
| 293 |
+
const stableIndex = samples.findIndex((_, index) => {
|
| 294 |
+
const stable = samples.slice(index, index + reviewReadyStreak)
|
| 295 |
+
return stable.length === reviewReadyStreak && stable.every((sample) => sample.ready)
|
| 296 |
+
})
|
| 297 |
+
return {
|
| 298 |
+
samples: samples.length,
|
| 299 |
+
firstReadyObservedMs: firstReady?.observedAtMs ?? null,
|
| 300 |
+
stableReadyObservedMs: stableIndex === -1 ? null : samples[stableIndex + reviewReadyStreak - 1]!.observedAtMs,
|
| 301 |
+
notReadySamples: samples.filter((sample) => !sample.ready).length,
|
| 302 |
+
maxDiffViewers: Math.max(0, ...samples.map((sample) => sample.diffViewers)),
|
| 303 |
+
maxDiffLines: Math.max(0, ...samples.map((sample) => sample.diffLines)),
|
| 304 |
+
maxCodeBlocks: Math.max(0, ...samples.map((sample) => sample.codeBlocks)),
|
| 305 |
+
}
|
| 306 |
+
}
|
packages/app/e2e/performance/timeline/session-timeline-profile.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CDPSession, Page } from "@playwright/test"
|
| 2 |
+
|
| 3 |
+
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
|
| 4 |
+
const cdp = await page.context().newCDPSession(page)
|
| 5 |
+
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
|
| 6 |
+
if (options.profileCPU) {
|
| 7 |
+
await cdp.send("Profiler.enable")
|
| 8 |
+
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
|
| 9 |
+
await cdp.send("Profiler.start")
|
| 10 |
+
}
|
| 11 |
+
return {
|
| 12 |
+
async stop() {
|
| 13 |
+
if (!options.profileCPU) return
|
| 14 |
+
const result = await cdp.send("Profiler.stop")
|
| 15 |
+
const self = new Map<number, number>()
|
| 16 |
+
result.profile.samples?.forEach((id, index) => {
|
| 17 |
+
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
|
| 18 |
+
self.set(id, (self.get(id) ?? 0) + duration)
|
| 19 |
+
})
|
| 20 |
+
console.log(
|
| 21 |
+
"timeline cpu profile",
|
| 22 |
+
JSON.stringify(
|
| 23 |
+
result.profile.nodes
|
| 24 |
+
.map((node) => ({
|
| 25 |
+
function: node.callFrame.functionName || "(anonymous)",
|
| 26 |
+
url: node.callFrame.url,
|
| 27 |
+
line: node.callFrame.lineNumber + 1,
|
| 28 |
+
selfMs: self.get(node.id) ?? 0,
|
| 29 |
+
}))
|
| 30 |
+
.filter((node) => node.selfMs > 1)
|
| 31 |
+
.sort((a, b) => b.selfMs - a.selfMs)
|
| 32 |
+
.slice(0, 40),
|
| 33 |
+
),
|
| 34 |
+
)
|
| 35 |
+
},
|
| 36 |
+
async reset() {
|
| 37 |
+
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
|
| 38 |
+
},
|
| 39 |
+
}
|
| 40 |
+
}
|
packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
|
| 3 |
+
const STREAM_MARKER_PATTERN = "stream-(\\d+)"
|
| 4 |
+
const STREAM_FRAGMENT_COUNT = 18
|
| 5 |
+
|
| 6 |
+
type TimelineProbeState = {
|
| 7 |
+
started: number
|
| 8 |
+
ended: number
|
| 9 |
+
profileVisual: boolean
|
| 10 |
+
minimal: boolean
|
| 11 |
+
frames: number[]
|
| 12 |
+
frameAt: number[]
|
| 13 |
+
applied: { at: number; index: number }[]
|
| 14 |
+
geometry: {
|
| 15 |
+
scrollTop: number
|
| 16 |
+
scrollHeight: number
|
| 17 |
+
clientHeight: number
|
| 18 |
+
distance: number
|
| 19 |
+
virtualHeight: number
|
| 20 |
+
headerHeight: number
|
| 21 |
+
}[]
|
| 22 |
+
blanks: number
|
| 23 |
+
longTasks: number[]
|
| 24 |
+
layoutShifts: number[]
|
| 25 |
+
visibleMounts: number
|
| 26 |
+
visibleUnmounts: number
|
| 27 |
+
visibleRows: Set<Element>
|
| 28 |
+
visibleSubtreeMounts: string[]
|
| 29 |
+
visibleSubtreeUnmounts: string[]
|
| 30 |
+
visibleSubtreeReplacements: number
|
| 31 |
+
visibleSubtreeDropouts: string[]
|
| 32 |
+
visibleSubtrees: Map<string, Element>
|
| 33 |
+
subtreeKeys: WeakMap<Element, string>
|
| 34 |
+
maxOverlap: number
|
| 35 |
+
maxGap: number
|
| 36 |
+
maxPartTopMovement: number
|
| 37 |
+
previousPartTop: number
|
| 38 |
+
slowFrames: {
|
| 39 |
+
duration: number
|
| 40 |
+
index: number
|
| 41 |
+
phase: "stream" | "boundary" | "complete" | "unknown"
|
| 42 |
+
tokenSpans: number
|
| 43 |
+
blocks: number
|
| 44 |
+
codeBlocks: number
|
| 45 |
+
height: number
|
| 46 |
+
distance: number
|
| 47 |
+
}[]
|
| 48 |
+
scroll: {
|
| 49 |
+
calls: number
|
| 50 |
+
callNoops: number
|
| 51 |
+
sameFrameCalls: number
|
| 52 |
+
assignments: number
|
| 53 |
+
assignmentNoops: number
|
| 54 |
+
lastCallFrame: number
|
| 55 |
+
frame: number
|
| 56 |
+
}
|
| 57 |
+
row: HTMLElement
|
| 58 |
+
markdown: HTMLElement
|
| 59 |
+
running: boolean
|
| 60 |
+
previous: number
|
| 61 |
+
cleanup: () => void
|
| 62 |
+
start: () => void
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export async function installTimelineStreamProbe(
|
| 66 |
+
page: Page,
|
| 67 |
+
options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
|
| 68 |
+
) {
|
| 69 |
+
await page.evaluate(
|
| 70 |
+
({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
|
| 71 |
+
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
| 72 |
+
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
| 73 |
+
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
| 74 |
+
const root = part?.closest<HTMLElement>(".scroll-view__viewport")
|
| 75 |
+
if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
|
| 76 |
+
const viewport = root.getBoundingClientRect()
|
| 77 |
+
const state: TimelineProbeState = {
|
| 78 |
+
started: 0,
|
| 79 |
+
ended: Infinity,
|
| 80 |
+
profileVisual,
|
| 81 |
+
minimal,
|
| 82 |
+
frames: [],
|
| 83 |
+
frameAt: [],
|
| 84 |
+
applied: [],
|
| 85 |
+
geometry: [],
|
| 86 |
+
blanks: 0,
|
| 87 |
+
longTasks: [],
|
| 88 |
+
layoutShifts: [],
|
| 89 |
+
visibleMounts: 0,
|
| 90 |
+
visibleUnmounts: 0,
|
| 91 |
+
visibleRows: new Set(
|
| 92 |
+
[...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
|
| 93 |
+
const rect = element.getBoundingClientRect()
|
| 94 |
+
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
| 95 |
+
}),
|
| 96 |
+
),
|
| 97 |
+
visibleSubtreeMounts: [],
|
| 98 |
+
visibleSubtreeUnmounts: [],
|
| 99 |
+
visibleSubtreeReplacements: 0,
|
| 100 |
+
visibleSubtreeDropouts: [],
|
| 101 |
+
visibleSubtrees: new Map<string, Element>(),
|
| 102 |
+
subtreeKeys: new WeakMap<Element, string>(),
|
| 103 |
+
maxOverlap: 0,
|
| 104 |
+
maxGap: 0,
|
| 105 |
+
maxPartTopMovement: 0,
|
| 106 |
+
previousPartTop: part.getBoundingClientRect().top,
|
| 107 |
+
slowFrames: [],
|
| 108 |
+
scroll: {
|
| 109 |
+
calls: 0,
|
| 110 |
+
callNoops: 0,
|
| 111 |
+
sameFrameCalls: 0,
|
| 112 |
+
assignments: 0,
|
| 113 |
+
assignmentNoops: 0,
|
| 114 |
+
lastCallFrame: -1,
|
| 115 |
+
frame: 0,
|
| 116 |
+
},
|
| 117 |
+
row,
|
| 118 |
+
markdown,
|
| 119 |
+
running: false,
|
| 120 |
+
previous: 0,
|
| 121 |
+
cleanup: () => {},
|
| 122 |
+
start: () => {},
|
| 123 |
+
}
|
| 124 |
+
;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
|
| 125 |
+
const scrollTo = Element.prototype.scrollTo
|
| 126 |
+
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
| 127 |
+
if (profileVisual) {
|
| 128 |
+
Element.prototype.scrollTo = function (...args) {
|
| 129 |
+
state.scroll.calls += 1
|
| 130 |
+
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
|
| 131 |
+
if (typeof top === "number") {
|
| 132 |
+
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
| 133 |
+
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
| 134 |
+
}
|
| 135 |
+
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
| 136 |
+
state.scroll.lastCallFrame = state.scroll.frame
|
| 137 |
+
return scrollTo.apply(this, args)
|
| 138 |
+
}
|
| 139 |
+
Object.defineProperty(Element.prototype, "scrollTop", {
|
| 140 |
+
configurable: true,
|
| 141 |
+
get: scrollTop.get,
|
| 142 |
+
set(value) {
|
| 143 |
+
state.scroll.assignments += 1
|
| 144 |
+
if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
|
| 145 |
+
scrollTop.set!.call(this, value)
|
| 146 |
+
},
|
| 147 |
+
})
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const recordLongTasks = (entries: PerformanceEntry[]) => {
|
| 151 |
+
if (!state.running) return
|
| 152 |
+
state.longTasks.push(
|
| 153 |
+
...entries
|
| 154 |
+
.filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
|
| 155 |
+
.map((entry) => entry.duration),
|
| 156 |
+
)
|
| 157 |
+
}
|
| 158 |
+
const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
|
| 159 |
+
longTaskObserver.observe({ type: "longtask" })
|
| 160 |
+
const recordLayoutShifts = (entries: PerformanceEntry[]) => {
|
| 161 |
+
if (!state.running) return
|
| 162 |
+
state.layoutShifts.push(
|
| 163 |
+
...entries
|
| 164 |
+
.map((entry) => {
|
| 165 |
+
const shift = entry as LayoutShiftEntry
|
| 166 |
+
if (shift.startTime < state.started || shift.hadRecentInput) return
|
| 167 |
+
return shift.value
|
| 168 |
+
})
|
| 169 |
+
.filter((value): value is number => value !== undefined),
|
| 170 |
+
)
|
| 171 |
+
}
|
| 172 |
+
const layoutShiftObserver = profileVisual
|
| 173 |
+
? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
|
| 174 |
+
: undefined
|
| 175 |
+
layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
|
| 176 |
+
|
| 177 |
+
const visible = (element: Element) => {
|
| 178 |
+
const rect = element.getBoundingClientRect()
|
| 179 |
+
const viewport = root.getBoundingClientRect()
|
| 180 |
+
const style = getComputedStyle(element)
|
| 181 |
+
return (
|
| 182 |
+
element.isConnected &&
|
| 183 |
+
rect.width > 0 &&
|
| 184 |
+
rect.height > 0 &&
|
| 185 |
+
rect.bottom > viewport.top &&
|
| 186 |
+
rect.top < viewport.bottom &&
|
| 187 |
+
style.display !== "none" &&
|
| 188 |
+
style.visibility !== "hidden" &&
|
| 189 |
+
Number(style.opacity) > 0
|
| 190 |
+
)
|
| 191 |
+
}
|
| 192 |
+
const critical = [
|
| 193 |
+
"[data-timeline-part-id]",
|
| 194 |
+
'[data-component="edit-content"]',
|
| 195 |
+
'[data-component="apply-patch-file-diff"]',
|
| 196 |
+
'[data-component="file"]',
|
| 197 |
+
'[data-component="markdown-code"]',
|
| 198 |
+
"[data-markdown-block]",
|
| 199 |
+
].join(",")
|
| 200 |
+
const describe = (element: Element) => {
|
| 201 |
+
const cached = state.subtreeKeys.get(element)
|
| 202 |
+
if (!element.isConnected && cached) return cached
|
| 203 |
+
const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
|
| 204 |
+
const block = element
|
| 205 |
+
.closest<HTMLElement>("[data-markdown-key]")
|
| 206 |
+
?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
|
| 207 |
+
const component =
|
| 208 |
+
element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
|
| 209 |
+
const key = `${part}:${block ?? "root"}:${component}`
|
| 210 |
+
state.subtreeKeys.set(element, key)
|
| 211 |
+
return key
|
| 212 |
+
}
|
| 213 |
+
const recordMutations = (records: MutationRecord[]) => {
|
| 214 |
+
if (!state.running) return
|
| 215 |
+
records.forEach((record) => {
|
| 216 |
+
record.addedNodes.forEach((node) => {
|
| 217 |
+
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
|
| 218 |
+
state.visibleMounts += 1
|
| 219 |
+
state.visibleRows.add(node)
|
| 220 |
+
}
|
| 221 |
+
if (!(node instanceof Element)) return
|
| 222 |
+
const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
| 223 |
+
added.forEach((element) => {
|
| 224 |
+
if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
|
| 225 |
+
})
|
| 226 |
+
})
|
| 227 |
+
record.removedNodes.forEach((node) => {
|
| 228 |
+
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
|
| 229 |
+
state.visibleUnmounts += 1
|
| 230 |
+
if (!(node instanceof Element)) return
|
| 231 |
+
const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
| 232 |
+
removed.forEach((element) => {
|
| 233 |
+
const key = describe(element)
|
| 234 |
+
if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
|
| 235 |
+
})
|
| 236 |
+
})
|
| 237 |
+
})
|
| 238 |
+
}
|
| 239 |
+
const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
|
| 240 |
+
mutationObserver?.observe(root, { childList: true, subtree: true })
|
| 241 |
+
const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
| 242 |
+
const observeProgress = (at: number) => {
|
| 243 |
+
if (!state.running) return
|
| 244 |
+
const content = currentPart()?.textContent ?? ""
|
| 245 |
+
const index = content.includes("benchmark-complete")
|
| 246 |
+
? finalIndex
|
| 247 |
+
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
| 248 |
+
if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
|
| 249 |
+
}
|
| 250 |
+
const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
|
| 251 |
+
progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
|
| 252 |
+
state.cleanup = () => {
|
| 253 |
+
recordLongTasks(longTaskObserver.takeRecords())
|
| 254 |
+
recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
|
| 255 |
+
recordMutations(mutationObserver?.takeRecords() ?? [])
|
| 256 |
+
if (progressObserver.takeRecords().length) observeProgress(performance.now())
|
| 257 |
+
longTaskObserver.disconnect()
|
| 258 |
+
layoutShiftObserver?.disconnect()
|
| 259 |
+
mutationObserver?.disconnect()
|
| 260 |
+
progressObserver.disconnect()
|
| 261 |
+
if (!profileVisual) return
|
| 262 |
+
Element.prototype.scrollTo = scrollTo
|
| 263 |
+
Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
const sample = (now: number) => {
|
| 267 |
+
if (!state.running) return
|
| 268 |
+
state.frameAt.push(now)
|
| 269 |
+
observeProgress(now)
|
| 270 |
+
if (minimal) {
|
| 271 |
+
state.frames.push(now - state.previous)
|
| 272 |
+
state.previous = now
|
| 273 |
+
requestAnimationFrame(sample)
|
| 274 |
+
return
|
| 275 |
+
}
|
| 276 |
+
setTimeout(() => {
|
| 277 |
+
if (!state.running) return
|
| 278 |
+
state.scroll.frame += 1
|
| 279 |
+
const duration = now - state.previous
|
| 280 |
+
state.frames.push(duration)
|
| 281 |
+
state.previous = now
|
| 282 |
+
const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
| 283 |
+
const header = root.querySelector<HTMLElement>("[data-session-title]")
|
| 284 |
+
state.geometry.push({
|
| 285 |
+
scrollTop: root.scrollTop,
|
| 286 |
+
scrollHeight: root.scrollHeight,
|
| 287 |
+
clientHeight: root.clientHeight,
|
| 288 |
+
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
| 289 |
+
virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
|
| 290 |
+
headerHeight: header?.getBoundingClientRect().height ?? 0,
|
| 291 |
+
})
|
| 292 |
+
const viewport = root.getBoundingClientRect()
|
| 293 |
+
if (profileVisual) {
|
| 294 |
+
const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
| 295 |
+
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
| 296 |
+
.filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
|
| 297 |
+
.sort((a, b) => a.rect.top - b.rect.top)
|
| 298 |
+
state.visibleRows = new Set(visibleRows.map((item) => item.element))
|
| 299 |
+
const rows = visibleRows.map((item) => item.rect)
|
| 300 |
+
rows.slice(1).forEach((rect, index) => {
|
| 301 |
+
const previous = rows[index]!
|
| 302 |
+
state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
|
| 303 |
+
state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
|
| 304 |
+
})
|
| 305 |
+
const partTop = part.getBoundingClientRect().top
|
| 306 |
+
state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
|
| 307 |
+
state.previousPartTop = partTop
|
| 308 |
+
}
|
| 309 |
+
const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
|
| 310 |
+
const rect = element.getBoundingClientRect()
|
| 311 |
+
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
| 312 |
+
})
|
| 313 |
+
if (!visibleRow) state.blanks += 1
|
| 314 |
+
if (profileVisual) {
|
| 315 |
+
const subtrees = new Map<string, { element: Element; rendered: boolean }>()
|
| 316 |
+
const visibleSubtrees = new Map<string, Element>()
|
| 317 |
+
root.querySelectorAll(critical).forEach((element) => {
|
| 318 |
+
const key = describe(element)
|
| 319 |
+
const rect = element.getBoundingClientRect()
|
| 320 |
+
const style = getComputedStyle(element)
|
| 321 |
+
const rendered =
|
| 322 |
+
element.isConnected &&
|
| 323 |
+
rect.width > 0 &&
|
| 324 |
+
rect.height > 0 &&
|
| 325 |
+
style.display !== "none" &&
|
| 326 |
+
style.visibility !== "hidden" &&
|
| 327 |
+
Number(style.opacity) > 0
|
| 328 |
+
subtrees.set(key, { element, rendered })
|
| 329 |
+
if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
|
| 330 |
+
const previous = state.visibleSubtrees.get(key)
|
| 331 |
+
if (previous && previous !== element && key.startsWith(`${textPartID}:`))
|
| 332 |
+
state.visibleSubtreeReplacements += 1
|
| 333 |
+
visibleSubtrees.set(key, element)
|
| 334 |
+
}
|
| 335 |
+
})
|
| 336 |
+
state.visibleSubtrees.forEach((element, key) => {
|
| 337 |
+
const current = subtrees.get(key)
|
| 338 |
+
if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
|
| 339 |
+
const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
|
| 340 |
+
state.visibleSubtreeDropouts.push(
|
| 341 |
+
`${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
|
| 342 |
+
)
|
| 343 |
+
}
|
| 344 |
+
if (element.matches('[data-component="file"]')) {
|
| 345 |
+
const hadLines = element.hasAttribute("data-profiler-had-lines")
|
| 346 |
+
const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
|
| 347 |
+
if (hasLines) element.setAttribute("data-profiler-had-lines", "")
|
| 348 |
+
if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
|
| 349 |
+
}
|
| 350 |
+
})
|
| 351 |
+
state.visibleSubtrees = visibleSubtrees
|
| 352 |
+
}
|
| 353 |
+
if (profileVisual && duration > 33.34) {
|
| 354 |
+
const livePart = currentPart()
|
| 355 |
+
const content = livePart?.textContent ?? ""
|
| 356 |
+
const complete = content.includes("benchmark-complete")
|
| 357 |
+
const index = complete
|
| 358 |
+
? finalIndex
|
| 359 |
+
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
| 360 |
+
state.slowFrames.push({
|
| 361 |
+
duration,
|
| 362 |
+
index,
|
| 363 |
+
phase: complete
|
| 364 |
+
? "complete"
|
| 365 |
+
: index >= 0 && index % fragmentCount === 0
|
| 366 |
+
? "boundary"
|
| 367 |
+
: index >= 0
|
| 368 |
+
? "stream"
|
| 369 |
+
: "unknown",
|
| 370 |
+
tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
|
| 371 |
+
blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
|
| 372 |
+
codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
|
| 373 |
+
height: livePart?.getBoundingClientRect().height ?? 0,
|
| 374 |
+
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
| 375 |
+
})
|
| 376 |
+
}
|
| 377 |
+
requestAnimationFrame(sample)
|
| 378 |
+
}, 0)
|
| 379 |
+
}
|
| 380 |
+
state.start = () => {
|
| 381 |
+
state.started = performance.now()
|
| 382 |
+
state.previous = state.started
|
| 383 |
+
state.running = true
|
| 384 |
+
requestAnimationFrame(sample)
|
| 385 |
+
}
|
| 386 |
+
},
|
| 387 |
+
{ ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
|
| 388 |
+
)
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
export function startTimelineStreamProbe(page: Page) {
|
| 392 |
+
return page.evaluate(() => {
|
| 393 |
+
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
| 394 |
+
if (!state) throw new Error("missing streaming benchmark state")
|
| 395 |
+
state.start()
|
| 396 |
+
})
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
|
| 400 |
+
|
| 401 |
+
export function layoutShiftValue(
|
| 402 |
+
entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
|
| 403 |
+
start: number,
|
| 404 |
+
) {
|
| 405 |
+
if (entry.startTime < start || entry.hadRecentInput) return
|
| 406 |
+
return entry.value
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
export function removeVisibleRow<T>(visible: Set<T>, row: T) {
|
| 410 |
+
return visible.delete(row)
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
export function streamProgress(content: string) {
|
| 414 |
+
const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
| 415 |
+
return {
|
| 416 |
+
index,
|
| 417 |
+
phase: content.includes("benchmark-complete")
|
| 418 |
+
? ("complete" as const)
|
| 419 |
+
: index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
|
| 420 |
+
? ("boundary" as const)
|
| 421 |
+
: index >= 0
|
| 422 |
+
? ("stream" as const)
|
| 423 |
+
: ("unknown" as const),
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
export async function collectTimelineStreamMetrics(
|
| 428 |
+
page: Page,
|
| 429 |
+
options: { textPartID: string; finalIndex: number; navigations: string[] },
|
| 430 |
+
) {
|
| 431 |
+
return page.evaluate(({ textPartID, finalIndex, navigations }) => {
|
| 432 |
+
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
| 433 |
+
if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
|
| 434 |
+
state.ended = performance.now()
|
| 435 |
+
state.cleanup()
|
| 436 |
+
state.running = false
|
| 437 |
+
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
| 438 |
+
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
| 439 |
+
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
| 440 |
+
const sorted = state.frames.slice().sort((a, b) => a - b)
|
| 441 |
+
const duration = state.frames.reduce((sum, value) => sum + value, 0)
|
| 442 |
+
const longestSlowStreak = state.frames.reduce(
|
| 443 |
+
(result, value) => {
|
| 444 |
+
const current = value > 33.34 ? result.current + 1 : 0
|
| 445 |
+
return { current, longest: Math.max(result.longest, current) }
|
| 446 |
+
},
|
| 447 |
+
{ current: 0, longest: 0 },
|
| 448 |
+
).longest
|
| 449 |
+
const busyStart = state.applied.at(0)?.at
|
| 450 |
+
const completion = state.applied.find((value) => value.index === finalIndex)
|
| 451 |
+
const busyEnd = completion?.at
|
| 452 |
+
const busyFrames =
|
| 453 |
+
busyStart === undefined || busyEnd === undefined
|
| 454 |
+
? []
|
| 455 |
+
: state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
|
| 456 |
+
const busySorted = busyFrames.slice().sort((a, b) => a - b)
|
| 457 |
+
const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
|
| 458 |
+
const completionObservedMs = (completion?.at ?? NaN) - state.started
|
| 459 |
+
const visual = state.profileVisual
|
| 460 |
+
? {
|
| 461 |
+
layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
|
| 462 |
+
maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
|
| 463 |
+
visibleMounts: state.visibleMounts,
|
| 464 |
+
visibleUnmounts: state.visibleUnmounts,
|
| 465 |
+
visibleSubtreeMounts: state.visibleSubtreeMounts,
|
| 466 |
+
visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
|
| 467 |
+
visibleSubtreeReplacements: state.visibleSubtreeReplacements,
|
| 468 |
+
visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
|
| 469 |
+
maxOverlapPx: state.maxOverlap,
|
| 470 |
+
maxGapPx: state.maxGap,
|
| 471 |
+
maxPartTopMovementPx: state.maxPartTopMovement,
|
| 472 |
+
slowestRafGaps: state.slowFrames
|
| 473 |
+
.sort((a, b) => b.duration - a.duration)
|
| 474 |
+
.slice(0, 20)
|
| 475 |
+
.map((frame) => ({
|
| 476 |
+
durationMs: frame.duration,
|
| 477 |
+
index: frame.index,
|
| 478 |
+
phase: frame.phase,
|
| 479 |
+
tokenSpans: frame.tokenSpans,
|
| 480 |
+
blocks: frame.blocks,
|
| 481 |
+
codeBlocks: frame.codeBlocks,
|
| 482 |
+
heightPx: frame.height,
|
| 483 |
+
distancePx: frame.distance,
|
| 484 |
+
})),
|
| 485 |
+
slowRafGapPhases: Object.fromEntries(
|
| 486 |
+
["stream", "boundary", "complete", "unknown"].map((phase) => {
|
| 487 |
+
const frames = state.slowFrames.filter((frame) => frame.phase === phase)
|
| 488 |
+
return [
|
| 489 |
+
phase,
|
| 490 |
+
{
|
| 491 |
+
count: frames.length,
|
| 492 |
+
totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
|
| 493 |
+
maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
|
| 494 |
+
},
|
| 495 |
+
]
|
| 496 |
+
}),
|
| 497 |
+
),
|
| 498 |
+
scroll: state.scroll,
|
| 499 |
+
}
|
| 500 |
+
: null
|
| 501 |
+
const geometry = state.minimal
|
| 502 |
+
? null
|
| 503 |
+
: {
|
| 504 |
+
maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
|
| 505 |
+
finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
|
| 506 |
+
final: state.geometry.at(-1),
|
| 507 |
+
distanceTransitionsPx: state.geometry
|
| 508 |
+
.map((sample) => Math.round(sample.distance))
|
| 509 |
+
.filter((value, index, values) => index === 0 || value !== values[index - 1]),
|
| 510 |
+
bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
|
| 511 |
+
const previous = state.geometry[index]?.distance ?? 0
|
| 512 |
+
return previous <= 1 && value.distance > 1
|
| 513 |
+
}).length,
|
| 514 |
+
blankSamples: state.blanks,
|
| 515 |
+
}
|
| 516 |
+
return {
|
| 517 |
+
capabilities: { visual: state.profileVisual, geometry: !state.minimal },
|
| 518 |
+
completionObservedMs,
|
| 519 |
+
deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
|
| 520 |
+
rafGapSamples: state.frames.length,
|
| 521 |
+
rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
|
| 522 |
+
observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
|
| 523 |
+
observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
|
| 524 |
+
observedProgressWindowRafGaps: busyFrames.length,
|
| 525 |
+
maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
|
| 526 |
+
observedProgressTransitions: state.applied.length,
|
| 527 |
+
rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
|
| 528 |
+
rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
|
| 529 |
+
rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
|
| 530 |
+
maxRafGapMs: sorted.at(-1) ?? 0,
|
| 531 |
+
rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
|
| 532 |
+
rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
|
| 533 |
+
missedFrameBudgetEquivalents: state.frames.reduce(
|
| 534 |
+
(sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
|
| 535 |
+
0,
|
| 536 |
+
),
|
| 537 |
+
longestRafGapOver33MsStreak: longestSlowStreak,
|
| 538 |
+
longTaskCount: state.longTasks.length,
|
| 539 |
+
longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
|
| 540 |
+
visual,
|
| 541 |
+
geometry,
|
| 542 |
+
rowReplaced: row !== state.row,
|
| 543 |
+
markdownReplaced: markdown !== state.markdown,
|
| 544 |
+
domTextCharacters: part?.textContent?.length ?? 0,
|
| 545 |
+
}
|
| 546 |
+
}, options)
|
| 547 |
+
}
|
packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const words = [
|
| 2 |
+
"alpha",
|
| 3 |
+
"bravo",
|
| 4 |
+
"charlie",
|
| 5 |
+
"delta",
|
| 6 |
+
"echo",
|
| 7 |
+
"foxtrot",
|
| 8 |
+
"golf",
|
| 9 |
+
"hotel",
|
| 10 |
+
"india",
|
| 11 |
+
"juliet",
|
| 12 |
+
"kilo",
|
| 13 |
+
"lima",
|
| 14 |
+
"metro",
|
| 15 |
+
"nova",
|
| 16 |
+
"orbit",
|
| 17 |
+
"pixel",
|
| 18 |
+
"quartz",
|
| 19 |
+
"river",
|
| 20 |
+
"signal",
|
| 21 |
+
"vector",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
const sourceID = "ses_smoke_source"
|
| 25 |
+
const targetID = "ses_smoke_target"
|
| 26 |
+
const childID = "ses_smoke_child"
|
| 27 |
+
const directory = "C:/OpenCode/SmokeProject"
|
| 28 |
+
const projectID = "proj_smoke_timeline"
|
| 29 |
+
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
| 30 |
+
|
| 31 |
+
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
| 32 |
+
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
| 33 |
+
type Message = { info: MessageInfo; parts: MessagePart[] }
|
| 34 |
+
|
| 35 |
+
function lorem(seed: number, length: number) {
|
| 36 |
+
let out = ""
|
| 37 |
+
let i = seed
|
| 38 |
+
while (out.length < length) {
|
| 39 |
+
const word = words[i % words.length]
|
| 40 |
+
out += (out ? " " : "") + word
|
| 41 |
+
if (i % 17 === 0) out += ".\n\n"
|
| 42 |
+
i += 7
|
| 43 |
+
}
|
| 44 |
+
return out.slice(0, length)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function id(prefix: string, value: number) {
|
| 48 |
+
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
| 52 |
+
const messageID = id("msg_user", index)
|
| 53 |
+
return {
|
| 54 |
+
info: {
|
| 55 |
+
id: messageID,
|
| 56 |
+
sessionID,
|
| 57 |
+
role: "user",
|
| 58 |
+
time: { created: 1700000000000 + index * 10_000 },
|
| 59 |
+
summary: { diffs },
|
| 60 |
+
agent: "build",
|
| 61 |
+
model,
|
| 62 |
+
},
|
| 63 |
+
parts: [
|
| 64 |
+
{
|
| 65 |
+
id: id("prt_user_text", index),
|
| 66 |
+
sessionID,
|
| 67 |
+
messageID,
|
| 68 |
+
type: "text",
|
| 69 |
+
text: lorem(index, textLength),
|
| 70 |
+
},
|
| 71 |
+
],
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
| 76 |
+
const messageID = id("msg_assistant", index)
|
| 77 |
+
return {
|
| 78 |
+
info: {
|
| 79 |
+
id: messageID,
|
| 80 |
+
sessionID,
|
| 81 |
+
role: "assistant",
|
| 82 |
+
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
| 83 |
+
parentID,
|
| 84 |
+
modelID: model.modelID,
|
| 85 |
+
providerID: model.providerID,
|
| 86 |
+
mode: "build",
|
| 87 |
+
agent: "build",
|
| 88 |
+
path: { cwd: directory, root: directory },
|
| 89 |
+
cost: 0.01,
|
| 90 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 91 |
+
variant: "max",
|
| 92 |
+
finish: "stop",
|
| 93 |
+
},
|
| 94 |
+
parts: parts.map((part) => ({
|
| 95 |
+
...part,
|
| 96 |
+
sessionID,
|
| 97 |
+
messageID,
|
| 98 |
+
})),
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
| 103 |
+
const prose = lorem(index * 13 + partIndex, length)
|
| 104 |
+
const text =
|
| 105 |
+
index % 12 === 0
|
| 106 |
+
? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
|
| 107 |
+
: index % 5 === 0
|
| 108 |
+
? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
|
| 109 |
+
: index % 7 === 0
|
| 110 |
+
? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
|
| 111 |
+
: prose
|
| 112 |
+
return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
| 116 |
+
return {
|
| 117 |
+
id: id(`prt_reasoning_${partIndex}`, index),
|
| 118 |
+
type: "reasoning",
|
| 119 |
+
text: lorem(index * 19 + partIndex, length),
|
| 120 |
+
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function toolPart(
|
| 125 |
+
index: number,
|
| 126 |
+
partIndex: number,
|
| 127 |
+
tool: string,
|
| 128 |
+
input: Record<string, unknown>,
|
| 129 |
+
outputLength = 160,
|
| 130 |
+
metadataOverride?: Record<string, unknown>,
|
| 131 |
+
): MessagePart {
|
| 132 |
+
const metadata =
|
| 133 |
+
metadataOverride ??
|
| 134 |
+
(tool === "apply_patch"
|
| 135 |
+
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
| 136 |
+
: tool === "edit" || tool === "write"
|
| 137 |
+
? {
|
| 138 |
+
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
| 139 |
+
diff: patch(index, outputLength),
|
| 140 |
+
preview: patch(index + 1, 420),
|
| 141 |
+
}
|
| 142 |
+
: tool === "question"
|
| 143 |
+
? { answers: [["Proceed"], ["Keep sample output"]] }
|
| 144 |
+
: {})
|
| 145 |
+
return {
|
| 146 |
+
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
| 147 |
+
type: "tool",
|
| 148 |
+
callID: id("call", index * 10 + partIndex),
|
| 149 |
+
tool,
|
| 150 |
+
state: {
|
| 151 |
+
status: "completed",
|
| 152 |
+
input,
|
| 153 |
+
output: lorem(index * 23 + partIndex, outputLength),
|
| 154 |
+
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
| 155 |
+
metadata,
|
| 156 |
+
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
| 157 |
+
},
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
| 162 |
+
return {
|
| 163 |
+
filePath: `src/generated/patch-${seed}.ts`,
|
| 164 |
+
relativePath: `src/generated/patch-${seed}.ts`,
|
| 165 |
+
type,
|
| 166 |
+
additions: (seed % 7) + 1,
|
| 167 |
+
deletions: type === "add" ? 0 : seed % 4,
|
| 168 |
+
patch: patch(seed, 520),
|
| 169 |
+
before: type === "add" ? undefined : code(seed, 18),
|
| 170 |
+
after: type === "delete" ? undefined : code(seed + 1, 24),
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function fileDiff(file: string, seed: number) {
|
| 175 |
+
const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
|
| 176 |
+
const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
|
| 177 |
+
const after =
|
| 178 |
+
lines === 2
|
| 179 |
+
? before.replace("value1", "updatedValue1")
|
| 180 |
+
: lines === 300
|
| 181 |
+
? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
|
| 182 |
+
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
|
| 183 |
+
return {
|
| 184 |
+
file,
|
| 185 |
+
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
| 186 |
+
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
| 187 |
+
before,
|
| 188 |
+
after,
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function patch(seed: number, length: number) {
|
| 193 |
+
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function code(seed: number, lines: number, width = 32) {
|
| 197 |
+
return Array.from(
|
| 198 |
+
{ length: lines },
|
| 199 |
+
(_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
|
| 200 |
+
).join("\n")
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function turn(index: number): Message[] {
|
| 204 |
+
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
| 205 |
+
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
| 206 |
+
const parts = [
|
| 207 |
+
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
| 208 |
+
...(index % 3 === 0
|
| 209 |
+
? [
|
| 210 |
+
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
| 211 |
+
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
| 212 |
+
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
| 213 |
+
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
| 214 |
+
]
|
| 215 |
+
: []),
|
| 216 |
+
textPart(index, 2, 160 + (index % 6) * 90),
|
| 217 |
+
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
| 218 |
+
...(index % 6 === 0
|
| 219 |
+
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
| 220 |
+
: []),
|
| 221 |
+
...(index % 8 === 0
|
| 222 |
+
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
| 223 |
+
: []),
|
| 224 |
+
...(index % 7 === 0
|
| 225 |
+
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
| 226 |
+
: []),
|
| 227 |
+
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
| 228 |
+
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
| 229 |
+
...(index % 13 === 0
|
| 230 |
+
? [
|
| 231 |
+
toolPart(
|
| 232 |
+
index,
|
| 233 |
+
11,
|
| 234 |
+
"question",
|
| 235 |
+
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
| 236 |
+
120,
|
| 237 |
+
),
|
| 238 |
+
]
|
| 239 |
+
: []),
|
| 240 |
+
...(index % 17 === 0
|
| 241 |
+
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
| 242 |
+
: []),
|
| 243 |
+
]
|
| 244 |
+
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
| 248 |
+
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
| 249 |
+
userMessage(sourceID, index + 1000, 120),
|
| 250 |
+
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
|
| 251 |
+
textPart(index + 1000, 0, 240),
|
| 252 |
+
...(index === 11
|
| 253 |
+
? [
|
| 254 |
+
toolPart(
|
| 255 |
+
index + 1000,
|
| 256 |
+
1,
|
| 257 |
+
"task",
|
| 258 |
+
{ description: "Inspect child navigation", subagent_type: "explore" },
|
| 259 |
+
160,
|
| 260 |
+
{ sessionId: childID },
|
| 261 |
+
),
|
| 262 |
+
]
|
| 263 |
+
: []),
|
| 264 |
+
]),
|
| 265 |
+
]).flat()
|
| 266 |
+
const childMessages = Array.from({ length: 4 }, (_, index) => [
|
| 267 |
+
userMessage(childID, index + 2000, 120),
|
| 268 |
+
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
| 269 |
+
]).flat()
|
| 270 |
+
|
| 271 |
+
function renderable(part: MessagePart) {
|
| 272 |
+
if (part.type === "tool" && part.tool === "todowrite") return false
|
| 273 |
+
if (part.type === "text") return !!part.text.trim()
|
| 274 |
+
if (part.type === "reasoning") return !!part.text.trim()
|
| 275 |
+
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function orderedParts(message: Message) {
|
| 279 |
+
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
export const fixture = {
|
| 283 |
+
directory,
|
| 284 |
+
project: {
|
| 285 |
+
id: projectID,
|
| 286 |
+
worktree: directory,
|
| 287 |
+
vcs: "git",
|
| 288 |
+
name: "smoke-project",
|
| 289 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 290 |
+
sandboxes: [],
|
| 291 |
+
},
|
| 292 |
+
provider: {
|
| 293 |
+
all: [
|
| 294 |
+
{
|
| 295 |
+
id: "opencode",
|
| 296 |
+
name: "OpenCode",
|
| 297 |
+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
| 298 |
+
},
|
| 299 |
+
],
|
| 300 |
+
connected: ["opencode"],
|
| 301 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 302 |
+
},
|
| 303 |
+
sessions: [
|
| 304 |
+
{
|
| 305 |
+
id: sourceID,
|
| 306 |
+
slug: "source",
|
| 307 |
+
projectID,
|
| 308 |
+
directory,
|
| 309 |
+
title: "Uncommitted changes inquiry",
|
| 310 |
+
version: "dev",
|
| 311 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
id: targetID,
|
| 315 |
+
slug: "target",
|
| 316 |
+
projectID,
|
| 317 |
+
directory,
|
| 318 |
+
title: "Example Game: sample jump movement & sample physics analysis",
|
| 319 |
+
version: "dev",
|
| 320 |
+
time: { created: 1700000001000, updated: 1700000001000 },
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
id: childID,
|
| 324 |
+
parentID: sourceID,
|
| 325 |
+
slug: "child",
|
| 326 |
+
projectID,
|
| 327 |
+
directory,
|
| 328 |
+
title: "Inspect child navigation",
|
| 329 |
+
version: "dev",
|
| 330 |
+
time: { created: 1700000002000, updated: 1700000002000 },
|
| 331 |
+
},
|
| 332 |
+
],
|
| 333 |
+
sourceID,
|
| 334 |
+
targetID,
|
| 335 |
+
childID,
|
| 336 |
+
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
| 337 |
+
expected: {
|
| 338 |
+
sourceTitle: "Uncommitted changes inquiry",
|
| 339 |
+
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
| 340 |
+
childTitle: "Inspect child navigation",
|
| 341 |
+
sourceMessageIDs: sourceMessages
|
| 342 |
+
.filter((message) => message.info.role === "user")
|
| 343 |
+
.map((message) => message.info.id),
|
| 344 |
+
targetMessageIDs: targetMessages
|
| 345 |
+
.filter((message) => message.info.role === "user")
|
| 346 |
+
.map((message) => message.info.id),
|
| 347 |
+
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
| 348 |
+
targetPartIDs: targetMessages.flatMap((message) =>
|
| 349 |
+
orderedParts(message)
|
| 350 |
+
.filter(renderable)
|
| 351 |
+
.map((part) => part.id),
|
| 352 |
+
),
|
| 353 |
+
},
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
| 357 |
+
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
| 358 |
+
const end = before
|
| 359 |
+
? Math.max(
|
| 360 |
+
0,
|
| 361 |
+
messages.findIndex((message) => message.info.id === before),
|
| 362 |
+
)
|
| 363 |
+
: messages.length
|
| 364 |
+
const start = Math.max(0, end - limit)
|
| 365 |
+
return {
|
| 366 |
+
items: messages.slice(start, end),
|
| 367 |
+
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
| 368 |
+
}
|
| 369 |
+
}
|
packages/app/e2e/performance/timeline/timeline-test-helpers.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { mockOpenCodeServer } from "../../utils/mock-server"
|
| 4 |
+
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
| 5 |
+
|
| 6 |
+
export async function installTimelineSettings(page: Page) {
|
| 7 |
+
await page.addInitScript(() => {
|
| 8 |
+
localStorage.setItem(
|
| 9 |
+
"settings.v3",
|
| 10 |
+
JSON.stringify({
|
| 11 |
+
general: {
|
| 12 |
+
newLayoutDesigns: true,
|
| 13 |
+
editToolPartsExpanded: true,
|
| 14 |
+
shellToolPartsExpanded: true,
|
| 15 |
+
showReasoningSummaries: true,
|
| 16 |
+
},
|
| 17 |
+
}),
|
| 18 |
+
)
|
| 19 |
+
})
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function mockStressTimeline(
|
| 23 |
+
page: Page,
|
| 24 |
+
input?: {
|
| 25 |
+
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
| 26 |
+
vcsDiff?: unknown[]
|
| 27 |
+
},
|
| 28 |
+
) {
|
| 29 |
+
return mockOpenCodeServer(page, {
|
| 30 |
+
sessions: fixture.sessions,
|
| 31 |
+
provider: fixture.provider,
|
| 32 |
+
directory: fixture.directory,
|
| 33 |
+
project: fixture.project,
|
| 34 |
+
pageMessages,
|
| 35 |
+
onMessages: input?.onMessages,
|
| 36 |
+
vcsDiff: input?.vcsDiff,
|
| 37 |
+
})
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
|
| 41 |
+
const server = stressServer()
|
| 42 |
+
await page.addInitScript(
|
| 43 |
+
({ directory, sessionIDs, dirBase64, server, draftID }) => {
|
| 44 |
+
localStorage.setItem(
|
| 45 |
+
"opencode.global.dat:server",
|
| 46 |
+
JSON.stringify({
|
| 47 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 48 |
+
lastProject: { local: directory },
|
| 49 |
+
}),
|
| 50 |
+
)
|
| 51 |
+
localStorage.setItem(
|
| 52 |
+
"opencode.window.browser.dat:tabs",
|
| 53 |
+
JSON.stringify([
|
| 54 |
+
...sessionIDs.map((sessionId) => ({
|
| 55 |
+
type: "session",
|
| 56 |
+
server,
|
| 57 |
+
dirBase64,
|
| 58 |
+
sessionId,
|
| 59 |
+
})),
|
| 60 |
+
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
|
| 61 |
+
]),
|
| 62 |
+
)
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
directory: fixture.directory,
|
| 66 |
+
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
|
| 67 |
+
dirBase64: base64Encode(fixture.directory),
|
| 68 |
+
server,
|
| 69 |
+
draftID: input?.draftID,
|
| 70 |
+
},
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export function stressSessionHref(sessionID: string) {
|
| 75 |
+
return `/server/${base64Encode(stressServer())}/session/${sessionID}`
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export function stressDraftHref(draftID: string) {
|
| 79 |
+
return `/new-session?draftId=${encodeURIComponent(draftID)}`
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function stressServer() {
|
| 83 |
+
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export function createReviewDiffs() {
|
| 87 |
+
return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => {
|
| 88 |
+
const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38
|
| 89 |
+
const file = `src/review/generated-${String(index).padStart(3, "0")}.ts`
|
| 90 |
+
const before = reviewSource(index, lines)
|
| 91 |
+
const after = before
|
| 92 |
+
.replace(`value_${index}_4`, `updated_${index}_4`)
|
| 93 |
+
.replace(
|
| 94 |
+
`value_${index}_${Math.max(8, Math.floor(lines / 2))}`,
|
| 95 |
+
`updated_${index}_${Math.max(8, Math.floor(lines / 2))}`,
|
| 96 |
+
)
|
| 97 |
+
.replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`)
|
| 98 |
+
return {
|
| 99 |
+
file,
|
| 100 |
+
patch: reviewPatch(file, before, after),
|
| 101 |
+
additions: 3,
|
| 102 |
+
deletions: 3,
|
| 103 |
+
status: "modified" as const,
|
| 104 |
+
}
|
| 105 |
+
})
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function reviewSource(seed: number, lines: number) {
|
| 109 |
+
return Array.from(
|
| 110 |
+
{ length: lines },
|
| 111 |
+
(_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`,
|
| 112 |
+
).join("\n")
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function reviewPatch(file: string, before: string, after: string) {
|
| 116 |
+
const beforeLines = before.split("\n")
|
| 117 |
+
const afterLines = after.split("\n")
|
| 118 |
+
return [
|
| 119 |
+
`diff --git a/${file} b/${file}`,
|
| 120 |
+
`--- a/${file}`,
|
| 121 |
+
`+++ b/${file}`,
|
| 122 |
+
`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`,
|
| 123 |
+
...beforeLines.flatMap((line, index) => {
|
| 124 |
+
const next = afterLines[index]!
|
| 125 |
+
if (line === next) return [` ${line}`]
|
| 126 |
+
return [`-${line}`, `+${next}`]
|
| 127 |
+
}),
|
| 128 |
+
].join("\n")
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function reviewWords(seed: number, length: number) {
|
| 132 |
+
const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"]
|
| 133 |
+
return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ")
|
| 134 |
+
}
|
packages/app/e2e/performance/unit/chrome-trace-write.test.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { mkdtemp, rm } from "node:fs/promises"
|
| 3 |
+
import path from "node:path"
|
| 4 |
+
import os from "node:os"
|
| 5 |
+
import { prepareChromeTrace } from "../chrome-trace"
|
| 6 |
+
|
| 7 |
+
test("creates the configured trace directory", async () => {
|
| 8 |
+
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
|
| 9 |
+
try {
|
| 10 |
+
const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
|
| 11 |
+
expect(file).toEndWith("-session-tab-458ed9e3-test.json")
|
| 12 |
+
} finally {
|
| 13 |
+
await rm(root, { recursive: true, force: true })
|
| 14 |
+
}
|
| 15 |
+
})
|
packages/app/e2e/performance/unit/first-navigation-metrics.test.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
|
| 3 |
+
|
| 4 |
+
test("reports blank frames before first destination and stable paint", () => {
|
| 5 |
+
expect(
|
| 6 |
+
summarizeFirstNavigation([
|
| 7 |
+
{ observedAtMs: 16, source: true, destination: false, content: true },
|
| 8 |
+
{ observedAtMs: 32, source: false, destination: false, content: false },
|
| 9 |
+
{ observedAtMs: 48, source: false, destination: true, content: true },
|
| 10 |
+
{ observedAtMs: 64, source: false, destination: true, content: true },
|
| 11 |
+
{ observedAtMs: 80, source: false, destination: true, content: true },
|
| 12 |
+
]),
|
| 13 |
+
).toEqual({
|
| 14 |
+
samples: 5,
|
| 15 |
+
firstDestinationObservedMs: 48,
|
| 16 |
+
stableDestinationObservedMs: 80,
|
| 17 |
+
sourceSamples: 1,
|
| 18 |
+
blankSamples: 1,
|
| 19 |
+
unknownSamples: 0,
|
| 20 |
+
destinationSamples: 3,
|
| 21 |
+
})
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
test("does not report stability for interrupted destination frames", () => {
|
| 25 |
+
expect(
|
| 26 |
+
summarizeFirstNavigation([
|
| 27 |
+
{ observedAtMs: 16, source: false, destination: true, content: true },
|
| 28 |
+
{ observedAtMs: 32, source: false, destination: false, content: true },
|
| 29 |
+
{ observedAtMs: 48, source: false, destination: true, content: true },
|
| 30 |
+
]).stableDestinationObservedMs,
|
| 31 |
+
).toBeNull()
|
| 32 |
+
})
|
packages/app/e2e/performance/unit/mock-server.test.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import type { Page, Route } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../../utils/mock-server"
|
| 4 |
+
|
| 5 |
+
test("applies message latency after a list response gate is released", async () => {
|
| 6 |
+
const events: string[] = []
|
| 7 |
+
const gate = Promise.withResolvers<void>()
|
| 8 |
+
let handler: ((route: Route) => Promise<void>) | undefined
|
| 9 |
+
const page = {
|
| 10 |
+
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
| 11 |
+
handler = callback
|
| 12 |
+
return Promise.resolve()
|
| 13 |
+
},
|
| 14 |
+
} as unknown as Page
|
| 15 |
+
await mockOpenCodeServer(page, {
|
| 16 |
+
provider: {},
|
| 17 |
+
directory: "C:/OpenCode",
|
| 18 |
+
project: {},
|
| 19 |
+
sessions: [{ id: "session" }],
|
| 20 |
+
messageDelay: 25,
|
| 21 |
+
beforeMessagesResponse: () => {
|
| 22 |
+
events.push("before")
|
| 23 |
+
return gate.promise
|
| 24 |
+
},
|
| 25 |
+
onMessages: (request) => events.push(request.phase),
|
| 26 |
+
pageMessages: () => {
|
| 27 |
+
events.push("page")
|
| 28 |
+
return { items: [] }
|
| 29 |
+
},
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
const response = handler!({
|
| 33 |
+
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
| 34 |
+
fulfill: () => {
|
| 35 |
+
events.push("fulfill")
|
| 36 |
+
return Promise.resolve()
|
| 37 |
+
},
|
| 38 |
+
} as unknown as Route)
|
| 39 |
+
expect(events).toEqual(["start", "before"])
|
| 40 |
+
|
| 41 |
+
const released = performance.now()
|
| 42 |
+
gate.resolve()
|
| 43 |
+
await response
|
| 44 |
+
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
|
| 45 |
+
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
|
| 46 |
+
})
|
packages/app/e2e/performance/unit/navigation-milestones.test.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
|
| 3 |
+
|
| 4 |
+
test("reports first and stable paint for each navigation milestone", () => {
|
| 5 |
+
expect(
|
| 6 |
+
summarizeNavigationMilestones([
|
| 7 |
+
{ observedAtMs: 16, milestones: { content: false, tab: false } },
|
| 8 |
+
{ observedAtMs: 32, milestones: { content: true, tab: false } },
|
| 9 |
+
{ observedAtMs: 48, milestones: { content: true, tab: true } },
|
| 10 |
+
{ observedAtMs: 64, milestones: { content: true, tab: true } },
|
| 11 |
+
{ observedAtMs: 80, milestones: { content: true, tab: true } },
|
| 12 |
+
]),
|
| 13 |
+
).toEqual({
|
| 14 |
+
samples: 5,
|
| 15 |
+
milestones: {
|
| 16 |
+
content: { firstObservedMs: 32, stableObservedMs: 64 },
|
| 17 |
+
tab: { firstObservedMs: 48, stableObservedMs: 80 },
|
| 18 |
+
},
|
| 19 |
+
all: { firstObservedMs: 48, stableObservedMs: 80 },
|
| 20 |
+
})
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
test("reports missing stability when a milestone appears in the final samples", () => {
|
| 24 |
+
expect(
|
| 25 |
+
summarizeNavigationMilestones([
|
| 26 |
+
{ observedAtMs: 16, milestones: { content: false } },
|
| 27 |
+
{ observedAtMs: 32, milestones: { content: true } },
|
| 28 |
+
]),
|
| 29 |
+
).toEqual({
|
| 30 |
+
samples: 2,
|
| 31 |
+
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
|
| 32 |
+
all: { firstObservedMs: 32, stableObservedMs: null },
|
| 33 |
+
})
|
| 34 |
+
})
|
packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
|
| 3 |
+
|
| 4 |
+
test("compresses repeated repaint states without losing frame samples", () => {
|
| 5 |
+
const state = {
|
| 6 |
+
root: 1,
|
| 7 |
+
scrollTop: 10,
|
| 8 |
+
scrollHeight: 20,
|
| 9 |
+
bottomErrorPx: 0,
|
| 10 |
+
last: true,
|
| 11 |
+
rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
|
| 12 |
+
mounted: 1,
|
| 13 |
+
center: "content",
|
| 14 |
+
}
|
| 15 |
+
const trace = {
|
| 16 |
+
timeOriginEpochMs: 1_000,
|
| 17 |
+
startedAtPerformanceMs: 100,
|
| 18 |
+
samples: [
|
| 19 |
+
{ observedAtMs: 16, ...state, destination: ["target"], source: [] },
|
| 20 |
+
{ observedAtMs: 32, ...state, destination: ["target"], source: [] },
|
| 21 |
+
{ observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
|
| 22 |
+
],
|
| 23 |
+
mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
|
| 24 |
+
shifts: [{ occurredAtMs: 24, value: 0.1 }],
|
| 25 |
+
windowMs: 1_000,
|
| 26 |
+
running: false,
|
| 27 |
+
stop() {},
|
| 28 |
+
}
|
| 29 |
+
const compressed = compressCachedRepaintTrace(trace)
|
| 30 |
+
const samples = compressed.samples.flatMap((group) =>
|
| 31 |
+
group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
expect(samples).toEqual(trace.samples)
|
| 35 |
+
expect(compressed.mutations).toEqual(trace.mutations)
|
| 36 |
+
expect(compressed.shifts).toEqual(trace.shifts)
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
test("records layout shifts at occurrence time within the probe window", () => {
|
| 40 |
+
expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
|
| 41 |
+
expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
|
| 42 |
+
})
|
packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
|
| 3 |
+
|
| 4 |
+
test("counts source and blank samples before the destination is observed", () => {
|
| 5 |
+
const result = classifySessionSwitch([
|
| 6 |
+
{ observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
|
| 7 |
+
{ observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
|
| 8 |
+
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
| 9 |
+
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
| 10 |
+
{ observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
| 11 |
+
])
|
| 12 |
+
|
| 13 |
+
expect(result.blankSamples).toBe(1)
|
| 14 |
+
expect(result.sourceSamples).toBe(1)
|
| 15 |
+
expect(result.unknownSamples).toBe(0)
|
| 16 |
+
expect(result.firstDestinationObservedMs).toBe(48)
|
| 17 |
+
expect(result.stableObservedMs).toBe(80)
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
test("does not classify mixed source and destination content as correct", () => {
|
| 21 |
+
const result = classifySessionSwitch([
|
| 22 |
+
{
|
| 23 |
+
observedAtMs: 16,
|
| 24 |
+
destination: ["destination"],
|
| 25 |
+
source: ["source"],
|
| 26 |
+
hasVisibleRows: true,
|
| 27 |
+
last: true,
|
| 28 |
+
bottomErrorPx: 0,
|
| 29 |
+
},
|
| 30 |
+
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
| 31 |
+
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
| 32 |
+
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
| 33 |
+
])
|
| 34 |
+
|
| 35 |
+
expect(result.firstCorrectObservedMs).toBe(32)
|
| 36 |
+
expect(result.stableObservedMs).toBe(64)
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
test("reports missing correctness without throwing", () => {
|
| 40 |
+
const result = classifySessionSwitch([
|
| 41 |
+
{
|
| 42 |
+
observedAtMs: 16,
|
| 43 |
+
destination: ["destination"],
|
| 44 |
+
source: ["source"],
|
| 45 |
+
hasVisibleRows: true,
|
| 46 |
+
last: true,
|
| 47 |
+
bottomErrorPx: 0,
|
| 48 |
+
},
|
| 49 |
+
])
|
| 50 |
+
|
| 51 |
+
expect(result.firstDestinationObservedMs).toBe(16)
|
| 52 |
+
expect(result.firstCorrectObservedMs).toBeNull()
|
| 53 |
+
expect(result.stableObservedMs).toBeNull()
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
test("requires an explicitly tracked part to be visible", () => {
|
| 57 |
+
const result = classifySessionSwitch([
|
| 58 |
+
{
|
| 59 |
+
observedAtMs: 16,
|
| 60 |
+
destination: ["destination"],
|
| 61 |
+
source: [],
|
| 62 |
+
hasVisibleRows: true,
|
| 63 |
+
last: true,
|
| 64 |
+
requiredPartVisible: false,
|
| 65 |
+
bottomErrorPx: 0,
|
| 66 |
+
},
|
| 67 |
+
])
|
| 68 |
+
|
| 69 |
+
expect(result.firstCorrectObservedMs).toBeNull()
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
test("can measure content correctness without requiring a bottom anchor", () => {
|
| 73 |
+
const result = classifySessionSwitch([
|
| 74 |
+
{
|
| 75 |
+
observedAtMs: 16,
|
| 76 |
+
destination: ["destination"],
|
| 77 |
+
source: [],
|
| 78 |
+
hasVisibleRows: true,
|
| 79 |
+
last: true,
|
| 80 |
+
requiredPartVisible: true,
|
| 81 |
+
bottomAnchorRequired: false,
|
| 82 |
+
},
|
| 83 |
+
])
|
| 84 |
+
|
| 85 |
+
expect(result.firstCorrectObservedMs).toBe(16)
|
| 86 |
+
})
|
packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import type { Page } from "@playwright/test"
|
| 3 |
+
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
|
| 4 |
+
|
| 5 |
+
function testPage(waitFailure?: Error) {
|
| 6 |
+
const stops: unknown[] = []
|
| 7 |
+
const page = {
|
| 8 |
+
evaluate: async (_callback: unknown, input?: unknown) => {
|
| 9 |
+
if (input) return
|
| 10 |
+
stops.push(undefined)
|
| 11 |
+
},
|
| 12 |
+
waitForFunction: async () => {
|
| 13 |
+
if (waitFailure) throw waitFailure
|
| 14 |
+
},
|
| 15 |
+
} as unknown as Page
|
| 16 |
+
return { page, stops }
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function input(run: () => Promise<void>) {
|
| 20 |
+
return {
|
| 21 |
+
destinationIDs: ["destination"],
|
| 22 |
+
sourceIDs: ["source"],
|
| 23 |
+
lastID: "destination",
|
| 24 |
+
href: "/session/destination",
|
| 25 |
+
switch: run,
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
test("stops sampling when the session switch fails", async () => {
|
| 30 |
+
const failure = new Error("switch failed")
|
| 31 |
+
const context = testPage()
|
| 32 |
+
|
| 33 |
+
await expect(
|
| 34 |
+
measureSessionSwitch(
|
| 35 |
+
context.page,
|
| 36 |
+
input(async () => Promise.reject(failure)),
|
| 37 |
+
),
|
| 38 |
+
).rejects.toBe(failure)
|
| 39 |
+
|
| 40 |
+
expect(context.stops).toHaveLength(1)
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
test("stops sampling when the stable wait fails", async () => {
|
| 44 |
+
const failure = new Error("stable wait failed")
|
| 45 |
+
const context = testPage(failure)
|
| 46 |
+
|
| 47 |
+
await expect(
|
| 48 |
+
measureSessionSwitch(
|
| 49 |
+
context.page,
|
| 50 |
+
input(async () => {}),
|
| 51 |
+
),
|
| 52 |
+
).rejects.toBe(failure)
|
| 53 |
+
|
| 54 |
+
expect(context.stops).toHaveLength(1)
|
| 55 |
+
})
|
packages/app/e2e/performance/unit/timeline-test-helpers.test.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
| 4 |
+
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
| 5 |
+
|
| 6 |
+
test("builds stress session links for the benchmark server", () => {
|
| 7 |
+
expect(stressSessionHref(fixture.sourceID)).toBe(
|
| 8 |
+
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
|
| 9 |
+
)
|
| 10 |
+
})
|
packages/app/e2e/performance/unit/visual-stability.test.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import {
|
| 3 |
+
analyzeVisualStability,
|
| 4 |
+
analyzeVisualStabilityByMarker,
|
| 5 |
+
type VisualStabilityTrace,
|
| 6 |
+
} from "../../utils/visual-stability"
|
| 7 |
+
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
| 8 |
+
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
| 9 |
+
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
| 10 |
+
|
| 11 |
+
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
| 12 |
+
return { markers: [], samples }
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
test("accepts continuous visible motion", () => {
|
| 16 |
+
expect(
|
| 17 |
+
analyzeVisualStability(
|
| 18 |
+
trace([
|
| 19 |
+
frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
| 20 |
+
frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
|
| 21 |
+
frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
| 22 |
+
]),
|
| 23 |
+
{ flow: ["changing", "following"] },
|
| 24 |
+
),
|
| 25 |
+
).toEqual([])
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
test("reports repeated geometry reversals", () => {
|
| 29 |
+
const issues = analyzeVisualStability(
|
| 30 |
+
trace([
|
| 31 |
+
frame(0, region({ width: 80 })),
|
| 32 |
+
frame(16, region({ width: 60 })),
|
| 33 |
+
frame(32, region({ width: 78 })),
|
| 34 |
+
frame(48, region({ width: 62 })),
|
| 35 |
+
]),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
test("reports visible blanking, label reversal, and overlap", () => {
|
| 42 |
+
const issues = analyzeVisualStability(
|
| 43 |
+
trace([
|
| 44 |
+
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
| 45 |
+
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
| 46 |
+
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
| 47 |
+
]),
|
| 48 |
+
{ flow: ["changing", "following"] },
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
|
| 52 |
+
expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
|
| 53 |
+
expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
test("reports duplicate regions and unexpected remounts", () => {
|
| 57 |
+
const issues = analyzeVisualStability(
|
| 58 |
+
trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
|
| 59 |
+
{ stable: ["changing"], unique: ["changing"] },
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
|
| 63 |
+
expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
test("reports bottom anchor loss but permits movement while scrolled away", () => {
|
| 67 |
+
const anchored = analyzeVisualStability(
|
| 68 |
+
trace([
|
| 69 |
+
{ at: 0, regions: { changing: region() }, viewport: viewport(0) },
|
| 70 |
+
{ at: 16, regions: { changing: region() }, viewport: viewport(24) },
|
| 71 |
+
]),
|
| 72 |
+
{ preserveBottomAnchor: true },
|
| 73 |
+
)
|
| 74 |
+
const away = analyzeVisualStability(
|
| 75 |
+
trace([
|
| 76 |
+
{ at: 0, regions: { changing: region() }, viewport: viewport(80) },
|
| 77 |
+
{ at: 16, regions: { changing: region() }, viewport: viewport(104) },
|
| 78 |
+
]),
|
| 79 |
+
{ preserveBottomAnchor: true },
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
|
| 83 |
+
expect(away).toEqual([])
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
test("reports up down up movement while preserving a bottom anchor", () => {
|
| 87 |
+
const issues = analyzeVisualStability(
|
| 88 |
+
trace([
|
| 89 |
+
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
| 90 |
+
{ at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
| 91 |
+
{ at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
|
| 92 |
+
{ at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
|
| 93 |
+
]),
|
| 94 |
+
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
|
| 98 |
+
expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
test("accepts monotonic upward movement while preserving a bottom anchor", () => {
|
| 102 |
+
expect(
|
| 103 |
+
analyzeVisualStability(
|
| 104 |
+
trace([
|
| 105 |
+
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
| 106 |
+
{ at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
|
| 107 |
+
{ at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
| 108 |
+
]),
|
| 109 |
+
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
| 110 |
+
),
|
| 111 |
+
).toEqual([])
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
test("ignores overlap entirely outside the clipped timeline viewport", () => {
|
| 115 |
+
expect(
|
| 116 |
+
analyzeVisualStability(
|
| 117 |
+
trace([
|
| 118 |
+
{
|
| 119 |
+
at: 0,
|
| 120 |
+
regions: {
|
| 121 |
+
changing: region({ top: -200, bottom: -100 }),
|
| 122 |
+
following: region({ top: -150, bottom: -50 }),
|
| 123 |
+
},
|
| 124 |
+
viewport: viewport(0),
|
| 125 |
+
},
|
| 126 |
+
]),
|
| 127 |
+
{ flow: ["changing", "following"] },
|
| 128 |
+
),
|
| 129 |
+
).toEqual([])
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
|
| 133 |
+
const issues = analyzeVisualStability(
|
| 134 |
+
trace([
|
| 135 |
+
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
|
| 136 |
+
{ at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
|
| 137 |
+
]),
|
| 138 |
+
{ fixed: ["anchor"] },
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
expect(issues).toEqual([])
|
| 142 |
+
|
| 143 |
+
const moved = analyzeVisualStability(
|
| 144 |
+
trace([
|
| 145 |
+
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
|
| 146 |
+
{ at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
|
| 147 |
+
]),
|
| 148 |
+
{ fixed: ["anchor"] },
|
| 149 |
+
)
|
| 150 |
+
expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
test("analyzes each marked event independently", () => {
|
| 154 |
+
const input: VisualStabilityTrace = {
|
| 155 |
+
markers: [
|
| 156 |
+
{ at: 10, label: "grow" },
|
| 157 |
+
{ at: 40, label: "shrink" },
|
| 158 |
+
],
|
| 159 |
+
samples: [
|
| 160 |
+
frame(0, region({ top: 100 })),
|
| 161 |
+
frame(16, region({ top: 90 })),
|
| 162 |
+
frame(32, region({ top: 80 })),
|
| 163 |
+
frame(48, region({ top: 90 })),
|
| 164 |
+
frame(64, region({ top: 100 })),
|
| 165 |
+
],
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
| 169 |
+
expect(
|
| 170 |
+
analyzeVisualStabilityByMarker(input, {
|
| 171 |
+
maxPositionReversals: 0,
|
| 172 |
+
motion: ["changing"],
|
| 173 |
+
aggregateMotion: false,
|
| 174 |
+
}),
|
| 175 |
+
).toEqual([])
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
test("reports cross-event motion reversals by default", () => {
|
| 179 |
+
const input: VisualStabilityTrace = {
|
| 180 |
+
markers: [
|
| 181 |
+
{ at: 10, label: "up" },
|
| 182 |
+
{ at: 40, label: "down" },
|
| 183 |
+
],
|
| 184 |
+
samples: [
|
| 185 |
+
frame(0, region({ top: 100 })),
|
| 186 |
+
frame(16, region({ top: 90 })),
|
| 187 |
+
frame(32, region({ top: 80 })),
|
| 188 |
+
frame(48, region({ top: 90 })),
|
| 189 |
+
frame(64, region({ top: 100 })),
|
| 190 |
+
],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
test("reports regions rendered in the wrong flow order", () => {
|
| 197 |
+
const issues = analyzeVisualStability(
|
| 198 |
+
trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
|
| 199 |
+
{ flow: ["changing", "following"] },
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
test("uses painted bounds instead of clipped layout overflow", () => {
|
| 206 |
+
expect(
|
| 207 |
+
analyzeVisualStability(
|
| 208 |
+
trace([
|
| 209 |
+
frame(
|
| 210 |
+
0,
|
| 211 |
+
region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
|
| 212 |
+
region({ top: 140, bottom: 180 }),
|
| 213 |
+
),
|
| 214 |
+
]),
|
| 215 |
+
{ flow: ["changing", "following"] },
|
| 216 |
+
),
|
| 217 |
+
).toEqual([])
|
| 218 |
+
})
|
| 219 |
+
|
| 220 |
+
test("does not report disappearance when a present row moves outside the viewport", () => {
|
| 221 |
+
expect(
|
| 222 |
+
analyzeVisualStability(
|
| 223 |
+
trace([
|
| 224 |
+
frame(0, region({ visible: true })),
|
| 225 |
+
frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
|
| 226 |
+
frame(32, region({ visible: true })),
|
| 227 |
+
]),
|
| 228 |
+
),
|
| 229 |
+
).toEqual([])
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
test("reports an in-viewport transparent frame between visible frames", () => {
|
| 233 |
+
const issues = analyzeVisualStability(
|
| 234 |
+
trace([
|
| 235 |
+
frame(0, region()),
|
| 236 |
+
frame(16, region({ visible: false, opacity: 0, inViewport: true })),
|
| 237 |
+
frame(32, region()),
|
| 238 |
+
]),
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
| 242 |
+
})
|
| 243 |
+
|
| 244 |
+
test("reports an in-viewport display-none frame between visible frames", () => {
|
| 245 |
+
const issues = analyzeVisualStability(
|
| 246 |
+
trace([
|
| 247 |
+
frame(0, region()),
|
| 248 |
+
frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
|
| 249 |
+
frame(32, region()),
|
| 250 |
+
]),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
| 254 |
+
})
|
| 255 |
+
|
| 256 |
+
test("can limit motion analysis to unaffected regions", () => {
|
| 257 |
+
expect(
|
| 258 |
+
analyzeVisualStability(
|
| 259 |
+
trace([
|
| 260 |
+
frame(0, region({ height: 20 }), region()),
|
| 261 |
+
frame(16, region({ height: 40 }), region()),
|
| 262 |
+
frame(32, region({ height: 30 }), region()),
|
| 263 |
+
]),
|
| 264 |
+
{ motion: ["following"] },
|
| 265 |
+
),
|
| 266 |
+
).toEqual([])
|
| 267 |
+
})
|
| 268 |
+
|
| 269 |
+
test("reports a blank frame across replacement surfaces", () => {
|
| 270 |
+
const issues = analyzeVisualStability(
|
| 271 |
+
{
|
| 272 |
+
markers: [],
|
| 273 |
+
samples: [
|
| 274 |
+
{ at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
|
| 275 |
+
{
|
| 276 |
+
at: 16,
|
| 277 |
+
regions: {
|
| 278 |
+
thinking: region({ present: false, visible: false }),
|
| 279 |
+
error: region({ present: false, visible: false }),
|
| 280 |
+
},
|
| 281 |
+
},
|
| 282 |
+
{ at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
|
| 283 |
+
],
|
| 284 |
+
},
|
| 285 |
+
{ continuousAny: [["thinking", "error"]] },
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
|
| 289 |
+
})
|
| 290 |
+
|
| 291 |
+
test("reports failure to acquire the bottom anchor", () => {
|
| 292 |
+
const issues = analyzeVisualStability(
|
| 293 |
+
trace([
|
| 294 |
+
{ at: 0, regions: { changing: region() }, viewport: viewport(600) },
|
| 295 |
+
{ at: 16, regions: { changing: region() }, viewport: viewport(120) },
|
| 296 |
+
]),
|
| 297 |
+
{ acquireBottomAnchor: true },
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
|
| 301 |
+
})
|
| 302 |
+
|
| 303 |
+
test("reports a required region that never renders", () => {
|
| 304 |
+
const issues = analyzeVisualStability(
|
| 305 |
+
trace([
|
| 306 |
+
{
|
| 307 |
+
at: 0,
|
| 308 |
+
regions: { changing: region({ present: false, visible: false }) },
|
| 309 |
+
},
|
| 310 |
+
]),
|
| 311 |
+
{ required: ["changing"] },
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
expect(issues).toContain("changing never rendered")
|
| 315 |
+
})
|
| 316 |
+
|
| 317 |
+
test("preserves typed region names while mapping definitions", () => {
|
| 318 |
+
const regions = defineVisualRegions({
|
| 319 |
+
changing: { selector: "[data-changing]" },
|
| 320 |
+
following: { selector: "[data-following]", closest: "[data-row]" },
|
| 321 |
+
})
|
| 322 |
+
const selectors = mapVisualRegions(regions, (region) => region.selector)
|
| 323 |
+
|
| 324 |
+
expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
|
| 325 |
+
const name: keyof typeof selectors = "changing"
|
| 326 |
+
expect(name).toBe("changing")
|
| 327 |
+
})
|
| 328 |
+
|
| 329 |
+
test("evaluates the typed invariant algebra over explicit observations", () => {
|
| 330 |
+
const regions = defineVisualRegions({
|
| 331 |
+
changing: { selector: "[data-changing]" },
|
| 332 |
+
following: { selector: "[data-following]" },
|
| 333 |
+
})
|
| 334 |
+
const invariants = [
|
| 335 |
+
{ type: "required", regions: ["changing"] },
|
| 336 |
+
{ type: "flow", regions: ["changing", "following"] },
|
| 337 |
+
] satisfies VisualInvariant<keyof typeof regions>[]
|
| 338 |
+
// @ts-expect-error Plans reject names that are not in the region definition.
|
| 339 |
+
const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
|
| 340 |
+
const plan = visualPlan(regions, invariants, { perMarker: true })
|
| 341 |
+
|
| 342 |
+
expect(invalid.regions).toEqual(["missing"])
|
| 343 |
+
expect(plan.perMarker).toBe(true)
|
| 344 |
+
expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
|
| 345 |
+
"changing overlapped following by 1px at 0ms",
|
| 346 |
+
])
|
| 347 |
+
})
|
| 348 |
+
|
| 349 |
+
test("legacy plan adapter preserves analyzer messages and order", () => {
|
| 350 |
+
const input = trace([
|
| 351 |
+
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
| 352 |
+
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
| 353 |
+
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
| 354 |
+
])
|
| 355 |
+
const options = { flow: ["changing", "following"], stable: ["changing"] }
|
| 356 |
+
|
| 357 |
+
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
|
| 358 |
+
analyzeVisualStability(input, options),
|
| 359 |
+
)
|
| 360 |
+
})
|
| 361 |
+
|
| 362 |
+
function frame(
|
| 363 |
+
at: number,
|
| 364 |
+
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
| 365 |
+
following?: VisualStabilityTrace["samples"][number]["regions"][string],
|
| 366 |
+
) {
|
| 367 |
+
return { at, regions: { changing, ...(following ? { following } : {}) } }
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
|
| 371 |
+
return {
|
| 372 |
+
present: true,
|
| 373 |
+
visible: true,
|
| 374 |
+
inViewport: true,
|
| 375 |
+
top: 0,
|
| 376 |
+
bottom: 20,
|
| 377 |
+
width: 100,
|
| 378 |
+
height: 20,
|
| 379 |
+
opacity: 1,
|
| 380 |
+
count: 1,
|
| 381 |
+
node: 1,
|
| 382 |
+
label: "",
|
| 383 |
+
text: "",
|
| 384 |
+
layoutTop: input.top ?? 0,
|
| 385 |
+
layoutBottom: input.bottom ?? 20,
|
| 386 |
+
...input,
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
function viewport(distanceFromBottom: number) {
|
| 391 |
+
return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
|
| 392 |
+
}
|
packages/app/e2e/reproduction/timeline-suspense/index.html
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Timeline Suspense Reproduction</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
box-sizing: border-box;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
html,
|
| 13 |
+
body,
|
| 14 |
+
#root {
|
| 15 |
+
margin: 0;
|
| 16 |
+
min-height: 100%;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
background: #171717;
|
| 21 |
+
color: #f5f5f5;
|
| 22 |
+
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
#root {
|
| 26 |
+
padding: 24px;
|
| 27 |
+
}
|
| 28 |
+
</style>
|
| 29 |
+
</head>
|
| 30 |
+
<body>
|
| 31 |
+
<main id="root"></main>
|
| 32 |
+
<script type="module" src="/main.tsx"></script>
|
| 33 |
+
</body>
|
| 34 |
+
</html>
|
packages/app/e2e/reproduction/timeline-suspense/main.tsx
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
|
| 2 |
+
import { render } from "solid-js/web"
|
| 3 |
+
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
|
| 4 |
+
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
|
| 5 |
+
|
| 6 |
+
const rowCount = 2_000
|
| 7 |
+
const rowHeight = 40
|
| 8 |
+
const parameters = new URLSearchParams(location.search)
|
| 9 |
+
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
|
| 10 |
+
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
|
| 11 |
+
|
| 12 |
+
type MutationEvent = {
|
| 13 |
+
kind: "removed" | "added"
|
| 14 |
+
callbackTime: number
|
| 15 |
+
callbackFrame: number
|
| 16 |
+
routeConnectedInCallback: boolean
|
| 17 |
+
nativeOffsetInCallback: number
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
type Snapshot = {
|
| 21 |
+
mode: {
|
| 22 |
+
resource: "baseline" | "guard"
|
| 23 |
+
reconnect: "baseline" | "candidate"
|
| 24 |
+
}
|
| 25 |
+
operation: {
|
| 26 |
+
sequence: number
|
| 27 |
+
phase: string
|
| 28 |
+
time: number
|
| 29 |
+
frame: number
|
| 30 |
+
}
|
| 31 |
+
resourceState: string
|
| 32 |
+
routeConnected: boolean
|
| 33 |
+
viewportConnected: boolean
|
| 34 |
+
viewportOwnedByRoute: boolean
|
| 35 |
+
sameRoute: boolean
|
| 36 |
+
sameViewport: boolean
|
| 37 |
+
sameSurface: boolean
|
| 38 |
+
sameMountedRows: boolean
|
| 39 |
+
nativeOffset: number
|
| 40 |
+
coreOffset: number
|
| 41 |
+
rangeStart: number
|
| 42 |
+
rangeEnd: number
|
| 43 |
+
indexes: number[]
|
| 44 |
+
domIndexes: number[]
|
| 45 |
+
logicalSurfaceHeight: number
|
| 46 |
+
renderedSurfaceHeight: number
|
| 47 |
+
viewportClientHeight: number
|
| 48 |
+
viewportScrollHeight: number
|
| 49 |
+
visibleRows: number
|
| 50 |
+
minimumRowTop: number
|
| 51 |
+
domScrollEvents: number
|
| 52 |
+
lastScrollTrusted: boolean
|
| 53 |
+
coreOffsetCallbackCalls: number
|
| 54 |
+
offsetCallbackSources: "observer"[]
|
| 55 |
+
rectObserverCallbacks: number
|
| 56 |
+
ignoredDetachedZeroRects: number
|
| 57 |
+
syntheticScrollDispatches: number
|
| 58 |
+
mutationEvents: MutationEvent[]
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
declare global {
|
| 62 |
+
interface Window {
|
| 63 |
+
timelineSuspense: {
|
| 64 |
+
prepare: () => Promise<Snapshot>
|
| 65 |
+
trigger: () => void
|
| 66 |
+
resolve: () => void
|
| 67 |
+
frames: (count?: number) => Promise<void>
|
| 68 |
+
snapshot: () => Snapshot
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function App() {
|
| 74 |
+
const [refresh, setRefresh] = createSignal(false)
|
| 75 |
+
let resolveResource: (() => void) | undefined
|
| 76 |
+
const [resource] = createResource(
|
| 77 |
+
refresh,
|
| 78 |
+
(version) =>
|
| 79 |
+
new Promise<string>((resolve) => {
|
| 80 |
+
resolveResource = () => resolve(`settled-${version}`)
|
| 81 |
+
}),
|
| 82 |
+
{ initialValue: "settled" },
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
function Route() {
|
| 86 |
+
let route: HTMLElement | undefined
|
| 87 |
+
let viewport: HTMLDivElement | undefined
|
| 88 |
+
let surface: HTMLDivElement | undefined
|
| 89 |
+
let initialRoute: HTMLElement | undefined
|
| 90 |
+
let initialViewport: HTMLDivElement | undefined
|
| 91 |
+
let initialSurface: HTMLDivElement | undefined
|
| 92 |
+
let initialRows: HTMLElement[] = []
|
| 93 |
+
let phase = "mounting"
|
| 94 |
+
let browserFrame = 0
|
| 95 |
+
let snapshotSequence = 0
|
| 96 |
+
let domScrollEvents = 0
|
| 97 |
+
let lastScrollTrusted = false
|
| 98 |
+
let coreOffsetCallbackCalls = 0
|
| 99 |
+
let rectObserverCallbacks = 0
|
| 100 |
+
let ignoredDetachedZeroRects = 0
|
| 101 |
+
const offsetCallbackSources: "observer"[] = []
|
| 102 |
+
const mutationEvents: MutationEvent[] = []
|
| 103 |
+
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
| 104 |
+
count: rowCount,
|
| 105 |
+
getScrollElement: () => viewport ?? null,
|
| 106 |
+
estimateSize: () => rowHeight,
|
| 107 |
+
initialRect: { width: 900, height: 600 },
|
| 108 |
+
overscan: 2,
|
| 109 |
+
observeElementRect: (instance, callback) =>
|
| 110 |
+
observeElementRect(instance, (rect) => {
|
| 111 |
+
rectObserverCallbacks++
|
| 112 |
+
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
|
| 113 |
+
if (!instance.scrollElement?.isConnected && rect.height === 0) {
|
| 114 |
+
ignoredDetachedZeroRects++
|
| 115 |
+
return
|
| 116 |
+
}
|
| 117 |
+
callback(rect)
|
| 118 |
+
}),
|
| 119 |
+
observeElementOffset: (instance, callback) => {
|
| 120 |
+
const deliver = (offset: number, isScrolling: boolean) => {
|
| 121 |
+
coreOffsetCallbackCalls++
|
| 122 |
+
offsetCallbackSources.push("observer")
|
| 123 |
+
callback(offset, isScrolling)
|
| 124 |
+
}
|
| 125 |
+
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
|
| 126 |
+
return observeElementOffset(instance, deliver)
|
| 127 |
+
},
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
const frames = async (count = 2) => {
|
| 131 |
+
for (let index = 0; index < count; index++) {
|
| 132 |
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
|
| 136 |
+
const snapshot = (): Snapshot => {
|
| 137 |
+
const rows = mountedRows()
|
| 138 |
+
const view = viewport?.getBoundingClientRect()
|
| 139 |
+
const visibleRows =
|
| 140 |
+
viewport?.isConnected && view
|
| 141 |
+
? rows.filter((row) => {
|
| 142 |
+
const rect = row.getBoundingClientRect()
|
| 143 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 144 |
+
}).length
|
| 145 |
+
: 0
|
| 146 |
+
return {
|
| 147 |
+
mode: { resource: resourceMode, reconnect: reconnectMode },
|
| 148 |
+
operation: {
|
| 149 |
+
sequence: ++snapshotSequence,
|
| 150 |
+
phase,
|
| 151 |
+
time: performance.now(),
|
| 152 |
+
frame: browserFrame,
|
| 153 |
+
},
|
| 154 |
+
resourceState: resource.state,
|
| 155 |
+
routeConnected: route?.isConnected ?? false,
|
| 156 |
+
viewportConnected: viewport?.isConnected ?? false,
|
| 157 |
+
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
|
| 158 |
+
sameRoute: route === initialRoute,
|
| 159 |
+
sameViewport: viewport === initialViewport,
|
| 160 |
+
sameSurface: surface === initialSurface,
|
| 161 |
+
sameMountedRows:
|
| 162 |
+
initialRows.length > 0 &&
|
| 163 |
+
initialRows.length === rows.length &&
|
| 164 |
+
initialRows.every((row, index) => row === rows[index]),
|
| 165 |
+
nativeOffset: viewport?.scrollTop ?? -1,
|
| 166 |
+
coreOffset: virtualizer.scrollOffset ?? -1,
|
| 167 |
+
rangeStart: virtualizer.range?.startIndex ?? -1,
|
| 168 |
+
rangeEnd: virtualizer.range?.endIndex ?? -1,
|
| 169 |
+
indexes: virtualizer.getVirtualItems().map((item) => item.index),
|
| 170 |
+
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
|
| 171 |
+
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
|
| 172 |
+
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
|
| 173 |
+
viewportClientHeight: viewport?.clientHeight ?? -1,
|
| 174 |
+
viewportScrollHeight: viewport?.scrollHeight ?? -1,
|
| 175 |
+
visibleRows,
|
| 176 |
+
minimumRowTop:
|
| 177 |
+
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
|
| 178 |
+
domScrollEvents,
|
| 179 |
+
lastScrollTrusted,
|
| 180 |
+
coreOffsetCallbackCalls,
|
| 181 |
+
offsetCallbackSources: [...offsetCallbackSources],
|
| 182 |
+
rectObserverCallbacks,
|
| 183 |
+
ignoredDetachedZeroRects,
|
| 184 |
+
syntheticScrollDispatches: 0,
|
| 185 |
+
mutationEvents: mutationEvents.map((event) => ({ ...event })),
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
onMount(() => {
|
| 190 |
+
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
|
| 191 |
+
const routeRoot = route.parentElement
|
| 192 |
+
if (!routeRoot) throw new Error("Timeline route root did not mount")
|
| 193 |
+
initialRoute = route
|
| 194 |
+
initialViewport = viewport
|
| 195 |
+
initialSurface = surface
|
| 196 |
+
viewport.addEventListener("scroll", (event) => {
|
| 197 |
+
domScrollEvents++
|
| 198 |
+
lastScrollTrusted = event.isTrusted
|
| 199 |
+
})
|
| 200 |
+
const countFrames = () => {
|
| 201 |
+
browserFrame++
|
| 202 |
+
requestAnimationFrame(countFrames)
|
| 203 |
+
}
|
| 204 |
+
requestAnimationFrame(countFrames)
|
| 205 |
+
new MutationObserver((records) => {
|
| 206 |
+
const callbackTime = performance.now()
|
| 207 |
+
records.forEach((record) => {
|
| 208 |
+
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
|
| 209 |
+
if (node !== route) return
|
| 210 |
+
phase = "detached"
|
| 211 |
+
mutationEvents.push({
|
| 212 |
+
kind: "removed",
|
| 213 |
+
callbackTime,
|
| 214 |
+
callbackFrame: browserFrame,
|
| 215 |
+
routeConnectedInCallback: route.isConnected,
|
| 216 |
+
nativeOffsetInCallback: viewport.scrollTop,
|
| 217 |
+
})
|
| 218 |
+
})
|
| 219 |
+
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
|
| 220 |
+
if (node !== route) return
|
| 221 |
+
phase = "reinserted"
|
| 222 |
+
mutationEvents.push({
|
| 223 |
+
kind: "added",
|
| 224 |
+
callbackTime,
|
| 225 |
+
callbackFrame: browserFrame,
|
| 226 |
+
routeConnectedInCallback: route.isConnected,
|
| 227 |
+
nativeOffsetInCallback: viewport.scrollTop,
|
| 228 |
+
})
|
| 229 |
+
})
|
| 230 |
+
})
|
| 231 |
+
}).observe(routeRoot, { childList: true })
|
| 232 |
+
window.timelineSuspense = {
|
| 233 |
+
prepare: async () => {
|
| 234 |
+
phase = "preparing"
|
| 235 |
+
await frames(2)
|
| 236 |
+
viewport.scrollTop = viewport.scrollHeight
|
| 237 |
+
await frames(3)
|
| 238 |
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
| 239 |
+
await frames(2)
|
| 240 |
+
initialRows = mountedRows()
|
| 241 |
+
phase = "prepared"
|
| 242 |
+
return snapshot()
|
| 243 |
+
},
|
| 244 |
+
trigger: () => {
|
| 245 |
+
phase = "triggering"
|
| 246 |
+
setRefresh(true)
|
| 247 |
+
},
|
| 248 |
+
resolve: () => {
|
| 249 |
+
if (!resolveResource) throw new Error("Resource is not pending")
|
| 250 |
+
phase = "resolving"
|
| 251 |
+
resolveResource()
|
| 252 |
+
},
|
| 253 |
+
frames,
|
| 254 |
+
snapshot,
|
| 255 |
+
}
|
| 256 |
+
})
|
| 257 |
+
|
| 258 |
+
return (
|
| 259 |
+
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
|
| 260 |
+
<span aria-hidden="true" style={{ display: "none" }}>
|
| 261 |
+
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
|
| 262 |
+
</span>
|
| 263 |
+
<div
|
| 264 |
+
ref={viewport}
|
| 265 |
+
data-viewport
|
| 266 |
+
style={{
|
| 267 |
+
height: "600px",
|
| 268 |
+
overflow: "auto",
|
| 269 |
+
"overflow-anchor": "none",
|
| 270 |
+
position: "relative",
|
| 271 |
+
background: "#202020",
|
| 272 |
+
outline: "1px solid #3f3f46",
|
| 273 |
+
}}
|
| 274 |
+
>
|
| 275 |
+
<div
|
| 276 |
+
ref={surface}
|
| 277 |
+
data-surface
|
| 278 |
+
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
|
| 279 |
+
>
|
| 280 |
+
<For each={virtualizer.getVirtualItems()}>
|
| 281 |
+
{(item) => (
|
| 282 |
+
<div
|
| 283 |
+
data-row-index={item.index}
|
| 284 |
+
style={{
|
| 285 |
+
position: "absolute",
|
| 286 |
+
top: "0",
|
| 287 |
+
left: "0",
|
| 288 |
+
width: "100%",
|
| 289 |
+
height: `${item.size}px`,
|
| 290 |
+
transform: `translateY(${item.start}px)`,
|
| 291 |
+
padding: "10px 14px",
|
| 292 |
+
border: "0 solid #333",
|
| 293 |
+
"border-bottom-width": "1px",
|
| 294 |
+
}}
|
| 295 |
+
>
|
| 296 |
+
logical row {item.index}
|
| 297 |
+
</div>
|
| 298 |
+
)}
|
| 299 |
+
</For>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</section>
|
| 303 |
+
)
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
return (
|
| 307 |
+
<main>
|
| 308 |
+
<Suspense>
|
| 309 |
+
<Route />
|
| 310 |
+
</Suspense>
|
| 311 |
+
</main>
|
| 312 |
+
)
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
render(() => <App />, document.getElementById("root")!)
|
packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from "@playwright/test"
|
| 2 |
+
|
| 3 |
+
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
testDir: ".",
|
| 7 |
+
testMatch: "timeline-suspense.repro.ts",
|
| 8 |
+
outputDir: "../../test-results/timeline-suspense",
|
| 9 |
+
fullyParallel: false,
|
| 10 |
+
workers: 1,
|
| 11 |
+
retries: 0,
|
| 12 |
+
reporter: "line",
|
| 13 |
+
timeout: 30_000,
|
| 14 |
+
expect: {
|
| 15 |
+
timeout: 10_000,
|
| 16 |
+
},
|
| 17 |
+
webServer: {
|
| 18 |
+
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
| 19 |
+
cwd: import.meta.dirname,
|
| 20 |
+
url: `http://127.0.0.1:${port}`,
|
| 21 |
+
reuseExistingServer: false,
|
| 22 |
+
},
|
| 23 |
+
use: {
|
| 24 |
+
baseURL: `http://127.0.0.1:${port}`,
|
| 25 |
+
trace: "retain-on-failure",
|
| 26 |
+
screenshot: "only-on-failure",
|
| 27 |
+
},
|
| 28 |
+
projects: [
|
| 29 |
+
{
|
| 30 |
+
name: "chromium",
|
| 31 |
+
use: { ...devices["Desktop Chrome"] },
|
| 32 |
+
},
|
| 33 |
+
],
|
| 34 |
+
})
|
packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
|
| 3 |
+
test.beforeEach(async ({ page }) => {
|
| 4 |
+
page.on("pageerror", (error) => console.error(error))
|
| 5 |
+
await page.goto("/")
|
| 6 |
+
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
| 7 |
+
})
|
| 8 |
+
|
| 9 |
+
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
| 10 |
+
await page.goto("/?reconnect=candidate")
|
| 11 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
| 12 |
+
const before = await prepare(page)
|
| 13 |
+
await triggerBaselineSuspension(page)
|
| 14 |
+
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
| 15 |
+
expect(pending.nativeOffset).toBe(0)
|
| 16 |
+
expect(pending.coreOffset).toBe(before.coreOffset)
|
| 17 |
+
expect(pending.indexes).toEqual(before.indexes)
|
| 18 |
+
expect(pending.sameRoute).toBe(true)
|
| 19 |
+
expect(pending.sameViewport).toBe(true)
|
| 20 |
+
expect(pending.sameSurface).toBe(true)
|
| 21 |
+
expect(pending.sameMountedRows).toBe(true)
|
| 22 |
+
|
| 23 |
+
await resolveSuspension(page)
|
| 24 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
| 25 |
+
await page.waitForTimeout(250)
|
| 26 |
+
await page.evaluate(() => window.timelineSuspense.frames(2))
|
| 27 |
+
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
| 28 |
+
expect(after.sameRoute).toBe(true)
|
| 29 |
+
expect(after.sameViewport).toBe(true)
|
| 30 |
+
expect(after.sameSurface).toBe(true)
|
| 31 |
+
expect(after.nativeOffset).toBe(0)
|
| 32 |
+
expect(after.coreOffset).toBe(0)
|
| 33 |
+
expect(after.rangeStart).toBeLessThan(10)
|
| 34 |
+
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
| 35 |
+
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
| 36 |
+
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
| 37 |
+
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
| 38 |
+
expect(after.syntheticScrollDispatches).toBe(0)
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
| 42 |
+
page,
|
| 43 |
+
}) => {
|
| 44 |
+
const before = await prepare(page)
|
| 45 |
+
const beforeRows = before.domIndexes
|
| 46 |
+
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
| 47 |
+
expect(before.logicalSurfaceHeight).toBe(80_000)
|
| 48 |
+
expect(before.renderedSurfaceHeight).toBe(80_000)
|
| 49 |
+
expect(before.viewportClientHeight).toBe(600)
|
| 50 |
+
expect(before.viewportScrollHeight).toBe(80_000)
|
| 51 |
+
expect(before.rangeStart).toBeGreaterThan(1_900)
|
| 52 |
+
expect(before.nativeOffset).toBe(before.coreOffset)
|
| 53 |
+
expect(before.visibleRows).toBeGreaterThan(0)
|
| 54 |
+
|
| 55 |
+
await triggerBaselineSuspension(page)
|
| 56 |
+
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
| 57 |
+
expect(pending.resourceState).toBe("refreshing")
|
| 58 |
+
expect(pending.routeConnected).toBe(false)
|
| 59 |
+
expect(pending.viewportConnected).toBe(false)
|
| 60 |
+
expect(pending.viewportOwnedByRoute).toBe(true)
|
| 61 |
+
expect(pending.nativeOffset).toBe(0)
|
| 62 |
+
expect(pending.coreOffset).toBe(before.coreOffset)
|
| 63 |
+
expect(pending.rangeStart).toBe(before.rangeStart)
|
| 64 |
+
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
| 65 |
+
expect(pending.indexes).toEqual(before.indexes)
|
| 66 |
+
expect(pending.domIndexes).toEqual(beforeRows)
|
| 67 |
+
expect(pending.sameMountedRows).toBe(true)
|
| 68 |
+
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
| 69 |
+
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
| 70 |
+
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
| 71 |
+
expect(pending.mutationEvents).toHaveLength(1)
|
| 72 |
+
expect(pending.mutationEvents[0]).toMatchObject({
|
| 73 |
+
kind: "removed",
|
| 74 |
+
routeConnectedInCallback: false,
|
| 75 |
+
nativeOffsetInCallback: 0,
|
| 76 |
+
})
|
| 77 |
+
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
| 78 |
+
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
| 79 |
+
|
| 80 |
+
const after = await resolveSuspension(page)
|
| 81 |
+
expect(after.resourceState).toBe("ready")
|
| 82 |
+
expect(after.routeConnected).toBe(true)
|
| 83 |
+
expect(after.viewportConnected).toBe(true)
|
| 84 |
+
expect(after.viewportOwnedByRoute).toBe(true)
|
| 85 |
+
expect(after.sameRoute).toBe(true)
|
| 86 |
+
expect(after.sameViewport).toBe(true)
|
| 87 |
+
expect(after.sameSurface).toBe(true)
|
| 88 |
+
expect(after.sameMountedRows).toBe(true)
|
| 89 |
+
expect(after.nativeOffset).toBe(0)
|
| 90 |
+
expect(after.coreOffset).toBe(before.coreOffset)
|
| 91 |
+
expect(after.rangeStart).toBe(before.rangeStart)
|
| 92 |
+
expect(after.rangeEnd).toBe(before.rangeEnd)
|
| 93 |
+
expect(after.indexes).toEqual(before.indexes)
|
| 94 |
+
expect(after.domIndexes).toEqual(beforeRows)
|
| 95 |
+
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
| 96 |
+
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
| 97 |
+
expect(after.mutationEvents).toHaveLength(2)
|
| 98 |
+
expect(after.mutationEvents[1]).toMatchObject({
|
| 99 |
+
kind: "added",
|
| 100 |
+
routeConnectedInCallback: true,
|
| 101 |
+
nativeOffsetInCallback: 0,
|
| 102 |
+
})
|
| 103 |
+
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
| 104 |
+
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
| 105 |
+
expect(after.visibleRows).toBe(0)
|
| 106 |
+
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
| 107 |
+
expect(after.syntheticScrollDispatches).toBe(0)
|
| 108 |
+
|
| 109 |
+
await page.locator("[data-viewport]").hover()
|
| 110 |
+
await page.mouse.wheel(0, 80)
|
| 111 |
+
await expect
|
| 112 |
+
.poll(() =>
|
| 113 |
+
page.evaluate(() => {
|
| 114 |
+
const value = window.timelineSuspense.snapshot()
|
| 115 |
+
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
| 116 |
+
}),
|
| 117 |
+
)
|
| 118 |
+
.toBe(true)
|
| 119 |
+
await page.evaluate(() => window.timelineSuspense.frames(2))
|
| 120 |
+
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
| 121 |
+
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
| 122 |
+
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
| 123 |
+
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
| 124 |
+
expect(recovered.lastScrollTrusted).toBe(true)
|
| 125 |
+
expect(recovered.rangeStart).toBeLessThan(10)
|
| 126 |
+
expect(recovered.visibleRows).toBeGreaterThan(0)
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
| 130 |
+
await page.goto("/?resource=guard")
|
| 131 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
| 132 |
+
const before = await prepare(page)
|
| 133 |
+
|
| 134 |
+
await page.evaluate(() => window.timelineSuspense.trigger())
|
| 135 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
| 136 |
+
await page.evaluate(() => window.timelineSuspense.frames(3))
|
| 137 |
+
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
| 138 |
+
expect(pending.routeConnected).toBe(true)
|
| 139 |
+
expect(pending.mutationEvents).toEqual([])
|
| 140 |
+
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
| 141 |
+
expect(pending.coreOffset).toBe(before.coreOffset)
|
| 142 |
+
expect(pending.visibleRows).toBeGreaterThan(0)
|
| 143 |
+
|
| 144 |
+
const after = await resolveSuspension(page)
|
| 145 |
+
expect(after.routeConnected).toBe(true)
|
| 146 |
+
expect(after.nativeOffset).toBe(before.nativeOffset)
|
| 147 |
+
expect(after.coreOffset).toBe(before.coreOffset)
|
| 148 |
+
expect(after.visibleRows).toBeGreaterThan(0)
|
| 149 |
+
})
|
| 150 |
+
|
| 151 |
+
async function prepare(page: Page) {
|
| 152 |
+
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
| 153 |
+
expect(before.routeConnected).toBe(true)
|
| 154 |
+
expect(before.viewportConnected).toBe(true)
|
| 155 |
+
expect(before.viewportOwnedByRoute).toBe(true)
|
| 156 |
+
expect(before.sameMountedRows).toBe(true)
|
| 157 |
+
expect(before.rangeStart).toBeGreaterThan(1_900)
|
| 158 |
+
expect(before.nativeOffset).toBe(before.coreOffset)
|
| 159 |
+
return before
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
async function triggerBaselineSuspension(page: Page) {
|
| 163 |
+
await page.evaluate(() => window.timelineSuspense.trigger())
|
| 164 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
| 165 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
| 166 |
+
await page.evaluate(() => window.timelineSuspense.frames(3))
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function resolveSuspension(page: Page) {
|
| 170 |
+
await page.evaluate(() => window.timelineSuspense.resolve())
|
| 171 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
| 172 |
+
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
| 173 |
+
await page.evaluate(() => window.timelineSuspense.frames(3))
|
| 174 |
+
return page.evaluate(() => window.timelineSuspense.snapshot())
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function diagnostic(value: unknown) {
|
| 178 |
+
return JSON.stringify(value, null, 2)
|
| 179 |
+
}
|
packages/app/e2e/reproduction/timeline-suspense/vite.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite"
|
| 2 |
+
import solid from "vite-plugin-solid"
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
root: import.meta.dirname,
|
| 6 |
+
plugins: [solid()],
|
| 7 |
+
})
|