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

This commit is contained in:
Kit Langton
2026-04-01 13:15:42 -04:00
committed by GitHub
parent d1e0a4640c
commit c559af51ce
8 changed files with 607 additions and 537 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { assistantText, sessionIDFromUrl, withSession } from "../actions" 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() 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, llmUrl: llm.url,
fn: async () => { fn: async () => {
const token = `E2E_ASYNC_${Date.now()}` const token = `E2E_ASYNC_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token) await llm.textMatch(promptMatch(token), token)
await withBackendProject( await withBackendProject(
+122 -89
View File
@@ -1,8 +1,9 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { withSession } from "../actions" import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() 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) 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 await expect
.poll( .poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
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 },
)
.toContain(token) .toContain(token)
} }
@@ -79,106 +73,145 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
.toContain(token) .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) test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => { await withMockOpenAI({
await gotoSession(session.id) 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) await llm.textMatch(titleMatch, "E2E Title")
const firstToken = `E2E_HISTORY_ONE_${Date.now()}` await llm.textMatch(promptMatch(firstToken), firstToken)
const secondToken = `E2E_HISTORY_TWO_${Date.now()}` await llm.textMatch(promptMatch(secondToken), secondToken)
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click() await withBackendProject(
await page.keyboard.type(first) async (project) => {
await page.keyboard.press("Enter") const prompt = page.locator(promptSelector)
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type(second) await page.keyboard.type(first)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click() await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
await page.keyboard.type(draft) const sessionID = sessionIDFromUrl(page.url())!
await wait(page, draft) project.trackSession(sessionID)
await reply(project.sdk, sessionID, firstToken)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty) await prompt.click()
await prompt.fill("") await page.keyboard.type(second)
await wait(page, "") await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, secondToken)
await page.keyboard.press("ArrowUp") await prompt.click()
await wait(page, second) await page.keyboard.type(draft)
await wait(page, draft)
await page.keyboard.press("ArrowUp") await prompt.fill("")
await wait(page, first) await wait(page, "")
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowUp")
await wait(page, second) await wait(page, second)
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowUp")
await wait(page, "") 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) test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => { await withMockOpenAI({
await gotoSession(session.id) 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) await llm.textMatch(titleMatch, "E2E Title")
const firstToken = `E2E_SHELL_ONE_${Date.now()}` await llm.textMatch(promptMatch(normalToken), normalToken)
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 withBackendProject(
await page.keyboard.type("!") async (project) => {
await page.keyboard.type(first) const prompt = page.locator(promptSelector)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(second) await page.keyboard.type(first)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click() await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
await page.keyboard.type("!") const sessionID = sessionIDFromUrl(page.url())!
await page.keyboard.press("ArrowUp") project.trackSession(sessionID)
await wait(page, second) await shell(project.sdk, sessionID, first, firstToken)
await page.keyboard.press("ArrowUp") await prompt.click()
await wait(page, first) 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 prompt.click()
await wait(page, second) await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowUp")
await wait(page, "") await wait(page, first)
await page.keyboard.press("Escape") await page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, second)
await prompt.click() await page.keyboard.press("ArrowDown")
await page.keyboard.type(normal) await wait(page, "")
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click() await page.keyboard.press("Escape")
await page.keyboard.press("ArrowUp") await wait(page, "")
await wait(page, normal)
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
View File
@@ -10,10 +10,10 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part 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) test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession, sdk }) => { await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "command ls" const cmd = process.platform === "win32" ? "dir" : "command ls"
@@ -22,43 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0) .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).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => { await withBackendProject(async (project) => {
const prompt = page.locator(promptSelector) await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id) await seed(project.sdk, session.id)
await gotoSession(session.id) await project.gotoSession(session.id)
await prompt.click() await prompt.click()
await page.keyboard.type("/share") await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await prompt.click() await prompt.click()
await page.keyboard.type("/unshare") await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
})
}) })
}) })
@@ -1,7 +1,7 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" 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) test.setTimeout(120_000)
const errs: string[] = [] const errs: string[] = []
@@ -10,28 +10,32 @@ test("task tool child-session link does not trigger stale show errors", async ({
} }
page.on("pageerror", onError) page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
const child = await seedSessionTask(sdk, { await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
sessionID: session.id, trackSession(session.id)
description: "Open child session", const child = await seedSessionTask(sdk, {
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", 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)
}
}) })
}) })
@@ -256,235 +256,50 @@ async function withMockPermission<T>(
} }
} }
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => { test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => { await withBackendProject(async (project) => {
await 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(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0) await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0) await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused() 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)
}) })
}) })
}) })
test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => { test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => { await withBackendProject(async ({ gotoSession }) => {
await withDockSeed(sdk, session.id, async () => { await gotoSession()
await gotoSession(session.id)
await seedSessionQuestion(sdk, { const button = page.locator('[data-action="prompt-permissions"]').first()
sessionID: session.id, await expect(button).toBeVisible()
questions: [ await expect(button).toHaveAttribute("aria-pressed", "false")
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector) await setAutoAccept(page, true)
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, false) 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 }) => { test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => { await withBackendProject(async (project) => {
await gotoSession(session.id) await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
await setAutoAccept(page, false) await withDockSeed(project.sdk, session.id, async () => {
await withMockPermission( await project.gotoSession(session.id)
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 seedSessionQuestion(project.sdk, {
await state.resolved() sessionID: session.id,
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,
questions: [ questions: [
{ {
header: "Child input", header: "Need input",
question: "Pick one child option", question: "Pick one option",
options: [ options: [
{ label: "Continue", description: "Continue child" }, { label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop child" }, { label: "Stop", description: "Stop here" },
], ],
}, },
], ],
@@ -498,40 +313,96 @@ test("child session question request blocks parent dock and unblocks after submi
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
} finally { })
await cleanupSession({ sdk, sessionID: child.id })
}
}) })
}) })
test("child session permission request blocks parent dock and supports allow once", async ({ test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
page, await withBackendProject(async (project) => {
sdk, await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
gotoSession, await withDockSeed(project.sdk, session.id, async () => {
}) => { await project.gotoSession(session.id)
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
const child = await sdk.session await seedSessionQuestion(project.sdk, {
.create({ sessionID: session.id,
title: "e2e composer dock child permission", questions: [
parentID: session.id, {
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( await withMockPermission(
page, page,
{ {
id: "per_e2e_child", id: "per_e2e_once",
sessionID: child.id, sessionID: session.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"], patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need child permission" }, metadata: { description: "Need permission for command" },
}, },
{ child }, undefined,
async (state) => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionBlocked(page) 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 clearPermissionDock(page, /allow once/i)
await state.resolved() await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
} finally { })
await cleanupSession({ sdk, sessionID: child.id })
}
}) })
}) })
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => { test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => { await withBackendProject(async (project) => {
const dock = await todoDock(page, session.id) await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
await gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await setAutoAccept(page, false)
await withMockPermission(
try { page,
await dock.open([ {
{ content: "first task", status: "pending", priority: "high" }, id: "per_e2e_reject",
{ content: "second task", status: "in_progress", priority: "medium" }, sessionID: session.id,
]) permission: "bash",
await dock.expectOpen(["pending", "in_progress"]) patterns: ["/tmp/opencode-e2e-perm-reject"],
},
await dock.collapse() undefined,
await dock.expectCollapsed(["pending", "in_progress"]) async (state) => {
await page.goto(page.url())
await dock.expand() await expectPermissionBlocked(page)
await dock.expectOpen(["pending", "in_progress"])
await clearPermissionDock(page, /deny/i)
await dock.finish([ await state.resolved()
{ content: "first task", status: "completed", priority: "high" }, await page.goto(page.url())
{ content: "second task", status: "cancelled", priority: "medium" }, await expectPermissionOpen(page)
]) },
await dock.expectClosed() )
} finally { })
await dock.clear() })
} })
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" },
],
},
],
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => { const dock = page.locator(questionDockSelector)
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => { await expectQuestionBlocked(page)
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, { await dock.locator('[data-slot="question-option"]').first().click()
sessionID: session.id, await dock.getByRole("button", { name: /submit/i }).click()
questions: [
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,
{ {
header: "Need input", id: "per_e2e_child",
question: "Pick one option", sessionID: child.id,
options: [{ label: "Continue", description: "Continue now" }], 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 expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } }) await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc") await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0) await expect(page.locator(promptSelector)).toHaveCount(0)
})
}) })
}) })
}) })
@@ -49,13 +49,13 @@ async function seedConversation(input: {
return { prompt, userMessageID } 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) test.setTimeout(120_000)
const token = `undo_${Date.now()}` const token = `undo_${Date.now()}`
await withProject(async (project) => { await withBackendProject(async (project) => {
const sdk = createSdk(project.directory) const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id) 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) test.setTimeout(120_000)
const token = `redo_${Date.now()}` const token = `redo_${Date.now()}`
await withProject(async (project) => { await withBackendProject(async (project) => {
const sdk = createSdk(project.directory) const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id) 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) test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}` const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}` const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => { await withBackendProject(async (project) => {
const sdk = createSdk(project.directory) const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
+107 -99
View File
@@ -31,144 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0) .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 stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}` const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}` const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, originalTitle, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toBeFocused() await expect(input).toBeFocused()
await input.fill(renamedTitle) await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle) await expect(input).toHaveValue(renamedTitle)
await input.press("Enter") await input.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title return data?.title
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBe(renamedTitle) .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 stamp = Date.now()
const title = `e2e archive test ${stamp}` const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, title, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
const menu = await openSessionMoreMenu(page, session.id) await project.gotoSession(session.id)
await clickMenuItem(menu, /archive/i) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived return data?.time?.archived
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) 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 stamp = Date.now()
const title = `e2e delete test ${stamp}` const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, title, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
const menu = await openSessionMoreMenu(page, session.id) await project.gotoSession(session.id)
await clickMenuItem(menu, /delete/i) const menu = await openSessionMoreMenu(page, session.id)
await confirmDialog(page, /delete/i) await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session const data = await project.sdk.session
.get({ sessionID: session.id }) .get({ sessionID: session.id })
.then((r) => r.data) .then((r) => r.data)
.catch(() => undefined) .catch(() => undefined)
return data?.id return data?.id
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) 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).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now() const stamp = Date.now()
const title = `e2e share test ${stamp}` const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, title, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const shared = await openSharePopover(page) const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 }) await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click() await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 }) await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click() await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
const unshared = await openSharePopover(page) const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
})
}) })
}) })
}) })