test(app): add session timeline smoke coverage (#26619)
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { test } from "@playwright/test"
|
|
||||||
|
|
||||||
test(
|
|
||||||
"test something cool",
|
|
||||||
{
|
|
||||||
annotation: { type: "todo" },
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
test.fixme()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -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: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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" })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user