Просмотр исходного кода

test(app): migrate more e2e suites to isolated backend (#20505)

Kit Langton 2 недель назад
Родитель
Сommit
c559af51ce

+ 2 - 1
packages/app/e2e/prompt/prompt-async.spec.ts

@@ -1,7 +1,7 @@
 import { test, expect } from "../fixtures"
 import { promptSelector } from "../selectors"
 import { assistantText, sessionIDFromUrl, withSession } from "../actions"
-import { openaiModel, promptMatch, withMockOpenAI } from "./mock"
+import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
 
 const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
 
@@ -24,6 +24,7 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({
     llmUrl: llm.url,
     fn: async () => {
       const token = `E2E_ASYNC_${Date.now()}`
+      await llm.textMatch(titleMatch, "E2E Title")
       await llm.textMatch(promptMatch(token), token)
 
       await withBackendProject(

+ 141 - 108
packages/app/e2e/prompt/prompt-history.spec.ts

@@ -1,8 +1,9 @@
 import type { ToolPart } from "@opencode-ai/sdk/v2/client"
 import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { withSession } from "../actions"
+import { assistantText, sessionIDFromUrl } from "../actions"
 import { promptSelector } from "../selectors"
+import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
 
 const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
 
@@ -43,20 +44,13 @@ async function wait(page: Page, value: string) {
   await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
 }
 
-async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
+async function reply(
+  sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
+  sessionID: string,
+  token: string,
+) {
   await expect
-    .poll(
-      async () => {
-        const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
-        return messages
-          .filter((item) => item.info.role === "assistant")
-          .flatMap((item) => item.parts)
-          .filter((item) => item.type === "text")
-          .map((item) => item.text)
-          .join("\n")
-      },
-      { timeout: 90_000 },
-    )
+    .poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
     .toContain(token)
 }
 
@@ -79,106 +73,145 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
     .toContain(token)
 }
 
-test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
+test("prompt history restores unsent draft with arrow navigation", async ({
+  page,
+  llm,
+  backend,
+  withBackendProject,
+}) => {
   test.setTimeout(120_000)
 
-  await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
-    await gotoSession(session.id)
-
-    const prompt = page.locator(promptSelector)
-    const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
-    const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
-    const first = `Reply with exactly: ${firstToken}`
-    const second = `Reply with exactly: ${secondToken}`
-    const draft = `draft ${Date.now()}`
-
-    await prompt.click()
-    await page.keyboard.type(first)
-    await page.keyboard.press("Enter")
-    await wait(page, "")
-    await reply(sdk, session.id, firstToken)
-
-    await prompt.click()
-    await page.keyboard.type(second)
-    await page.keyboard.press("Enter")
-    await wait(page, "")
-    await reply(sdk, session.id, secondToken)
-
-    await prompt.click()
-    await page.keyboard.type(draft)
-    await wait(page, draft)
-
-    // Clear the draft before navigating history (ArrowUp only works when prompt is empty)
-    await prompt.fill("")
-    await wait(page, "")
-
-    await page.keyboard.press("ArrowUp")
-    await wait(page, second)
-
-    await page.keyboard.press("ArrowUp")
-    await wait(page, first)
-
-    await page.keyboard.press("ArrowDown")
-    await wait(page, second)
-
-    await page.keyboard.press("ArrowDown")
-    await wait(page, "")
+  await withMockOpenAI({
+    serverUrl: backend.url,
+    llmUrl: llm.url,
+    fn: async () => {
+      const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
+      const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
+      const first = `Reply with exactly: ${firstToken}`
+      const second = `Reply with exactly: ${secondToken}`
+      const draft = `draft ${Date.now()}`
+
+      await llm.textMatch(titleMatch, "E2E Title")
+      await llm.textMatch(promptMatch(firstToken), firstToken)
+      await llm.textMatch(promptMatch(secondToken), secondToken)
+
+      await withBackendProject(
+        async (project) => {
+          const prompt = page.locator(promptSelector)
+
+          await prompt.click()
+          await page.keyboard.type(first)
+          await page.keyboard.press("Enter")
+          await wait(page, "")
+
+          await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+          const sessionID = sessionIDFromUrl(page.url())!
+          project.trackSession(sessionID)
+          await reply(project.sdk, sessionID, firstToken)
+
+          await prompt.click()
+          await page.keyboard.type(second)
+          await page.keyboard.press("Enter")
+          await wait(page, "")
+          await reply(project.sdk, sessionID, secondToken)
+
+          await prompt.click()
+          await page.keyboard.type(draft)
+          await wait(page, draft)
+
+          await prompt.fill("")
+          await wait(page, "")
+
+          await page.keyboard.press("ArrowUp")
+          await wait(page, second)
+
+          await page.keyboard.press("ArrowUp")
+          await wait(page, first)
+
+          await page.keyboard.press("ArrowDown")
+          await wait(page, second)
+
+          await page.keyboard.press("ArrowDown")
+          await wait(page, "")
+        },
+        {
+          model: openaiModel,
+        },
+      )
+    },
   })
 })
 
-test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
+test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => {
   test.setTimeout(120_000)
 
-  await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
-    await gotoSession(session.id)
-
-    const prompt = page.locator(promptSelector)
-    const firstToken = `E2E_SHELL_ONE_${Date.now()}`
-    const secondToken = `E2E_SHELL_TWO_${Date.now()}`
-    const normalToken = `E2E_NORMAL_${Date.now()}`
-    const first = `echo ${firstToken}`
-    const second = `echo ${secondToken}`
-    const normal = `Reply with exactly: ${normalToken}`
-
-    await prompt.click()
-    await page.keyboard.type("!")
-    await page.keyboard.type(first)
-    await page.keyboard.press("Enter")
-    await wait(page, "")
-    await shell(sdk, session.id, first, firstToken)
-
-    await prompt.click()
-    await page.keyboard.type("!")
-    await page.keyboard.type(second)
-    await page.keyboard.press("Enter")
-    await wait(page, "")
-    await shell(sdk, session.id, second, secondToken)
-
-    await prompt.click()
-    await page.keyboard.type("!")
-    await page.keyboard.press("ArrowUp")
-    await wait(page, second)
-
-    await page.keyboard.press("ArrowUp")
-    await wait(page, first)
-
-    await page.keyboard.press("ArrowDown")
-    await wait(page, second)
-
-    await page.keyboard.press("ArrowDown")
-    await wait(page, "")
-
-    await page.keyboard.press("Escape")
-    await wait(page, "")
-
-    await prompt.click()
-    await page.keyboard.type(normal)
-    await page.keyboard.press("Enter")
-    await wait(page, "")
-    await reply(sdk, session.id, normalToken)
-
-    await prompt.click()
-    await page.keyboard.press("ArrowUp")
-    await wait(page, normal)
+  await withMockOpenAI({
+    serverUrl: backend.url,
+    llmUrl: llm.url,
+    fn: async () => {
+      const firstToken = `E2E_SHELL_ONE_${Date.now()}`
+      const secondToken = `E2E_SHELL_TWO_${Date.now()}`
+      const normalToken = `E2E_NORMAL_${Date.now()}`
+      const first = `echo ${firstToken}`
+      const second = `echo ${secondToken}`
+      const normal = `Reply with exactly: ${normalToken}`
+
+      await llm.textMatch(titleMatch, "E2E Title")
+      await llm.textMatch(promptMatch(normalToken), normalToken)
+
+      await withBackendProject(
+        async (project) => {
+          const prompt = page.locator(promptSelector)
+
+          await prompt.click()
+          await page.keyboard.type("!")
+          await page.keyboard.type(first)
+          await page.keyboard.press("Enter")
+          await wait(page, "")
+
+          await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+          const sessionID = sessionIDFromUrl(page.url())!
+          project.trackSession(sessionID)
+          await shell(project.sdk, sessionID, first, firstToken)
+
+          await prompt.click()
+          await page.keyboard.type("!")
+          await page.keyboard.type(second)
+          await page.keyboard.press("Enter")
+          await wait(page, "")
+          await shell(project.sdk, sessionID, second, secondToken)
+
+          await prompt.click()
+          await page.keyboard.type("!")
+          await page.keyboard.press("ArrowUp")
+          await wait(page, second)
+
+          await page.keyboard.press("ArrowUp")
+          await wait(page, first)
+
+          await page.keyboard.press("ArrowDown")
+          await wait(page, second)
+
+          await page.keyboard.press("ArrowDown")
+          await wait(page, "")
+
+          await page.keyboard.press("Escape")
+          await wait(page, "")
+
+          await prompt.click()
+          await page.keyboard.type(normal)
+          await page.keyboard.press("Enter")
+          await wait(page, "")
+          await reply(project.sdk, sessionID, normalToken)
+
+          await prompt.click()
+          await page.keyboard.press("ArrowUp")
+          await wait(page, normal)
+        },
+        {
+          model: openaiModel,
+        },
+      )
+    },
   })
 })

+ 2 - 2
packages/app/e2e/prompt/prompt-shell.spec.ts

@@ -10,10 +10,10 @@ const isBash = (part: unknown): part is ToolPart => {
   return "state" in part
 }
 
-test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
+test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
   test.setTimeout(120_000)
 
-  await withProject(async ({ directory, gotoSession, trackSession, sdk }) => {
+  await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
     const prompt = page.locator(promptSelector)
     const cmd = process.platform === "win32" ? "dir" : "command ls"
 

+ 33 - 31
packages/app/e2e/prompt/prompt-slash-share.spec.ts

@@ -22,43 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
     .toBeGreaterThan(0)
 }
 
-test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
+test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
   test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
 
-  await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
-    const prompt = page.locator(promptSelector)
+  await withBackendProject(async (project) => {
+    await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
+      const prompt = page.locator(promptSelector)
 
-    await seed(sdk, session.id)
-    await gotoSession(session.id)
+      await seed(project.sdk, session.id)
+      await project.gotoSession(session.id)
 
-    await prompt.click()
-    await page.keyboard.type("/share")
-    await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
-    await page.keyboard.press("Enter")
+      await prompt.click()
+      await page.keyboard.type("/share")
+      await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
+      await page.keyboard.press("Enter")
 
-    await expect
-      .poll(
-        async () => {
-          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-          return data?.share?.url || undefined
-        },
-        { timeout: 30_000 },
-      )
-      .not.toBeUndefined()
+      await expect
+        .poll(
+          async () => {
+            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+            return data?.share?.url || undefined
+          },
+          { timeout: 30_000 },
+        )
+        .not.toBeUndefined()
 
-    await prompt.click()
-    await page.keyboard.type("/unshare")
-    await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
-    await page.keyboard.press("Enter")
+      await prompt.click()
+      await page.keyboard.type("/unshare")
+      await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
+      await page.keyboard.press("Enter")
 
-    await expect
-      .poll(
-        async () => {
-          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-          return data?.share?.url || undefined
-        },
-        { timeout: 30_000 },
-      )
-      .toBeUndefined()
+      await expect
+        .poll(
+          async () => {
+            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+            return data?.share?.url || undefined
+          },
+          { timeout: 30_000 },
+        )
+        .toBeUndefined()
+    })
   })
 })

+ 25 - 21
packages/app/e2e/session/session-child-navigation.spec.ts

@@ -1,7 +1,7 @@
 import { seedSessionTask, withSession } from "../actions"
 import { test, expect } from "../fixtures"
 
-test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
+test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
   test.setTimeout(120_000)
 
   const errs: string[] = []
@@ -10,28 +10,32 @@ test("task tool child-session link does not trigger stale show errors", async ({
   }
   page.on("pageerror", onError)
 
-  await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
-    const child = await seedSessionTask(sdk, {
-      sessionID: session.id,
-      description: "Open child session",
-      prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
-    })
+  await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
+    await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
+      trackSession(session.id)
+      const child = await seedSessionTask(sdk, {
+        sessionID: session.id,
+        description: "Open child session",
+        prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
+      })
+      trackSession(child.sessionID)
 
-    try {
-      await gotoSession(session.id)
+      try {
+        await gotoSession(session.id)
 
-      const link = page
-        .locator("a.subagent-link")
-        .filter({ hasText: /open child session/i })
-        .first()
-      await expect(link).toBeVisible({ timeout: 30_000 })
-      await link.click()
+        const link = page
+          .locator("a.subagent-link")
+          .filter({ hasText: /open child session/i })
+          .first()
+        await expect(link).toBeVisible({ timeout: 30_000 })
+        await link.click()
 
-      await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
-      await page.waitForTimeout(1000)
-      expect(errs).toEqual([])
-    } finally {
-      page.off("pageerror", onError)
-    }
+        await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
+        await page.waitForTimeout(1000)
+        expect(errs).toEqual([])
+      } finally {
+        page.off("pageerror", onError)
+      }
+    })
   })
 })

+ 296 - 274
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -256,350 +256,372 @@ async function withMockPermission<T>(
   }
 }
 
-test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock default", async (session) => {
-    await gotoSession(session.id)
-
-    await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
-    await expect(page.locator(promptSelector)).toBeVisible()
-    await expect(page.locator(questionDockSelector)).toHaveCount(0)
-    await expect(page.locator(permissionDockSelector)).toHaveCount(0)
-
-    await page.locator(promptSelector).click()
-    await expect(page.locator(promptSelector)).toBeFocused()
+test("default dock shows prompt input", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock default", async (session) => {
+      await project.gotoSession(session.id)
+
+      await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
+      await expect(page.locator(promptSelector)).toBeVisible()
+      await expect(page.locator(questionDockSelector)).toHaveCount(0)
+      await expect(page.locator(permissionDockSelector)).toHaveCount(0)
+
+      await page.locator(promptSelector).click()
+      await expect(page.locator(promptSelector)).toBeFocused()
+    })
   })
 })
 
-test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
-  await gotoSession()
+test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
+  await withBackendProject(async ({ gotoSession }) => {
+    await gotoSession()
 
-  const button = page.locator('[data-action="prompt-permissions"]').first()
-  await expect(button).toBeVisible()
-  await expect(button).toHaveAttribute("aria-pressed", "false")
+    const button = page.locator('[data-action="prompt-permissions"]').first()
+    await expect(button).toBeVisible()
+    await expect(button).toHaveAttribute("aria-pressed", "false")
 
-  await setAutoAccept(page, true)
-  await setAutoAccept(page, false)
+    await setAutoAccept(page, true)
+    await setAutoAccept(page, false)
+  })
 })
 
-test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock question", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
+test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
 
-      await seedSessionQuestion(sdk, {
-        sessionID: session.id,
-        questions: [
-          {
-            header: "Need input",
-            question: "Pick one option",
-            options: [
-              { label: "Continue", description: "Continue now" },
-              { label: "Stop", description: "Stop here" },
-            ],
-          },
-        ],
-      })
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
+          questions: [
+            {
+              header: "Need input",
+              question: "Pick one option",
+              options: [
+                { label: "Continue", description: "Continue now" },
+                { label: "Stop", description: "Stop here" },
+              ],
+            },
+          ],
+        })
 
-      const dock = page.locator(questionDockSelector)
-      await expectQuestionBlocked(page)
+        const dock = page.locator(questionDockSelector)
+        await expectQuestionBlocked(page)
 
-      await dock.locator('[data-slot="question-option"]').first().click()
-      await dock.getByRole("button", { name: /submit/i }).click()
+        await dock.locator('[data-slot="question-option"]').first().click()
+        await dock.getByRole("button", { name: /submit/i }).click()
 
-      await expectQuestionOpen(page)
+        await expectQuestionOpen(page)
+      })
     })
   })
 })
 
-test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
-
-      await seedSessionQuestion(sdk, {
-        sessionID: session.id,
-        questions: [
-          {
-            header: "Need input",
-            question: "Pick one option",
-            options: [
-              { label: "Continue", description: "Continue now" },
-              { label: "Stop", description: "Stop here" },
-            ],
-          },
-        ],
-      })
-
-      const dock = page.locator(questionDockSelector)
-      const first = dock.locator('[data-slot="question-option"]').first()
-      const second = dock.locator('[data-slot="question-option"]').nth(1)
+test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
 
-      await expectQuestionBlocked(page)
-      await expect(first).toBeFocused()
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
+          questions: [
+            {
+              header: "Need input",
+              question: "Pick one option",
+              options: [
+                { label: "Continue", description: "Continue now" },
+                { label: "Stop", description: "Stop here" },
+              ],
+            },
+          ],
+        })
 
-      await page.keyboard.press("ArrowDown")
-      await expect(second).toBeFocused()
+        const dock = page.locator(questionDockSelector)
+        const first = dock.locator('[data-slot="question-option"]').first()
+        const second = dock.locator('[data-slot="question-option"]').nth(1)
 
-      await page.keyboard.press("Space")
-      await page.keyboard.press(`${modKey}+Enter`)
-      await expectQuestionOpen(page)
-    })
-  })
-})
+        await expectQuestionBlocked(page)
+        await expect(first).toBeFocused()
 
-test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock question escape", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
+        await page.keyboard.press("ArrowDown")
+        await expect(second).toBeFocused()
 
-      await seedSessionQuestion(sdk, {
-        sessionID: session.id,
-        questions: [
-          {
-            header: "Need input",
-            question: "Pick one option",
-            options: [
-              { label: "Continue", description: "Continue now" },
-              { label: "Stop", description: "Stop here" },
-            ],
-          },
-        ],
+        await page.keyboard.press("Space")
+        await page.keyboard.press(`${modKey}+Enter`)
+        await expectQuestionOpen(page)
       })
-
-      const dock = page.locator(questionDockSelector)
-      const first = dock.locator('[data-slot="question-option"]').first()
-
-      await expectQuestionBlocked(page)
-      await expect(first).toBeFocused()
-
-      await page.keyboard.press("Escape")
-      await expectQuestionOpen(page)
     })
   })
 })
 
-test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
-    await gotoSession(session.id)
-    await setAutoAccept(page, false)
-    await withMockPermission(
-      page,
-      {
-        id: "per_e2e_once",
-        sessionID: session.id,
-        permission: "bash",
-        patterns: ["/tmp/opencode-e2e-perm-once"],
-        metadata: { description: "Need permission for command" },
-      },
-      undefined,
-      async (state) => {
-        await page.goto(page.url())
-        await expectPermissionBlocked(page)
-
-        await clearPermissionDock(page, /allow once/i)
-        await state.resolved()
-        await page.goto(page.url())
-        await expectPermissionOpen(page)
-      },
-    )
-  })
-})
-
-test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
-    await gotoSession(session.id)
-    await setAutoAccept(page, false)
-    await withMockPermission(
-      page,
-      {
-        id: "per_e2e_reject",
-        sessionID: session.id,
-        permission: "bash",
-        patterns: ["/tmp/opencode-e2e-perm-reject"],
-      },
-      undefined,
-      async (state) => {
-        await page.goto(page.url())
-        await expectPermissionBlocked(page)
-
-        await clearPermissionDock(page, /deny/i)
-        await state.resolved()
-        await page.goto(page.url())
-        await expectPermissionOpen(page)
-      },
-    )
-  })
-})
-
-test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
-    await gotoSession(session.id)
-    await setAutoAccept(page, false)
-    await withMockPermission(
-      page,
-      {
-        id: "per_e2e_always",
-        sessionID: session.id,
-        permission: "bash",
-        patterns: ["/tmp/opencode-e2e-perm-always"],
-        metadata: { description: "Need permission for command" },
-      },
-      undefined,
-      async (state) => {
-        await page.goto(page.url())
-        await expectPermissionBlocked(page)
-
-        await clearPermissionDock(page, /allow always/i)
-        await state.resolved()
-        await page.goto(page.url())
-        await expectPermissionOpen(page)
-      },
-    )
-  })
-})
-
-test("child session question request blocks parent dock and unblocks after submit", async ({
-  page,
-  sdk,
-  gotoSession,
-}) => {
-  await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
-    await gotoSession(session.id)
-
-    const child = await sdk.session
-      .create({
-        title: "e2e composer dock child question",
-        parentID: session.id,
-      })
-      .then((r) => r.data)
-    if (!child?.id) throw new Error("Child session create did not return an id")
+test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock question escape", async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
 
-    try {
-      await withDockSeed(sdk, child.id, async () => {
-        await seedSessionQuestion(sdk, {
-          sessionID: child.id,
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
           questions: [
             {
-              header: "Child input",
-              question: "Pick one child option",
+              header: "Need input",
+              question: "Pick one option",
               options: [
-                { label: "Continue", description: "Continue child" },
-                { label: "Stop", description: "Stop child" },
+                { label: "Continue", description: "Continue now" },
+                { label: "Stop", description: "Stop here" },
               ],
             },
           ],
         })
 
         const dock = page.locator(questionDockSelector)
-        await expectQuestionBlocked(page)
+        const first = dock.locator('[data-slot="question-option"]').first()
 
-        await dock.locator('[data-slot="question-option"]').first().click()
-        await dock.getByRole("button", { name: /submit/i }).click()
+        await expectQuestionBlocked(page)
+        await expect(first).toBeFocused()
 
+        await page.keyboard.press("Escape")
         await expectQuestionOpen(page)
       })
-    } finally {
-      await cleanupSession({ sdk, sessionID: child.id })
-    }
+    })
   })
 })
 
-test("child session permission request blocks parent dock and supports allow once", async ({
-  page,
-  sdk,
-  gotoSession,
-}) => {
-  await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
-    await gotoSession(session.id)
-    await setAutoAccept(page, false)
+test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
+      await withMockPermission(
+        page,
+        {
+          id: "per_e2e_once",
+          sessionID: session.id,
+          permission: "bash",
+          patterns: ["/tmp/opencode-e2e-perm-once"],
+          metadata: { description: "Need permission for command" },
+        },
+        undefined,
+        async (state) => {
+          await page.goto(page.url())
+          await expectPermissionBlocked(page)
 
-    const child = await sdk.session
-      .create({
-        title: "e2e composer dock child permission",
-        parentID: session.id,
-      })
-      .then((r) => r.data)
-    if (!child?.id) throw new Error("Child session create did not return an id")
+          await clearPermissionDock(page, /allow once/i)
+          await state.resolved()
+          await page.goto(page.url())
+          await expectPermissionOpen(page)
+        },
+      )
+    })
+  })
+})
 
-    try {
+test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
       await withMockPermission(
         page,
         {
-          id: "per_e2e_child",
-          sessionID: child.id,
+          id: "per_e2e_reject",
+          sessionID: session.id,
           permission: "bash",
-          patterns: ["/tmp/opencode-e2e-perm-child"],
-          metadata: { description: "Need child permission" },
+          patterns: ["/tmp/opencode-e2e-perm-reject"],
         },
-        { child },
+        undefined,
         async (state) => {
           await page.goto(page.url())
           await expectPermissionBlocked(page)
 
-          await clearPermissionDock(page, /allow once/i)
+          await clearPermissionDock(page, /deny/i)
           await state.resolved()
           await page.goto(page.url())
+          await expectPermissionOpen(page)
+        },
+      )
+    })
+  })
+})
+
+test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
+      await withMockPermission(
+        page,
+        {
+          id: "per_e2e_always",
+          sessionID: session.id,
+          permission: "bash",
+          patterns: ["/tmp/opencode-e2e-perm-always"],
+          metadata: { description: "Need permission for command" },
+        },
+        undefined,
+        async (state) => {
+          await page.goto(page.url())
+          await expectPermissionBlocked(page)
 
+          await clearPermissionDock(page, /allow always/i)
+          await state.resolved()
+          await page.goto(page.url())
           await expectPermissionOpen(page)
         },
       )
-    } finally {
-      await cleanupSession({ sdk, sessionID: child.id })
-    }
+    })
   })
 })
 
-test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock todo", async (session) => {
-    const dock = await todoDock(page, session.id)
-    await gotoSession(session.id)
-    await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
-
-    try {
-      await dock.open([
-        { content: "first task", status: "pending", priority: "high" },
-        { content: "second task", status: "in_progress", priority: "medium" },
-      ])
-      await dock.expectOpen(["pending", "in_progress"])
-
-      await dock.collapse()
-      await dock.expectCollapsed(["pending", "in_progress"])
-
-      await dock.expand()
-      await dock.expectOpen(["pending", "in_progress"])
-
-      await dock.finish([
-        { content: "first task", status: "completed", priority: "high" },
-        { content: "second task", status: "cancelled", priority: "medium" },
-      ])
-      await dock.expectClosed()
-    } finally {
-      await dock.clear()
-    }
+test("child session question request blocks parent dock and unblocks after submit", async ({
+  page,
+  withBackendProject,
+}) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => {
+      await project.gotoSession(session.id)
+
+      const child = await project.sdk.session
+        .create({
+          title: "e2e composer dock child question",
+          parentID: session.id,
+        })
+        .then((r) => r.data)
+      if (!child?.id) throw new Error("Child session create did not return an id")
+
+      try {
+        await withDockSeed(project.sdk, child.id, async () => {
+          await seedSessionQuestion(project.sdk, {
+            sessionID: child.id,
+            questions: [
+              {
+                header: "Child input",
+                question: "Pick one child option",
+                options: [
+                  { label: "Continue", description: "Continue child" },
+                  { label: "Stop", description: "Stop child" },
+                ],
+              },
+            ],
+          })
+
+          const dock = page.locator(questionDockSelector)
+          await expectQuestionBlocked(page)
+
+          await dock.locator('[data-slot="question-option"]').first().click()
+          await dock.getByRole("button", { name: /submit/i }).click()
+
+          await expectQuestionOpen(page)
+        })
+      } finally {
+        await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+      }
+    })
   })
 })
 
-test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
-  await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
+test("child session permission request blocks parent dock and supports allow once", async ({
+  page,
+  withBackendProject,
+}) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
+
+      const child = await project.sdk.session
+        .create({
+          title: "e2e composer dock child permission",
+          parentID: session.id,
+        })
+        .then((r) => r.data)
+      if (!child?.id) throw new Error("Child session create did not return an id")
 
-      await seedSessionQuestion(sdk, {
-        sessionID: session.id,
-        questions: [
+      try {
+        await withMockPermission(
+          page,
           {
-            header: "Need input",
-            question: "Pick one option",
-            options: [{ label: "Continue", description: "Continue now" }],
+            id: "per_e2e_child",
+            sessionID: child.id,
+            permission: "bash",
+            patterns: ["/tmp/opencode-e2e-perm-child"],
+            metadata: { description: "Need child permission" },
           },
-        ],
-      })
+          { child },
+          async (state) => {
+            await page.goto(page.url())
+            await expectPermissionBlocked(page)
+
+            await clearPermissionDock(page, /allow once/i)
+            await state.resolved()
+            await page.goto(page.url())
 
-      await expectQuestionBlocked(page)
+            await expectPermissionOpen(page)
+          },
+        )
+      } finally {
+        await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+      }
+    })
+  })
+})
+
+test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock todo", async (session) => {
+      const dock = await todoDock(page, session.id)
+      await project.gotoSession(session.id)
+      await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
+
+      try {
+        await dock.open([
+          { content: "first task", status: "pending", priority: "high" },
+          { content: "second task", status: "in_progress", priority: "medium" },
+        ])
+        await dock.expectOpen(["pending", "in_progress"])
+
+        await dock.collapse()
+        await dock.expectCollapsed(["pending", "in_progress"])
+
+        await dock.expand()
+        await dock.expectOpen(["pending", "in_progress"])
+
+        await dock.finish([
+          { content: "first task", status: "completed", priority: "high" },
+          { content: "second task", status: "cancelled", priority: "medium" },
+        ])
+        await dock.expectClosed()
+      } finally {
+        await dock.clear()
+      }
+    })
+  })
+})
 
-      await page.locator("main").click({ position: { x: 5, y: 5 } })
-      await page.keyboard.type("abc")
-      await expect(page.locator(promptSelector)).toHaveCount(0)
+test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
+  await withBackendProject(async (project) => {
+    await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
+
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
+          questions: [
+            {
+              header: "Need input",
+              question: "Pick one option",
+              options: [{ label: "Continue", description: "Continue now" }],
+            },
+          ],
+        })
+
+        await expectQuestionBlocked(page)
+
+        await page.locator("main").click({ position: { x: 5, y: 5 } })
+        await page.keyboard.type("abc")
+        await expect(page.locator(promptSelector)).toHaveCount(0)
+      })
     })
   })
 })

+ 9 - 9
packages/app/e2e/session/session-undo-redo.spec.ts

@@ -49,13 +49,13 @@ async function seedConversation(input: {
   return { prompt, userMessageID }
 }
 
-test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
+test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
   test.setTimeout(120_000)
 
   const token = `undo_${Date.now()}`
 
-  await withProject(async (project) => {
-    const sdk = createSdk(project.directory)
+  await withBackendProject(async (project) => {
+    const sdk = project.sdk
 
     await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
       await project.gotoSession(session.id)
@@ -81,13 +81,13 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withProj
   })
 })
 
-test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
+test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
   test.setTimeout(120_000)
 
   const token = `redo_${Date.now()}`
 
-  await withProject(async (project) => {
-    const sdk = createSdk(project.directory)
+  await withBackendProject(async (project) => {
+    const sdk = project.sdk
 
     await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
       await project.gotoSession(session.id)
@@ -128,14 +128,14 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
   })
 })
 
-test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
+test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
   test.setTimeout(120_000)
 
   const firstToken = `undo_redo_first_${Date.now()}`
   const secondToken = `undo_redo_second_${Date.now()}`
 
-  await withProject(async (project) => {
-    const sdk = createSdk(project.directory)
+  await withBackendProject(async (project) => {
+    const sdk = project.sdk
 
     await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
       await project.gotoSession(session.id)

+ 122 - 114
packages/app/e2e/session/session.spec.ts

@@ -31,144 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
     .toBeGreaterThan(0)
 }
 
-test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
+test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
   const stamp = Date.now()
   const originalTitle = `e2e rename test ${stamp}`
   const renamedTitle = `e2e renamed ${stamp}`
 
-  await withSession(sdk, originalTitle, async (session) => {
-    await seedMessage(sdk, session.id)
-    await gotoSession(session.id)
-    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
-
-    const menu = await openSessionMoreMenu(page, session.id)
-    await clickMenuItem(menu, /rename/i)
-
-    const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
-    await expect(input).toBeVisible()
-    await expect(input).toBeFocused()
-    await input.fill(renamedTitle)
-    await expect(input).toHaveValue(renamedTitle)
-    await input.press("Enter")
-
-    await expect
-      .poll(
-        async () => {
-          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-          return data?.title
-        },
-        { timeout: 30_000 },
-      )
-      .toBe(renamedTitle)
-
-    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
+  await withBackendProject(async (project) => {
+    await withSession(project.sdk, originalTitle, async (session) => {
+      await seedMessage(project.sdk, session.id)
+      await project.gotoSession(session.id)
+      await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
+
+      const menu = await openSessionMoreMenu(page, session.id)
+      await clickMenuItem(menu, /rename/i)
+
+      const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
+      await expect(input).toBeVisible()
+      await expect(input).toBeFocused()
+      await input.fill(renamedTitle)
+      await expect(input).toHaveValue(renamedTitle)
+      await input.press("Enter")
+
+      await expect
+        .poll(
+          async () => {
+            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+            return data?.title
+          },
+          { timeout: 30_000 },
+        )
+        .toBe(renamedTitle)
+
+      await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
+    })
   })
 })
 
-test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
+test("session can be archived via header menu", async ({ page, withBackendProject }) => {
   const stamp = Date.now()
   const title = `e2e archive test ${stamp}`
 
-  await withSession(sdk, title, async (session) => {
-    await seedMessage(sdk, session.id)
-    await gotoSession(session.id)
-    const menu = await openSessionMoreMenu(page, session.id)
-    await clickMenuItem(menu, /archive/i)
-
-    await expect
-      .poll(
-        async () => {
-          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-          return data?.time?.archived
-        },
-        { timeout: 30_000 },
-      )
-      .not.toBeUndefined()
-
-    await openSidebar(page)
-    await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
+  await withBackendProject(async (project) => {
+    await withSession(project.sdk, title, async (session) => {
+      await seedMessage(project.sdk, session.id)
+      await project.gotoSession(session.id)
+      const menu = await openSessionMoreMenu(page, session.id)
+      await clickMenuItem(menu, /archive/i)
+
+      await expect
+        .poll(
+          async () => {
+            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+            return data?.time?.archived
+          },
+          { timeout: 30_000 },
+        )
+        .not.toBeUndefined()
+
+      await openSidebar(page)
+      await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
+    })
   })
 })
 
-test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
+test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
   const stamp = Date.now()
   const title = `e2e delete test ${stamp}`
 
-  await withSession(sdk, title, async (session) => {
-    await seedMessage(sdk, session.id)
-    await gotoSession(session.id)
-    const menu = await openSessionMoreMenu(page, session.id)
-    await clickMenuItem(menu, /delete/i)
-    await confirmDialog(page, /delete/i)
-
-    await expect
-      .poll(
-        async () => {
-          const data = await sdk.session
-            .get({ sessionID: session.id })
-            .then((r) => r.data)
-            .catch(() => undefined)
-          return data?.id
-        },
-        { timeout: 30_000 },
-      )
-      .toBeUndefined()
-
-    await openSidebar(page)
-    await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
+  await withBackendProject(async (project) => {
+    await withSession(project.sdk, title, async (session) => {
+      await seedMessage(project.sdk, session.id)
+      await project.gotoSession(session.id)
+      const menu = await openSessionMoreMenu(page, session.id)
+      await clickMenuItem(menu, /delete/i)
+      await confirmDialog(page, /delete/i)
+
+      await expect
+        .poll(
+          async () => {
+            const data = await project.sdk.session
+              .get({ sessionID: session.id })
+              .then((r) => r.data)
+              .catch(() => undefined)
+            return data?.id
+          },
+          { timeout: 30_000 },
+        )
+        .toBeUndefined()
+
+      await openSidebar(page)
+      await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
+    })
   })
 })
 
-test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
+test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
   test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
 
   const stamp = Date.now()
   const title = `e2e share test ${stamp}`
 
-  await withSession(sdk, title, async (session) => {
-    await seedMessage(sdk, session.id)
-    await gotoSession(session.id)
-
-    const shared = await openSharePopover(page)
-    const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
-    await expect(publish).toBeVisible({ timeout: 30_000 })
-    await publish.click()
-
-    await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
-      timeout: 30_000,
-    })
-
-    await expect
-      .poll(
-        async () => {
-          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-          return data?.share?.url || undefined
-        },
-        { timeout: 30_000 },
-      )
-      .not.toBeUndefined()
-
-    const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
-    await expect(unpublish).toBeVisible({ timeout: 30_000 })
-    await unpublish.click()
-
-    await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
-      timeout: 30_000,
-    })
-
-    await expect
-      .poll(
-        async () => {
-          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-          return data?.share?.url || undefined
-        },
-        { timeout: 30_000 },
-      )
-      .toBeUndefined()
-
-    const unshared = await openSharePopover(page)
-    await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
-      timeout: 30_000,
+  await withBackendProject(async (project) => {
+    await withSession(project.sdk, title, async (session) => {
+      await seedMessage(project.sdk, session.id)
+      await project.gotoSession(session.id)
+
+      const shared = await openSharePopover(page)
+      const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
+      await expect(publish).toBeVisible({ timeout: 30_000 })
+      await publish.click()
+
+      await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
+        timeout: 30_000,
+      })
+
+      await expect
+        .poll(
+          async () => {
+            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+            return data?.share?.url || undefined
+          },
+          { timeout: 30_000 },
+        )
+        .not.toBeUndefined()
+
+      const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
+      await expect(unpublish).toBeVisible({ timeout: 30_000 })
+      await unpublish.click()
+
+      await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+        timeout: 30_000,
+      })
+
+      await expect
+        .poll(
+          async () => {
+            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+            return data?.share?.url || undefined
+          },
+          { timeout: 30_000 },
+        )
+        .toBeUndefined()
+
+      const unshared = await openSharePopover(page)
+      await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+        timeout: 30_000,
+      })
     })
   })
 })