summaryrefslogtreecommitdiffhomepage
path: root/packages/app/e2e
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-19 15:02:45 -0600
committerGitHub <[email protected]>2026-02-19 15:02:45 -0600
commit49cc872c4415f081b4208d16fd0d85e425a75eed (patch)
treeed78239c2a2bb941c5008f378a00592f5b10717a /packages/app/e2e
parentf8dad0ae170acb9667d9402c162f7c29980373c1 (diff)
downloadopencode-49cc872c4415f081b4208d16fd0d85e425a75eed.tar.gz
opencode-49cc872c4415f081b4208d16fd0d85e425a75eed.zip
chore: refactor composer/dock components (#14328)
Diffstat (limited to 'packages/app/e2e')
-rw-r--r--packages/app/e2e/actions.ts157
-rw-r--r--packages/app/e2e/selectors.ts10
-rw-r--r--packages/app/e2e/session/session-composer-dock.spec.ts207
3 files changed, 374 insertions, 0 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 3467effa6..d42c0fceb 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -332,6 +332,163 @@ export async function withSession<T>(
}
}
+const seedSystem = [
+ "You are seeding deterministic e2e UI state.",
+ "Follow the user's instruction exactly.",
+ "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
+ "Do not call any extra tools.",
+].join(" ")
+
+const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
+ const timeout = input.timeout ?? 30_000
+ const end = Date.now() + timeout
+ while (Date.now() < end) {
+ const value = await input.probe()
+ if (value !== undefined) return value
+ await new Promise((resolve) => setTimeout(resolve, 250))
+ }
+}
+
+const seed = async <T>(input: {
+ sessionID: string
+ prompt: string
+ sdk: ReturnType<typeof createSdk>
+ probe: () => Promise<T | undefined>
+ timeout?: number
+ attempts?: number
+}) => {
+ for (let i = 0; i < (input.attempts ?? 2); i++) {
+ await input.sdk.session.promptAsync({
+ sessionID: input.sessionID,
+ agent: "build",
+ system: seedSystem,
+ parts: [{ type: "text", text: input.prompt }],
+ })
+ const value = await wait({ probe: input.probe, timeout: input.timeout })
+ if (value !== undefined) return value
+ }
+}
+
+export async function seedSessionQuestion(
+ sdk: ReturnType<typeof createSdk>,
+ input: {
+ sessionID: string
+ questions: Array<{
+ header: string
+ question: string
+ options: Array<{ label: string; description: string }>
+ multiple?: boolean
+ custom?: boolean
+ }>
+ },
+) {
+ const first = input.questions[0]
+ if (!first) throw new Error("Question seed requires at least one question")
+
+ const text = [
+ "Your only valid response is one question tool call.",
+ `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
+ "Do not output plain text.",
+ "After calling the tool, wait for the user response.",
+ ].join("\n")
+
+ const result = await seed({
+ sdk,
+ sessionID: input.sessionID,
+ prompt: text,
+ timeout: 30_000,
+ probe: async () => {
+ const list = await sdk.question.list().then((x) => x.data ?? [])
+ return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
+ },
+ })
+
+ if (!result) throw new Error("Timed out seeding question request")
+ return { id: result.id }
+}
+
+export async function seedSessionPermission(
+ sdk: ReturnType<typeof createSdk>,
+ input: {
+ sessionID: string
+ permission: string
+ patterns: string[]
+ description?: string
+ },
+) {
+ const text = [
+ "Your only valid response is one bash tool call.",
+ `Use this JSON input: ${JSON.stringify({
+ command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
+ workdir: "/",
+ description: input.description ?? `seed ${input.permission} permission request`,
+ })}`,
+ "Do not output plain text.",
+ ].join("\n")
+
+ const result = await seed({
+ sdk,
+ sessionID: input.sessionID,
+ prompt: text,
+ timeout: 30_000,
+ probe: async () => {
+ const list = await sdk.permission.list().then((x) => x.data ?? [])
+ return list.find((item) => item.sessionID === input.sessionID)
+ },
+ })
+
+ if (!result) throw new Error("Timed out seeding permission request")
+ return { id: result.id }
+}
+
+export async function seedSessionTodos(
+ sdk: ReturnType<typeof createSdk>,
+ input: {
+ sessionID: string
+ todos: Array<{ content: string; status: string; priority: string }>
+ },
+) {
+ const text = [
+ "Your only valid response is one todowrite tool call.",
+ `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
+ "Do not output plain text.",
+ ].join("\n")
+ const target = JSON.stringify(input.todos)
+
+ const result = await seed({
+ sdk,
+ sessionID: input.sessionID,
+ prompt: text,
+ timeout: 30_000,
+ probe: async () => {
+ const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
+ if (JSON.stringify(todos) !== target) return
+ return true
+ },
+ })
+
+ if (!result) throw new Error("Timed out seeding todos")
+ return true
+}
+
+export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
+ const [questions, permissions] = await Promise.all([
+ sdk.question.list().then((x) => x.data ?? []),
+ sdk.permission.list().then((x) => x.data ?? []),
+ ])
+
+ await Promise.all([
+ ...questions
+ .filter((item) => item.sessionID === sessionID)
+ .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
+ ...permissions
+ .filter((item) => item.sessionID === sessionID)
+ .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
+ ])
+
+ return true
+}
+
export async function openStatusPopover(page: Page) {
await defocus(page)
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 1a0afbab1..be0bc0571 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -1,5 +1,15 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
+export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
+export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
+export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
+export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
+export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
+export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
+export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
+export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
+export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
+export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts
new file mode 100644
index 000000000..6bf7714a6
--- /dev/null
+++ b/packages/app/e2e/session/session-composer-dock.spec.ts
@@ -0,0 +1,207 @@
+import { test, expect } from "../fixtures"
+import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
+import {
+ permissionDockSelector,
+ promptSelector,
+ questionDockSelector,
+ sessionComposerDockSelector,
+ sessionTodoDockSelector,
+ sessionTodoListSelector,
+ sessionTodoToggleButtonSelector,
+} from "../selectors"
+
+type Sdk = Parameters<typeof clearSessionDockSeed>[0]
+
+async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
+ const session = await sdk.session.create({ title }).then((r) => r.data)
+ if (!session?.id) throw new Error("Session create did not return an id")
+ return fn(session)
+}
+
+test.setTimeout(120_000)
+
+async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
+ try {
+ return await fn()
+ } finally {
+ await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
+ }
+}
+
+test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
+ await withDockSession(sdk, "e2e composer dock default", async (session) => {
+ await gotoSession(session.id)
+
+ await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
+ await expect(page.locator(promptSelector)).toBeVisible()
+ await expect(page.locator(questionDockSelector)).toHaveCount(0)
+ await expect(page.locator(permissionDockSelector)).toHaveCount(0)
+
+ await page.locator(promptSelector).click()
+ await expect(page.locator(promptSelector)).toBeFocused()
+ })
+})
+
+test("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 expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await dock.locator('[data-slot="question-option"]').first().click()
+ await dock.getByRole("button", { name: /submit/i }).click()
+
+ await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ })
+ })
+})
+
+test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
+ await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
+ await withDockSeed(sdk, session.id, async () => {
+ await gotoSession(session.id)
+
+ await seedSessionPermission(sdk, {
+ sessionID: session.id,
+ permission: "bash",
+ patterns: ["README.md"],
+ description: "Need permission for command",
+ })
+
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await page
+ .locator(permissionDockSelector)
+ .getByRole("button", { name: /allow once/i })
+ .click()
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ })
+ })
+})
+
+test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
+ await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
+ await withDockSeed(sdk, session.id, async () => {
+ await gotoSession(session.id)
+
+ await seedSessionPermission(sdk, {
+ sessionID: session.id,
+ permission: "bash",
+ patterns: ["REJECT.md"],
+ })
+
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ })
+ })
+})
+
+test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
+ await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
+ await withDockSeed(sdk, session.id, async () => {
+ await gotoSession(session.id)
+
+ await seedSessionPermission(sdk, {
+ sessionID: session.id,
+ permission: "bash",
+ patterns: ["README.md"],
+ description: "Need permission for command",
+ })
+
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await page
+ .locator(permissionDockSelector)
+ .getByRole("button", { name: /allow always/i })
+ .click()
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ })
+ })
+})
+
+test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
+ await withDockSession(sdk, "e2e composer dock todo", async (session) => {
+ await withDockSeed(sdk, session.id, async () => {
+ await gotoSession(session.id)
+
+ await seedSessionTodos(sdk, {
+ sessionID: session.id,
+ todos: [
+ { content: "first task", status: "pending", priority: "high" },
+ { content: "second task", status: "in_progress", priority: "medium" },
+ ],
+ })
+
+ await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(sessionTodoListSelector)).toBeVisible()
+
+ await page.locator(sessionTodoToggleButtonSelector).click()
+ await expect(page.locator(sessionTodoListSelector)).toBeHidden()
+
+ await page.locator(sessionTodoToggleButtonSelector).click()
+ await expect(page.locator(sessionTodoListSelector)).toBeVisible()
+
+ await seedSessionTodos(sdk, {
+ sessionID: session.id,
+ todos: [
+ { content: "first task", status: "completed", priority: "high" },
+ { content: "second task", status: "cancelled", priority: "medium" },
+ ],
+ })
+
+ await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ })
+ })
+})
+
+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 expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ 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)
+ })
+ })
+})