diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts index 97c15e44c3..a9a12cb951 100644 --- a/packages/app/e2e/prompt/prompt-async.spec.ts +++ b/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( diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts index 1c9c079550..6420534e02 100644 --- a/packages/app/e2e/prompt/prompt-history.spec.ts +++ b/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[0], sessionID: string, token: string) { +async function reply( + sdk: { session: { messages: Parameters[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[0], sessionID), { timeout: 90_000 }) .toContain(token) } @@ -79,106 +73,145 @@ async function shell(sdk: Parameters[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) + 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()}` - 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 llm.textMatch(titleMatch, "E2E Title") + await llm.textMatch(promptMatch(firstToken), firstToken) + await llm.textMatch(promptMatch(secondToken), secondToken) - await prompt.click() - await page.keyboard.type(first) - await page.keyboard.press("Enter") - await wait(page, "") - await reply(sdk, session.id, firstToken) + await withBackendProject( + async (project) => { + const prompt = page.locator(promptSelector) - 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(first) + await page.keyboard.press("Enter") + await wait(page, "") - await prompt.click() - await page.keyboard.type(draft) - await wait(page, draft) + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + const sessionID = sessionIDFromUrl(page.url())! + project.trackSession(sessionID) + await reply(project.sdk, sessionID, firstToken) - // Clear the draft before navigating history (ArrowUp only works when prompt is empty) - await prompt.fill("") - await wait(page, "") + await prompt.click() + await page.keyboard.type(second) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(project.sdk, sessionID, secondToken) - await page.keyboard.press("ArrowUp") - await wait(page, second) + await prompt.click() + await page.keyboard.type(draft) + await wait(page, draft) - await page.keyboard.press("ArrowUp") - await wait(page, first) + await prompt.fill("") + await wait(page, "") - await page.keyboard.press("ArrowDown") - await wait(page, second) + await page.keyboard.press("ArrowUp") + await wait(page, second) - await page.keyboard.press("ArrowDown") - await wait(page, "") + 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) + 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 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 llm.textMatch(titleMatch, "E2E Title") + await llm.textMatch(promptMatch(normalToken), 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 withBackendProject( + async (project) => { + const prompt = page.locator(promptSelector) - 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.type(first) + await page.keyboard.press("Enter") + await wait(page, "") - await prompt.click() - await page.keyboard.type("!") - await page.keyboard.press("ArrowUp") - await wait(page, second) + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + const sessionID = sessionIDFromUrl(page.url())! + project.trackSession(sessionID) + await shell(project.sdk, sessionID, first, firstToken) - await page.keyboard.press("ArrowUp") - await wait(page, first) + 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 page.keyboard.press("ArrowDown") - await wait(page, second) + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.press("ArrowUp") + await wait(page, second) - await page.keyboard.press("ArrowDown") - await wait(page, "") + await page.keyboard.press("ArrowUp") + await wait(page, first) - await page.keyboard.press("Escape") - await wait(page, "") + await page.keyboard.press("ArrowDown") + await wait(page, second) - await prompt.click() - await page.keyboard.type(normal) - await page.keyboard.press("Enter") - await wait(page, "") - await reply(sdk, session.id, normalToken) + await page.keyboard.press("ArrowDown") + await wait(page, "") - await prompt.click() - await page.keyboard.press("ArrowUp") - await wait(page, normal) + 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, + }, + ) + }, }) }) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts index 019219bf5e..7c39a2db34 100644 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ b/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" diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts index 817b353a7c..efb0272b57 100644 --- a/packages/app/e2e/prompt/prompt-slash-share.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-share.spec.ts @@ -22,43 +22,45 @@ async function seed(sdk: Parameters[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() + }) }) }) diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index ac2dca33c8..616f694a30 100644 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ b/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) + + 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) + } }) - - 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() - - await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) - await page.waitForTimeout(1000) - expect(errs).toEqual([]) - } finally { - page.off("pageerror", onError) - } }) }) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index c560793375..9d44683c8b 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -256,235 +256,50 @@ async function withMockPermission( } } -test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => { - await withDockSession(sdk, "e2e composer dock default", async (session) => { - await gotoSession(session.id) +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 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() - - 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) -}) - -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) - - 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) - await expectQuestionBlocked(page) - - await dock.locator('[data-slot="question-option"]').first().click() - await dock.getByRole("button", { name: /submit/i }).click() - - await expectQuestionOpen(page) + await page.locator(promptSelector).click() + await expect(page.locator(promptSelector)).toBeFocused() }) }) }) -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) +test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => { + await withBackendProject(async ({ gotoSession }) => { + await gotoSession() - 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 button = page.locator('[data-action="prompt-permissions"]').first() + await expect(button).toBeVisible() + await expect(button).toHaveAttribute("aria-pressed", "false") - 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 page.keyboard.press("ArrowDown") - await expect(second).toBeFocused() - - await page.keyboard.press("Space") - await page.keyboard.press(`${modKey}+Enter`) - await expectQuestionOpen(page) - }) - }) -}) - -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 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() - - 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, true) 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) +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 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") - - 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" }, ], }, ], @@ -498,40 +313,96 @@ test("child session question request blocks parent dock and unblocks after submi 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 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) - const child = await sdk.session - .create({ - title: "e2e composer dock child permission", - parentID: 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" }, + ], + }, + ], + }) + + 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 page.keyboard.press("ArrowDown") + await expect(second).toBeFocused() + + await page.keyboard.press("Space") + await page.keyboard.press(`${modKey}+Enter`) + await expectQuestionOpen(page) }) - .then((r) => r.data) - if (!child?.id) throw new Error("Child session create did not return an id") + }) + }) +}) - try { +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 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() + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("Escape") + await expectQuestionOpen(page) + }) + }) + }) +}) + +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_child", - sessionID: child.id, + id: "per_e2e_once", + sessionID: session.id, permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-child"], - metadata: { description: "Need child permission" }, + patterns: ["/tmp/opencode-e2e-perm-once"], + metadata: { description: "Need permission for command" }, }, - { child }, + undefined, async (state) => { await page.goto(page.url()) await expectPermissionBlocked(page) @@ -539,67 +410,218 @@ test("child session permission request blocks parent dock and supports allow onc await clearPermissionDock(page, /allow once/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("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) - - await seedSessionQuestion(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) + }) + }) +}) + +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) + }, + ) + }) + }) +}) + +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) + }, + ) + }) + }) +}) + +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("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") + + 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 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 }) + } + }) + }) +}) + +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() + } + }) + }) +}) + +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) + }) }) }) }) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index eb0840f7cc..b3a75e0dd1 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/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) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 68d9929499..d56e83f2fe 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/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) + 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 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") + 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 + .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) + 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 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 sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.time?.archived - }, - { 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?.time?.archived + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() - await openSidebar(page) - await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) + 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 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 sdk.session - .get({ sessionID: session.id }) - .then((r) => r.data) - .catch(() => undefined) - return data?.id - }, - { timeout: 30_000 }, - ) - .toBeUndefined() + 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) + 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) + 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() + 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(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() + 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() + 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(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() + 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, + const unshared = await openSharePopover(page) + await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, + }) }) }) })