|
|
@@ -0,0 +1,389 @@
|
|
|
+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()
|
|
|
+}
|