mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
923 lines
38 KiB
TypeScript
923 lines
38 KiB
TypeScript
import { expect, test, type Locator, type Page } from "@playwright/test"
|
|
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
|
import { createTwoFilesPatch } from "diff"
|
|
import {
|
|
assistantMessage,
|
|
historyMessages,
|
|
partUpdated,
|
|
renderedPartID,
|
|
session,
|
|
sessionID,
|
|
setupTimeline,
|
|
shell,
|
|
textPart,
|
|
toolPart,
|
|
userMessage,
|
|
userText,
|
|
validateTimelineMessages,
|
|
waitForVisualSettle,
|
|
} from "../utils/timeline"
|
|
import {
|
|
analyzeVisualObservations,
|
|
defineVisualRegions,
|
|
startVisualProbe,
|
|
stopVisualProbe,
|
|
visualPlan,
|
|
} from "../utils/visual-stability"
|
|
|
|
const expanded = { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true }
|
|
|
|
const contextTools = [
|
|
{ id: "ctx_0100_read", tool: "read", input: { path: "src/recent-a.ts", offset: 0, limit: 120 } },
|
|
{ id: "ctx_0101_glob", tool: "glob", input: { path: "C:/OpenCode/TimelineStability", pattern: "**/*.ts" } },
|
|
{
|
|
id: "ctx_0102_grep",
|
|
tool: "grep",
|
|
input: { path: "C:/OpenCode/TimelineStability", pattern: "Explored", include: "*.ts" },
|
|
},
|
|
{ id: "ctx_0103_list", tool: "list", input: { path: "src" } },
|
|
]
|
|
|
|
const contextSelector = `[data-timeline-part-ids="${contextTools.map((tool) => tool.id).join(",")}"]`
|
|
|
|
const output = (tool: string) => `Completed ${tool}.\n${"detail line\n".repeat(8)}`
|
|
|
|
function contextTurn(status: "running" | "completed") {
|
|
return [
|
|
userMessage(),
|
|
assistantMessage([
|
|
...contextTools.map((tool) =>
|
|
status === "running"
|
|
? toolPart(tool.id, tool.tool, "running", tool.input)
|
|
: toolPart(tool.id, tool.tool, "completed", tool.input, { output: output(tool.tool) }),
|
|
),
|
|
textPart("prt_after_context", "This assistant text is immediately after the explored context group."),
|
|
]),
|
|
]
|
|
}
|
|
|
|
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
|
|
await setupTimeline(page, { seedHistory: true, settings: expanded, messages: contextTurn("completed") })
|
|
const following = `[data-timeline-part-id="${renderedPartID("prt_after_context")}"]`
|
|
await expect(page.locator(contextSelector)).toBeVisible()
|
|
await expect(page.locator(following)).toBeVisible()
|
|
await waitForVisualSettle(page, [contextSelector, following])
|
|
|
|
// Samples every painted frame after the click until the open group's row holds its measured content, so a
|
|
// stale row height shows up as overlap before the virtualizer catches up.
|
|
const samples = await page.evaluate(
|
|
({ contextSelector, following }) =>
|
|
new Promise<{ frame: number; overlap: number; expanded: string | null; measured: boolean }[]>((resolve) => {
|
|
const context = document.querySelector<HTMLElement>(contextSelector)
|
|
const text = document.querySelector<HTMLElement>(following)
|
|
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
|
|
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
|
|
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
|
const virtualRow = context?.closest<HTMLElement>("[data-timeline-key]")
|
|
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
|
|
|
if (!scroller || !trigger || !contextRow || !virtualRow || !textRow) throw new Error("missing regression nodes")
|
|
scroller.scrollTop = scroller.scrollHeight
|
|
const samples: { frame: number; overlap: number; expanded: string | null; measured: boolean }[] = []
|
|
|
|
const capture = (frame: number) => {
|
|
const content = context!.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
|
|
const allocated = virtualRow.getBoundingClientRect().height
|
|
const inner = virtualRow.firstElementChild?.getBoundingClientRect().height ?? Infinity
|
|
samples.push({
|
|
frame,
|
|
overlap: Math.max(
|
|
0,
|
|
Math.round((contextRow.getBoundingClientRect().bottom - textRow.getBoundingClientRect().top) * 10) / 10,
|
|
),
|
|
expanded: trigger.getAttribute("aria-expanded"),
|
|
measured:
|
|
!!content &&
|
|
Math.abs(content.getBoundingClientRect().height - content.scrollHeight) <= 1 &&
|
|
Math.abs(allocated - inner) <= 1,
|
|
})
|
|
}
|
|
|
|
capture(-1)
|
|
trigger.click()
|
|
capture(0)
|
|
|
|
const tick = (frame: number) =>
|
|
setTimeout(() => {
|
|
capture(frame)
|
|
const last = samples.at(-1)!
|
|
|
|
if (last.expanded === "true" && last.measured) return resolve(samples)
|
|
requestAnimationFrame(() => tick(frame + 1))
|
|
}, 0)
|
|
|
|
requestAnimationFrame(() => tick(1))
|
|
}),
|
|
{ contextSelector, following },
|
|
)
|
|
|
|
expect(samples[0]?.overlap).toBe(0)
|
|
expect(samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)).toEqual([])
|
|
expect(samples.at(-1)).toMatchObject({ expanded: "true", measured: true })
|
|
})
|
|
|
|
test("keeps a grouped tool summary stable as its calls complete", async ({ page }) => {
|
|
const timeline = await setupTimeline(page, {
|
|
seedHistory: true,
|
|
settings: expanded,
|
|
messages: contextTurn("running"),
|
|
cpuRate: 4,
|
|
})
|
|
|
|
const context = page.locator(contextSelector)
|
|
const label = "Used 4 Read, Glob, Grep, List"
|
|
const trigger = context.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
|
await expect(trigger).toHaveAccessibleName(label)
|
|
// Open the group so each call shows whether it is still running.
|
|
await trigger.click()
|
|
|
|
const status = (tool: string) =>
|
|
context.locator(
|
|
`[data-slot="context-tool-group-item"] [data-component="text-shimmer"][aria-label="${tool[0]!.toUpperCase()}${tool.slice(1)}"]`,
|
|
)
|
|
|
|
for (const tool of contextTools) await expect(status(tool.tool)).toHaveAttribute("data-active", "true")
|
|
const following = `[data-timeline-part-id="${renderedPartID("prt_after_context")}"]`
|
|
await waitForVisualSettle(page, [contextSelector, following])
|
|
|
|
const regions = defineVisualRegions({
|
|
status: { selector: `${contextSelector} [data-component="context-tool-group-trigger"]` },
|
|
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
|
following: { selector: following, closest: '[data-timeline-row="AssistantPart"]' },
|
|
})
|
|
|
|
await startVisualProbe(page, regions)
|
|
|
|
// Each completion lands in its own settled frame so the probe sees every intermediate state.
|
|
for (const tool of contextTools) {
|
|
await timeline.send(
|
|
partUpdated(toolPart(tool.id, tool.tool, "completed", tool.input, { output: output(tool.tool) })),
|
|
)
|
|
await expect(status(tool.tool)).toHaveAttribute("data-active", "false")
|
|
await waitForVisualSettle(page, [contextSelector, following])
|
|
}
|
|
|
|
for (const tool of contextTools) await expect(status(tool.tool)).toHaveAttribute("data-active", "false")
|
|
await expect(trigger).toHaveAccessibleName(label)
|
|
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([label])
|
|
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
|
})
|
|
|
|
test("keeps a file diff anchored while it expands and collapses", async ({ page }) => {
|
|
const id = "prt_file_projection_anchored_patch"
|
|
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
|
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
|
|
await setupTimeline(page, {
|
|
settings: {
|
|
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
|
},
|
|
messages: [
|
|
userMessage(),
|
|
assistantMessage([
|
|
textPart("prt_file_projection_preceding", "Preceding context ".repeat(240)),
|
|
toolPart(
|
|
id,
|
|
"patch",
|
|
"completed",
|
|
{ patchText: "Update src/anchored.ts" },
|
|
{
|
|
metadata: {
|
|
files: [
|
|
{
|
|
file: "src/anchored.ts",
|
|
status: "modified",
|
|
patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after),
|
|
additions: 80,
|
|
deletions: 80,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
),
|
|
]),
|
|
],
|
|
viewport: { width: 1200, height: 600 },
|
|
})
|
|
|
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
|
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
|
const diff = wrapper.locator('[data-component="apply-patch-file-diff"]')
|
|
const row = page.locator("[data-timeline-key]", { has: wrapper })
|
|
const trigger = wrapper.getByRole("button")
|
|
|
|
const bottom = () =>
|
|
scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop))
|
|
|
|
const measured = () =>
|
|
row.evaluate((element) => {
|
|
const content = element.querySelector<HTMLElement>("[data-index]")
|
|
|
|
return content
|
|
? Math.abs(element.getBoundingClientRect().height - content.getBoundingClientRect().height)
|
|
: Number.POSITIVE_INFINITY
|
|
})
|
|
|
|
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
|
await expect.poll(measured).toBeLessThanOrEqual(1)
|
|
|
|
// Pinned to the tail, toggling the diff keeps the view at the bottom.
|
|
for (const visible of [true, false]) {
|
|
await trigger.click()
|
|
await expect(diff).toHaveCount(visible ? 1 : 0)
|
|
await expect.poll(bottom).toBeLessThanOrEqual(1)
|
|
}
|
|
|
|
// Scrolled away from the tail, the header keeps its viewport position.
|
|
await expect
|
|
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
|
.toBeGreaterThan(1)
|
|
await scroller.evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
|
|
})
|
|
await expect(trigger).toBeInViewport()
|
|
await expect.poll(bottom).toBeLessThanOrEqual(0.5)
|
|
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
|
|
await scroller.hover()
|
|
await page.mouse.wheel(0, -20)
|
|
await expect
|
|
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
|
|
.toBeGreaterThan(0)
|
|
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
|
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
|
|
await trigger.click()
|
|
await expect(diff).toBeVisible()
|
|
await expect
|
|
.poll(() =>
|
|
row.evaluate((element, collapsed) => {
|
|
const content = element.querySelector<HTMLElement>("[data-index]")
|
|
const allocated = element.getBoundingClientRect().height
|
|
|
|
return {
|
|
grew: allocated > collapsed + 1,
|
|
measured: content ? Math.abs(allocated - content.getBoundingClientRect().height) <= 1 : false,
|
|
}
|
|
}, collapsedHeight),
|
|
)
|
|
.toEqual({ grew: true, measured: true })
|
|
await expect
|
|
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
|
|
.toBeLessThanOrEqual(5)
|
|
|
|
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
|
|
await scroller.hover()
|
|
await page.mouse.wheel(0, 200)
|
|
await expect
|
|
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
|
|
.toBeGreaterThan(50)
|
|
expect(await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)).toBeLessThan(400)
|
|
|
|
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
|
await trigger.click()
|
|
await expect(diff).toHaveCount(0)
|
|
await expect.poll(bottom).toBeLessThanOrEqual(1)
|
|
await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY)
|
|
|
|
await trigger.click()
|
|
await expect(diff).toBeVisible()
|
|
await expect.poll(measured).toBeLessThanOrEqual(1)
|
|
await expect.poll(bottom).toBeLessThanOrEqual(1)
|
|
})
|
|
|
|
for (const outline of [
|
|
{ name: "shell outline at 1.25x", kind: "shell", deviceScaleFactor: 1.25 },
|
|
{ name: "shell outline at 1.5x", kind: "shell", deviceScaleFactor: 1.5 },
|
|
{ name: "patch card", kind: "patch", deviceScaleFactor: undefined },
|
|
] as const) {
|
|
test(`keeps the ${outline.name} inside a fractionally short virtual row`, async ({ page }) => {
|
|
const partID = outline.kind === "shell" ? "prt_shell_outline" : "prt_patch_outline"
|
|
const secondUserID = "msg_outline_second_user"
|
|
|
|
const tool =
|
|
outline.kind === "shell"
|
|
? shell(partID, "completed", "shell output")
|
|
: toolPart(
|
|
partID,
|
|
"patch",
|
|
"completed",
|
|
{ patchText: "Update src/outline.ts" },
|
|
{
|
|
metadata: {
|
|
files: [
|
|
{
|
|
file: "src/outline.ts",
|
|
status: "modified",
|
|
patch:
|
|
"diff --git a/src/outline.ts b/src/outline.ts\n--- a/src/outline.ts\n+++ b/src/outline.ts\n@@ -1 +1 @@\n-const outline = false\n+const outline = true\n",
|
|
additions: 1,
|
|
deletions: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
)
|
|
|
|
// A second turn adds the fixed turn gap, which must not get the paint-rounding margin.
|
|
await setupTimeline(page, {
|
|
messages: [
|
|
userMessage(undefined, {
|
|
summary: {
|
|
diffs: [
|
|
{
|
|
file: "src/summary.ts",
|
|
additions: 1,
|
|
deletions: 1,
|
|
status: "modified",
|
|
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
assistantMessage([tool]),
|
|
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
|
assistantMessage([], { id: "msg_outline_second_assistant", parentID: secondUserID, created: 1700000011000 }),
|
|
],
|
|
settings:
|
|
outline.kind === "shell"
|
|
? { shellToolPartsExpanded: true }
|
|
: { timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } } },
|
|
reducedMotion: true,
|
|
deviceScaleFactor: outline.deviceScaleFactor,
|
|
})
|
|
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
|
|
|
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
|
elements.map((element) => ({
|
|
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
|
clipMargin: getComputedStyle(element).overflowClipMargin,
|
|
})),
|
|
)
|
|
|
|
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
|
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
|
|
|
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
|
const row = page.locator("[data-timeline-key]", { has: part })
|
|
|
|
if (outline.kind === "patch") {
|
|
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
|
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
|
|
|
const geometry = await row.evaluate((element) => {
|
|
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
|
|
|
if (!card) throw new Error("Patch card is unavailable")
|
|
const cardRect = card.getBoundingClientRect()
|
|
element.style.height = `${cardRect.bottom - element.getBoundingClientRect().top - 0.49}px`
|
|
const clipMargin = getComputedStyle(element).overflowClipMargin
|
|
const bottom = element.getBoundingClientRect().bottom
|
|
|
|
return {
|
|
overflow: card.getBoundingClientRect().bottom - bottom,
|
|
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
|
cardWidth: cardRect.width,
|
|
cardHeight: cardRect.height,
|
|
}
|
|
})
|
|
|
|
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
|
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
|
const edges = await captureCardEdges(page, card)
|
|
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
|
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
|
expect(edges.luminance.top).toBeLessThan(245)
|
|
expect(edges.luminance.bottom).toBeLessThan(245)
|
|
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
|
|
|
return
|
|
}
|
|
|
|
const bash = part.locator('[data-component="bash-output"]')
|
|
await expect(bash).toBeVisible()
|
|
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"] [data-component="bash-output"]`])
|
|
|
|
const geometry = await row.evaluate((element) => {
|
|
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
|
|
|
if (!output) throw new Error("Shell output is unavailable")
|
|
const outputRect = output.getBoundingClientRect()
|
|
// Match a rounded-down measurement at a fractional device-pixel phase.
|
|
element.style.height = `${outputRect.bottom - element.getBoundingClientRect().top - 0.49}px`
|
|
element.style.transform = "translateY(0.25px)"
|
|
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
|
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
|
const style = getComputedStyle(output)
|
|
|
|
return {
|
|
outputWidth: outputRect.width,
|
|
outputHeight: outputRect.height,
|
|
borderColor: style.borderTopColor,
|
|
boxShadow: style.boxShadow,
|
|
}
|
|
})
|
|
|
|
await expect
|
|
.poll(() =>
|
|
row.evaluate(
|
|
(element) =>
|
|
element.querySelector<HTMLElement>('[data-component="bash-output"]')!.getBoundingClientRect().bottom -
|
|
element.getBoundingClientRect().bottom,
|
|
),
|
|
)
|
|
.toBeCloseTo(0.49, 1)
|
|
expect(await page.evaluate(() => devicePixelRatio)).toBe(outline.deviceScaleFactor)
|
|
const edges = await captureCardEdges(page, bash)
|
|
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
|
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
|
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
|
expect(geometry.boxShadow).toBe("none")
|
|
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
|
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
|
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
|
})
|
|
}
|
|
|
|
for (const failed of [false, true]) {
|
|
test(`keeps a submitted prompt in place until Working shows${failed ? " after an error" : ""}`, async ({ page }) => {
|
|
await setupTimeline(page, {
|
|
sessionMessages: [
|
|
...validateTimelineMessages([
|
|
...historyMessages(18),
|
|
userMessage(),
|
|
...(failed
|
|
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
|
|
: []),
|
|
]),
|
|
// The previous execution ended at an idle marker, so the submitted prompt starts a turn instead of
|
|
// waiting as a steer behind that one.
|
|
{
|
|
id: "msg_1002_timeline_idle",
|
|
type: "idle",
|
|
outcome: failed ? "failed" : "succeeded",
|
|
time: { created: 1700000003000 },
|
|
},
|
|
],
|
|
})
|
|
const working = page.locator('[data-component="session-working"]')
|
|
await expect(working).toHaveCount(0)
|
|
const release = Promise.withResolvers<void>()
|
|
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
|
if (route.request().method() !== "POST") return route.fallback()
|
|
await release.promise
|
|
|
|
return route.fallback()
|
|
})
|
|
|
|
const text = "Observe optimistic prompt spacing."
|
|
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
|
await expect(editor).toBeEditable()
|
|
await editor.fill(text)
|
|
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
|
|
|
const bottom = () =>
|
|
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
|
const root = element.parentElement!
|
|
|
|
return root.scrollHeight - root.clientHeight - root.scrollTop
|
|
})
|
|
|
|
await expect.poll(bottom).toBe(0)
|
|
|
|
// Records the prompt row in every frame from submission until Working has shown.
|
|
const observation = await page.evaluateHandle((text) => {
|
|
const frames: { prompt: number | undefined; working: boolean }[] = []
|
|
let frame = 0
|
|
|
|
const sample = () => {
|
|
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
|
row.textContent?.includes(text),
|
|
)
|
|
|
|
frames.push({
|
|
prompt: prompt?.getBoundingClientRect().y,
|
|
working: !!document.querySelector('[data-component="session-working"]'),
|
|
})
|
|
frame = requestAnimationFrame(sample)
|
|
}
|
|
|
|
frame = requestAnimationFrame(sample)
|
|
|
|
return {
|
|
stop: () => {
|
|
cancelAnimationFrame(frame)
|
|
|
|
return frames
|
|
},
|
|
}
|
|
}, text)
|
|
|
|
const requested = page.waitForRequest(
|
|
(request) =>
|
|
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
|
)
|
|
|
|
try {
|
|
await editor.press("Enter")
|
|
expect((await requested).postDataJSON()).toMatchObject({ text })
|
|
await expect(page.locator('[data-timeline-row="UserMessage"]').filter({ hasText: text })).toBeInViewport()
|
|
await expect(working).toHaveRole("status")
|
|
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
|
await expect(working).toBeInViewport()
|
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
|
await expect.poll(bottom).toBe(0)
|
|
const frames = await observation.evaluate((value) => value.stop())
|
|
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
|
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
|
expect(positions.length).toBeGreaterThan(0)
|
|
expect(new Set(positions).size).toBe(1)
|
|
} finally {
|
|
release.resolve()
|
|
await observation.dispose()
|
|
}
|
|
})
|
|
}
|
|
|
|
async function captureCardEdges(page: Page, card: Locator) {
|
|
const box = await card.boundingBox()
|
|
|
|
if (!box) throw new Error("Tool card bounds are unavailable")
|
|
const viewport = page.viewportSize()
|
|
|
|
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
|
const screenshot = await page.screenshot()
|
|
|
|
return page.evaluate(
|
|
async ({ source, box, viewport }) => {
|
|
const image = new Image()
|
|
image.src = source
|
|
await image.decode()
|
|
const canvas = document.createElement("canvas")
|
|
canvas.width = image.naturalWidth
|
|
canvas.height = image.naturalHeight
|
|
const context = canvas.getContext("2d")
|
|
|
|
if (!context) throw new Error("2D canvas is unavailable")
|
|
context.drawImage(image, 0, 0)
|
|
const scale = { x: image.naturalWidth / viewport.width, y: image.naturalHeight / viewport.height }
|
|
|
|
const rows = (candidates: number[]) => {
|
|
const left = Math.floor((box.x + 8) * scale.x)
|
|
const width = Math.floor((box.width - 16) * scale.x)
|
|
|
|
return candidates.map((row) => {
|
|
const pixels = context.getImageData(left, row, width, 1).data
|
|
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
|
|
|
return {
|
|
luminance:
|
|
indexes
|
|
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
|
.reduce((sum, value) => sum + value, 0) / width,
|
|
magenta:
|
|
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
|
.length / width,
|
|
}
|
|
})
|
|
}
|
|
|
|
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
|
const columns = new Uint32Array(image.naturalWidth)
|
|
|
|
for (let index = 0; index < pixels.length; index += 4) {
|
|
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
|
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
|
}
|
|
|
|
const top = box.y * scale.y
|
|
const bottom = (box.y + box.height) * scale.y
|
|
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
|
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
|
|
|
return {
|
|
box,
|
|
luminance: {
|
|
top: Math.min(...topRows.map((row) => row.luminance)),
|
|
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
|
},
|
|
magenta: {
|
|
top: Math.max(...topRows.map((row) => row.magenta)),
|
|
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
|
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
|
},
|
|
}
|
|
},
|
|
{ source: `data:image/png;base64,${screenshot.toString("base64")}`, viewport, box },
|
|
)
|
|
}
|
|
|
|
test("scrolls the timeline behind the composer region, fading at the card, and keeps its end above the region", async ({
|
|
page,
|
|
}) => {
|
|
await setupTimeline(page, {
|
|
sessionMessages: [
|
|
...validateTimelineMessages(historyMessages(18)),
|
|
{ id: "msg_9000_user", type: "user", text: "Split the work.", time: { created: 1 } },
|
|
{
|
|
id: "msg_9001_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model: { id: "model", providerID: "provider" },
|
|
content: [
|
|
{
|
|
type: "tool",
|
|
id: "call_running_subagent",
|
|
name: "subagent",
|
|
state: {
|
|
status: "running",
|
|
input: { description: "Trace frame budget", agent: "explore" },
|
|
metadata: { sessionID: "ses_running_subagent" },
|
|
},
|
|
time: { created: 2 },
|
|
},
|
|
{ type: "text", id: "prt_timeline_end", text: "The last answer ends above the composer region." },
|
|
],
|
|
time: { created: 2 },
|
|
},
|
|
],
|
|
sessions: [
|
|
session(),
|
|
session({ id: "ses_running_subagent", parentID: sessionID, title: "Trace frame budget", agent: "explore" }),
|
|
],
|
|
sessionStatus: { [sessionID]: { type: "busy" }, ses_running_subagent: { type: "busy" } },
|
|
})
|
|
const dock = page.locator('[data-component="session-composer-dock"]')
|
|
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
|
await expect(page.getByRole("button", { name: "1 subagent", exact: true })).toBeVisible()
|
|
await expect(page.locator('[data-component="session-background-hint-row"]')).toBeVisible()
|
|
await expect(page.getByText("The last answer ends above the composer region.", { exact: true })).toBeVisible()
|
|
|
|
const geometry = () =>
|
|
page.evaluate(() => {
|
|
const viewport = document.querySelector<HTMLElement>('[data-slot="session-timeline-scroll"] > *')!
|
|
const region = document.querySelector('[data-component="session-composer-dock"]')!
|
|
const dock = region.getBoundingClientRect()
|
|
const card = region.querySelector('[data-component="composer"]')!.getBoundingClientRect()
|
|
const pill = region.querySelector('[data-slot="session-activity-chip"]')!.getBoundingClientRect()
|
|
const hint = viewport.querySelector('[data-component="session-background-hint-row"]')!.getBoundingClientRect()
|
|
const parts = [...viewport.querySelectorAll("[data-timeline-part-id]")]
|
|
const last = parts.find((element) => element.textContent?.includes("The last answer ends"))!
|
|
|
|
return {
|
|
// The scroll area runs on behind the composer region to the panel's bottom edge.
|
|
behind: Math.round(viewport.getBoundingClientRect().bottom - dock.bottom),
|
|
inset: Math.round(viewport.querySelector<HTMLElement>("[data-timeline-inset]")!.offsetHeight - dock.height),
|
|
// Content fades out where the composer card starts.
|
|
fade: Math.round(
|
|
dock.bottom - card.top - parseFloat(getComputedStyle(viewport).getPropertyValue("--session-timeline-inset")),
|
|
),
|
|
// The pill floats above the region out of flow, so it never changes the inset the timeline scrolls above.
|
|
floats: pill.bottom <= dock.top,
|
|
// It floats in the timeline's reserved tail, below the background hint and the last answer.
|
|
reserved: hint.bottom <= pill.top && last.getBoundingClientRect().bottom <= hint.top,
|
|
atEnd: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= 1,
|
|
}
|
|
})
|
|
|
|
const expected = { behind: 0, inset: 0, fade: 0, floats: true, reserved: true, atEnd: true }
|
|
await expect.poll(geometry).toEqual(expected)
|
|
const before = (await dock.boundingBox())!.height
|
|
|
|
await editor.click()
|
|
await page.keyboard.type("A draft")
|
|
|
|
for (const line of ["that grows", "the composer", "by several lines"]) {
|
|
await page.keyboard.press("Shift+Enter")
|
|
await page.keyboard.type(line)
|
|
}
|
|
|
|
await expect.poll(async () => (await dock.boundingBox())!.height).toBeGreaterThan(before + 40)
|
|
await expect.poll(geometry).toEqual(expected)
|
|
})
|
|
|
|
test("groups consecutive sent prompts under the last one's meta line, with each bubble's actions beside it", async ({
|
|
page,
|
|
}) => {
|
|
const prompt = (id: string, text: string, created: number) =>
|
|
userMessage([userText(text, { id: `prt_${id}_text` })], { id, created })
|
|
|
|
await setupTimeline(page, {
|
|
messages: [
|
|
prompt("msg_0001_run", "Group these prompts.", 1700000000000),
|
|
prompt("msg_0002_run", "Like the iOS app does.", 1700000001000),
|
|
],
|
|
})
|
|
|
|
const row = (id: string) =>
|
|
page.locator(`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${id}"]`)
|
|
|
|
const first = row("msg_0001_run")
|
|
const last = row("msg_0002_run")
|
|
const bubble = (row: Locator) => row.locator('[data-slot="user-message-text"]')
|
|
await expect(bubble(last)).toHaveText("Like the iOS app does.")
|
|
|
|
// Bubbles of a run sit 4px apart, as in the iOS app, and only the last carries the meta line.
|
|
await expect
|
|
.poll(async () => {
|
|
const above = (await bubble(first).boundingBox())!
|
|
const below = (await bubble(last).boundingBox())!
|
|
|
|
return Math.round(below.y - above.y - above.height)
|
|
})
|
|
.toBe(4)
|
|
await expect(first.locator('[data-slot="user-message-copy-wrapper"]')).toBeHidden()
|
|
await expect(last.locator('[data-slot="user-message-copy-wrapper"]')).toBeVisible()
|
|
|
|
// The first message keeps its own actions, shown beside its bubble on hover.
|
|
await bubble(first).hover()
|
|
await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1")
|
|
await expect(first.getByRole("button", { name: "More actions" })).toHaveCount(1)
|
|
})
|
|
|
|
test("leaves a long prompt unfolded while Fold long messages is off", async ({ page }) => {
|
|
const text = Array.from({ length: 20 }, (_, index) => `Long line ${index + 1}`).join("\n")
|
|
|
|
await setupTimeline(page, {
|
|
messages: [
|
|
userMessage([userText(text, { id: "prt_msg_0001_nofold_text" })], {
|
|
id: "msg_0001_nofold",
|
|
created: 1700000000000,
|
|
}),
|
|
],
|
|
viewport: { width: 1200, height: 700 },
|
|
})
|
|
|
|
const row = page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_0001_nofold"]')
|
|
await expect(row).toContainText("Long line 20")
|
|
await expect(row.locator('[data-slot="user-message-fold"]')).toHaveCount(0)
|
|
await expect(row.getByRole("button", { name: "Read more" })).toHaveCount(0)
|
|
})
|
|
|
|
test("folds a prompt past 12 lines to its first 8 and keeps it open after scrolling out of view", async ({ page }) => {
|
|
const lines = (name: string, count: number) =>
|
|
Array.from({ length: count }, (_, index) => `${name} line ${index + 1}`).join("\n")
|
|
|
|
const prompt = (id: string, text: string, created: number) =>
|
|
userMessage([userText(text, { id: `prt_${id}_text` })], { id, created })
|
|
|
|
const reply = (id: string, parentID: string, created: number) =>
|
|
assistantMessage(
|
|
[{ id: `prt_${id}_text`, type: "text", text: `Reply ${id}. ${"Later work fills the timeline. ".repeat(12)}` }],
|
|
{ id, parentID, created },
|
|
)
|
|
|
|
await setupTimeline(page, {
|
|
messages: [
|
|
prompt("msg_0001_fold_edge", lines("Edge", 12), 1700000000000),
|
|
reply("msg_0002_fold_edge", "msg_0001_fold_edge", 1700000001000),
|
|
prompt("msg_0003_fold_long", lines("Long", 13), 1700000002000),
|
|
reply("msg_0004_fold_long", "msg_0003_fold_long", 1700000003000),
|
|
...Array.from({ length: 16 }, (_, index) => {
|
|
const created = 1700000010000 + index * 2_000
|
|
const id = `msg_${String(100 + index * 2).padStart(4, "0")}_fold_later`
|
|
|
|
return [prompt(id, `Later prompt ${index}`, created), reply(`${id}_reply`, id, created + 1_000)]
|
|
}).flat(),
|
|
],
|
|
settings: { foldUserMessages: true },
|
|
viewport: { width: 1200, height: 700 },
|
|
})
|
|
|
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
|
|
|
const row = (id: string) =>
|
|
page.locator(`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${id}"]`)
|
|
|
|
const edge = row("msg_0001_fold_edge")
|
|
const long = row("msg_0003_fold_long")
|
|
const bubble = long.locator('[data-slot="user-message-text"]')
|
|
const fold = long.locator('[data-slot="user-message-fold"]')
|
|
const lastLater = row("msg_0130_fold_later")
|
|
await expect(lastLater).toBeVisible()
|
|
|
|
// A wheel, not a scroll write, so the timeline stops following its end.
|
|
await scroller.hover()
|
|
await page.mouse.wheel(0, -100_000)
|
|
await expect(long).toBeVisible()
|
|
|
|
// 12 lines show in full; 13 fold to 8, and all 13 stay in the text for copy and select-all.
|
|
await expect(edge.locator('[data-slot="user-message-fold"]')).toContainText("Edge line 12")
|
|
await expect(edge.getByRole("button", { name: "Read more" })).toHaveCount(0)
|
|
const toggle = long.getByRole("button", { name: /^(Read more|Show less)$/ })
|
|
await expect(toggle).toHaveAccessibleName("Read more")
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
|
const lineHeight = await fold.evaluate((element) => parseFloat(getComputedStyle(element).lineHeight))
|
|
await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(8)
|
|
expect(
|
|
await fold.evaluate((element) => {
|
|
const selection = getSelection()!
|
|
selection.selectAllChildren(element)
|
|
const text = selection.toString()
|
|
selection.removeAllRanges()
|
|
|
|
return text
|
|
}),
|
|
).toContain("Long line 13")
|
|
|
|
// A cached tab's timeline is detached while hidden; the message stays folded rather than measuring 0 lines.
|
|
expect(
|
|
await bubble.evaluate(async (element) => {
|
|
const parent = element.parentElement!
|
|
const next = element.nextSibling
|
|
element.remove()
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
const folded = element.querySelector('[data-slot="user-message-fold"]')!.hasAttribute("data-folded")
|
|
parent.insertBefore(element, next)
|
|
|
|
return folded
|
|
}),
|
|
).toBe(true)
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
|
|
|
// Opening grows the bubble downward, and the virtual row takes its new height.
|
|
await bubble.evaluate((element) => element.scrollIntoView({ block: "center" }))
|
|
const top = (await bubble.boundingBox())!.y
|
|
await toggle.click()
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
|
await expect(toggle).toHaveAccessibleName("Show less")
|
|
await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(13)
|
|
expect(Math.abs((await bubble.boundingBox())!.y - top)).toBeLessThanOrEqual(1)
|
|
await expect
|
|
.poll(() =>
|
|
long.evaluate((element) => {
|
|
const allocated = element.closest<HTMLElement>("[data-timeline-key]")!.getBoundingClientRect().height
|
|
const content = element.closest<HTMLElement>("[data-index]")!.getBoundingClientRect().height
|
|
|
|
return Math.abs(allocated - content)
|
|
}),
|
|
)
|
|
.toBeLessThanOrEqual(1)
|
|
|
|
// The row unmounts at the end of the timeline and comes back open.
|
|
await page.mouse.wheel(0, 100_000)
|
|
await expect(lastLater).toBeVisible()
|
|
await expect(long).toHaveCount(0)
|
|
await page.mouse.wheel(0, -100_000)
|
|
await expect(long.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true")
|
|
await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(13)
|
|
|
|
// Closing a message whose top is on screen shrinks it in place, without scrolling the timeline.
|
|
await scroller.evaluate(
|
|
(element, target) => {
|
|
element.scrollTop += target!.y - element.getBoundingClientRect().top - 100
|
|
},
|
|
await bubble.boundingBox(),
|
|
)
|
|
|
|
const closing = {
|
|
top: (await bubble.boundingBox())!.y,
|
|
scroll: await scroller.evaluate((element) => element.scrollTop),
|
|
}
|
|
|
|
expect(closing.scroll).toBeGreaterThan(150)
|
|
await toggle.click()
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
|
expect(Math.abs((await bubble.boundingBox())!.y - closing.top)).toBeLessThanOrEqual(1)
|
|
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(closing.scroll)
|
|
|
|
// Find skips the Read more button, and a match hidden in the fold opens the message.
|
|
const highlights = () =>
|
|
page.evaluate(() =>
|
|
["timeline-search-hit", "timeline-search-hit-active"].reduce(
|
|
(count, name) => count + (CSS.highlights.get(name)?.size ?? 0),
|
|
0,
|
|
),
|
|
)
|
|
|
|
await page.keyboard.press("ControlOrMeta+f")
|
|
const search = page.locator('[data-component="timeline-search-bar"] input')
|
|
await expect(search).toBeFocused()
|
|
await search.fill("more")
|
|
await expect.poll(highlights).toBe(0)
|
|
await search.fill("Long line 13")
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
|
await expect.poll(highlights).toBe(1)
|
|
})
|