| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- 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"
- const sessionID = "ses_context_resize_regression"
- const title = "Context resize regression"
- const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
- const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
- const followingTextID = "prt_0104_text"
- type Message = {
- info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
- parts: Record<string, unknown>[]
- }
- const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
- test.describe("regression: session timeline context group resize", () => {
- test("remeasures a recent explored context group before the next paint", async ({ page }) => {
- await page.setViewportSize({ width: 1400, height: 900 })
- await mockServer(page)
- await configurePage(page)
- await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
- await expectSessionTitle(page, title)
- await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
- await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
- await settle(page)
- const samples = await sampleExpansion(page)
- const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
- expect(samples[0]?.overlap).toBe(0)
- 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) {
- await page.addInitScript(() => {
- localStorage.setItem(
- "settings.v3",
- JSON.stringify({
- general: {
- editToolPartsExpanded: true,
- shellToolPartsExpanded: true,
- showReasoningSummaries: true,
- },
- }),
- )
- })
- }
- async function sampleExpansion(page: Page) {
- return page.evaluate(
- ({ contextIDs, followingTextID }) =>
- new Promise<
- {
- frame: number
- label: string
- scrollTop: number
- scrollHeight: number
- contextBottom: number
- textTop: number
- overlap: number
- gap: number
- expanded: string | null
- }[]
- >((resolve) => {
- const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
- const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
- 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 textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
- if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
- throw new Error("missing regression nodes")
- scroller.scrollTop = scroller.scrollHeight
- const samples: {
- frame: number
- label: string
- scrollTop: number
- scrollHeight: number
- contextBottom: number
- textTop: number
- overlap: number
- gap: number
- expanded: string | null
- }[] = []
- const capture = (frame: number, label: string) => {
- const contextRect = contextRow.getBoundingClientRect()
- const textRect = textRow.getBoundingClientRect()
- samples.push({
- frame,
- label,
- scrollTop: Math.round(scroller.scrollTop * 10) / 10,
- scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
- contextBottom: Math.round(contextRect.bottom * 10) / 10,
- textTop: Math.round(textRect.top * 10) / 10,
- overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
- gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
- expanded: trigger.getAttribute("aria-expanded"),
- })
- }
- capture(-1, "before")
- trigger.click()
- capture(0, "sync-after-click")
- let frame = 1
- const tick = () => {
- setTimeout(() => {
- capture(frame, "painted")
- frame += 1
- if (frame > 8) {
- resolve(samples)
- return
- }
- requestAnimationFrame(tick)
- }, 0)
- }
- requestAnimationFrame(tick)
- }),
- { contextIDs, followingTextID },
- )
- }
- function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
- const userID = id("msg_user", index)
- const assistantID = id("msg_assistant", index)
- return [
- {
- info: {
- id: userID,
- sessionID,
- role: "user",
- time: { created: 1700000000000 + index * 10_000 },
- summary: { diffs: [] },
- agent: "build",
- model,
- },
- parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
- },
- {
- info: {
- id: assistantID,
- sessionID,
- role: "assistant",
- time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
- 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",
- finish: "stop",
- },
- parts: target
- ? [
- 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,
- messageID: assistantID,
- type: "text",
- text: "This assistant text is immediately after the explored context group.",
- },
- ]
- : [
- {
- id: id("prt_text", index),
- sessionID,
- messageID: assistantID,
- type: "text",
- text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
- },
- ],
- },
- ]
- }
- function contextTool(
- partID: string,
- messageID: string,
- tool: string,
- input: Record<string, unknown>,
- status: "running" | "completed" = "completed",
- ) {
- return {
- id: partID,
- sessionID,
- messageID,
- type: "tool",
- callID: `call_${partID}`,
- tool,
- state: {
- status,
- input,
- output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
- title: input.filePath || input.path || input.pattern || "completed",
- metadata: {},
- time: { start: 1700000000000, end: 1700000000100 },
- },
- }
- }
- 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: fixtureMessages }),
- events: () => events.splice(0, 1),
- eventRetry: 50,
- })
- }
- async function settle(page: Page) {
- await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
- }
- function id(prefix: string, index: number) {
- return `${prefix}_${String(index).padStart(4, "0")}`
- }
- function project() {
- return {
- id: projectID,
- worktree: directory,
- vcs: "git",
- name: "context-resize-regression",
- time: { created: 1700000000000, updated: 1700000000000 },
- sandboxes: [],
- }
- }
- function session() {
- return {
- id: sessionID,
- slug: "context-resize-regression",
- projectID,
- directory,
- title,
- version: "dev",
- time: { created: 1700000000000, updated: 1700000000000 },
- }
- }
- 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" },
- }
- }
- function base64Encode(value: string) {
- return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
- }
|