Prechádzať zdrojové kódy

test(app): add session timeline smoke coverage (#26619)

Luke Parker 3 mesiacov pred
rodič
commit
44a35c5895

+ 270 - 0
packages/app/e2e/smoke/session-timeline.fixture.ts

@@ -0,0 +1,270 @@
+const words = [
+  "alpha",
+  "bravo",
+  "charlie",
+  "delta",
+  "echo",
+  "foxtrot",
+  "golf",
+  "hotel",
+  "india",
+  "juliet",
+  "kilo",
+  "lima",
+  "metro",
+  "nova",
+  "orbit",
+  "pixel",
+  "quartz",
+  "river",
+  "signal",
+  "vector",
+]
+
+const sourceID = "ses_smoke_source"
+const targetID = "ses_smoke_target"
+const directory = "C:/OpenCode/SmokeProject"
+const projectID = "proj_smoke_timeline"
+const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
+
+type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
+type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
+type Message = { info: MessageInfo; parts: MessagePart[] }
+
+function lorem(seed: number, length: number) {
+  let out = ""
+  let i = seed
+  while (out.length < length) {
+    const word = words[i % words.length]
+    out += (out ? " " : "") + word
+    if (i % 17 === 0) out += ".\n\n"
+    i += 7
+  }
+  return out.slice(0, length)
+}
+
+function id(prefix: string, value: number) {
+  return `${prefix}_smoke_${String(value).padStart(4, "0")}`
+}
+
+function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
+  const messageID = id("msg_user", index)
+  return {
+    info: {
+      id: messageID,
+      sessionID,
+      role: "user",
+      time: { created: 1700000000000 + index * 10_000 },
+      summary: { diffs },
+      agent: "build",
+      model,
+    },
+    parts: [
+      {
+        id: id("prt_user_text", index),
+        sessionID,
+        messageID,
+        type: "text",
+        text: lorem(index, textLength),
+      },
+    ],
+  }
+}
+
+function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
+  const messageID = id("msg_assistant", index)
+  return {
+    info: {
+      id: messageID,
+      sessionID,
+      role: "assistant",
+      time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
+      parentID,
+      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: parts.map((part) => ({
+      ...part,
+      sessionID,
+      messageID,
+    })),
+  }
+}
+
+function textPart(index: number, partIndex: number, length: number): MessagePart {
+  return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
+}
+
+function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
+  return {
+    id: id(`prt_reasoning_${partIndex}`, index),
+    type: "reasoning",
+    text: lorem(index * 19 + partIndex, length),
+    time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
+  }
+}
+
+function toolPart(index: number, partIndex: number, tool: string, input: Record<string, unknown>, outputLength = 160): MessagePart {
+  const metadata =
+    tool === "apply_patch"
+      ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
+      : tool === "edit" || tool === "write"
+        ? {
+            filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
+            diff: patch(index, outputLength),
+            preview: patch(index + 1, 420),
+          }
+        : tool === "question"
+          ? { answers: [["Proceed"], ["Keep sample output"]] }
+          : {}
+  return {
+    id: id(`prt_tool_${tool}_${partIndex}`, index),
+    type: "tool",
+    callID: id("call", index * 10 + partIndex),
+    tool,
+    state: {
+      status: "completed",
+      input,
+      output: lorem(index * 23 + partIndex, outputLength),
+      title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
+      metadata,
+      time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
+    },
+  }
+}
+
+function patchFile(seed: number, type: "add" | "update" | "delete") {
+  return {
+    filePath: `src/generated/patch-${seed}.ts`,
+    relativePath: `src/generated/patch-${seed}.ts`,
+    type,
+    additions: (seed % 7) + 1,
+    deletions: type === "add" ? 0 : seed % 4,
+    patch: patch(seed, 520),
+    before: type === "add" ? undefined : code(seed, 18),
+    after: type === "delete" ? undefined : code(seed + 1, 24),
+  }
+}
+
+function fileDiff(file: string, seed: number) {
+  return {
+    file,
+    additions: (seed % 9) + 1,
+    deletions: seed % 4,
+    before: code(seed, 32),
+    after: code(seed + 1, 38),
+  }
+}
+
+function patch(seed: number, length: number) {
+  return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
+}
+
+function code(seed: number, lines: number) {
+  return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join("\n")
+}
+
+function turn(index: number): Message[] {
+  const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
+  const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
+  const parts = [
+    ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
+    ...(index % 3 === 0
+      ? [
+          toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
+          toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
+          toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
+          toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
+        ]
+      : []),
+    textPart(index, 2, 160 + (index % 6) * 90),
+    ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
+    ...(index % 6 === 0
+      ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
+      : []),
+    ...(index % 8 === 0 ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []),
+    ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] : []),
+    ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
+    ...(index % 11 === 0
+      ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)]
+      : []),
+    ...(index % 13 === 0
+      ? [
+          toolPart(
+            index,
+            11,
+            "question",
+            { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
+            120,
+          ),
+        ]
+      : []),
+    ...(index % 17 === 0
+      ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
+      : []),
+  ]
+  return [user, assistantMessage(targetID, index, user.info.id, parts)]
+}
+
+const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
+const sourceMessages = Array.from({ length: 12 }, (_, index) => [
+  userMessage(sourceID, index + 1000, 120),
+  assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
+]).flat()
+
+function renderable(part: MessagePart) {
+  if (part.type === "tool" && part.tool === "todowrite") return false
+  if (part.type === "text") return !!part.text.trim()
+  if (part.type === "reasoning") return !!part.text.trim()
+  return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
+}
+
+function orderedParts(message: Message) {
+  return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
+}
+
+export const fixture = {
+  directory,
+  project: { id: projectID, worktree: directory, vcs: "git", name: "smoke-project", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] },
+  provider: {
+    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" },
+  },
+  sessions: [
+    { id: sourceID, slug: "source", projectID, directory, title: "Uncommitted changes inquiry", version: "dev", time: { created: 1700000000000, updated: 1700000000000 } },
+    { id: targetID, slug: "target", projectID, directory, title: "Example Game: sample jump movement & sample physics analysis", version: "dev", time: { created: 1700000001000, updated: 1700000001000 } },
+  ],
+  sourceID,
+  targetID,
+  messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
+  expected: {
+    sourceTitle: "Uncommitted changes inquiry",
+    targetTitle: "Example Game: sample jump movement & sample physics analysis",
+    targetMessageIDs: targetMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
+    targetPartIDs: targetMessages.flatMap((message) => orderedParts(message).filter(renderable).map((part) => part.id)),
+  },
+}
+
+export function pageMessages(sessionID: string, limit: number, before?: string) {
+  const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
+  const end = before ? Math.max(0, messages.findIndex((message) => message.info.id === before)) : messages.length
+  const start = Math.max(0, end - limit)
+  return {
+    items: messages.slice(start, end),
+    cursor: start > 0 ? messages[start]!.info.id : undefined,
+  }
+}

+ 389 - 0
packages/app/e2e/smoke/session-timeline.spec.ts

@@ -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()
+}

+ 0 - 11
packages/app/e2e/todo.spec.ts

@@ -1,11 +0,0 @@
-import { test } from "@playwright/test"
-
-test(
-  "test something cool",
-  {
-    annotation: { type: "todo" },
-  },
-  async () => {
-    test.fixme()
-  },
-)

+ 18 - 0
packages/app/e2e/utils/errors.ts

@@ -0,0 +1,18 @@
+import { expect, type Page } from "@playwright/test"
+
+export function trackPageErrors(page: Page) {
+  const errors: string[] = []
+  page.on("console", (message) => {
+    if (message.type() === "error") errors.push(message.text())
+  })
+  page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
+  return errors
+}
+
+export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
+  expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
+    consoleErrors: [],
+    toastErrors: [],
+    forbiddenText: [],
+  })
+}

+ 77 - 0
packages/app/e2e/utils/mock-server.ts

@@ -0,0 +1,77 @@
+import type { Page, Route } from "@playwright/test"
+
+const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"])
+const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
+
+export interface MockServerConfig {
+  provider: unknown
+  directory: string
+  project: unknown
+  sessions: ({ id: string } & Record<string, unknown>)[]
+  pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
+}
+
+export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
+  const staticRoutes: Record<string, unknown> = {
+    "/provider": config.provider,
+    "/path": {
+      state: config.directory,
+      config: config.directory,
+      worktree: config.directory,
+      directory: config.directory,
+      home: "C:/OpenCode",
+    },
+    "/project": [config.project],
+    "/project/current": config.project,
+    "/agent": [{ name: "build", mode: "primary" }],
+    "/vcs": { branch: "main", default_branch: "main" },
+    "/session": config.sessions,
+  }
+
+  await page.route("**/*", async (route) => {
+    const url = new URL(route.request().url())
+    const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
+    if (url.port !== targetPort) return route.fallback()
+
+    const path = url.pathname
+    if (path === "/global/event" || path === "/event") return sse(route)
+    if (emptyObject.has(path)) return json(route, {})
+    if (emptyList.has(path)) return json(route, [])
+    if (path in staticRoutes) return json(route, staticRoutes[path])
+
+    const sessionMatch = path.match(/^\/session\/([^/]+)$/)
+    if (sessionMatch) {
+      const session = config.sessions.find((s) => s.id === sessionMatch[1])
+      return json(route, session ?? {})
+    }
+
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
+
+    const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
+    if (messagesMatch) {
+      const limit = Number(url.searchParams.get("limit") ?? 80)
+      const before = url.searchParams.get("before") ?? undefined
+      const pageData = config.pageMessages(messagesMatch[1], limit, before)
+      return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
+    }
+
+    return json(route, {})
+  })
+}
+
+function json(route: Route, body: unknown, headers?: Record<string, string>) {
+  return route.fulfill({
+    status: 200,
+    contentType: "application/json",
+    headers: {
+      "access-control-allow-origin": "*",
+      "access-control-expose-headers": "x-next-cursor",
+      ...headers,
+    },
+    body: JSON.stringify(body ?? null),
+  })
+}
+
+function sse(route: Route) {
+  return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
+}