Skip to content
171 changes: 170 additions & 1 deletion packages/app/e2e/session/session-renderer-diagnostics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ type TimelineMetrics = {
distanceFromBottom: number
}

function directoryCompareKey(directory: string | undefined) {
if (!directory) return ""
const value = directory.replaceAll("\\", "/").replace(/\/+$/, "") || "/"
return value.startsWith("/private/var/") ? value.slice("/private".length) : value
}

async function installRendererDiagnosticsCapture(page: Page) {
await page.addInitScript(() => {
const win = window as typeof window & {
Expand Down Expand Up @@ -109,6 +115,21 @@ async function scrollTimelineToBottom(page: Page) {
expect(found, "session timeline viewport should exist").toBe(true)
}

async function scrollTimelineToOffset(page: Page, top: number) {
const found = await page.evaluate(
({ scrollViewportSelector, turnListSelector, top }) => {
const list = document.querySelector(turnListSelector)
const viewport = list?.closest(scrollViewportSelector)
if (!(viewport instanceof HTMLElement)) return false
viewport.scrollTop = top
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
return true
},
{ scrollViewportSelector, turnListSelector: sessionTurnListSelector, top },
)
expect(found, "session timeline viewport should exist").toBe(true)
}

async function resetTimelineToTop(page: Page) {
const found = await page.evaluate(
({ scrollViewportSelector, turnListSelector }) => {
Expand All @@ -128,11 +149,66 @@ async function sendVisiblePrompt(input: { page: Page; text: string }) {
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await input.page.keyboard.insertText(input.text)
await prompt.fill("")
await prompt.fill(input.text)
await expect.poll(async () => (await prompt.textContent())?.replace(/\u200B/g, "").trim()).toBe(input.text)
await input.page.keyboard.press("Enter")
}

async function expandRenderedTimeline(page: Page, target: number) {
const viewport = page.locator(scrollViewportSelector).first()
await expect(viewport).toBeVisible()
await expect
.poll(
async () => {
const count = await page.locator(sessionMessageItemSelector).count()
if (count >= target) return count
await viewport.hover()
await page.mouse.wheel(0, -2400)
await page.waitForTimeout(100)
return page.locator(sessionMessageItemSelector).count()
},
{ timeout: 30_000 },
)
.toBeGreaterThanOrEqual(target)
}
Comment thread
Astro-Han marked this conversation as resolved.

async function readPromptSent(page: Page) {
return page.evaluate(() => {
const win = window as typeof window & {
__opencode_e2e?: {
prompt?: {
sent?: {
started?: number
count?: number
sessionID?: string
directory?: string
}
}
}
}
const sent = win.__opencode_e2e?.prompt?.sent
return {
started: sent?.started ?? 0,
count: sent?.count ?? 0,
sessionID: sent?.sessionID,
directory: sent?.directory,
}
})
}

async function waitSessionActiveDirectory(input: { sdk: Sdk; sessionID: string; directory: string }) {
await expect
.poll(
async () => {
const session = await input.sdk.session.get({ sessionID: input.sessionID }).then((res) => res.data)
return directoryCompareKey(session?.executionContext.activeDirectory)
},
{ timeout: 45_000 },
)
.toBe(directoryCompareKey(input.directory))
}

function numberData(event: CapturedDiagnosticEvent, key: string) {
const value = event.data?.[key]
return typeof value === "number" && Number.isFinite(value) ? value : undefined
Expand Down Expand Up @@ -194,3 +270,96 @@ test("captures renderer diagnostics while guarding send scroll position", async
).toBe(true)
})
})

test("keeps long timeline stable across worktree exit follow-up", async ({ page, project, llm }) => {
test.setTimeout(180_000)

await installRendererDiagnosticsCapture(page)
await project.open()
const sdk = project.sdk

await withSession(sdk, `e2e long timeline worktree ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await seedSessionTurns({ sdk, sessionID: session.id, count: 90 })

await project.gotoSession(session.id)
await expect(page.locator(sessionMessageItemSelector)).toHaveCount(10, { timeout: 30_000 })
await expandRenderedTimeline(page, 80)
await scrollTimelineToOffset(page, 240)
await expect.poll(async () => (await expectTimelineMetrics(page)).top).toBeGreaterThan(20)

const created = await sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
if (!created?.directory) throw new Error("Failed to create worktree for long timeline diagnostics")
const worktreeDirectory = created.directory
project.trackDirectory(worktreeDirectory)

await llm.tool("enter-worktree", { path: worktreeDirectory })
await sendVisiblePrompt({ page, text: "enter diagnostics worktree" })
await waitSessionActiveDirectory({ sdk, sessionID: session.id, directory: worktreeDirectory })
await expect
.poll(async () => page.locator(sessionMessageItemSelector).count(), { timeout: 30_000 })
.toBeGreaterThanOrEqual(80)

await llm.tool("exit-worktree", {})
await sendVisiblePrompt({ page, text: "exit diagnostics worktree" })
await waitSessionActiveDirectory({ sdk, sessionID: session.id, directory: project.directory })
await expect
.poll(async () => page.locator(sessionMessageItemSelector).count(), { timeout: 30_000 })
.toBeGreaterThanOrEqual(80)
await expect.poll(async () => (await expectTimelineMetrics(page)).top).toBeGreaterThan(20)

const followupCheckpoint = (await readRendererDiagnostics(page)).length
const followupText = `worktree exit follow-up ${Date.now()}`
await sendVisiblePrompt({ page, text: followupText })
await expect
.poll(async () => page.locator(sessionMessageItemSelector).count(), { timeout: 30_000 })
.toBeGreaterThanOrEqual(80)

await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 200 }).then((res) => res.data ?? [])
return {
count: messages.length,
hasFollowup: messages.some((message) =>
message.parts.some((part) => part.type === "text" && part.text.includes(followupText)),
),
}
},
{ timeout: 30_000 },
)
.toEqual({ count: expect.any(Number), hasFollowup: true })

const messages = await sdk.session.messages({ sessionID: session.id, limit: 200 }).then((res) => res.data ?? [])
expect(messages.length).toBeGreaterThanOrEqual(91)

const sent = await readPromptSent(page)
expect(sent.sessionID).toBe(session.id)
expect(sent.directory).toBe(project.directory)
Comment thread
Astro-Han marked this conversation as resolved.

const events = await readRendererDiagnostics(page)
const sessionEvents = events.filter((event) => event.timeline_session_id === session.id)
expect(sessionEvents.filter((event) => event.name === "session.timeline.mount")).toHaveLength(1)
expect(sessionEvents.filter((event) => event.name === "session.timeline.unmount")).toHaveLength(0)
expect(sessionEvents.filter((event) => event.name === "session.identity.transition")).toHaveLength(0)

const followupEvents = events.slice(followupCheckpoint).filter((event) => event.timeline_session_id === session.id)
const followupVisibleCounts = followupEvents
.filter((event) => event.name === "session.timeline.visible")
.map((event) => numberData(event, "rendered_count") ?? 0)
if (followupVisibleCounts.length > 0) expect(Math.max(...followupVisibleCounts)).toBeGreaterThanOrEqual(80)

const followupScrollJumps = followupEvents.filter((event) => {
if (event.name !== "session.scroll.sample") return false
const top = numberData(event, "scroll_top")
const distance = numberData(event, "distance_from_bottom")
return top !== undefined && distance !== undefined && top < 20 && distance > 100
})
expect(followupScrollJumps).toEqual([])

const viewMessageCounts = events
.filter((event) => event.name === "session.view.state" && event.timeline_session_id === session.id)
.map((event) => numberData(event, "message_count") ?? 0)
expect(Math.max(...viewMessageCounts)).toBeGreaterThanOrEqual(91)
})
})
107 changes: 102 additions & 5 deletions packages/app/src/components/prompt-input/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ const syncedDirectories: string[] = []
const promptAsyncCalls: Array<Record<string, unknown>> = []
const commandCalls: Array<Record<string, unknown>> = []
const commandDefinitions: Array<{ name: string }> = []
let commandsReady = true
let promptAsyncFailure: Error | undefined
const abortedSessions: string[] = []
const globalTodoSets: Array<{ sessionID: string; todos: unknown }> = []
const childTodoSets: Array<{ directory: string; sessionID: string; todos: unknown }> = []
const promptSetCalls: Array<{ prompt: Prompt; cursor?: number; target?: { dir: string; id?: string } }> = []

let params: { id?: string } = {}
let params: { dir?: string; id?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined

Expand Down Expand Up @@ -63,6 +66,7 @@ const clientFor = (directory: string) => {
prompt: async () => ({ data: undefined }),
promptAsync: async (input: Record<string, unknown>) => {
promptAsyncCalls.push(input)
if (promptAsyncFailure) throw promptAsyncFailure
return { data: undefined }
},
command: async (input: Record<string, unknown>) => {
Expand Down Expand Up @@ -132,8 +136,10 @@ beforeAll(async () => {
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
reset: () => undefined,
set: () => undefined,
reset: (_target?: { dir: string; id?: string }) => undefined,
set: (next: Prompt, cursor?: number, target?: { dir: string; id?: string }) => {
promptSetCalls.push({ prompt: next, cursor, target })
},
context: {
add: () => undefined,
remove: () => undefined,
Expand Down Expand Up @@ -166,7 +172,7 @@ beforeAll(async () => {

mock.module("@/context/sync", () => ({
useSync: () => ({
data: { command: commandDefinitions },
data: { command: commandDefinitions, get command_ready() { return commandsReady } },
session: {
optimistic: {
add: (value: {
Expand Down Expand Up @@ -248,9 +254,12 @@ beforeEach(() => {
promptAsyncCalls.length = 0
commandCalls.length = 0
commandDefinitions.length = 0
commandsReady = true
promptAsyncFailure = undefined
abortedSessions.length = 0
globalTodoSets.length = 0
childTodoSets.length = 0
promptSetCalls.length = 0
params = {}
sentShell.length = 0
syncedDirectories.length = 0
Expand Down Expand Up @@ -327,6 +336,64 @@ describe("prompt submit worktree selection", () => {
expect(promptAsyncCalls).toEqual([])
})

test("blocks normal slash submit until commands hydrate", async () => {
params = { id: "session-existing" }
commandsReady = false
commandDefinitions.push({ name: "summarize" })
promptValue = [{ type: "text", content: "/summarize this", start: 0, end: 15 }]
const submit = createPromptSubmit({
sessionID: () => "session-existing",
isNewSession: () => false,
info: () => ({ id: "session-existing" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
})

await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)

expect(commandCalls).toEqual([])
expect(promptAsyncCalls).toEqual([])
})

test("does not block shell absolute paths on command hydration", async () => {
params = { id: "session-existing" }
commandsReady = false
promptValue = [{ type: "text", content: "/bin/ls", start: 0, end: 7 }]
const submit = createPromptSubmit({
sessionID: () => "session-existing",
isNewSession: () => false,
info: () => ({ id: "session-existing" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
})

await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)

expect(sentShell).toEqual(["/repo/main"])
})

test("allows abort while submit readiness is blocked", async () => {
params = { id: "session-visible" }
promptValue = [{ type: "text", content: "", start: 0, end: 0 }]
Expand Down Expand Up @@ -517,6 +584,36 @@ describe("prompt submit worktree selection", () => {
expect(optimisticSeeded).toEqual([true])
})

test("new worktree submit rollback targets final prompt route scope", async () => {
params = { dir: "/repo/main" }
selected = "/repo/worktree-a"
promptValue = [{ type: "text", content: "run tests", start: 0, end: 9 }]
promptAsyncFailure = new Error("send failed")
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})

await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
await waitForCall(() => promptSetCalls.length > 0)

expect(promptSetCalls.at(-1)?.target).toEqual({ dir: "/repo/worktree-a", id: "session-1" })
})

test("sends locale with promptAsync requests", async () => {
params = { id: "session-existing" }
currentIntl = "pt-BR"
Expand Down Expand Up @@ -607,7 +704,7 @@ describe("prompt submit worktree selection", () => {
child: () => [{}, () => undefined],
} as any,
sync: {
data: { command: [{ name: "summarize" }] },
data: { command: [{ name: "summarize" }], command_ready: true },
session: {
optimistic: {
add: () => undefined,
Expand Down
Loading
Loading