test(app): stabilize review and workspace e2e

pull/20593/head
Kit Langton 2026-04-02 10:51:30 -04:00
parent cfcdd5c1dd
commit 1c0812fe01
7 changed files with 114 additions and 27 deletions

View File

@ -44,7 +44,7 @@ export async function defocus(page: Page) {
}
export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
const url = "**/session/*/prompt_async"
const urls = ["**/session/*/prompt_async", "**/session/*/message"]
const route = async (input: Route) => {
const body = input.request().postDataJSON()
await input.continue({
@ -56,11 +56,11 @@ export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
})
}
await page.route(url, route)
await Promise.all(urls.map((url) => page.route(url, route)))
try {
return await fn()
} finally {
await page.unroute(url, route)
await Promise.all(urls.map((url) => page.unroute(url, route)))
}
}
@ -479,7 +479,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
export async function waitSession(
page: Page,
input: {
directory: string
sessionID?: string
serverUrl?: string
allowAnySession?: boolean
},
) {
const target = await resolveDirectory(input.directory, input.serverUrl)
await expect
.poll(
@ -491,9 +499,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio
if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false
if (!input.sessionID && !input.allowAnySession && current) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false
@ -1020,7 +1030,7 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
if ((await current()) === enabled) return
if (enabled) {
await page.goto(page.url())
await page.reload()
await openSidebar(page)
if ((await current()) === enabled) return
}
@ -1053,7 +1063,7 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
}
if ((await current()) !== enabled) {
await page.goto(page.url())
await page.reload()
await openSidebar(page)
}

View File

@ -295,7 +295,12 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => {
await visit(page, sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID, serverUrl: backend.url })
await waitSession(page, {
directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
}
await use(gotoSession)
},
@ -365,7 +370,12 @@ function makeProject(
const gotoSession = async (sessionID?: string) => {
const cur = need()
await visit(page, sessionPath(cur.directory, sessionID))
await waitSession(page, { directory: cur.directory, sessionID, serverUrl: backend.url })
await waitSession(page, {
directory: cur.directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
@ -394,6 +404,46 @@ function makeProject(
}
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
if (input.noReply) {
const cur = need()
const state = await page.evaluate(() => {
const model = (window as E2EWindow).__opencode_e2e?.model?.current
if (!model) return null
return {
dir: model.dir,
sessionID: model.sessionID,
agent: model.agent,
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
variant: model.variant ?? undefined,
}
})
const dir = state?.dir ?? cur.directory
const sdk = backend.sdk(dir)
const sessionID = state?.sessionID
? state.sessionID
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
if (!res.data?.id) throw new Error("Failed to create no-reply session")
return res.data.id
})
await sdk.session.prompt({
sessionID,
agent: state?.agent,
model: state?.model,
variant: state?.variant,
noReply: true,
parts: [{ type: "text", text }],
})
await visit(page, sessionPath(dir, sessionID))
const active = await waitSession(page, {
directory: dir,
sessionID,
serverUrl: backend.url,
})
trackSession(sessionID, active.directory)
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
return sessionID
}
const prev = await promptSend(page)
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
await llm.text("ok")
@ -430,11 +480,7 @@ function makeProject(
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
}
if (input.noReply) {
await withNoReplyPrompt(page, submit)
} else {
await submit()
}
await submit()
let next: { sessionID: string; directory: string } | undefined
await expect
@ -537,7 +583,12 @@ async function runProject<T>(
const gotoSession = async (sessionID?: string) => {
await visit(page, sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID, serverUrl: url })
await waitSession(page, {
directory: root,
sessionID,
serverUrl: url,
allowAnySession: !sessionID,
})
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}

View File

@ -315,7 +315,20 @@ test("can delete a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
const rootSlug = project.slug
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
const directory = created.directory
const slug = dirSlug(directory)
project.trackDirectory(directory)
await page.reload()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
await expect
.poll(

View File

@ -222,7 +222,7 @@ async function goto(page: Page, directory: string, sessionID?: string) {
}
async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
return project.user(value)
return project.prompt(value)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {

View File

@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { promptMatch } from "../prompt/mock"
import { bodyText } from "../prompt/mock"
const count = 14
@ -47,8 +47,12 @@ async function patchWithMock(
patchText: string,
) {
const callsBefore = await llm.calls()
await llm.toolMatch(promptMatch("Apply the provided patch exactly once."), "apply_patch", { patchText })
await sdk.session.promptAsync({
await llm.toolMatch(
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
"apply_patch",
{ patchText },
)
await sdk.session.prompt({
sessionID,
agent: "build",
system: [
@ -61,12 +65,12 @@ async function patchWithMock(
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
// Wait for the agent loop to actually start before checking idle.
// promptAsync is fire-and-forget — without this, waitSessionIdle can
// return immediately because the session status is still undefined.
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
await waitSessionIdle(sdk, sessionID, 120_000)
await expect
.poll(async () => (await sdk.session.get({ sessionID }).then((res) => res.data?.summary?.files)) ?? 0, {
timeout: 120_000,
})
.toBeGreaterThan(0)
}
async function show(page: Parameters<typeof test>[0]["page"]) {

View File

@ -14,6 +14,7 @@ import type {
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { sanitizeProject } from "./utils"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@ -33,12 +34,12 @@ export function applyGlobalEvent(input: {
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
input.setGlobalProject((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
draft[result.index] = sanitizeProject({ ...draft[result.index], ...properties })
})
return
}
input.setGlobalProject((draft) => {
draft.splice(result.index, 0, properties)
draft.splice(result.index, 0, sanitizeProject(properties))
})
}

View File

@ -27,13 +27,21 @@ export function normalizeProviderList(input: ProviderListResponse): ProviderList
}
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
commands: project.commands
? {
...project.commands,
}
: undefined,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
sandboxes: [...(project.sandboxes ?? [])],
time: {
...project.time,
},
}
}