From 1c0812fe01e0853378246b061ea04ae9dd9556a6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 10:51:30 -0400 Subject: [PATCH] test(app): stabilize review and workspace e2e --- packages/app/e2e/actions.ts | 22 ++++-- packages/app/e2e/fixtures.ts | 67 ++++++++++++++++--- packages/app/e2e/projects/workspaces.spec.ts | 15 ++++- .../session/session-model-persistence.spec.ts | 2 +- .../app/e2e/session/session-review.spec.ts | 20 +++--- .../src/context/global-sync/event-reducer.ts | 5 +- packages/app/src/context/global-sync/utils.ts | 10 ++- 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 63935d8b4f..32e3d6ba7c 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -44,7 +44,7 @@ export async function defocus(page: Page) { } export async function withNoReplyPrompt(page: Page, fn: () => Promise) { - 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(page: Page, fn: () => Promise) { }) } - 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) } diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 38b445a205..bb3ea4a6c4 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -295,7 +295,12 @@ export const test = base.extend({ 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( 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) } diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index aea4c80686..206baa47ce 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -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( diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 8801e410f2..c107cc5187 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -222,7 +222,7 @@ async function goto(page: Page, directory: string, sessionID?: string) { } async function submit(project: Parameters[0]["project"], value: string) { - return project.user(value) + return project.prompt(value) } async function createWorkspace(page: Page, root: string, seen: string[]) { diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 6e7df88eed..8693f1c30e 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -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[0]["page"]) { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5d8b7c4e3d..4b93997805 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -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)) }) } diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index cac58f3174..82f797945a 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -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, + }, } }