소스 검색

test(app): fix isolated backend follow-ups (#20513)

Kit Langton 2 주 전
부모
커밋
f3f728ec27

+ 13 - 2
packages/app/e2e/backend.ts

@@ -44,6 +44,14 @@ async function waitForHealth(url: string, probe = "/global/health") {
   throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
 }
 
+async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
+  if (proc.exitCode !== null) return
+  await Promise.race([
+    new Promise<void>((resolve) => proc.once("exit", () => resolve())),
+    new Promise<void>((resolve) => setTimeout(resolve, timeout)),
+  ])
+}
+
 const LOG_CAP = 100
 
 function cap(input: string[]) {
@@ -62,7 +70,6 @@ export async function startBackend(label: string): Promise<Handle> {
   const opencodeDir = path.join(repoDir, "packages", "opencode")
   const env = {
     ...process.env,
-    OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
     OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
     OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
     OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
@@ -117,7 +124,11 @@ export async function startBackend(label: string): Promise<Handle> {
     async stop() {
       if (proc.exitCode === null) {
         proc.kill("SIGTERM")
-        await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
+        await waitExit(proc)
+      }
+      if (proc.exitCode === null) {
+        proc.kill("SIGKILL")
+        await waitExit(proc)
       }
       await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
     },

+ 51 - 93
packages/app/e2e/prompt/prompt-history.spec.ts

@@ -3,9 +3,11 @@ import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
 import { assistantText, sessionIDFromUrl } from "../actions"
 import { promptSelector } from "../selectors"
+import { createSdk } from "../utils"
 import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
 
 const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
+type Sdk = ReturnType<typeof createSdk>
 
 const isBash = (part: unknown): part is ToolPart => {
   if (!part || typeof part !== "object") return false
@@ -14,47 +16,15 @@ const isBash = (part: unknown): part is ToolPart => {
   return "state" in part
 }
 
-async function edge(page: Page, pos: "start" | "end") {
-  await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
-    const selection = window.getSelection()
-    if (!selection) return
-
-    const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
-    const nodes: Text[] = []
-    for (let node = walk.nextNode(); node; node = walk.nextNode()) {
-      nodes.push(node as Text)
-    }
-
-    if (nodes.length === 0) {
-      const node = document.createTextNode("")
-      el.appendChild(node)
-      nodes.push(node)
-    }
-
-    const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
-    const range = document.createRange()
-    range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
-    range.collapse(true)
-    selection.removeAllRanges()
-    selection.addRange(range)
-  }, pos)
-}
-
 async function wait(page: Page, value: string) {
   await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
 }
 
-async function reply(
-  sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
-  sessionID: string,
-  token: string,
-) {
-  await expect
-    .poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
-    .toContain(token)
+async function reply(sdk: Sdk, sessionID: string, token: string) {
+  await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
 }
 
-async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
+async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
   await expect
     .poll(
       async () => {
@@ -142,76 +112,64 @@ test("prompt history restores unsent draft with arrow navigation", async ({
   })
 })
 
-test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => {
+test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
   test.setTimeout(120_000)
 
-  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}`
+  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 gotoSession()
 
-      await withBackendProject(
-        async (project) => {
-          const prompt = page.locator(promptSelector)
+  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 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 expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+  const sessionID = sessionIDFromUrl(page.url())!
+  await shell(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.type(second)
+  await page.keyboard.press("Enter")
+  await wait(page, "")
+  await shell(sdk, sessionID, second, secondToken)
 
-          await prompt.click()
-          await page.keyboard.type("!")
-          await page.keyboard.press("ArrowUp")
-          await wait(page, second)
+  await page.keyboard.press("Escape")
+  await wait(page, "")
 
-          await page.keyboard.press("ArrowUp")
-          await wait(page, first)
+  await prompt.click()
+  await page.keyboard.type("!")
+  await page.keyboard.press("ArrowUp")
+  await wait(page, second)
 
-          await page.keyboard.press("ArrowDown")
-          await wait(page, second)
+  await page.keyboard.press("ArrowUp")
+  await wait(page, first)
 
-          await page.keyboard.press("ArrowDown")
-          await wait(page, "")
+  await page.keyboard.press("ArrowDown")
+  await wait(page, second)
 
-          await page.keyboard.press("Escape")
-          await wait(page, "")
+  await page.keyboard.press("ArrowDown")
+  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 page.keyboard.press("Escape")
+  await wait(page, "")
 
-          await prompt.click()
-          await page.keyboard.press("ArrowUp")
-          await wait(page, normal)
-        },
-        {
-          model: openaiModel,
-        },
-      )
-    },
-  })
+  await prompt.click()
+  await page.keyboard.type(normal)
+  await page.keyboard.press("Enter")
+  await wait(page, "")
+  await reply(sdk, sessionID, normalToken)
+
+  await prompt.click()
+  await page.keyboard.press("ArrowUp")
+  await wait(page, normal)
 })

+ 1 - 0
packages/app/e2e/prompt/prompt-slash-share.spec.ts

@@ -27,6 +27,7 @@ test("/share and /unshare update session share state", async ({ page, withBacken
 
   await withBackendProject(async (project) => {
     await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
+      project.trackSession(session.id)
       const prompt = page.locator(promptSelector)
 
       await seed(project.sdk, session.id)

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

@@ -1,5 +1,6 @@
 import { seedSessionTask, withSession } from "../actions"
 import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
 
 test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
   test.setTimeout(120_000)
@@ -10,17 +11,16 @@ test("task tool child-session link does not trigger stale show errors", async ({
   }
   page.on("pageerror", onError)
 
-  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 withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
+      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.",
+        })
+        trackSession(child.sessionID)
 
-      try {
         await gotoSession(session.id)
 
         const link = page
@@ -31,11 +31,11 @@ test("task tool child-session link does not trigger stale show errors", async ({
         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.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
+        await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
+      })
     })
-  })
+  } finally {
+    page.off("pageerror", onError)
+  }
 })

+ 329 - 271
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -22,12 +22,13 @@ async function withDockSession<T>(
   sdk: Sdk,
   title: string,
   fn: (session: { id: string; title: string }) => Promise<T>,
-  opts?: { permission?: PermissionRule[] },
+  opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
 ) {
   const session = await sdk.session
     .create(opts?.permission ? { title, permission: opts.permission } : { title })
     .then((r) => r.data)
   if (!session?.id) throw new Error("Session create did not return an id")
+  opts?.trackSession?.(session.id)
   try {
     return await fn(session)
   } finally {
@@ -258,17 +259,22 @@ async function withMockPermission<T>(
 
 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 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 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()
-    })
+        await page.locator(promptSelector).click()
+        await expect(page.locator(promptSelector)).toBeFocused()
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
@@ -287,190 +293,220 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP
 
 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 withDockSession(
+      project.sdk,
+      "e2e composer dock question",
+      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" },
-                { 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)
+        })
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
 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 withDockSession(
+      project.sdk,
+      "e2e composer dock question 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" },
-                { 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)
-        const first = dock.locator('[data-slot="question-option"]').first()
-        const second = dock.locator('[data-slot="question-option"]').nth(1)
+          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 expectQuestionBlocked(page)
-        await expect(first).toBeFocused()
+          await expectQuestionBlocked(page)
+          await expect(first).toBeFocused()
 
-        await page.keyboard.press("ArrowDown")
-        await expect(second).toBeFocused()
+          await page.keyboard.press("ArrowDown")
+          await expect(second).toBeFocused()
 
-        await page.keyboard.press("Space")
-        await page.keyboard.press(`${modKey}+Enter`)
-        await expectQuestionOpen(page)
-      })
-    })
+          await page.keyboard.press("Space")
+          await page.keyboard.press(`${modKey}+Enter`)
+          await expectQuestionOpen(page)
+        })
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
 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)
+    await withDockSession(
+      project.sdk,
+      "e2e composer dock question escape",
+      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" },
-                { 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)
-        const first = dock.locator('[data-slot="question-option"]').first()
+          const dock = page.locator(questionDockSelector)
+          const first = dock.locator('[data-slot="question-option"]').first()
 
-        await expectQuestionBlocked(page)
-        await expect(first).toBeFocused()
+          await expectQuestionBlocked(page)
+          await expect(first).toBeFocused()
 
-        await page.keyboard.press("Escape")
-        await expectQuestionOpen(page)
-      })
-    })
+          await page.keyboard.press("Escape")
+          await expectQuestionOpen(page)
+        })
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
 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)
-
-          await clearPermissionDock(page, /allow once/i)
-          await state.resolved()
-          await page.goto(page.url())
-          await expectPermissionOpen(page)
-        },
-      )
-    })
+    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)
+
+            await clearPermissionDock(page, /allow once/i)
+            await state.resolved()
+            await page.goto(page.url())
+            await expectPermissionOpen(page)
+          },
+        )
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
 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_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)
-        },
-      )
-    })
+    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_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)
+          },
+        )
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
 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)
-        },
-      )
-    })
+    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)
+          },
+        )
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
@@ -479,45 +515,51 @@ test("child session question request blocks parent dock and unblocks after submi
   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")
+    await withDockSession(
+      project.sdk,
+      "e2e composer dock child question parent",
+      async (session) => {
+        await project.gotoSession(session.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 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")
+        project.trackSession(child.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)
+            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)
-        })
-      } finally {
-        await cleanupSession({ sdk: project.sdk, sessionID: child.id })
-      }
-    })
+            await expectQuestionOpen(page)
+          })
+        } finally {
+          await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+        }
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
@@ -526,102 +568,118 @@ test("child session permission request blocks parent dock and supports allow onc
   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 withDockSession(
+      project.sdk,
+      "e2e composer dock child permission parent",
+      async (session) => {
+        await project.gotoSession(session.id)
+        await setAutoAccept(page, false)
 
-      try {
-        await withMockPermission(
-          page,
-          {
-            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)
+        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")
+        project.trackSession(child.id)
 
-            await clearPermissionDock(page, /allow once/i)
-            await state.resolved()
-            await page.goto(page.url())
+        try {
+          await withMockPermission(
+            page,
+            {
+              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 expectPermissionOpen(page)
-          },
-        )
-      } finally {
-        await cleanupSession({ sdk: project.sdk, sessionID: child.id })
-      }
-    })
+              await clearPermissionDock(page, /allow once/i)
+              await state.resolved()
+              await page.goto(page.url())
+
+              await expectPermissionOpen(page)
+            },
+          )
+        } finally {
+          await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+        }
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
 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 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()
+        }
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })
 
 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 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 seedSessionQuestion(project.sdk, {
+            sessionID: session.id,
+            questions: [
+              {
+                header: "Need input",
+                question: "Pick one option",
+                options: [{ label: "Continue", description: "Continue now" }],
+              },
+            ],
+          })
 
-        await expectQuestionBlocked(page)
+          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)
-      })
-    })
+          await page.locator("main").click({ position: { x: 5, y: 5 } })
+          await page.keyboard.type("abc")
+          await expect(page.locator(promptSelector)).toHaveCount(0)
+        })
+      },
+      { trackSession: project.trackSession },
+    )
   })
 })

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

@@ -58,6 +58,7 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
     const sdk = project.sdk
 
     await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
+      project.trackSession(session.id)
       await project.gotoSession(session.id)
 
       const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -90,6 +91,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
     const sdk = project.sdk
 
     await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
+      project.trackSession(session.id)
       await project.gotoSession(session.id)
 
       const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -138,6 +140,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withBac
     const sdk = project.sdk
 
     await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
+      project.trackSession(session.id)
       await project.gotoSession(session.id)
 
       const first = await seedConversation({

+ 4 - 0
packages/app/e2e/session/session.spec.ts

@@ -38,6 +38,7 @@ test("session can be renamed via header menu", async ({ page, withBackendProject
 
   await withBackendProject(async (project) => {
     await withSession(project.sdk, originalTitle, async (session) => {
+      project.trackSession(session.id)
       await seedMessage(project.sdk, session.id)
       await project.gotoSession(session.id)
       await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
@@ -73,6 +74,7 @@ test("session can be archived via header menu", async ({ page, withBackendProjec
 
   await withBackendProject(async (project) => {
     await withSession(project.sdk, title, async (session) => {
+      project.trackSession(session.id)
       await seedMessage(project.sdk, session.id)
       await project.gotoSession(session.id)
       const menu = await openSessionMoreMenu(page, session.id)
@@ -100,6 +102,7 @@ test("session can be deleted via header menu", async ({ page, withBackendProject
 
   await withBackendProject(async (project) => {
     await withSession(project.sdk, title, async (session) => {
+      project.trackSession(session.id)
       await seedMessage(project.sdk, session.id)
       await project.gotoSession(session.id)
       const menu = await openSessionMoreMenu(page, session.id)
@@ -133,6 +136,7 @@ test("session can be shared and unshared via header button", async ({ page, with
 
   await withBackendProject(async (project) => {
     await withSession(project.sdk, title, async (session) => {
+      project.trackSession(session.id)
       await seedMessage(project.sdk, session.id)
       await project.gotoSession(session.id)