mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-19 10:43:28 +00:00
fix(app): stabilize session timeline layout continuity (#34533)
This commit is contained in:
parent
f266e829cf
commit
3cf71808c4
57 changed files with 6159 additions and 142 deletions
1
bun.lock
1
bun.lock
|
|
@ -37,6 +37,7 @@
|
|||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
|
|
|||
61
packages/app/e2e/performance/timeline-stability/README.md
Normal file
61
packages/app/e2e/performance/timeline-stability/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Timeline Layout Continuity
|
||||
|
||||
Run from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run test:stability
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## What It Proves
|
||||
|
||||
The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as:
|
||||
|
||||
- Preserve a visible semantic anchor while the user is away from the bottom.
|
||||
- Preserve end anchoring while active content grows or new content appears.
|
||||
- Keep adjacent visible rows ordered without material overlap.
|
||||
- Keep user-selected disclosure state through updates and virtualization.
|
||||
- Avoid a sampled blank interval while one visible surface replaces another.
|
||||
- Preserve logical row and control identity where local state or focus depends on it.
|
||||
- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement.
|
||||
|
||||
The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures.
|
||||
|
||||
## What It Does Not Prove
|
||||
|
||||
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.
|
||||
|
||||
The suite does not provide complete coverage for:
|
||||
|
||||
- Compositor-only or raster-only glitches.
|
||||
- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion.
|
||||
- Physical display refresh rates, native OS scaling, or a named low-end device.
|
||||
- TCP packetization, proxy buffering, or the complete real server/provider pipeline.
|
||||
|
||||
Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions.
|
||||
|
||||
For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing.
|
||||
|
||||
## Test Layers
|
||||
|
||||
- **Projection:** admitted rows, grouping, labels, and final visible states.
|
||||
- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration.
|
||||
- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior.
|
||||
- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs.
|
||||
- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events.
|
||||
- **Oracle contract:** pure analyzer and browser sampler calibration tests.
|
||||
|
||||
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.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Failures retain:
|
||||
|
||||
- `video.webm`
|
||||
- `trace.zip`
|
||||
- failure screenshot
|
||||
- sampled DOM/layout trace JSON
|
||||
- event markers and summarized violations
|
||||
|
||||
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.
|
||||
250
packages/app/e2e/performance/timeline-stability/adverse.spec.ts
Normal file
250
packages/app/e2e/performance/timeline-stability/adverse.spec.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline adverse visual stability", () => {
|
||||
test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
|
||||
const activeShellID = "prt_adverse_01_shell"
|
||||
const messages = [
|
||||
...history(24),
|
||||
userMessage(),
|
||||
assistantMessage([shell(activeShellID, "running")], { completed: false }),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages,
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
eventRetry: 30,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", {
|
||||
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
||||
})
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
|
||||
})
|
||||
await page.waitForTimeout(150)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(100)
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"scrolled-away-shell",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "fixed", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves an explicit shell state across virtualization", async ({ page }) => {
|
||||
const targetID = "prt_virtual_shell"
|
||||
const messages = [
|
||||
userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
|
||||
assistantMessage([shell(targetID, "completed", lines(20))], {
|
||||
id: "msg_0001_virtual_assistant",
|
||||
parentID: "msg_0000_virtual_user",
|
||||
created: 1700000001000,
|
||||
}),
|
||||
...history(35, 10),
|
||||
]
|
||||
await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_narrow_01_shell"
|
||||
const followingID = "prt_narrow_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
|
||||
{
|
||||
completed: false,
|
||||
},
|
||||
),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
viewport: { width: 430, height: 800 },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"narrow-shell",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_resize_01_shell"
|
||||
const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
|
||||
const followingID = "prt_resize_04_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
shell(shellID, "completed", wideLines(15)),
|
||||
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
|
||||
]),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.setViewportSize({ width: 430, height: 800 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.setViewportSize({ width: 900, height: 800 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await page.waitForTimeout(500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"responsive-resize",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "context", "following"] },
|
||||
{ type: "unique", regions: ["shell", "context", "following"] },
|
||||
{ type: "stable", regions: ["shell", "context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "context", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function history(count: number, offset = 0): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = index + offset
|
||||
const prefix = `msg_0${String(value).padStart(3, "0")}_history`
|
||||
const userID = `${prefix}_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(
|
||||
`prt_history_${String(value).padStart(3, "0")}`,
|
||||
`Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
|
||||
),
|
||||
],
|
||||
{
|
||||
id: `${prefix}_b_assistant`,
|
||||
parentID: userID,
|
||||
created: 1699990001000 + value * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function wideLines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
event,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
grep: { path: ".", pattern: "stable", include: "*.ts" },
|
||||
list: { path: "src" },
|
||||
}
|
||||
|
||||
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_append_01_read"
|
||||
const followingID = "prt_append_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
|
||||
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
|
||||
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-append",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
|
||||
const textID = "prt_split_02_text"
|
||||
const followingID = "prt_split_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart("prt_split_01_read", "read", "completed", inputs.read),
|
||||
textPart(textID, "Boundary"),
|
||||
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
|
||||
textPart(followingID, "Following split groups"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-split-merge",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["following"] },
|
||||
{ type: "unique", regions: ["following"] },
|
||||
{ type: "stable", regions: ["following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("removing the first context member replaces the group once without overlapping following content", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
|
||||
const followingID = "prt_key_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", inputs.read),
|
||||
toolPart(ids[1]!, "glob", "completed", inputs.glob),
|
||||
toolPart(ids[2]!, "grep", "completed", inputs.grep),
|
||||
textPart(followingID, "Following replaced group"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const originalRowKey = await original.evaluate((element) =>
|
||||
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
|
||||
)
|
||||
await original.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-first-remove",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
|
||||
expect(
|
||||
await page
|
||||
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
|
||||
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
|
||||
).toBe(originalRowKey)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
// Fractional scaling exercises different browser rounding than the baseline.
|
||||
for (const deviceScaleFactor of [1, 1.25]) {
|
||||
test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => {
|
||||
const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell`
|
||||
const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
deviceScaleFactor,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions))
|
||||
})
|
||||
}
|
||||
|
||||
for (const reducedMotion of [true]) {
|
||||
test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const shellID = `prt_motion_${reducedMotion}_01_shell`
|
||||
const followingID = `prt_motion_${reducedMotion}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
reducedMotion,
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions))
|
||||
})
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function shellPlan<Regions extends ReturnType<typeof defineVisualRegions>>(
|
||||
regions: Regions & Record<"shell" | "following", { selector: string }>,
|
||||
) {
|
||||
return visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "apply_patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
|
||||
const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
|
||||
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(partID, profile.tool, "pending", profile.input),
|
||||
textPart(followingID, `Following ${profile.name}`),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180)
|
||||
await timeline.send(partUpdated(completedPart(partID, profile)), 900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
`file-${profile.name}`,
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function completedPart(partID: string, profile: (typeof profiles)[number]) {
|
||||
if (profile.tool === "edit") {
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, {
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 50,
|
||||
deletions: 50,
|
||||
before: source(50, false),
|
||||
after: source(50, true),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
const files = [
|
||||
patchFile("src/a.ts", "update"),
|
||||
patchFile("src/b.ts", "add"),
|
||||
patchFile("src/old.ts", "delete"),
|
||||
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
||||
]
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 20,
|
||||
deletions: type === "add" ? 0 : 20,
|
||||
before: type === "add" ? undefined : source(20, false),
|
||||
after: type === "delete" ? undefined : source(20, true),
|
||||
}
|
||||
}
|
||||
|
||||
function source(count: number, changed: boolean) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
||||
).join("")
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
|
||||
const patchID = "prt_incremental_01_patch"
|
||||
const followingID = "prt_incremental_02_following"
|
||||
const first = patchFile("src/a.ts", "update")
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
const second = patchFile("src/b.ts", "add")
|
||||
const third = patchFile("src/old.ts", "delete")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ metadata: { files: [first, second] } },
|
||||
),
|
||||
),
|
||||
240,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
),
|
||||
),
|
||||
800,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"incremental-patch",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["patch", "following"] },
|
||||
{ type: "unique", regions: ["patch", "following"] },
|
||||
{ type: "stable", regions: ["patch", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["patch", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 4,
|
||||
deletions: type === "add" ? 0 : 3,
|
||||
before: type === "add" ? undefined : source(false),
|
||||
after: type === "delete" ? undefined : source(true),
|
||||
}
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
assistantMessage,
|
||||
event,
|
||||
toolPart,
|
||||
userMessage,
|
||||
validateTimelineEvent,
|
||||
validateTimelineMessages,
|
||||
type PartSeed,
|
||||
} from "./fixture"
|
||||
|
||||
describe("timeline fixture validation", () => {
|
||||
test("accepts a valid timeline", () => {
|
||||
expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("rejects malformed SDK values at runtime", () => {
|
||||
expect(() =>
|
||||
assistantMessage([], {
|
||||
error: { name: "APIError", data: { message: "failed" } } as never,
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
validateTimelineEvent({
|
||||
directory: "C:/OpenCode/TimelineStability",
|
||||
payload: {
|
||||
id: "evt_invalid_status",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||
},
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects duplicate IDs and orphan assistants", () => {
|
||||
expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/)
|
||||
expect(() =>
|
||||
validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]),
|
||||
).toThrow(/parent user/)
|
||||
})
|
||||
|
||||
test("assigns deterministic event IDs", () => {
|
||||
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
||||
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
if (false) {
|
||||
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
|
||||
userMessage([userSeed])
|
||||
|
||||
// @ts-expect-error Tool completion fields are not valid while pending.
|
||||
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
|
||||
// @ts-expect-error Tool completion fields are not valid while running.
|
||||
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
|
||||
// @ts-expect-error Tool error fields are not valid after completion.
|
||||
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
|
||||
|
||||
assistantMessage([
|
||||
// @ts-expect-error Agent references belong to user messages, not assistant messages.
|
||||
{ id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } },
|
||||
])
|
||||
|
||||
// @ts-expect-error Retry status events require message and next.
|
||||
event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } })
|
||||
}
|
||||
561
packages/app/e2e/performance/timeline-stability/fixture.ts
Normal file
561
packages/app/e2e/performance/timeline-stability/fixture.ts
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
GlobalEvent,
|
||||
Message,
|
||||
Part,
|
||||
Session,
|
||||
SessionStatus,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { installSseTransport } from "../../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
|
||||
export const directory = "C:/OpenCode/TimelineStability"
|
||||
export const projectID = "proj_timeline_stability"
|
||||
export const sessionID = "ses_timeline_stability"
|
||||
export const userID = "msg_1000_timeline_user"
|
||||
export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type TimelinePayload = Extract<
|
||||
GlobalEvent["payload"],
|
||||
{
|
||||
type:
|
||||
| "message.updated"
|
||||
| "message.removed"
|
||||
| "message.part.updated"
|
||||
| "message.part.removed"
|
||||
| "message.part.delta"
|
||||
| "session.status"
|
||||
}
|
||||
>
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value extends object
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value
|
||||
|
||||
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
|
||||
export type EventPayload = TimelineEvent
|
||||
export type ToolStatus = ToolState["status"]
|
||||
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
|
||||
|
||||
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
|
||||
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
|
||||
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
|
||||
export type PartSeed<Owner extends Message["role"]> =
|
||||
OwnedPart<Owner> extends infer Candidate
|
||||
? Candidate extends Part
|
||||
? Omit<Candidate, "sessionID" | "messageID">
|
||||
: never
|
||||
: never
|
||||
|
||||
type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||
? { output?: never; title?: never; metadata?: never; error?: never }
|
||||
: State extends "running"
|
||||
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
|
||||
: State extends "error"
|
||||
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
||||
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
|
||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
|
||||
const timelineEventSchema = Schema.Union([
|
||||
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
|
||||
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
|
||||
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
|
||||
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
|
||||
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
|
||||
eventSchema("session.status", SessionStatusEvent.Status.data),
|
||||
])
|
||||
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
|
||||
let eventSequence = 0
|
||||
|
||||
export async function setupTimeline(
|
||||
page: Page,
|
||||
input: {
|
||||
messages?: TimelineMessage[]
|
||||
settings?: Record<string, boolean>
|
||||
sessions?: Session[]
|
||||
cpuRate?: number
|
||||
viewport?: { width: number; height: number }
|
||||
eventRetry?: number
|
||||
reducedMotion?: boolean
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
const messages = validateTimelineMessages([
|
||||
...(input.seedHistory ? historyMessages(18) : []),
|
||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||
])
|
||||
const active = messages.findLast((message) => message.info.role === "assistant")
|
||||
const initialStatus = decodeStatus(
|
||||
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
|
||||
decodeOptions,
|
||||
)
|
||||
const transport = await installSseTransport<EventPayload>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions,
|
||||
sessionStatus: { [sessionID]: initialStatus },
|
||||
pageMessages: () => ({
|
||||
items: messages,
|
||||
}),
|
||||
})
|
||||
await page.addInitScript((settings) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: false,
|
||||
shellToolPartsExpanded: false,
|
||||
showReasoningSummaries: false,
|
||||
showSessionProgressBar: true,
|
||||
...settings,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, input.settings ?? {})
|
||||
if (input.locale) {
|
||||
await page.addInitScript((locale) => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
|
||||
}, input.locale)
|
||||
}
|
||||
if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
|
||||
if (input.deviceScaleFactor) {
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
const viewport = input.viewport ?? { width: 1400, height: 900 }
|
||||
await devtools.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: input.deviceScaleFactor,
|
||||
mobile: false,
|
||||
})
|
||||
}
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
if (input.cpuRate && input.cpuRate > 1) {
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
|
||||
}
|
||||
|
||||
return {
|
||||
transport,
|
||||
async send(event: TimelineEvent, delay = 0) {
|
||||
const valid = validateTimelineEvent(event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
if (delay) await page.waitForTimeout(delay)
|
||||
},
|
||||
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
||||
for (const item of sequence) {
|
||||
const valid = validateTimelineEvent(item.event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
await page.waitForTimeout(item.delay)
|
||||
}
|
||||
},
|
||||
async settle(frames = 3) {
|
||||
await page.evaluate(
|
||||
(frames) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let remaining = frames
|
||||
const tick = () => {
|
||||
remaining--
|
||||
if (remaining <= 0) return resolve()
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}),
|
||||
frames,
|
||||
)
|
||||
},
|
||||
async waitForPart(partID: string) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function describeEvent(event: EventPayload) {
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return [
|
||||
event.payload.type,
|
||||
part.id,
|
||||
part.type === "tool" ? part.tool : part.type,
|
||||
part.type === "tool" ? part.state.status : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":")
|
||||
}
|
||||
if (event.payload.type === "session.status") {
|
||||
const status = event.payload.properties.status
|
||||
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(":")
|
||||
}
|
||||
return event.payload.type
|
||||
}
|
||||
|
||||
export function event<const Type extends TimelinePayload["type"]>(
|
||||
type: Type,
|
||||
properties: Extract<TimelinePayload, { type: Type }>["properties"],
|
||||
): TimelineEvent
|
||||
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
|
||||
return validateTimelineEvent({
|
||||
directory,
|
||||
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
|
||||
})
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions)
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
input.forEach((message) => decodeMessage(message, decodeOptions))
|
||||
const messages = [...input]
|
||||
const messageIDs = new Set<string>()
|
||||
const partIDs = new Set<string>()
|
||||
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (messageIDs.has(message.info.id))
|
||||
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
|
||||
messageIDs.add(message.info.id)
|
||||
if (message.info.role === "assistant" && !users.has(message.info.parentID))
|
||||
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
|
||||
message.parts.forEach((part) => {
|
||||
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
|
||||
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
|
||||
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||
partIDs.add(part.id)
|
||||
})
|
||||
})
|
||||
return messages
|
||||
}
|
||||
|
||||
export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
|
||||
await page.waitForFunction(
|
||||
({ selectors, stableFrames }) => {
|
||||
const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
|
||||
if (elements.some((element) => !element)) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let stable = 0
|
||||
let previous = ""
|
||||
const sample = () => {
|
||||
const signature = JSON.stringify(
|
||||
elements.map((element) => {
|
||||
const rect = element!.getBoundingClientRect()
|
||||
return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
|
||||
}),
|
||||
)
|
||||
stable = signature === previous ? stable + 1 : 0
|
||||
previous = signature
|
||||
const ordered = elements
|
||||
.slice(1)
|
||||
.every(
|
||||
(element, index) =>
|
||||
elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
|
||||
)
|
||||
if (stable >= stableFrames && ordered) return resolve(true)
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
},
|
||||
{ selectors, stableFrames },
|
||||
)
|
||||
}
|
||||
|
||||
export function historyMessages(count: number): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = String(index).padStart(4, "0")
|
||||
const historyUserID = `msg_0${value}_history_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
|
||||
assistantMessage(
|
||||
[
|
||||
{
|
||||
id: `prt_0${value}_history_text`,
|
||||
type: "text",
|
||||
text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
|
||||
},
|
||||
],
|
||||
{
|
||||
id: `msg_0${value}_history_b_assistant`,
|
||||
parentID: historyUserID,
|
||||
created: 1690000001000 + index * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
export function partUpdated(part: Part | PartSeed<"assistant">) {
|
||||
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
|
||||
decodePart(owned, decodeOptions)
|
||||
return event("message.part.updated", {
|
||||
sessionID,
|
||||
part: owned,
|
||||
time: 1700000002000,
|
||||
})
|
||||
}
|
||||
|
||||
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
||||
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
|
||||
}
|
||||
|
||||
export function messageUpdated(info: Message) {
|
||||
return event("message.updated", { sessionID, info })
|
||||
}
|
||||
|
||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
return event("session.status", {
|
||||
sessionID,
|
||||
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
|
||||
})
|
||||
}
|
||||
|
||||
export function userMessage(
|
||||
parts?: PartSeed<"user">[],
|
||||
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
|
||||
): Extract<TimelineMessage, { info: { role: "user" } }> {
|
||||
const id = input.id ?? userID
|
||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
summary: input.summary ?? { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: seeds.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID: id,
|
||||
})),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
}
|
||||
|
||||
export function assistantMessage(
|
||||
parts: PartSeed<"assistant">[] = [],
|
||||
input: {
|
||||
id?: string
|
||||
parentID?: string
|
||||
completed?: boolean
|
||||
error?: AssistantMessage["error"]
|
||||
created?: number
|
||||
} = {},
|
||||
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
|
||||
const id = input.id ?? assistantID
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: input.created ?? 1700000001000,
|
||||
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
|
||||
},
|
||||
parentID: input.parentID ?? userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
},
|
||||
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
}
|
||||
|
||||
export function userText(
|
||||
text: string,
|
||||
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
|
||||
): Extract<PartSeed<"user">, { type: "text" }> {
|
||||
return { id: "prt_user_text", type: "text", text, ...input }
|
||||
}
|
||||
|
||||
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
|
||||
return { id, type: "text", text }
|
||||
}
|
||||
|
||||
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
|
||||
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
||||
}
|
||||
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "pending",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"pending">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "running",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"running">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "completed",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"completed">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "error",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"error">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: ToolStatus,
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
if (state === "running")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
title: options.title,
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000 },
|
||||
},
|
||||
}
|
||||
if (state === "error")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
error: options.error ?? "Tool failed",
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
output: options.output ?? "Completed",
|
||||
title: options.title ?? tool,
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function shell(
|
||||
id: string,
|
||||
state: ToolStatus,
|
||||
output = "",
|
||||
command = `echo ${id}`,
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
if (state === "pending") return toolPart(id, "bash", state, { command })
|
||||
if (state === "running")
|
||||
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
||||
if (state === "error")
|
||||
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
|
||||
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
||||
}
|
||||
|
||||
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
|
||||
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
||||
}
|
||||
|
||||
export function project() {
|
||||
return {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "timeline-stability",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function session(input: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: sessionID,
|
||||
slug: "timeline-stability",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
function eventSchema<
|
||||
const Type extends TimelinePayload["type"],
|
||||
const Properties extends Schema.Codec<unknown, unknown>,
|
||||
>(type: Type, properties: Properties) {
|
||||
return Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
|
||||
})
|
||||
}
|
||||
|
||||
function provider() {
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
|
||||
|
||||
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_interaction_01_shell"
|
||||
const followingID = "prt_interaction_02_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
const plan = visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
])
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await page.waitForTimeout(500)
|
||||
const expanded = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "shell-expand", expanded, plan)
|
||||
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await page.waitForTimeout(500)
|
||||
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
|
||||
})
|
||||
|
||||
test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
|
||||
const ids = [
|
||||
"prt_interaction_01_read",
|
||||
"prt_interaction_02_glob",
|
||||
"prt_interaction_03_grep",
|
||||
"prt_interaction_04_list",
|
||||
]
|
||||
const group = `[data-timeline-part-ids="${ids.join(",")}"]`
|
||||
const followingID = "prt_interaction_context_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
toolPart(ids[3]!, "list", "completed", { path: "src" }),
|
||||
textPart(followingID, "Following context expansion"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
|
||||
for (const [name, expanded] of [
|
||||
["context-expand", true],
|
||||
["context-collapse", false],
|
||||
["context-reexpand", true],
|
||||
] as const) {
|
||||
const regions = defineVisualRegions({
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await page.waitForTimeout(500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
name,
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
|
||||
const editID = "prt_interaction_edit"
|
||||
const followingID = "prt_interaction_edit_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
editID,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 40,
|
||||
deletions: 40,
|
||||
before: source(40, false),
|
||||
after: source(40, true),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
textPart(followingID, "Following edit expansion"),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"edit-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["edit", "following"] },
|
||||
{ type: "unique", regions: ["edit", "following"] },
|
||||
{ type: "stable", regions: ["edit", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["edit", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
|
||||
const firstUser = userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
})),
|
||||
},
|
||||
})
|
||||
const nextUserID = "msg_2000_diff_interaction_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
firstUser,
|
||||
assistantMessage(),
|
||||
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_2001_diff_interaction_assistant",
|
||||
parentID: nextUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
const diff = page.locator('[data-timeline-row="DiffSummary"]')
|
||||
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
|
||||
await expect(diff).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
diff: { selector: '[data-timeline-row="DiffSummary"]' },
|
||||
following: { selector: `[data-message-id="${nextUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByText(/show all/i).click()
|
||||
await page.waitForTimeout(500)
|
||||
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"diff-summary-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["diff", "following"] },
|
||||
{ type: "unique", regions: ["diff", "following"] },
|
||||
{ type: "stable", regions: ["diff", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["diff", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function source(count: number, changed: boolean) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
||||
).join("")
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
mapVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline visual lifecycle stability", () => {
|
||||
test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
|
||||
const initial = ids.map((id) => shell(id, "running"))
|
||||
const followingID = "prt_parallel_04_following"
|
||||
const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
|
||||
completed: false,
|
||||
})
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
|
||||
cpuRate: 4,
|
||||
eventRetry: 24,
|
||||
seedHistory: true,
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
for (const id of ids) await timeline.waitForPart(id)
|
||||
const scroller = page.locator(".scroll-view__viewport", {
|
||||
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
||||
})
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
const regions = defineVisualRegions({
|
||||
prt_shell_empty: shellRegion(ids[0]),
|
||||
prt_shell_short: shellRegion(ids[1]),
|
||||
prt_shell_long: shellRegion(ids[2]),
|
||||
following: shellRegion(followingID),
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.sendAll([
|
||||
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
|
||||
{ event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
|
||||
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
|
||||
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
||||
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
|
||||
{ event: status("idle"), delay: 700 },
|
||||
])
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"parallel-shells",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
{ type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
|
||||
{ type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
|
||||
|
||||
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
|
||||
await short.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const reasoningID = "prt_reasoning_visible"
|
||||
const textID = "prt_streamed_text"
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
reasoning: {
|
||||
selector: `[data-timeline-part-id="${reasoningID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
||||
await timeline.waitForPart(reasoningID)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
|
||||
await timeline.send(partDelta(textID, " **stable"), 90)
|
||||
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
||||
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"reasoning-text-handoff",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["reasoning", "text"] },
|
||||
{ type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
|
||||
{ type: "unique", regions: ["reasoning", "text"] },
|
||||
{ type: "stable", regions: ["reasoning", "text"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["reasoning", "text"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
|
||||
})
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function shellRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
analyzeVisualObservations,
|
||||
defineVisualRegions,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
|
||||
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_ancestor_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
const regions = defineVisualRegions({
|
||||
content: { selector: `[data-timeline-part-id="${partID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
element.parentElement!.style.opacity = "0"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
await row.evaluate((element) => {
|
||||
element.parentElement!.style.opacity = "1"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_descendant_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
await row.evaluate((element) => {
|
||||
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
content: {
|
||||
selector: `[data-timeline-part-id="${partID}"]`,
|
||||
opacitySelectors: ['[data-probe-opacity="true"]'],
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
;(element as HTMLElement).style.opacity = "0"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
await row.evaluate((element) => {
|
||||
;(element as HTMLElement).style.opacity = "1"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import config from "../playwright.config"
|
||||
|
||||
export default {
|
||||
...config,
|
||||
testDir: ".",
|
||||
testMatch: "**/*.spec.ts",
|
||||
outputDir: "../../test-results/timeline-stability",
|
||||
reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]],
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
...config.use,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_wheel_01_shell"
|
||||
const followingID = "prt_wheel_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(12),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
||||
await scroller.evaluate((element) =>
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
|
||||
)
|
||||
await scroller.evaluate((element) => (element.scrollTop -= 180))
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_keyboard_01_shell"
|
||||
const followingID = "prt_keyboard_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(12),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await scroller.press("PageUp")
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeGreaterThan(80)
|
||||
await page.waitForFunction(() => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!root) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const top = root.scrollTop
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
||||
})
|
||||
})
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
||||
await row.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await row.press("PageUp")
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(5)
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
|
||||
const shellID = "prt_nested_keyboard_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
|
||||
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await nested.focus()
|
||||
await page.waitForFunction(() => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!root) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const top = root.scrollTop
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
||||
})
|
||||
})
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
|
||||
await nested.press("PageUp")
|
||||
await page.waitForTimeout(300)
|
||||
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
||||
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
|
||||
|
||||
await nested.evaluate((element) => (element.scrollTop = 0))
|
||||
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
|
||||
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
|
||||
expect(boundaryBefore).toBeGreaterThan(0)
|
||||
await nested.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
||||
|
||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
await nonOverflowing.evaluate((element) => {
|
||||
element.setAttribute("data-scrollable", "")
|
||||
element.setAttribute("tabindex", "0")
|
||||
})
|
||||
await nonOverflowing.focus()
|
||||
const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
|
||||
await nonOverflowing.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
|
||||
})
|
||||
|
||||
test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_jump_01_shell"
|
||||
const followingID = "prt_jump_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(20),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate(
|
||||
(element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
|
||||
)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"jump-latest",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "acquire-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_tall_01_shell"
|
||||
const followingID = "prt_tall_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
viewport: { width: 900, height: 360 },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"taller-than-viewport",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function history(count: number): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
|
||||
const userID = `${prefix}_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
|
||||
assistantMessage(
|
||||
[textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
|
||||
{
|
||||
id: `${prefix}_b_assistant`,
|
||||
parentID: userID,
|
||||
created: 1690000001000 + index * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function rowPairPlan(
|
||||
regions: Record<"shell" | "following", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
])
|
||||
}
|
||||
|
||||
function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "fixed", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
])
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
name: "empty running to completed",
|
||||
updates: [{ state: "completed" as const, output: "", delay: 350 }],
|
||||
},
|
||||
{
|
||||
name: "50 lines arriving incrementally",
|
||||
updates: [
|
||||
{ state: "running" as const, output: lines(1), delay: 100 },
|
||||
{ state: "running" as const, output: lines(10), delay: 160 },
|
||||
{ state: "running" as const, output: lines(25), delay: 90 },
|
||||
{ state: "running" as const, output: lines(50), delay: 220 },
|
||||
{ state: "completed" as const, output: lines(50), delay: 500 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "wide ANSI and CRLF output",
|
||||
updates: [
|
||||
{
|
||||
state: "running" as const,
|
||||
output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
|
||||
"\r\n",
|
||||
),
|
||||
delay: 240,
|
||||
},
|
||||
{
|
||||
state: "completed" as const,
|
||||
output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
|
||||
delay: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
|
||||
const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
|
||||
const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const update of profile.updates) {
|
||||
await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
|
||||
}
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
`shell-${profiles.indexOf(profile)}`,
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_matrix_collapsed_01_shell"
|
||||
const followingID = "prt_matrix_collapsed_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"collapsed-shell",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_matrix_error_01_shell"
|
||||
const followingID = "prt_matrix_error_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated({
|
||||
...shell(shellID, "error"),
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: `echo ${shellID}` },
|
||||
error: "Command failed after output",
|
||||
metadata: {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"shell-error",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_late_text_01_shell"
|
||||
const followingID = "prt_late_text_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"late-text-before-shell-output",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
|
||||
const taskID = "prt_task_link"
|
||||
const childID = "ses_task_child"
|
||||
const input = { description: "Inspect child", subagent_type: "explore" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"task-link",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["task"] },
|
||||
{ type: "unique", regions: ["task"] },
|
||||
{ type: "stable", regions: ["task"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
|
||||
const toolID = "prt_generic_mutation"
|
||||
const followingID = "prt_generic_mutation_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
|
||||
textPart(followingID, "Following generic tool"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
|
||||
200,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
|
||||
400,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"generic-mutation",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
198
packages/app/e2e/performance/timeline-stability/tools.spec.ts
Normal file
198
packages/app/e2e/performance/timeline-stability/tools.spec.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
directory,
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline tool state stability", () => {
|
||||
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
|
||||
const inputs = {
|
||||
webfetch: { url: "https://example.com/docs" },
|
||||
websearch: { query: "timeline stability" },
|
||||
task: { description: "Inspect timeline", subagent_type: "explore" },
|
||||
skill: { name: "stability" },
|
||||
custom: { target: "timeline", depth: 2 },
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(initial, { completed: false })],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
"prt_state_websearch",
|
||||
"prt_state_task",
|
||||
"prt_state_skill",
|
||||
"prt_state_custom",
|
||||
] as const
|
||||
const regions = defineVisualRegions({
|
||||
prt_state_webfetch: toolRegion(regionIDs[0]),
|
||||
prt_state_websearch: toolRegion(regionIDs[1]),
|
||||
prt_state_task: toolRegion(regionIDs[2]),
|
||||
prt_state_skill: toolRegion(regionIDs[3]),
|
||||
prt_state_custom: toolRegion(regionIDs[4]),
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, id] of ids.entries()) {
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
|
||||
[80, 240, 100, 360, 140][index],
|
||||
)
|
||||
}
|
||||
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
|
||||
const key = id as (typeof ids)[number]
|
||||
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
|
||||
const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
|
||||
[110, 70, 280, 130, 420][index],
|
||||
)
|
||||
}
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
|
||||
),
|
||||
350,
|
||||
)
|
||||
await timeline.waitForPart(questionID)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"lightweight-tools",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: regionIDs },
|
||||
{ type: "unique", regions: regionIDs },
|
||||
{ type: "stable", regions: regionIDs },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("keeps an expanded mixed context group stable through staggered completion and error", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
|
||||
const tools = ["read", "glob", "grep", "list"]
|
||||
const inputs = [
|
||||
{ filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "stability", include: "*.ts" },
|
||||
{ path: "src" },
|
||||
]
|
||||
const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 100)
|
||||
const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
|
||||
const group = page.locator(groupSelector)
|
||||
await expect(group).toBeVisible()
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
status: {
|
||||
selector: `${groupSelector} [data-component="tool-status-title"]`,
|
||||
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
||||
},
|
||||
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: '[data-timeline-part-id="prt_ctx_following"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [90, 260, 70, 380].entries()) {
|
||||
await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
|
||||
}
|
||||
await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
|
||||
await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
|
||||
110,
|
||||
)
|
||||
await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await timeline.send(status("idle"), 700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"mixed-context",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context"] },
|
||||
{ type: "stable", regions: ["context"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function toolRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
event,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_mutation_01_first"
|
||||
const middleID = "prt_mutation_02_middle"
|
||||
const lastID = "prt_mutation_03_last"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
||||
const textID = "prt_text_reconcile"
|
||||
const followingID = "prt_text_reconcile_following"
|
||||
const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
|
||||
completed: false,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
const regions = defineVisualRegions({
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partDelta(textID, " streamed content"), 100)
|
||||
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
|
||||
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"text-reconcile",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["text", "following"] },
|
||||
{ type: "unique", regions: ["text", "following"] },
|
||||
{ type: "stable", regions: ["text", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["text", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_question_01_first"
|
||||
const questionID = "prt_question_02_hidden"
|
||||
const lastID = "prt_question_03_last"
|
||||
const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(firstID, "Before question"),
|
||||
toolPart(questionID, "question", "running", input),
|
||||
textPart(lastID, "After question"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
||||
600,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
||||
})
|
||||
|
||||
test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
await timeline.send(status("busy"), 150)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
error: { selector: '[data-timeline-row="Error"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
messageUpdated({
|
||||
...assistant.info,
|
||||
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
|
||||
}),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"thinking-error",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["thinking", "error"] },
|
||||
{ type: "continuous-any", regions: ["thinking", "error"] },
|
||||
{ type: "unique", regions: ["thinking", "error"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("updates retry attempts and long provider messages without remounting the retry row", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("retry", 1), 120)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
retry: { selector: '[data-timeline-row="Retry"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("session.status", {
|
||||
sessionID: "ses_timeline_stability",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "A very long provider retry message ".repeat(8),
|
||||
next: Date.now() + 10_000,
|
||||
},
|
||||
}),
|
||||
300,
|
||||
)
|
||||
await timeline.send(status("retry", 3), 300)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"retry-evolution",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["retry"] },
|
||||
{ type: "unique", regions: ["retry"] },
|
||||
{ type: "stable", regions: ["retry"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const removeUserID = "msg_0500_remove_user"
|
||||
const removeAssistantID = "msg_0501_remove_assistant"
|
||||
const anchorUserID = "msg_2000_anchor_user"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
|
||||
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
|
||||
id: removeAssistantID,
|
||||
parentID: removeUserID,
|
||||
created: 1690000001000,
|
||||
}),
|
||||
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
|
||||
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
|
||||
id: "msg_2001_anchor_assistant",
|
||||
parentID: anchorUserID,
|
||||
created: 1700000001000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
|
||||
200,
|
||||
)
|
||||
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"historical-turn-remove",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function stablePairPlan(
|
||||
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["first", "last"] },
|
||||
{ type: "unique", regions: ["first", "last"] },
|
||||
{ type: "stable", regions: ["first", "last"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
])
|
||||
}
|
||||
392
packages/app/e2e/performance/unit/visual-stability.test.ts
Normal file
392
packages/app/e2e/performance/unit/visual-stability.test.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
analyzeVisualStability,
|
||||
analyzeVisualStabilityByMarker,
|
||||
type VisualStabilityTrace,
|
||||
} from "../../utils/visual-stability"
|
||||
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
||||
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
||||
|
||||
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
||||
return { markers: [], samples }
|
||||
}
|
||||
|
||||
test("accepts continuous visible motion", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
|
||||
frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports repeated geometry reversals", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ width: 80 })),
|
||||
frame(16, region({ width: 60 })),
|
||||
frame(32, region({ width: 78 })),
|
||||
frame(48, region({ width: 62 })),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports visible blanking, label reversal, and overlap", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports duplicate regions and unexpected remounts", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
|
||||
{ stable: ["changing"], unique: ["changing"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports bottom anchor loss but permits movement while scrolled away", () => {
|
||||
const anchored = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(24) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true },
|
||||
)
|
||||
const away = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(80) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(104) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true },
|
||||
)
|
||||
|
||||
expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
|
||||
expect(away).toEqual([])
|
||||
})
|
||||
|
||||
test("reports up down up movement while preserving a bottom anchor", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
||||
{ at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
|
||||
{ at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts monotonic upward movement while preserving a bottom anchor", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
|
||||
{ at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores overlap entirely outside the clipped timeline viewport", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
{
|
||||
at: 0,
|
||||
regions: {
|
||||
changing: region({ top: -200, bottom: -100 }),
|
||||
following: region({ top: -150, bottom: -50 }),
|
||||
},
|
||||
viewport: viewport(0),
|
||||
},
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
|
||||
{ at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
|
||||
]),
|
||||
{ fixed: ["anchor"] },
|
||||
)
|
||||
|
||||
expect(issues).toEqual([])
|
||||
|
||||
const moved = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
|
||||
{ at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
|
||||
]),
|
||||
{ fixed: ["anchor"] },
|
||||
)
|
||||
expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
|
||||
})
|
||||
|
||||
test("analyzes each marked event independently", () => {
|
||||
const input: VisualStabilityTrace = {
|
||||
markers: [
|
||||
{ at: 10, label: "grow" },
|
||||
{ at: 40, label: "shrink" },
|
||||
],
|
||||
samples: [
|
||||
frame(0, region({ top: 100 })),
|
||||
frame(16, region({ top: 90 })),
|
||||
frame(32, region({ top: 80 })),
|
||||
frame(48, region({ top: 90 })),
|
||||
frame(64, region({ top: 100 })),
|
||||
],
|
||||
}
|
||||
|
||||
expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
||||
expect(
|
||||
analyzeVisualStabilityByMarker(input, {
|
||||
maxPositionReversals: 0,
|
||||
motion: ["changing"],
|
||||
aggregateMotion: false,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports cross-event motion reversals by default", () => {
|
||||
const input: VisualStabilityTrace = {
|
||||
markers: [
|
||||
{ at: 10, label: "up" },
|
||||
{ at: 40, label: "down" },
|
||||
],
|
||||
samples: [
|
||||
frame(0, region({ top: 100 })),
|
||||
frame(16, region({ top: 90 })),
|
||||
frame(32, region({ top: 80 })),
|
||||
frame(48, region({ top: 90 })),
|
||||
frame(64, region({ top: 100 })),
|
||||
],
|
||||
}
|
||||
|
||||
expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
||||
})
|
||||
|
||||
test("reports regions rendered in the wrong flow order", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
|
||||
{ flow: ["changing", "following"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
|
||||
})
|
||||
|
||||
test("uses painted bounds instead of clipped layout overflow", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(
|
||||
0,
|
||||
region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
|
||||
region({ top: 140, bottom: 180 }),
|
||||
),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("does not report disappearance when a present row moves outside the viewport", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ visible: true })),
|
||||
frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
|
||||
frame(32, region({ visible: true })),
|
||||
]),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports an in-viewport transparent frame between visible frames", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region()),
|
||||
frame(16, region({ visible: false, opacity: 0, inViewport: true })),
|
||||
frame(32, region()),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports an in-viewport display-none frame between visible frames", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region()),
|
||||
frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
|
||||
frame(32, region()),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("can limit motion analysis to unaffected regions", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ height: 20 }), region()),
|
||||
frame(16, region({ height: 40 }), region()),
|
||||
frame(32, region({ height: 30 }), region()),
|
||||
]),
|
||||
{ motion: ["following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports a blank frame across replacement surfaces", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
{
|
||||
markers: [],
|
||||
samples: [
|
||||
{ at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
|
||||
{
|
||||
at: 16,
|
||||
regions: {
|
||||
thinking: region({ present: false, visible: false }),
|
||||
error: region({ present: false, visible: false }),
|
||||
},
|
||||
},
|
||||
{ at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
|
||||
],
|
||||
},
|
||||
{ continuousAny: [["thinking", "error"]] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports failure to acquire the bottom anchor", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(600) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(120) },
|
||||
]),
|
||||
{ acquireBottomAnchor: true },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports a required region that never renders", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{
|
||||
at: 0,
|
||||
regions: { changing: region({ present: false, visible: false }) },
|
||||
},
|
||||
]),
|
||||
{ required: ["changing"] },
|
||||
)
|
||||
|
||||
expect(issues).toContain("changing never rendered")
|
||||
})
|
||||
|
||||
test("preserves typed region names while mapping definitions", () => {
|
||||
const regions = defineVisualRegions({
|
||||
changing: { selector: "[data-changing]" },
|
||||
following: { selector: "[data-following]", closest: "[data-row]" },
|
||||
})
|
||||
const selectors = mapVisualRegions(regions, (region) => region.selector)
|
||||
|
||||
expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
|
||||
const name: keyof typeof selectors = "changing"
|
||||
expect(name).toBe("changing")
|
||||
})
|
||||
|
||||
test("evaluates the typed invariant algebra over explicit observations", () => {
|
||||
const regions = defineVisualRegions({
|
||||
changing: { selector: "[data-changing]" },
|
||||
following: { selector: "[data-following]" },
|
||||
})
|
||||
const invariants = [
|
||||
{ type: "required", regions: ["changing"] },
|
||||
{ type: "flow", regions: ["changing", "following"] },
|
||||
] satisfies VisualInvariant<keyof typeof regions>[]
|
||||
// @ts-expect-error Plans reject names that are not in the region definition.
|
||||
const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
|
||||
const plan = visualPlan(regions, invariants, { perMarker: true })
|
||||
|
||||
expect(invalid.regions).toEqual(["missing"])
|
||||
expect(plan.perMarker).toBe(true)
|
||||
expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
|
||||
"changing overlapped following by 1px at 0ms",
|
||||
])
|
||||
})
|
||||
|
||||
test("legacy plan adapter preserves analyzer messages and order", () => {
|
||||
const input = trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
])
|
||||
const options = { flow: ["changing", "following"], stable: ["changing"] }
|
||||
|
||||
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
|
||||
analyzeVisualStability(input, options),
|
||||
)
|
||||
})
|
||||
|
||||
function frame(
|
||||
at: number,
|
||||
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
following?: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
) {
|
||||
return { at, regions: { changing, ...(following ? { following } : {}) } }
|
||||
}
|
||||
|
||||
function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
|
||||
return {
|
||||
present: true,
|
||||
visible: true,
|
||||
inViewport: true,
|
||||
top: 0,
|
||||
bottom: 20,
|
||||
width: 100,
|
||||
height: 20,
|
||||
opacity: 1,
|
||||
count: 1,
|
||||
node: 1,
|
||||
label: "",
|
||||
text: "",
|
||||
layoutTop: input.top ?? 0,
|
||||
layoutBottom: input.bottom ?? 20,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
function viewport(distanceFromBottom: number) {
|
||||
return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
|
||||
const shellID = "prt_space_button_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await trigger.focus()
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
await trigger.press("Space")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import {
|
||||
analyzeVisualObservations,
|
||||
defineVisualRegions,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../utils/visual-stability"
|
||||
|
||||
const directory = "C:/OpenCode/ContextResizeRegression"
|
||||
const projectID = "proj_context_resize_regression"
|
||||
|
|
@ -36,6 +43,82 @@ test.describe("regression: session timeline context group resize", () => {
|
|||
expect(visibleOverlap).toEqual([])
|
||||
expect(samples.at(-1)?.expanded).toBe("true")
|
||||
})
|
||||
|
||||
test("paints a stable exploring to explored transition", async ({ page }) => {
|
||||
const events: { directory: string; payload: Record<string, unknown> }[] = []
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockServer(page, events, [
|
||||
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
||||
...turn(10, true, "running"),
|
||||
])
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
status: {
|
||||
selector: `${contextSelector} [data-component="tool-status-title"]`,
|
||||
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
||||
},
|
||||
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingTextID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
.map((sample) => sample.regions.status?.label)
|
||||
.filter((value): value is string => !!value)
|
||||
.filter((value, index, all) => value !== all[index - 1])
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Exploring", "Explored"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
|
|
@ -128,7 +211,7 @@ async function sampleExpansion(page: Page) {
|
|||
)
|
||||
}
|
||||
|
||||
function turn(index: number, target: boolean): Message[] {
|
||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
|
||||
const userID = id("msg_user", index)
|
||||
const assistantID = id("msg_assistant", index)
|
||||
return [
|
||||
|
|
@ -163,10 +246,22 @@ function turn(index: number, target: boolean): Message[] {
|
|||
},
|
||||
parts: target
|
||||
? [
|
||||
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
|
||||
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
|
||||
contextTool(
|
||||
contextIDs[2]!,
|
||||
assistantID,
|
||||
"grep",
|
||||
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||
{
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
|
|
@ -188,7 +283,13 @@ function turn(index: number, target: boolean): Message[] {
|
|||
]
|
||||
}
|
||||
|
||||
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
|
||||
function contextTool(
|
||||
partID: string,
|
||||
messageID: string,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
status: "running" | "completed" = "completed",
|
||||
) {
|
||||
return {
|
||||
id: partID,
|
||||
sessionID,
|
||||
|
|
@ -197,7 +298,7 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
|
|||
callID: `call_${partID}`,
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
status,
|
||||
input,
|
||||
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
|
||||
title: input.filePath || input.path || input.pattern || "completed",
|
||||
|
|
@ -207,13 +308,19 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
|
|||
}
|
||||
}
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: { directory: string; payload: Record<string, unknown> }[] = [],
|
||||
fixtureMessages = messages,
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
pageMessages: () => ({ items: fixtureMessages }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 50,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
|
||||
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const trigger = group.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
|
||||
await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
const id = "prt_file_projection_write"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders a completed single-file patch", async ({ page }) => {
|
||||
const id = "prt_file_projection_single_patch"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
filePath: "src/a.ts",
|
||||
relativePath: "src/a.ts",
|
||||
type: "update",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
|
||||
const editID = "prt_diagnostics_edit"
|
||||
const base = editPart(editID, [])
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([base])],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(
|
||||
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(editPart(editID, [])), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "false")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 4,
|
||||
deletions: type === "add" ? 0 : 3,
|
||||
before: type === "add" ? undefined : source(false),
|
||||
after: type === "delete" ? undefined : source(true),
|
||||
}
|
||||
}
|
||||
|
||||
function editPart(id: string, diagnostics: Record<string, unknown>[]) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
|
||||
diagnostics,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function diagnostic(message: string, line: number) {
|
||||
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const expanded of [false, true]) {
|
||||
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
|
||||
const id = `prt_shell_default_${expanded}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
|
||||
settings: { shellToolPartsExpanded: expanded },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
|
||||
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
|
||||
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
})
|
||||
}
|
||||
|
||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||
const reasoningID = "prt_reasoning_hidden"
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: [
|
||||
{
|
||||
file: "src/retry.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
assistant,
|
||||
],
|
||||
})
|
||||
await timeline.send(status("busy"), 140)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.send(status("retry"), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(status("busy", 2), 180)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const profile of [
|
||||
{ locale: "de", label: "Erkundet" },
|
||||
{ locale: "ar", label: "تم الاستكشاف" },
|
||||
] as const) {
|
||||
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
||||
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
]),
|
||||
],
|
||||
locale: profile.locale,
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
|
||||
})
|
||||
}
|
||||
284
packages/app/e2e/regression/session-timeline-projection.spec.ts
Normal file
284
packages/app/e2e/regression/session-timeline-projection.spec.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
status,
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
type PartSeed,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.describe("session timeline projection", () => {
|
||||
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
|
||||
toolPart("prt_04_list", "list", "completed", { path: "src" }),
|
||||
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
|
||||
toolPart(
|
||||
"prt_websearch",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "timeline stability" },
|
||||
{ output: "https://example.com/result" },
|
||||
),
|
||||
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
|
||||
toolPart(
|
||||
"prt_bash",
|
||||
"bash",
|
||||
"completed",
|
||||
{ command: "printf stable" },
|
||||
{ output: "stable", title: "printf stable" },
|
||||
),
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
"completed",
|
||||
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
|
||||
{ metadata: { answers: [["Yes"]] } },
|
||||
),
|
||||
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
|
||||
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
|
||||
).toBeVisible()
|
||||
for (const id of [
|
||||
"prt_webfetch",
|
||||
"prt_websearch",
|
||||
"prt_task",
|
||||
"prt_bash",
|
||||
"prt_edit",
|
||||
"prt_write",
|
||||
"prt_patch",
|
||||
"prt_question",
|
||||
"prt_skill",
|
||||
"prt_custom",
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_visible_user" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const aborted = assistantMessage(
|
||||
[
|
||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
||||
{ id: "prt_compaction", type: "compaction", auto: true },
|
||||
],
|
||||
{
|
||||
id: "msg_1001_assistant_aborted",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
)
|
||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||
id: "msg_1002_assistant_failed",
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
|
||||
isRetryable: false,
|
||||
},
|
||||
},
|
||||
created: 1700000003000,
|
||||
})
|
||||
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
|
||||
id: "msg_2000_second_user",
|
||||
created: 1700000005000,
|
||||
})
|
||||
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
|
||||
id: "msg_2001_second_assistant",
|
||||
parentID: "msg_2000_second_user",
|
||||
created: 1700000006000,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
|
||||
await timeline.send(status("idle"), 100)
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
|
||||
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Visible provider failure")).toBeVisible()
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
|
||||
const user = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment_only",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_comment_visible" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
|
||||
const nextAssistant = assistantMessage([], {
|
||||
id: "msg_2001_diff_next_assistant",
|
||||
parentID: "msg_2000_diff_next_user",
|
||||
created: 1700000011000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.getByText(/show all/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
const user = userMessage()
|
||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||
id: "msg_1001_before",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
})
|
||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||
id: "msg_1002_after",
|
||||
created: 1700000003000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, before, after] })
|
||||
|
||||
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
const rows = await page
|
||||
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
|
||||
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
|
||||
const text = "Use @explore with @src/a.ts and inspect the attachments"
|
||||
const parts: PartSeed<"user">[] = [
|
||||
userText(text, { id: "prt_user_rich" }),
|
||||
{
|
||||
id: "prt_user_image",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "pixel.png",
|
||||
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
},
|
||||
{
|
||||
id: "prt_user_attachment",
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "tsconfig.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
id: "prt_user_reference",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
|
||||
},
|
||||
{
|
||||
id: "prt_user_agent",
|
||||
type: "agent",
|
||||
name: "explore",
|
||||
source: { value: "@explore", start: 4, end: 12 },
|
||||
},
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
|
||||
|
||||
await expect(page.getByAltText("pixel.png")).toBeVisible()
|
||||
await expect(page.getByText("tsconfig.json")).toBeVisible()
|
||||
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function editPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/a.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/a.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
patchFile("src/a.ts", "update"),
|
||||
patchFile("src/b.ts", "add"),
|
||||
patchFile("src/old.ts", "delete"),
|
||||
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 1,
|
||||
deletions: type === "add" ? 0 : 1,
|
||||
before: type === "add" ? undefined : "export const before = true\n",
|
||||
after: type === "delete" ? undefined : "export const after = true\n",
|
||||
}
|
||||
}
|
||||
|
||||
function summaryDiff(index: number) {
|
||||
return {
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries off reasoning heading",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries off with visible tool",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: true,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries on blank reasoning",
|
||||
summaries: true,
|
||||
reasoning: " ",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible reasoning",
|
||||
summaries: true,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: false,
|
||||
body: true,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible tool no reasoning",
|
||||
summaries: true,
|
||||
reasoning: "",
|
||||
other: true,
|
||||
thinking: false,
|
||||
body: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
|
||||
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
|
||||
const parts = [
|
||||
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
|
||||
...(profile.other
|
||||
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(parts, { completed: false })],
|
||||
settings: { showReasoningSummaries: profile.summaries },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
if (!profile.summaries && profile.reasoning.trim()) {
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
|
||||
],
|
||||
settings: { showReasoningSummaries: true },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
|
||||
})
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
||||
textPart("prt_boundary_02_text", "Boundary text"),
|
||||
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
shell("prt_boundary_05_shell", "completed", "done"),
|
||||
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
||||
})
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
parts.push(
|
||||
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
await timeline.waitForPart(shellID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
180,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
|
||||
),
|
||||
250,
|
||||
)
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
|
||||
})
|
||||
|
||||
test("labels all web search provider variants", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_search_parallel",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "parallel" },
|
||||
{ metadata: { provider: "parallel" } },
|
||||
),
|
||||
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
|
||||
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "bash") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||
if (tool === "skill") return { name: "failure" }
|
||||
return { target: "failure" }
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates expanded web search links without resetting expansion", async ({ page }) => {
|
||||
const searchID = "prt_websearch_mutation"
|
||||
const input = { query: "timeline stability" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(searchID, "websearch", "completed", input, {
|
||||
output: "https://example.com/one\nhttps://example.com/two",
|
||||
}),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
|
||||
const toolID = "prt_duplicate_error"
|
||||
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(partUpdated(failed), 150)
|
||||
await timeline.send(partUpdated(failed), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Command failed visibly")
|
||||
})
|
||||
|
||||
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
|
||||
const questionID = "prt_multi_question"
|
||||
const input = {
|
||||
questions: [
|
||||
{ header: "First", question: "First choice?", options: [] },
|
||||
{ header: "Second", question: "Second choice?", options: [], multiple: true },
|
||||
],
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Updated")
|
||||
await expect(wrapper).toContainText("B, C")
|
||||
})
|
||||
116
packages/app/e2e/regression/session-timeline-transport.spec.ts
Normal file
116
packages/app/e2e/regression/session-timeline-transport.spec.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
||||
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_first")
|
||||
await timeline.waitForPart("prt_transport_second")
|
||||
expect(first.connectionID).toBe(second.connectionID)
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("delivers a burst from one stream chunk", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const acknowledgements = await timeline.transport.burst([
|
||||
partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
])
|
||||
|
||||
await timeline.waitForPart("prt_transport_burst_a")
|
||||
await timeline.waitForPart("prt_transport_burst_b")
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
||||
})
|
||||
|
||||
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const snowman = new TextEncoder().encode("\u2603")[0]!
|
||||
const multibyte = encoded.indexOf(snowman)
|
||||
|
||||
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
||||
|
||||
await timeline.waitForPart("prt_transport_split")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
||||
"split snowman \u2603\u2603\u2603",
|
||||
)
|
||||
expect(acknowledgement.chunkCount).toBe(4)
|
||||
})
|
||||
|
||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
||||
})
|
||||
const before = await page.locator("[data-timeline-row]").allTextContents()
|
||||
|
||||
await timeline.transport.heartbeat()
|
||||
await timeline.settle()
|
||||
|
||||
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reconnects after a clean close", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.waitForConnection()
|
||||
|
||||
await timeline.transport.close()
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_close")
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
|
||||
})
|
||||
|
||||
test("reconnects after a stream error", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.waitForConnection()
|
||||
|
||||
await timeline.transport.error("contract failure")
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(status("busy"))
|
||||
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
||||
})
|
||||
|
||||
test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/global/health")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ healthy: true })
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
|
|
@ -5,5 +5,12 @@
|
|||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
"include": [
|
||||
"./performance/timeline-stability/**/*.spec.ts",
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
|
|
@ -17,6 +17,7 @@ export interface MockServerConfig {
|
|||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
sessionStatus?: unknown
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
|
|
@ -53,6 +54,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
|
|
|
|||
284
packages/app/e2e/utils/sse-transport.ts
Normal file
284
packages/app/e2e/utils/sse-transport.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
endedBy?: "close" | "disconnect" | "error" | "abort"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SseDeliveryAcknowledgement = {
|
||||
deliveryID: number
|
||||
connectionID: number
|
||||
bytes: number
|
||||
chunkCount: number
|
||||
deliveredAt: number
|
||||
eventID?: string
|
||||
}
|
||||
|
||||
export type SseEventOptions = {
|
||||
id?: string
|
||||
event?: string
|
||||
retry?: number
|
||||
marker?: string
|
||||
}
|
||||
|
||||
export type SseTransport<T> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||
close(): Promise<void>
|
||||
disconnect(message?: string): Promise<void>
|
||||
error(message?: string): Promise<void>
|
||||
connections(): Promise<SseConnectionRecord[]>
|
||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||
}
|
||||
|
||||
type BrowserCommand<T> =
|
||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
||||
| { type: "connections" }
|
||||
| { type: "acknowledgements" }
|
||||
|
||||
type BrowserTransport = Window & {
|
||||
__testSseTransport?: {
|
||||
command: (command: BrowserCommand<unknown>) => unknown
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSseTransport<T>(
|
||||
page: Page,
|
||||
options: { server: string; retry?: number },
|
||||
): Promise<SseTransport<T>> {
|
||||
const server = new URL(options.server).origin
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController<Uint8Array> }
|
||||
type ProbeWindow = Window & {
|
||||
__visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] }
|
||||
}
|
||||
const originalFetch = window.fetch.bind(window)
|
||||
const connections: Connection[] = []
|
||||
const acknowledgements: SseDeliveryAcknowledgement[] = []
|
||||
const encoder = new TextEncoder()
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
const boundaries = [...new Set(cuts ?? [])]
|
||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||
.sort((a, b) => a - b)
|
||||
return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength))
|
||||
}
|
||||
const marker = (label?: string) => {
|
||||
if (!label) return
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) return
|
||||
probe.markers.push({ at: performance.now() - probe.startedAt, label })
|
||||
}
|
||||
const frame = (payload: unknown, eventOptions: SseEventOptions = {}) =>
|
||||
[
|
||||
eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`,
|
||||
eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`,
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
chunkCount: number,
|
||||
eventID?: string,
|
||||
): SseDeliveryAcknowledgement => {
|
||||
const acknowledgement = {
|
||||
deliveryID: ++nextDeliveryID,
|
||||
connectionID: connection.id,
|
||||
bytes,
|
||||
chunkCount,
|
||||
deliveredAt: performance.now(),
|
||||
...(eventID === undefined ? {} : { eventID }),
|
||||
}
|
||||
acknowledgements.push(acknowledgement)
|
||||
return acknowledgement
|
||||
}
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
||||
const connection = current()
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
connection.endedAt = performance.now()
|
||||
connection.endedBy = mode
|
||||
if (message) connection.error = message
|
||||
if (mode === "close") {
|
||||
connection.controller.close()
|
||||
return
|
||||
}
|
||||
const error = new DOMException(
|
||||
message ?? "SSE connection disconnected",
|
||||
mode === "error" ? "Error" : "NetworkError",
|
||||
)
|
||||
connection.controller.error(error)
|
||||
}
|
||||
|
||||
const command = (input: BrowserCommand<unknown>) => {
|
||||
if (input.type === "connections")
|
||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||
if (input.type === "acknowledgements") return acknowledgements
|
||||
if (input.type === "end") return end(input.mode, input.message)
|
||||
const connection = current()
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
if (input.type === "raw") {
|
||||
marker(input.marker)
|
||||
const output = chunks(new Uint8Array(input.bytes), input.cuts)
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(
|
||||
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
||||
)
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
const output = chunks(encoded[0]!.bytes, input.cuts)
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id)
|
||||
}
|
||||
|
||||
;(window as BrowserTransport).__testSseTransport = { command }
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(input, init)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
id,
|
||||
url: url.href,
|
||||
path: url.pathname,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
openedAt: performance.now(),
|
||||
} as Connection
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (record.endedAt !== undefined) return
|
||||
record.endedAt = performance.now()
|
||||
record.endedBy = "abort"
|
||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
},
|
||||
cancel() {
|
||||
if (record.endedAt !== undefined) return
|
||||
record.endedAt = performance.now()
|
||||
record.endedBy = "disconnect"
|
||||
},
|
||||
})
|
||||
return Promise.resolve(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
||||
},
|
||||
{ server, retry: options.retry },
|
||||
)
|
||||
|
||||
const command = <Result>(input: BrowserCommand<T>) =>
|
||||
page.evaluate((input) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
if (!transport) throw new Error("SSE transport was not installed before page load")
|
||||
return transport.command(input as BrowserCommand<unknown>)
|
||||
}, input) as Promise<Result>
|
||||
|
||||
return {
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
await page.waitForFunction(
|
||||
(after) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.some((connection) => connection.id > after)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
|
||||
(connection) => connection.id > (input.after ?? 0),
|
||||
)!
|
||||
},
|
||||
send(payload, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
||||
},
|
||||
burst(payloads, eventOptions = []) {
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
|
||||
burst: true,
|
||||
})
|
||||
},
|
||||
split(payload, cuts, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
|
||||
},
|
||||
heartbeat(eventOptions) {
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: [
|
||||
{
|
||||
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
|
||||
options: eventOptions,
|
||||
},
|
||||
],
|
||||
burst: false,
|
||||
})
|
||||
},
|
||||
writeRaw(value, cuts, marker) {
|
||||
return command({
|
||||
type: "raw",
|
||||
bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
|
||||
cuts: cuts ? [...cuts] : undefined,
|
||||
marker,
|
||||
})
|
||||
},
|
||||
close() {
|
||||
return command({ type: "end", mode: "close" })
|
||||
},
|
||||
disconnect(message) {
|
||||
return command({ type: "end", mode: "disconnect", message })
|
||||
},
|
||||
error(message) {
|
||||
return command({ type: "end", mode: "error", message })
|
||||
},
|
||||
connections() {
|
||||
return command({ type: "connections" })
|
||||
},
|
||||
acknowledgements() {
|
||||
return command({ type: "acknowledgements" })
|
||||
},
|
||||
}
|
||||
}
|
||||
54
packages/app/e2e/utils/visual-stability.ts
Normal file
54
packages/app/e2e/utils/visual-stability.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
|
||||
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
|
||||
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
|
||||
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
|
||||
import type { VisualRegionDefinition } from "./visual-stability/regions"
|
||||
import { reportVisualStability } from "./visual-stability/reporter"
|
||||
|
||||
export * from "./visual-stability/index"
|
||||
|
||||
const capturedFrames = Symbol("capturedFrames")
|
||||
|
||||
export async function startVisualStabilityProbe(page: Page, regions: Record<string, VisualRegionDefinition>) {
|
||||
await startVisualProbe(page, regions)
|
||||
}
|
||||
|
||||
export async function stopVisualStabilityProbe(page: Page) {
|
||||
const result = await stopVisualProbe(page)
|
||||
const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples }
|
||||
Object.defineProperty(trace, capturedFrames, { value: result.frames })
|
||||
return trace
|
||||
}
|
||||
|
||||
export async function markVisualStability(page: Page, label: string) {
|
||||
await markVisualProbe(page, label)
|
||||
}
|
||||
|
||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
|
||||
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
|
||||
}
|
||||
|
||||
export function analyzeVisualStabilityByMarker(
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
|
||||
}
|
||||
|
||||
export async function expectVisualStability(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
name,
|
||||
{
|
||||
...trace,
|
||||
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
|
||||
},
|
||||
legacyVisualPlan(options),
|
||||
)
|
||||
}
|
||||
209
packages/app/e2e/utils/visual-stability/analyzer.ts
Normal file
209
packages/app/e2e/utils/visual-stability/analyzer.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import type { VisualInvariant, VisualPlan } from "./invariant"
|
||||
import type { VisualObservation, VisualStabilityTrace } from "./model"
|
||||
|
||||
export function analyzeVisualObservations<RegionName extends string>(
|
||||
observations: readonly VisualObservation<RegionName>[],
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const issues: string[] = []
|
||||
const invariants = plan.invariants
|
||||
const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))]
|
||||
const required = regions(invariants, "required")
|
||||
const continuousAny = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuous-any" }> =>
|
||||
invariant.type === "continuous-any",
|
||||
)
|
||||
const unique = new Set(regions(invariants, "unique"))
|
||||
const stable = new Set(regions(invariants, "stable"))
|
||||
const fixed = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "fixed" }> => invariant.type === "fixed",
|
||||
)
|
||||
const opacity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "opacity" }> => invariant.type === "opacity",
|
||||
)
|
||||
const continuity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuity" }> =>
|
||||
invariant.type === "continuity",
|
||||
)
|
||||
const motion = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "motion" }> => invariant.type === "motion",
|
||||
)
|
||||
const labelStability = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "label-stability" }> =>
|
||||
invariant.type === "label-stability",
|
||||
)
|
||||
|
||||
for (const name of new Set(required)) {
|
||||
if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`)
|
||||
}
|
||||
for (const invariant of continuousAny) {
|
||||
if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible)))
|
||||
issues.push(`${invariant.regions.join(" | ")} never rendered`)
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const samples = observations.flatMap((observation) => {
|
||||
const region = observation.regions[name]
|
||||
if (!region) return []
|
||||
const clipped =
|
||||
observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom)
|
||||
return [{ at: observation.at, ...region, visible: region.visible && !clipped }]
|
||||
})
|
||||
const visible = samples.filter((sample) => sample.visible)
|
||||
if (visible.length === 0) continue
|
||||
if (unique.has(name)) {
|
||||
const duplicate = samples.find((sample) => sample.count > 1)
|
||||
if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`)
|
||||
}
|
||||
if (stable.has(name)) {
|
||||
const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))]
|
||||
if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`)
|
||||
}
|
||||
for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) {
|
||||
const origin = visible[0]
|
||||
const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0
|
||||
if (movement > (invariant.tolerance ?? 1))
|
||||
issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`)
|
||||
}
|
||||
for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const sample of visible) {
|
||||
if (sample.opacity < (invariant.floor ?? 0.65))
|
||||
issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`)
|
||||
}
|
||||
}
|
||||
if (continuity.some((invariant) => includes(invariant.regions, name))) {
|
||||
const firstPresent = samples.findIndex((sample) => sample.present)
|
||||
const lastPresent = samples.findLastIndex((sample) => sample.present)
|
||||
if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present))
|
||||
issues.push(`${name} disappeared between present frames`)
|
||||
const firstVisible = samples.findIndex((sample) => sample.visible)
|
||||
const lastVisible = samples.findLastIndex((sample) => sample.visible)
|
||||
if (
|
||||
firstVisible >= 0 &&
|
||||
samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport)
|
||||
)
|
||||
issues.push(`${name} blanked between visible frames`)
|
||||
}
|
||||
for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const metric of ["top", "bottom", "width", "height"] as const) {
|
||||
const directions = visible
|
||||
.slice(1)
|
||||
.map((sample, index) => sample[metric] - visible[index]![metric])
|
||||
.filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1))
|
||||
.map(Math.sign)
|
||||
const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length
|
||||
const allowed =
|
||||
metric === "top" || metric === "bottom"
|
||||
? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1)
|
||||
: (invariant.maxReversals ?? 1)
|
||||
if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`)
|
||||
}
|
||||
}
|
||||
if (labelStability.some((invariant) => includes(invariant.regions, name))) {
|
||||
const labels = samples
|
||||
.map((sample) => sample.label)
|
||||
.filter((label) => label.length > 0)
|
||||
.filter((label, index, all) => label !== all[index - 1])
|
||||
if (labels.some((label, index) => labels.indexOf(label) !== index))
|
||||
issues.push(`${name} label reverted: ${labels.join(" -> ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) {
|
||||
const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : []))
|
||||
if (viewports[0] && viewports[0].distanceFromBottom <= 4) {
|
||||
const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4)
|
||||
if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`)
|
||||
}
|
||||
}
|
||||
if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) {
|
||||
const final = observations.findLast((sample) => sample.viewport)?.viewport
|
||||
if (!final || final.distanceFromBottom > 4)
|
||||
issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`)
|
||||
}
|
||||
|
||||
for (const invariant of continuousAny) {
|
||||
const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible))
|
||||
const first = active.indexOf(true)
|
||||
const last = active.lastIndexOf(true)
|
||||
if (first >= 0 && active.slice(first, last + 1).some((value) => !value))
|
||||
issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`)
|
||||
}
|
||||
|
||||
for (const invariant of invariants.filter(
|
||||
(item): item is Extract<VisualInvariant<RegionName>, { type: "flow" }> => item.type === "flow",
|
||||
)) {
|
||||
for (const [before, after] of invariant.regions
|
||||
.slice(1)
|
||||
.map((after, index) => [invariant.regions[index]!, after])) {
|
||||
let maximum: { overlap: number; at: number } | undefined
|
||||
let inverted: { at: number } | undefined
|
||||
for (const sample of observations) {
|
||||
const first = sample.regions[before]
|
||||
const second = sample.regions[after]
|
||||
if (!first?.visible || !second?.visible) continue
|
||||
if (
|
||||
sample.viewport &&
|
||||
(first.bottom <= sample.viewport.top ||
|
||||
first.top >= sample.viewport.bottom ||
|
||||
second.bottom <= sample.viewport.top ||
|
||||
second.top >= sample.viewport.bottom)
|
||||
)
|
||||
continue
|
||||
const overlap = first.bottom - second.top
|
||||
if (first.top > second.top && !inverted) inverted = { at: sample.at }
|
||||
if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap))
|
||||
maximum = { overlap, at: sample.at }
|
||||
}
|
||||
if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`)
|
||||
if (maximum)
|
||||
issues.push(
|
||||
`${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return [...new Set(issues)]
|
||||
}
|
||||
|
||||
export function analyzeVisualTraceByMarker<RegionName extends string>(
|
||||
trace: VisualStabilityTrace<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan)
|
||||
const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) =>
|
||||
trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`],
|
||||
)
|
||||
const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required")
|
||||
const windows = trace.markers.flatMap((marker, index) => {
|
||||
const end = trace.markers[index + 1]?.at ?? Infinity
|
||||
const before = trace.samples.findLast((sample) => sample.at < marker.at)
|
||||
const samples = [
|
||||
...(before ? [before] : []),
|
||||
...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end),
|
||||
]
|
||||
if (samples.length < 2) return []
|
||||
return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map(
|
||||
(issue) => `${marker.label}: ${issue}`,
|
||||
)
|
||||
})
|
||||
const aggregateMotion =
|
||||
plan.aggregateMotion === false
|
||||
? []
|
||||
: analyzeVisualObservations(trace.samples, {
|
||||
invariants: plan.invariants.filter((invariant) => invariant.type === "motion"),
|
||||
}).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue))
|
||||
return [...new Set([...required, ...aggregateMotion, ...windows])]
|
||||
}
|
||||
|
||||
function regions<RegionName extends string, Type extends VisualInvariant<RegionName>["type"]>(
|
||||
invariants: readonly VisualInvariant<RegionName>[],
|
||||
type: Type,
|
||||
) {
|
||||
return invariants.flatMap((invariant) =>
|
||||
invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [],
|
||||
) as RegionName[]
|
||||
}
|
||||
|
||||
function includes<RegionName extends string>(regions: readonly RegionName[] | "all", name: RegionName) {
|
||||
return regions === "all" || regions.includes(name)
|
||||
}
|
||||
51
packages/app/e2e/utils/visual-stability/capture.ts
Normal file
51
packages/app/e2e/utils/visual-stability/capture.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import type { CapturedFrame } from "./model"
|
||||
|
||||
export type VisualCapture = {
|
||||
session: CDPSession
|
||||
frames: CapturedFrame[]
|
||||
startedAtEpoch: number
|
||||
running: boolean
|
||||
capture: Promise<void>
|
||||
}
|
||||
|
||||
export async function startVisualCapture(page: Page, startedAtEpoch: number) {
|
||||
if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send("Page.enable")
|
||||
const recording: VisualCapture = {
|
||||
session,
|
||||
frames: [],
|
||||
startedAtEpoch,
|
||||
running: true,
|
||||
capture: Promise.resolve(),
|
||||
}
|
||||
recording.capture = (async () => {
|
||||
try {
|
||||
while (recording.running && recording.frames.length < 900) {
|
||||
const frame = await session.send("Page.captureScreenshot", {
|
||||
format: "jpeg",
|
||||
quality: 80,
|
||||
captureBeyondViewport: false,
|
||||
optimizeForSpeed: true,
|
||||
})
|
||||
recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch {
|
||||
recording.running = false
|
||||
}
|
||||
})()
|
||||
return recording
|
||||
}
|
||||
|
||||
export async function stopVisualCapture(recording: VisualCapture | undefined) {
|
||||
if (!recording) return []
|
||||
recording.running = false
|
||||
try {
|
||||
await recording.capture
|
||||
} finally {
|
||||
await recording.session.detach().catch(() => undefined)
|
||||
}
|
||||
return recording.frames
|
||||
}
|
||||
8
packages/app/e2e/utils/visual-stability/index.ts
Normal file
8
packages/app/e2e/utils/visual-stability/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./analyzer"
|
||||
export * from "./capture"
|
||||
export * from "./invariant"
|
||||
export * from "./model"
|
||||
export * from "./probe"
|
||||
export * from "./regions"
|
||||
export * from "./reporter"
|
||||
export * from "./scenario"
|
||||
112
packages/app/e2e/utils/visual-stability/invariant.ts
Normal file
112
packages/app/e2e/utils/visual-stability/invariant.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type RegionSet<RegionName extends string> = readonly RegionName[] | "all"
|
||||
|
||||
export type VisualInvariant<RegionName extends string = string> =
|
||||
| { type: "required"; regions: readonly RegionName[] }
|
||||
| { type: "continuous-any"; regions: readonly RegionName[] }
|
||||
| { type: "unique"; regions: readonly RegionName[] }
|
||||
| { type: "stable"; regions: readonly RegionName[] }
|
||||
| { type: "fixed"; regions: readonly RegionName[]; tolerance?: number }
|
||||
| { type: "opacity"; regions: RegionSet<RegionName>; floor?: number }
|
||||
| {
|
||||
type: "motion"
|
||||
regions: RegionSet<RegionName>
|
||||
tolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
}
|
||||
| { type: "continuity"; regions: RegionSet<RegionName> }
|
||||
| { type: "label-stability"; regions: RegionSet<RegionName> }
|
||||
| { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number }
|
||||
| { type: "preserve-bottom-anchor" }
|
||||
| { type: "acquire-bottom-anchor" }
|
||||
|
||||
export type VisualPlan<RegionName extends string = string> = {
|
||||
regionNames?: readonly RegionName[]
|
||||
invariants: readonly VisualInvariant<RegionName>[]
|
||||
markerRequired?: readonly RegionName[]
|
||||
perMarker?: boolean
|
||||
aggregateMotion?: boolean
|
||||
}
|
||||
|
||||
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
|
||||
flow?: RegionName[]
|
||||
motionTolerance?: number
|
||||
opacityFloor?: number
|
||||
overlapTolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
stable?: RegionName[]
|
||||
fixed?: RegionName[]
|
||||
motion?: RegionName[]
|
||||
unique?: RegionName[]
|
||||
preserveBottomAnchor?: boolean
|
||||
acquireBottomAnchor?: boolean
|
||||
perMarker?: boolean
|
||||
continuousAny?: RegionName[][]
|
||||
required?: RegionName[]
|
||||
aggregateMotion?: boolean
|
||||
inferRequired?: boolean
|
||||
}
|
||||
|
||||
export function visualPlan<const Regions extends Record<string, VisualRegionDefinition>>(
|
||||
regions: Regions,
|
||||
invariants: readonly VisualInvariant<Extract<keyof Regions, string>>[],
|
||||
options: Omit<VisualPlan<Extract<keyof Regions, string>>, "regionNames" | "invariants"> = {},
|
||||
): VisualPlan<Extract<keyof Regions, string>> {
|
||||
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
||||
}
|
||||
|
||||
export function legacyVisualPlan<RegionName extends string>(
|
||||
options: LegacyVisualStabilityOptions<RegionName> = {},
|
||||
): VisualPlan<RegionName> {
|
||||
const inferred =
|
||||
options.inferRequired === false
|
||||
? []
|
||||
: [
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
]
|
||||
return {
|
||||
perMarker: options.perMarker,
|
||||
aggregateMotion: options.aggregateMotion,
|
||||
markerRequired: [
|
||||
...(options.required ?? []),
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
],
|
||||
invariants: [
|
||||
{ type: "required", regions: [...(options.required ?? []), ...inferred] },
|
||||
...(options.continuousAny ?? []).map(
|
||||
(regions): VisualInvariant<RegionName> => ({ type: "continuous-any", regions }),
|
||||
),
|
||||
...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []),
|
||||
...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []),
|
||||
...(options.fixed
|
||||
? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }]
|
||||
: []),
|
||||
{ type: "opacity", regions: "all", floor: options.opacityFloor },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{
|
||||
type: "motion",
|
||||
regions: options.motion ?? "all",
|
||||
tolerance: options.motionTolerance,
|
||||
maxReversals: options.maxReversals,
|
||||
maxPositionReversals: options.maxPositionReversals,
|
||||
},
|
||||
{ type: "label-stability", regions: "all" },
|
||||
...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []),
|
||||
...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []),
|
||||
...(options.flow
|
||||
? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
47
packages/app/e2e/utils/visual-stability/model.ts
Normal file
47
packages/app/e2e/utils/visual-stability/model.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export type VisualRegionSample = {
|
||||
present: boolean
|
||||
visible: boolean
|
||||
inViewport: boolean
|
||||
cssHidden?: boolean
|
||||
top: number
|
||||
bottom: number
|
||||
layoutTop?: number
|
||||
layoutBottom?: number
|
||||
width: number
|
||||
height: number
|
||||
opacity: number
|
||||
count: number
|
||||
node: number
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type VisualViewportSample = {
|
||||
top: number
|
||||
bottom: number
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
distanceFromBottom: number
|
||||
}
|
||||
|
||||
export type VisualObservation<RegionName extends string = string> = {
|
||||
at: number
|
||||
regions: string extends RegionName
|
||||
? Record<string, VisualRegionSample>
|
||||
: Partial<Record<RegionName, VisualRegionSample>>
|
||||
viewport?: VisualViewportSample
|
||||
}
|
||||
|
||||
export type VisualMarker = { at: number; label: string }
|
||||
|
||||
export type VisualStabilityTrace<RegionName extends string = string> = {
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
}
|
||||
|
||||
export type CapturedFrame = { at: number; data: string }
|
||||
|
||||
export type VisualProbeResult<RegionName extends string = string> = VisualStabilityTrace<RegionName> & {
|
||||
frames: CapturedFrame[]
|
||||
}
|
||||
226
packages/app/e2e/utils/visual-stability/probe.ts
Normal file
226
packages/app/e2e/utils/visual-stability/probe.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture"
|
||||
import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type ProbeWindow<RegionName extends string = string> = Window & {
|
||||
__visualStabilityProbe?: {
|
||||
startedAt: number
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const captures = new WeakMap<Page, VisualCapture>()
|
||||
|
||||
export async function startVisualProbe<Regions extends Record<string, VisualRegionDefinition>>(
|
||||
page: Page,
|
||||
regions: Regions,
|
||||
) {
|
||||
await stopCapture(page)
|
||||
await page.evaluate(() => {
|
||||
;(window as ProbeWindow).__visualStabilityProbe?.stop()
|
||||
})
|
||||
const startedAtEpoch = await page.evaluate((regions) => {
|
||||
const samples: VisualObservation[] = []
|
||||
const markers: VisualMarker[] = []
|
||||
const startedAt = performance.now()
|
||||
const nodes = new WeakMap<Node, number>()
|
||||
const lastBounds = new Map<string, { top: number; bottom: number }>()
|
||||
let nextNode = 1
|
||||
let running = true
|
||||
const round = (value: number) => Math.round(value * 10) / 10
|
||||
const opacity = (element: Element) => Number(getComputedStyle(element).opacity)
|
||||
const sample = () => {
|
||||
if (!running) return
|
||||
setTimeout(() => {
|
||||
if (!running) return
|
||||
const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
const viewportRect = viewport?.getBoundingClientRect()
|
||||
samples.push({
|
||||
at: performance.now() - startedAt,
|
||||
viewport: viewport
|
||||
? {
|
||||
top: round(viewportRect!.top),
|
||||
bottom: round(viewportRect!.bottom),
|
||||
scrollTop: round(viewport.scrollTop),
|
||||
scrollHeight: round(viewport.scrollHeight),
|
||||
clientHeight: round(viewport.clientHeight),
|
||||
distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop),
|
||||
}
|
||||
: undefined,
|
||||
regions: Object.fromEntries(
|
||||
Object.entries(regions).map(([name, config]) => {
|
||||
const found = document.querySelector<HTMLElement>(config.selector)
|
||||
const count = document.querySelectorAll(config.selector).length
|
||||
const element = config.closest ? found?.closest<HTMLElement>(config.closest) : found
|
||||
if (!element)
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: false,
|
||||
visible: false,
|
||||
inViewport: false,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
count,
|
||||
node: 0,
|
||||
label: "",
|
||||
text: "",
|
||||
},
|
||||
]
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom })
|
||||
const known = rect.height > 0 ? rect : lastBounds.get(name)
|
||||
const painted = (() => {
|
||||
const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }
|
||||
let parent = element.parentElement
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyle(parent)
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.top = Math.max(result.top, parentRect.top)
|
||||
result.bottom = Math.min(result.bottom, parentRect.bottom)
|
||||
}
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.left = Math.max(result.left, parentRect.left)
|
||||
result.right = Math.min(result.right, parentRect.right)
|
||||
}
|
||||
if (parent === viewport) break
|
||||
parent = parent.parentElement
|
||||
}
|
||||
if (viewportRect) {
|
||||
result.top = Math.max(result.top, viewportRect.top)
|
||||
result.bottom = Math.min(result.bottom, viewportRect.bottom)
|
||||
result.left = Math.max(result.left, viewportRect.left)
|
||||
result.right = Math.min(result.right, viewportRect.right)
|
||||
}
|
||||
return result
|
||||
})()
|
||||
const contentOpacity = config.opacitySelectors?.length
|
||||
? Math.max(
|
||||
0,
|
||||
...config.opacitySelectors.flatMap((selector) =>
|
||||
[...element.querySelectorAll(selector)].map((node) => {
|
||||
let value = 1
|
||||
let current: Element | null = node
|
||||
while (current) {
|
||||
value *= opacity(current)
|
||||
if (current === element) break
|
||||
current = current.parentElement
|
||||
}
|
||||
return value
|
||||
}),
|
||||
),
|
||||
)
|
||||
: opacity(element)
|
||||
let visibleOpacity = contentOpacity
|
||||
let ancestor = element.parentElement
|
||||
let ancestorHidden = false
|
||||
while (ancestor) {
|
||||
const ancestorStyle = getComputedStyle(ancestor)
|
||||
visibleOpacity *= Number(ancestorStyle.opacity)
|
||||
if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true
|
||||
if (ancestor === viewport) break
|
||||
ancestor = ancestor.parentElement
|
||||
}
|
||||
const cssHidden =
|
||||
ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: true,
|
||||
visible:
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
visibleOpacity > 0 &&
|
||||
painted.right > painted.left &&
|
||||
painted.bottom > painted.top,
|
||||
inViewport:
|
||||
!viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom),
|
||||
cssHidden,
|
||||
top: round(painted.top),
|
||||
bottom: round(painted.bottom),
|
||||
layoutTop: round(rect.top),
|
||||
layoutBottom: round(rect.bottom),
|
||||
width: round(painted.right - painted.left),
|
||||
height: round(painted.bottom - painted.top),
|
||||
opacity: round(visibleOpacity),
|
||||
count,
|
||||
node: (() => {
|
||||
const current = nodes.get(element)
|
||||
if (current) return current
|
||||
nodes.set(element, nextNode)
|
||||
return nextNode++
|
||||
})(),
|
||||
label: element.getAttribute("aria-label") ?? "",
|
||||
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500),
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
})
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
;(window as ProbeWindow).__visualStabilityProbe = {
|
||||
startedAt,
|
||||
markers,
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
return new Promise<number>((resolve) => {
|
||||
const ready = () => {
|
||||
if (samples.length > 0) return resolve(performance.timeOrigin + startedAt)
|
||||
requestAnimationFrame(ready)
|
||||
}
|
||||
ready()
|
||||
})
|
||||
}, regions)
|
||||
const capture = await startVisualCapture(page, startedAtEpoch)
|
||||
if (capture) captures.set(page, capture)
|
||||
}
|
||||
|
||||
export async function stopVisualProbe<RegionName extends string = string>(
|
||||
page: Page,
|
||||
): Promise<VisualProbeResult<RegionName>> {
|
||||
return page
|
||||
.evaluate(() => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) throw new Error("Visual stability probe is not running")
|
||||
probe.stop()
|
||||
return { markers: probe.markers, samples: probe.samples }
|
||||
})
|
||||
.then(
|
||||
async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult<RegionName>,
|
||||
async (error: unknown) => {
|
||||
await stopCapture(page)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function markVisualProbe(page: Page, label: string) {
|
||||
await page.evaluate((label) => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) return
|
||||
probe.markers.push({ at: performance.now() - probe.startedAt, label })
|
||||
}, label)
|
||||
}
|
||||
|
||||
async function stopCapture(page: Page) {
|
||||
const capture = captures.get(page)
|
||||
if (capture) captures.delete(page)
|
||||
return stopVisualCapture(capture)
|
||||
}
|
||||
18
packages/app/e2e/utils/visual-stability/regions.ts
Normal file
18
packages/app/e2e/utils/visual-stability/regions.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type VisualRegionDefinition = {
|
||||
selector: string
|
||||
closest?: string
|
||||
opacitySelectors?: readonly string[]
|
||||
}
|
||||
|
||||
export function defineVisualRegions<const Regions extends Record<string, VisualRegionDefinition>>(regions: Regions) {
|
||||
return regions
|
||||
}
|
||||
|
||||
export function mapVisualRegions<const Regions extends Record<string, VisualRegionDefinition>, Result>(
|
||||
regions: Regions,
|
||||
map: (region: Regions[keyof Regions], name: keyof Regions) => Result,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]),
|
||||
) as { [Name in keyof Regions]: Result }
|
||||
}
|
||||
63
packages/app/e2e/utils/visual-stability/reporter.ts
Normal file
63
packages/app/e2e/utils/visual-stability/reporter.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { expect, type TestInfo } from "@playwright/test"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import type { VisualProbeResult } from "./model"
|
||||
|
||||
export async function reportVisualStability<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const trace = { markers: result.markers, samples: result.samples }
|
||||
const issues = plan.perMarker
|
||||
? analyzeVisualTraceByMarker(trace, plan)
|
||||
: analyzeVisualObservations(result.samples, plan)
|
||||
const tracePath = testInfo.outputPath(`${name}-visual-trace.json`)
|
||||
const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`)
|
||||
await writeFile(tracePath, JSON.stringify(trace, null, 2))
|
||||
await writeFile(
|
||||
issuesPath,
|
||||
JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2),
|
||||
)
|
||||
await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" })
|
||||
await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" })
|
||||
if (issues.length) await attachViolationFrames(testInfo, name, result, issues)
|
||||
expect(issues, `${name}: ${issues.join("\n")}`).toEqual([])
|
||||
}
|
||||
|
||||
async function attachViolationFrames<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
issues: string[],
|
||||
) {
|
||||
if (result.frames.length === 0) return
|
||||
const targets = [
|
||||
...new Set(
|
||||
issues.flatMap((issue) => {
|
||||
const match = issue.match(/ at (\d+)ms/)
|
||||
if (match) return [Number(match[1])]
|
||||
const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`))
|
||||
return marker ? [marker.at] : []
|
||||
}),
|
||||
),
|
||||
].slice(0, 6)
|
||||
for (const [violation, target] of targets.entries()) {
|
||||
const nearest = result.frames.reduce(
|
||||
(best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best),
|
||||
0,
|
||||
)
|
||||
for (const [label, index] of [
|
||||
["before", Math.max(0, nearest - 1)],
|
||||
["violation", nearest],
|
||||
["after", Math.min(result.frames.length - 1, nearest + 1)],
|
||||
] as const) {
|
||||
await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, {
|
||||
body: Buffer.from(result.frames[index]!.data, "base64"),
|
||||
contentType: "image/jpeg",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/app/e2e/utils/visual-stability/scenario.ts
Normal file
20
packages/app/e2e/utils/visual-stability/scenario.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import { startVisualProbe, stopVisualProbe } from "./probe"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
import { reportVisualStability } from "./reporter"
|
||||
|
||||
export async function runVisualStabilityScenario<const Regions extends Record<string, VisualRegionDefinition>>(input: {
|
||||
page: Page
|
||||
testInfo: TestInfo
|
||||
name: string
|
||||
regions: Regions
|
||||
plan: VisualPlan<Extract<keyof Regions, string>>
|
||||
run: () => Promise<void>
|
||||
}) {
|
||||
await startVisualProbe(input.page, input.regions)
|
||||
await input.run()
|
||||
const result = await stopVisualProbe<Extract<keyof Regions, string>>(input.page)
|
||||
await reportVisualStability(input.testInfo, input.name, result, input.plan)
|
||||
return result
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
"typecheck:e2e": "tsgo -p e2e/tsconfig.json",
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
"test:e2e:local": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
|
@ -51,6 +53,7 @@
|
|||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { FileProvider, selectionFromLines, useFile, type FileSelection, type Sel
|
|||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
|
|
@ -940,9 +941,11 @@ export default function Page() {
|
|||
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
|
||||
}
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
|
||||
markScrollGesture()
|
||||
const key = scrollKey(event)
|
||||
if (key) {
|
||||
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
|
||||
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
|
||||
markScrollGesture(scroller)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod
|
|||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
|
|
@ -441,6 +441,16 @@ export function MessageTimeline(props: {
|
|||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
let resizeAnchorScheduled = false
|
||||
const anchorResizedBottom = () => {
|
||||
if (resizeAnchorScheduled || props.hasScrollGesture()) return
|
||||
resizeAnchorScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeAnchorScheduled = false
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
const item = virtualizer.measurementsCache[index]
|
||||
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
|
||||
|
|
@ -462,9 +472,13 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
}
|
||||
resizeItem(index, size)
|
||||
if (root && props.shouldAnchorBottom()) anchorResizedBottom()
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
|
||||
if (props.shouldAnchorBottom()) return false
|
||||
const first = virtualizer.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
|
||||
item.end <= instance.getLogicalScrollOffset()
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
|
|
@ -491,24 +505,13 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
})
|
||||
|
||||
let bottomAnchorSessionKey = ""
|
||||
let bottomAnchorFrame: number | undefined
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
const key = sessionKey()
|
||||
if (bottomAnchorSessionKey === key) return
|
||||
if (timelineRows().length === 0) return
|
||||
bottomAnchorSessionKey = key
|
||||
if (!props.shouldAnchorBottom()) return
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
bottomAnchorFrame = requestAnimationFrame(() => {
|
||||
bottomAnchorFrame = undefined
|
||||
if (sessionKey() !== key) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
virtualizer.scrollToEnd()
|
||||
}
|
||||
|
||||
let measuredSessionKey = sessionKey()
|
||||
|
|
@ -527,7 +530,6 @@ export function MessageTimeline(props: {
|
|||
timelineCache.delete(ownerSessionKey)
|
||||
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
||||
props.setRevealMessage?.(() => {})
|
||||
|
|
@ -600,6 +602,15 @@ export function MessageTimeline(props: {
|
|||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const key = scrollKey(event)
|
||||
if (!key) return
|
||||
if (!isScrollKeyTarget(event.target, key)) return
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
|
|
@ -976,10 +987,16 @@ export function MessageTimeline(props: {
|
|||
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
||||
.filter((part): part is ToolPart => part?.type === "tool")
|
||||
})
|
||||
const contextOpenKey = () => `context:${row().group.key}`
|
||||
const open = createMemo(() => {
|
||||
return toolOpen[contextOpenKey()] === true
|
||||
})
|
||||
|
||||
return (
|
||||
<ContextToolGroup
|
||||
parts={parts()}
|
||||
open={open()}
|
||||
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
|
||||
busy={
|
||||
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
|
||||
}
|
||||
|
|
@ -1339,6 +1356,7 @@ export function MessageTimeline(props: {
|
|||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
|
|
|
|||
96
packages/app/src/pages/session/timeline/projection.test.ts
Normal file
96
packages/app/src/pages/session/timeline/projection.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID,
|
||||
group: {
|
||||
key,
|
||||
type: "context",
|
||||
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
|
||||
} satisfies PartGroup,
|
||||
previousAssistantPart: false,
|
||||
})
|
||||
|
||||
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true })
|
||||
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
|
||||
|
||||
describe("reuseTimelineRows", () => {
|
||||
test.each([
|
||||
{
|
||||
name: "reuses an unchanged context group",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [[0, 0]],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when a member is appended",
|
||||
previous: [context("context:a", ["a"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when the first member is removed",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "lets only the natural owner retain an old key after a split",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "chooses the earliest prior key when groups merge",
|
||||
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
rows: [context("context:b", ["b", "a"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "reserves an old key for its natural owner when two new groups compete",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
|
||||
expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "does not reuse context identity across user messages",
|
||||
previous: [context("context:a", ["a", "b"], "user-1")],
|
||||
rows: [context("context:b", ["b"], "user-2")],
|
||||
expected: ["assistant-part:user-2:context:b"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "reuses an unaffected ordinary row",
|
||||
previous: [user()],
|
||||
rows: [user()],
|
||||
expected: ["user-message:user-1"],
|
||||
reused: [[0, 0]],
|
||||
},
|
||||
{
|
||||
name: "does not create accidental key collisions",
|
||||
previous: [context("context:a", ["a", "b", "c"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
|
||||
expected: [
|
||||
"assistant-part:user-1:context:b",
|
||||
"assistant-part:user-1:context:a",
|
||||
"assistant-part:user-1:context:c",
|
||||
],
|
||||
reused: [],
|
||||
},
|
||||
])("$name", ({ previous, rows, expected, reused }) => {
|
||||
const result = reuseTimelineRows([...previous], [...rows])
|
||||
|
||||
expect(keys(result)).toEqual([...expected])
|
||||
expect(new Set(keys(result)).size).toBe(result.length)
|
||||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { Timeline, TimelineRow } from "./rows"
|
||||
|
||||
export { reuseTimelineRows } from "./row-reconciliation"
|
||||
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
|
||||
export function createTimelineProjection(input: {
|
||||
|
|
@ -102,15 +105,3 @@ export function createTimelineProjection(input: {
|
|||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
const next = rows.map((row) => {
|
||||
const existing = byKey.get(TimelineRow.key(row))
|
||||
if (!existing) return row
|
||||
return TimelineRow.equals(existing, row) ? existing : row
|
||||
})
|
||||
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
|
||||
return next
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
type PriorContext = { index: number; row: ContextRow }
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
const contextByPart = new Map<string, PriorContext>()
|
||||
previous.forEach((row, index) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
|
||||
})
|
||||
const reserved = new Map<string, number>()
|
||||
rows.forEach((row, index) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
const key = TimelineRow.key(row)
|
||||
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
|
||||
})
|
||||
const claimed = new Set<string>()
|
||||
const next = rows.map((input, index) => {
|
||||
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
|
||||
const existing = byKey.get(TimelineRow.key(row))
|
||||
if (!existing) return row
|
||||
return TimelineRow.equals(existing, row) ? existing : row
|
||||
})
|
||||
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
|
||||
return next
|
||||
}
|
||||
|
||||
function stabilizeContextKey(
|
||||
contextByPart: Map<string, PriorContext>,
|
||||
reserved: Map<string, number>,
|
||||
row: TimelineRow.TimelineRow,
|
||||
rowIndex: number,
|
||||
claimed: Set<string>,
|
||||
) {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
|
||||
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
|
||||
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
|
||||
if (!candidate) return result
|
||||
const key = TimelineRow.key(candidate.row)
|
||||
if (claimed.has(key)) return result
|
||||
const owner = reserved.get(key)
|
||||
if (owner !== undefined && owner !== rowIndex) return result
|
||||
return !result || candidate.index < result.index ? candidate : result
|
||||
}, undefined)
|
||||
if (!existing) return row
|
||||
const key = TimelineRow.key(existing.row)
|
||||
claimed.add(key)
|
||||
if (row.group.key === existing.row.group.key) return row
|
||||
return new TimelineRow.AssistantPart({
|
||||
...row,
|
||||
group: { ...row.group, key: existing.row.group.key },
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { groupParts, PartGroup, renderable } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
export type TimelineRowMap = {
|
||||
TurnGap: { userMessageID: string }
|
||||
|
|
@ -29,81 +29,6 @@ export type TimelineRowMap = {
|
|||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
}> {}
|
||||
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
}> {}
|
||||
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
previousAssistantPart: boolean
|
||||
}> {}
|
||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||
userMessageID: string
|
||||
reasoningHeading?: string
|
||||
}> {}
|
||||
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
|
||||
userMessageID: string
|
||||
diffs: SummaryDiff[]
|
||||
}> {}
|
||||
export class Error extends Data.TaggedClass("Error")<{
|
||||
userMessageID: string
|
||||
text: string
|
||||
}> {}
|
||||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| TurnDivider
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
case "TurnGap":
|
||||
return `turn-gap:${row.userMessageID}`
|
||||
case "CommentStrip":
|
||||
return `comment-strip:${row.userMessageID}`
|
||||
case "UserMessage":
|
||||
return `user-message:${row.userMessageID}`
|
||||
case "TurnDivider":
|
||||
return `turn-divider:${row.userMessageID}:${row.label}`
|
||||
case "AssistantPart":
|
||||
return `assistant-part:${row.userMessageID}:${row.group.key}`
|
||||
case "Thinking":
|
||||
return `thinking:${row.userMessageID}`
|
||||
case "DiffSummary":
|
||||
return `diff-summary:${row.userMessageID}`
|
||||
case "Error":
|
||||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
}
|
||||
}
|
||||
|
||||
export function equals(a: TimelineRow, b: TimelineRow) {
|
||||
return Equal.equals(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Timeline {
|
||||
export function constructMessageRows(
|
||||
userMessage: UserMessage,
|
||||
|
|
|
|||
80
packages/app/src/pages/session/timeline/timeline-row.ts
Normal file
80
packages/app/src/pages/session/timeline/timeline-row.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
}> {}
|
||||
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
}> {}
|
||||
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
previousAssistantPart: boolean
|
||||
}> {}
|
||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||
userMessageID: string
|
||||
reasoningHeading?: string
|
||||
}> {}
|
||||
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
|
||||
userMessageID: string
|
||||
diffs: SummaryDiff[]
|
||||
}> {}
|
||||
export class Error extends Data.TaggedClass("Error")<{
|
||||
userMessageID: string
|
||||
text: string
|
||||
}> {}
|
||||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| TurnDivider
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
case "TurnGap":
|
||||
return `turn-gap:${row.userMessageID}`
|
||||
case "CommentStrip":
|
||||
return `comment-strip:${row.userMessageID}`
|
||||
case "UserMessage":
|
||||
return `user-message:${row.userMessageID}`
|
||||
case "TurnDivider":
|
||||
return `turn-divider:${row.userMessageID}:${row.label}`
|
||||
case "AssistantPart":
|
||||
return `assistant-part:${row.userMessageID}:${row.group.key}`
|
||||
case "Thinking":
|
||||
return `thinking:${row.userMessageID}`
|
||||
case "DiffSummary":
|
||||
return `diff-summary:${row.userMessageID}`
|
||||
case "Error":
|
||||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
}
|
||||
}
|
||||
|
||||
export function equals(a: TimelineRow, b: TimelineRow) {
|
||||
return Equal.equals(a, b)
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,9 @@ export interface BasicToolProps {
|
|||
animated?: boolean
|
||||
onSubtitleClick?: () => void
|
||||
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
|
||||
onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
|
||||
triggerHref?: string
|
||||
triggerAsLink?: boolean
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +256,7 @@ export function BasicTool(props: BasicToolProps) {
|
|||
return (
|
||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||
<Show
|
||||
when={props.triggerHref}
|
||||
when={props.triggerAsLink || props.triggerHref}
|
||||
fallback={
|
||||
<Collapsible.Trigger
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
|
|
@ -264,16 +266,17 @@ export function BasicTool(props: BasicToolProps) {
|
|||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
{(href) => (
|
||||
<Collapsible.Trigger
|
||||
as="a"
|
||||
href={href()}
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
onClick={props.onTriggerClick}
|
||||
>
|
||||
{trigger()}
|
||||
</Collapsible.Trigger>
|
||||
)}
|
||||
<Collapsible.Trigger
|
||||
as="a"
|
||||
href={props.triggerHref}
|
||||
role={!props.triggerHref && props.clickable ? "button" : undefined}
|
||||
tabIndex={!props.triggerHref && props.clickable ? 0 : undefined}
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
onClick={props.onTriggerClick}
|
||||
onKeyDown={props.onTriggerKeyDown}
|
||||
>
|
||||
{trigger()}
|
||||
</Collapsible.Trigger>
|
||||
</Show>
|
||||
<Show when={props.animated && hasChildren() && !props.hideDetails}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1000,16 +1000,24 @@ export function AssistantMessageDisplay(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
|
||||
export function ContextToolGroup(props: {
|
||||
parts: ToolPart[]
|
||||
busy?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [localOpen, setLocalOpen] = createSignal(false)
|
||||
const open = () => props.open ?? localOpen()
|
||||
const pending = createMemo(
|
||||
() =>
|
||||
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
|
||||
)
|
||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value)
|
||||
if (props.open === undefined) setLocalOpen(value)
|
||||
props.onOpenChange?.(value)
|
||||
props.onSizeChange?.()
|
||||
}
|
||||
|
||||
|
|
@ -1710,7 +1718,13 @@ ToolRegistry.register({
|
|||
trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<div
|
||||
data-component="tool-output"
|
||||
data-scrollable
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={i18n.t("ui.scrollView.ariaLabel")}
|
||||
>
|
||||
<Markdown text={props.output!} />
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -1734,7 +1748,13 @@ ToolRegistry.register({
|
|||
}}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<div
|
||||
data-component="tool-output"
|
||||
data-scrollable
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={i18n.t("ui.scrollView.ariaLabel")}
|
||||
>
|
||||
<Markdown text={props.output!} />
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -1761,7 +1781,13 @@ ToolRegistry.register({
|
|||
}}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<div
|
||||
data-component="tool-output"
|
||||
data-scrollable
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={i18n.t("ui.scrollView.ariaLabel")}
|
||||
>
|
||||
<Markdown text={props.output!} />
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -1887,6 +1913,12 @@ ToolRegistry.register({
|
|||
event.preventDefault()
|
||||
open()
|
||||
}
|
||||
const navigateKey = (event: KeyboardEvent) => {
|
||||
if (!clickable() || href()) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
open()
|
||||
}
|
||||
|
||||
const trigger = () => (
|
||||
<div data-component="task-tool-card">
|
||||
|
|
@ -1919,9 +1951,11 @@ ToolRegistry.register({
|
|||
status={props.status}
|
||||
trigger={trigger()}
|
||||
hideDetails
|
||||
triggerAsLink
|
||||
triggerHref={href()}
|
||||
clickable={clickable()}
|
||||
onTriggerClick={navigate}
|
||||
onTriggerKeyDown={navigateKey}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
|
@ -1979,7 +2013,13 @@ ToolRegistry.register({
|
|||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<div data-slot="bash-scroll" data-scrollable>
|
||||
<div
|
||||
data-slot="bash-scroll"
|
||||
data-scrollable
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={i18n.t("ui.scrollView.ariaLabel")}
|
||||
>
|
||||
<pre data-slot="bash-pre">
|
||||
<code>{text()}</code>
|
||||
</pre>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
|
||||
import { canScrollKey, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
|
||||
|
||||
describe("scrollKey", () => {
|
||||
test("maps plain navigation keys", () => {
|
||||
|
|
@ -16,6 +16,27 @@ describe("scrollKey", () => {
|
|||
expect(scrollKey({ key: "PageUp", altKey: false, ctrlKey: true, metaKey: false, shiftKey: false })).toBeUndefined()
|
||||
expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps space and shift-space directions", () => {
|
||||
expect(scrollKey({ key: " ", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })).toBe("page-down")
|
||||
expect(scrollKey({ key: " ", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBe("page-up")
|
||||
})
|
||||
})
|
||||
|
||||
describe("canScrollKey", () => {
|
||||
const element = (scrollTop: number, clientHeight = 100, scrollHeight = 300) =>
|
||||
({ scrollTop, clientHeight, scrollHeight }) as HTMLElement
|
||||
|
||||
test("owns upward keys only above the top boundary", () => {
|
||||
expect(canScrollKey(element(50), "page-up")).toBe(true)
|
||||
expect(canScrollKey(element(0), "page-up")).toBe(false)
|
||||
})
|
||||
|
||||
test("owns downward keys only before the bottom boundary", () => {
|
||||
expect(canScrollKey(element(50), "page-down")).toBe(true)
|
||||
expect(canScrollKey(element(200), "page-down")).toBe(false)
|
||||
expect(canScrollKey(element(0, 100, 100), "page-down")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("scrollTopFromThumbPointer", () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ export interface ScrollViewProps extends ComponentProps<"div"> {
|
|||
}
|
||||
|
||||
export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
if (event.shiftKey && event.key !== " ") return
|
||||
|
||||
switch (event.key) {
|
||||
case "PageDown":
|
||||
|
|
@ -24,9 +25,36 @@ export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey
|
|||
return "up"
|
||||
case "ArrowDown":
|
||||
return "down"
|
||||
case " ":
|
||||
return event.shiftKey ? "page-up" : "page-down"
|
||||
}
|
||||
}
|
||||
|
||||
export function canScrollKey(element: HTMLElement, key: NonNullable<ReturnType<typeof scrollKey>>) {
|
||||
const up = key === "up" || key === "page-up" || key === "home"
|
||||
return up ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight
|
||||
}
|
||||
|
||||
export function scrollKeyOwner(
|
||||
root: HTMLElement,
|
||||
target: EventTarget | null,
|
||||
key: NonNullable<ReturnType<typeof scrollKey>>,
|
||||
) {
|
||||
const element = target instanceof Element ? target : undefined
|
||||
const owner = element?.closest<HTMLElement>("[data-scrollable]")
|
||||
if (!owner || owner === root) return root
|
||||
if (!root.contains(owner)) return owner
|
||||
return canScrollKey(owner, key) ? owner : root
|
||||
}
|
||||
|
||||
export function isScrollKeyTarget(target: EventTarget | null, key: NonNullable<ReturnType<typeof scrollKey>>) {
|
||||
const element = target instanceof HTMLElement ? target : undefined
|
||||
if (!element) return true
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName) || element.isContentEditable) return false
|
||||
if ((key === "page-up" || key === "page-down") && element.closest('button, a[href], [role="button"]')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function scrollTopFromThumbPointer(input: {
|
||||
pointer: number
|
||||
viewportTop: number
|
||||
|
|
@ -160,9 +188,10 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = scrollKey(e)
|
||||
if (!next) return
|
||||
if (!isScrollKeyTarget(e.target, next)) return
|
||||
if (scrollKeyOwner(viewportRef, e.target, next) !== viewportRef) return
|
||||
|
||||
const scrollAmount = viewportRef.clientHeight * 0.8
|
||||
const lineAmount = 40
|
||||
|
|
@ -208,6 +237,7 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
<div
|
||||
ref={viewportRef}
|
||||
class="scroll-view__viewport"
|
||||
data-scrollable
|
||||
onScroll={(e) => {
|
||||
updateThumb()
|
||||
if (typeof events.onScroll === "function") events.onScroll(e as any)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue