| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412 |
- import { expect, test, type Page } from "@playwright/test"
- import { fixture, pageMessages } from "./session-timeline.fixture"
- import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
- import { mockOpenCodeServer } from "../utils/mock-server"
- 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("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)
- await openProject(page, "SmokeProject")
- await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
- await expectSessionReady(page, "smoke-project")
- await navigateToSession(page, 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) {
- await page.addInitScript(() => {
- localStorage.setItem(
- "settings.v3",
- JSON.stringify({
- general: {
- editToolPartsExpanded: true,
- shellToolPartsExpanded: true,
- showReasoningSummaries: true,
- showSessionProgressBar: true,
- },
- }),
- )
- 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<HTMLElement>(".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<HTMLElement>(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<HTMLElement>("[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<HTMLElement>('[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<string>()
- const seenMessages = new Set<string>()
- 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<boolean>((resolve) => {
- const scroller = [...document.querySelectorAll<HTMLElement>(".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<string>, seenMessages: Set<string>) {
- 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<typeof sampleTraversal>
- 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<boolean>((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<string>,
- seenMessages: Set<string>,
- 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 openProject(page: Page, projectName: string) {
- await page.goto("/")
- await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
- }
- async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
- // Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
- await page
- .locator(`a[href*="${sessionId}"]`)
- .first()
- .evaluate((el) => (el as HTMLElement).click())
- await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
- }
- async function expectSessionReady(page: Page, projectName: string) {
- await expect(page.getByText(projectName).first()).toBeVisible()
- await expect(page.getByText("Ask anything...")).toBeVisible()
- }
|