import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { fixture, pageMessages } from "./session-timeline.fixture" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { mockOpenCodeServer } from "../utils/mock-server" import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits" const forbiddenText = ["Load details", "Show earlier steps"] type SmokeState = { ids: string[] visibleIds: string[] messageIds: string[] visibleMessageIds: string[] topVisibleId?: string signature: string scrollTop: number scrollHeight: number clientHeight: number errorToasts: string[] forbiddenText: string[] } type SmokeWindow = Window & { __timelineSmokeState?: () => SmokeState __timelineSmokeErrorToasts?: string[] __timelineSmokeForbiddenText?: string[] } test.describe("smoke: session timeline", () => { test.setTimeout(240_000) test("keeps the visible message fixed while prepending history", async ({ page }) => { const requests: { before?: string; phase: "start" | "end"; at: number }[] = [] await mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, pageMessages, messageDelay: 3_000, onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }), }) await configureSmokePage(page, fixture.directory) await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) await waitForTimelineStable(page) const scroller = timelineScroller(page) await pointAtTimeline(page) const deadline = Date.now() + 120_000 while (!requests.some((request) => request.before && request.phase === "start")) { if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") await page.mouse.wheel(0, -240) await page.waitForTimeout(20) } expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) for (let index = 0; index < 12; index++) { await page.mouse.wheel(0, -120) await page.waitForTimeout(20) } const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"] const positions = () => scroller.evaluate((element, keys) => { const top = element.getBoundingClientRect().top return Object.fromEntries( keys.map((key) => { const row = element.querySelector(`[data-timeline-part-id="${key}"]`) if (!row) throw new Error(`Missing stable timeline key: ${key}`) return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio] }), ) }, keys) const before = await positions() expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true) await waitForTimelineStable(page) await expect.poll(positions).toEqual(before) }) test("preserves the timeline gap above the composer", async ({ page }) => { await mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, pageMessages, }) await configureSmokePage(page, fixture.directory) await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) await waitForTimelineStable(page) const scroller = timelineScroller(page) await scroller.evaluate((element) => { element.scrollTop = element.scrollHeight }) await waitForTimelineStable(page) const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]') await expect(spacer).toBeVisible() expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64) await expect .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) .toBeLessThanOrEqual(1) }) test("paints cached session tabs at the latest message", async ({ page }) => { await mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), }) await configureSmokePage(page, fixture.directory) await page.addInitScript( ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( "opencode.global.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", server: "http://127.0.0.1:4096", dirBase64, sessionId, })), ), ) }, { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) await expectSessionTitle(page, fixture.expected.targetTitle) await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) const last = fixture.expected.targetMessageIDs.at(-1)! await page.evaluate( ({ destination, last }) => { const ids = new Set(destination) const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = [] const firstPaintNodes = new WeakSet() let firstPaint = false let removedFirstPaintNodes = 0 let running = true new MutationObserver((records) => { if (!firstPaint || !running) return records.forEach((record) => record.removedNodes.forEach((node) => { if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1 if (!(node instanceof Element)) return node.querySelectorAll("*").forEach((element) => { if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1 }) }), ) }).observe(document.documentElement, { childList: true, subtree: true }) const sample = () => { if (!running) return setTimeout(() => { if (!running) return const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => element.querySelector("[data-timeline-row]"), ) if (root) { const view = root.getBoundingClientRect() const visible = [...root.querySelectorAll("[data-message-id]")] .filter((element) => { const rect = element.getBoundingClientRect() return rect.bottom > view.top && rect.top < view.bottom }) .map((element) => element.dataset.messageId!) .filter((id) => ids.has(id)) const bottom = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) { firstPaint = true root.querySelectorAll("[data-timeline-key]").forEach((row) => { const rect = row.getBoundingClientRect() if (rect.bottom <= view.top || rect.top >= view.bottom) return firstPaintNodes.add(row) row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element)) }) } } requestAnimationFrame(sample) }, 0) } ;(window as Window & { __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } }).__sessionTabPaint = { samples, removed: () => removedFirstPaintNodes, stop: () => { running = false }, } requestAnimationFrame(sample) }, { destination, last }, ) await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await page.waitForFunction(() => (window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }).__sessionTabPaint?.samples.some( (sample) => sample.ids.length > 0, ), ) await page.waitForTimeout(200) const first = await page.evaluate(() => { const probe = (window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> removed: () => number stop: () => void } }).__sessionTabPaint! probe.stop() return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() } }) expect(first.first?.last).toBe(true) expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) expect(first.removed).toBe(0) }) test("paints a cold session tab at the latest message", async ({ page }) => { await mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), }) await configureSmokePage(page, fixture.directory) await page.addInitScript( ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( "opencode.global.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", server: "http://127.0.0.1:4096", dirBase64, sessionId, })), ), ) }, { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) await expectSessionTitle(page, fixture.expected.sourceTitle) const last = fixture.expected.targetMessageIDs.at(-1)! const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) await page.evaluate(({ destination, last }) => { const ids = new Set(destination) const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = [] const sample = () => { const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => element.querySelector("[data-timeline-row]"), ) if (root) { const view = root.getBoundingClientRect() const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() const messages = [...root.querySelectorAll("[data-message-id]")].filter((element) => { const rect = element.getBoundingClientRect() return rect.bottom > view.top && rect.top < view.bottom }) samples.push({ destination: messages.some((element) => ids.has(element.dataset.messageId!)), last: messages.some((element) => element.dataset.messageId === last), bottomError: spacer ? spacer.bottom - view.bottom : undefined, }) } requestAnimationFrame(() => setTimeout(sample, 0)) } ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples requestAnimationFrame(() => setTimeout(sample, 0)) }, { destination, last }) await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await page.waitForFunction(() => (window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some( (sample) => sample.destination, ), ) const result = await page.evaluate(() => { const samples = (window as Window & { __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }> }).__coldTabSamples! return samples.find((sample) => sample.destination)! }) expect(result.last).toBe(true) expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1) }) test("renders seeded timeline in order while paging through history", async ({ page }) => { const errors = trackPageErrors(page) await mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, pageMessages, }) await configureSmokePage(page, fixture.directory) await selectHomeProject(page, fixture.project.name) await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) await expectSessionReady(page) await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) const expectedPartIDs = fixture.expected.targetPartIDs const expectedMessageIDs = fixture.expected.targetMessageIDs await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) }) }) async function configureSmokePage(page: Page, directory: string) { await page.addInitScript(() => { localStorage.setItem( "settings.v3", JSON.stringify({ general: { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, showSessionProgressBar: true, }, }), ) }) await page.addInitScript((directory) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }], }, lastProject: { local: directory, }, }), ) }, directory) await page.addInitScript(() => { const smoke = window as SmokeWindow smoke.__timelineSmokeErrorToasts = [] smoke.__timelineSmokeForbiddenText = [] const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]" const idsOf = (el: HTMLElement) => [el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id) smoke.__timelineSmokeState = () => { const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => el.querySelector("[data-timeline-row], [data-session-title]"), ) if (!scroller) { return { ids: [], visibleIds: [], messageIds: [], visibleMessageIds: [], topVisibleId: undefined, signature: "", scrollTop: 0, scrollHeight: 0, clientHeight: 0, errorToasts: smoke.__timelineSmokeErrorToasts ?? [], forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], } } const ids: string[] = [] const visibleIds: string[] = [] const scrollerRect = scroller.getBoundingClientRect() let topVisibleId: string | undefined for (const el of scroller.querySelectorAll(partSelector)) { const next = idsOf(el) ids.push(...next) const rect = el.getBoundingClientRect() if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) { if (!topVisibleId) topVisibleId = next[0] visibleIds.push(...next) } } const messageIds: string[] = [] const visibleMessageIds: string[] = [] const rows = [...scroller.querySelectorAll("[data-message-id]")].map((el) => { const rect = el.getBoundingClientRect() const id = el.dataset.messageId if (id) { messageIds.push(id) if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id) } return { id, top: Math.round(rect.top), bottom: Math.round(rect.bottom), } }) const signature = JSON.stringify({ top: Math.round(scroller.scrollTop), height: Math.round(scroller.scrollHeight), rows, ids, }) return { ids, visibleIds, messageIds, visibleMessageIds, topVisibleId, signature, scrollTop: Math.round(scroller.scrollTop), scrollHeight: Math.round(scroller.scrollHeight), clientHeight: Math.round(scroller.clientHeight), errorToasts: smoke.__timelineSmokeErrorToasts ?? [], forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], } } let recordFrame: number | undefined const record = () => { for (const toast of document.querySelectorAll('[data-component="toast"][data-variant="error"]')) { const text = toast.textContent?.trim() if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text) } const text = document.body?.textContent ?? "" for (const value of ["Load details", "Show earlier steps"]) { if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) { smoke.__timelineSmokeForbiddenText!.push(value) } } } const start = () => { const root = document.documentElement ?? document.body if (!root) return new MutationObserver(() => { if (recordFrame) return recordFrame = requestAnimationFrame(() => { recordFrame = undefined record() }) }).observe(root, { childList: true, subtree: true }) record() } if (document.documentElement ?? document.body) start() else document.addEventListener("DOMContentLoaded", start, { once: true }) }) } async function expectCanScrollToStart( page: Page, expectedPartIDs: string[], expectedMessageIDs: string[], errors: string[], ) { await pointAtTimeline(page) const seenParts = new Set() const seenMessages = new Set() const samples: TraversalSample[] = [] let current = await timelineState(page) let unchangedAtTop = 0 for (let attempt = 0; attempt < 600; attempt++) { collectSeen(current, seenParts, seenMessages) samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText) expectOrderedIDs(expectedPartIDs, current.ids, "mounted part") expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part") expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message") expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message") if ( current.scrollTop <= 1 && seenParts.size === expectedPartIDs.length && seenMessages.size === expectedMessageIDs.length ) { expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) return } const before = current const changed = await scrollTimelineUp(page, current) current = await timelineState(page) if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++ else unchangedAtTop = 0 if (unchangedAtTop >= 2) break } collectSeen(current, seenParts, seenMessages) samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) } async function timelineState(page: Page) { return page.evaluate( () => (window as SmokeWindow).__timelineSmokeState?.() ?? { ids: [], visibleIds: [], messageIds: [], visibleMessageIds: [], topVisibleId: undefined, signature: "", scrollTop: 0, scrollHeight: 0, clientHeight: 0, errorToasts: [], forbiddenText: [], }, ) } function timelineScroller(page: Page) { return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) } async function pointAtTimeline(page: Page) { const box = await timelineScroller(page).boundingBox() if (!box) throw new Error("Timeline scroller is not visible") await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) } async function scrollTimelineUp(page: Page, before: SmokeState) { return page.evaluate( (prev) => new Promise((resolve) => { const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => el.querySelector("[data-timeline-row], [data-session-title]"), ) if (!scroller) { resolve(false) return } scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 })) scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45))) const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" let frames = 0 let stableFrames = 0 let last = "" let changed = false const check = () => { const current = read() if (current !== prev) changed = true if (current === last) stableFrames++ else { stableFrames = 0 last = current } if (changed && stableFrames >= 2) { resolve(true) return } frames++ if (frames >= 30) { resolve(changed) return } requestAnimationFrame(check) } requestAnimationFrame(check) }), before.signature, ) } function expectOrderedIDs(expected: string[], actual: string[], label: string) { expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0) const actualSet = new Set(actual) expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id))) } function unique(values: string[]) { return values.filter((value, index) => values.indexOf(value) === index) } function collectSeen(state: SmokeState, seenParts: Set, seenMessages: Set) { for (const id of state.ids) seenParts.add(id) for (const id of state.visibleIds) seenParts.add(id) for (const id of state.messageIds) seenMessages.add(id) for (const id of state.visibleMessageIds) seenMessages.add(id) } type TraversalSample = ReturnType function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) { return { seenParts, seenMessages, mounted: state.ids.length, visible: state.visibleIds.length, mountedMessages: unique(state.messageIds).length, visibleMessages: unique(state.visibleMessageIds).length, top: state.scrollTop, height: state.scrollHeight, first: state.ids[0], last: state.ids.at(-1), topVisible: state.topVisibleId, visibleFirst: state.visibleIds[0], visibleLast: state.visibleIds.at(-1), } } function sampleSummary(samples: TraversalSample[]) { return samples .filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1) .map( (sample, index) => `${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`, ) .join("\n") } async function waitForTimelineStable(page: Page) { await page.waitForFunction( () => new Promise((resolve) => { requestAnimationFrame(() => { const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" requestAnimationFrame(() => { const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" requestAnimationFrame(() => resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")), ) }) }) }), ) } async function expectSessionTimelineReady( page: Page, expectedPartIDs: string[], expectedMessageIDs: string[], errors: string[], ) { await waitForTimelineStable(page) for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0) const currentState = await timelineState(page) expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText) expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part") expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part") expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message") expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message") } function expectCompleteScroll( state: SmokeState, expectedPartIDs: string[], expectedMessageIDs: string[], seenParts: Set, seenMessages: Set, samples: TraversalSample[], ) { expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1) expect( expectedPartIDs.filter((id) => !seenParts.has(id)), `missing visible timeline parts\n${sampleSummary(samples)}`, ).toEqual([]) expect( expectedMessageIDs.filter((id) => !seenMessages.has(id)), `missing visible messages\n${sampleSummary(samples)}`, ).toEqual([]) expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length) expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length) expect(expectedPartIDs.length).toBe(331) } async function selectHomeProject(page: Page, projectName: string) { await page.goto("/") const row = page .locator('[data-component="home-project-row"]') .filter({ hasText: new RegExp(projectName, "i") }) .first() await expectAppVisible(row) await row.click() await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT }) await expect(page).toHaveURL(/\/$/) } async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) await expectSessionTitle(page, expectedTitle) } async function switchTitlebarSession(page: Page, sessionID: string, title: string) { const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() await expect(tab).toBeVisible() await tab.click() await expectSessionTitle(page, title) } async function expectSessionReady(page: Page) { await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) }