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

This commit is contained in:
Kit Langton
2026-04-01 17:43:19 +00:00
committed by GitHub
parent c619caefdd
commit f3f728ec27
7 changed files with 446 additions and 411 deletions
+13 -2
View File
@@ -44,6 +44,14 @@ async function waitForHealth(url: string, probe = "/global/health") {
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
} }
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (proc.exitCode !== null) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
])
}
const LOG_CAP = 100 const LOG_CAP = 100
function cap(input: string[]) { function cap(input: string[]) {
@@ -62,7 +70,6 @@ export async function startBackend(label: string): Promise<Handle> {
const opencodeDir = path.join(repoDir, "packages", "opencode") const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = { const env = {
...process.env, ...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
@@ -117,7 +124,11 @@ export async function startBackend(label: string): Promise<Handle> {
async stop() { async stop() {
if (proc.exitCode === null) { if (proc.exitCode === null) {
proc.kill("SIGTERM") proc.kill("SIGTERM")
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined) await waitExit(proc)
}
if (proc.exitCode === null) {
proc.kill("SIGKILL")
await waitExit(proc)
} }
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined) await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
}, },
+51 -93
View File
@@ -3,9 +3,11 @@ import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { assistantText, sessionIDFromUrl } from "../actions" import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
import { openaiModel, promptMatch, titleMatch, 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()
type Sdk = ReturnType<typeof createSdk>
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -14,47 +16,15 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part return "state" in part
} }
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) { 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( async function reply(sdk: Sdk, sessionID: string, token: string) {
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } }, await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
sessionID: string,
token: string,
) {
await expect
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
.toContain(token)
} }
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) { async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -142,76 +112,64 @@ test("prompt history restores unsent draft with arrow navigation", async ({
}) })
}) })
test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => { test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withMockOpenAI({ const firstToken = `E2E_SHELL_ONE_${Date.now()}`
serverUrl: backend.url, const secondToken = `E2E_SHELL_TWO_${Date.now()}`
llmUrl: llm.url, const normalToken = `E2E_NORMAL_${Date.now()}`
fn: async () => { const first = `echo ${firstToken}`
const firstToken = `E2E_SHELL_ONE_${Date.now()}` const second = `echo ${secondToken}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}` const normal = `Reply with exactly: ${normalToken}`
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 gotoSession()
await llm.textMatch(promptMatch(normalToken), normalToken)
await withBackendProject( const prompt = page.locator(promptSelector)
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(first) await page.keyboard.type(first)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())! const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID) await shell(sdk, sessionID, first, firstToken)
await shell(project.sdk, sessionID, 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(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(project.sdk, sessionID, second, secondToken) await shell(sdk, sessionID, second, secondToken)
await prompt.click() await page.keyboard.press("Escape")
await page.keyboard.type("!") await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp") await prompt.click()
await wait(page, first) 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, second) await wait(page, first)
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, second)
await page.keyboard.press("Escape") await page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, "")
await prompt.click() await page.keyboard.press("Escape")
await page.keyboard.type(normal) await wait(page, "")
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, normalToken)
await prompt.click() await prompt.click()
await page.keyboard.press("ArrowUp") await page.keyboard.type(normal)
await wait(page, normal) await page.keyboard.press("Enter")
}, await wait(page, "")
{ await reply(sdk, sessionID, normalToken)
model: openaiModel,
}, await prompt.click()
) await page.keyboard.press("ArrowUp")
}, await wait(page, normal)
})
}) })
@@ -27,6 +27,7 @@ test("/share and /unshare update session share state", async ({ page, withBacken
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await seed(project.sdk, session.id) await seed(project.sdk, session.id)
@@ -1,5 +1,6 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => { test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
@@ -10,17 +11,16 @@ test("task tool child-session link does not trigger stale show errors", async ({
} }
page.on("pageerror", onError) page.on("pageerror", onError)
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => { try {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
trackSession(session.id) await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, { const child = await seedSessionTask(sdk, {
sessionID: session.id, sessionID: session.id,
description: "Open child session", description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
}) })
trackSession(child.sessionID) trackSession(child.sessionID)
try {
await gotoSession(session.id) await gotoSession(session.id)
const link = page const link = page
@@ -31,11 +31,11 @@ test("task tool child-session link does not trigger stale show errors", async ({
await link.click() await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000) await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
expect(errs).toEqual([]) await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
} finally { })
page.off("pageerror", onError)
}
}) })
}) } finally {
page.off("pageerror", onError)
}
}) })
@@ -22,12 +22,13 @@ async function withDockSession<T>(
sdk: Sdk, sdk: Sdk,
title: string, title: string,
fn: (session: { id: string; title: string }) => Promise<T>, fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] }, opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
) { ) {
const session = await sdk.session const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title }) .create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data) .then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id") if (!session?.id) throw new Error("Session create did not return an id")
opts?.trackSession?.(session.id)
try { try {
return await fn(session) return await fn(session)
} finally { } finally {
@@ -258,17 +259,22 @@ async function withMockPermission<T>(
test("default dock shows prompt input", async ({ page, withBackendProject }) => { test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock default", async (session) => { await withDockSession(
await project.gotoSession(session.id) 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()
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
@@ -287,220 +293,22 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => { test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question", async (session) => { await withDockSession(
await withDockSeed(project.sdk, session.id, async () => { project.sdk,
await project.gotoSession(session.id) "e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
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, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await 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)
})
})
})
})
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_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, 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, { await seedSessionQuestion(project.sdk, {
sessionID: child.id, sessionID: session.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" },
], ],
}, },
], ],
@@ -514,10 +322,244 @@ test("child session question request blocks parent dock and unblocks after submi
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
} finally { },
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) { trackSession: project.trackSession },
} )
}) })
})
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await 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)
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question escape",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await 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)
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission once",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission reject",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission always",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
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")
project.trackSession(child.id)
try {
await withDockSeed(project.sdk, child.id, async () => {
await seedSessionQuestion(project.sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
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 })
}
},
{ trackSession: project.trackSession },
)
}) })
}) })
@@ -526,102 +568,118 @@ test("child session permission request blocks parent dock and supports allow onc
withBackendProject, withBackendProject,
}) => { }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => { await withDockSession(
await project.gotoSession(session.id) project.sdk,
await setAutoAccept(page, false) "e2e composer dock child permission parent",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
const child = await project.sdk.session const child = await project.sdk.session
.create({ .create({
title: "e2e composer dock child permission", title: "e2e composer dock child permission",
parentID: session.id, parentID: session.id,
}) })
.then((r) => r.data) .then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try { try {
await withMockPermission( await withMockPermission(
page, page,
{ {
id: "per_e2e_child", id: "per_e2e_child",
sessionID: child.id, sessionID: child.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"], patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" }, metadata: { description: "Need child permission" },
}, },
{ child }, { child },
async (state) => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionBlocked(page) await expectPermissionBlocked(page)
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 { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => { test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => { await withDockSession(
const dock = await todoDock(page, session.id) project.sdk,
await project.gotoSession(session.id) "e2e composer dock todo",
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() async (session) => {
const dock = await todoDock(page, session.id)
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try { try {
await dock.open([ await dock.open([
{ content: "first task", status: "pending", priority: "high" }, { content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" }, { content: "second task", status: "in_progress", priority: "medium" },
]) ])
await dock.expectOpen(["pending", "in_progress"]) await dock.expectOpen(["pending", "in_progress"])
await dock.collapse() await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"]) await dock.expectCollapsed(["pending", "in_progress"])
await dock.expand() await dock.expand()
await dock.expectOpen(["pending", "in_progress"]) await dock.expectOpen(["pending", "in_progress"])
await dock.finish([ await dock.finish([
{ content: "first task", status: "completed", priority: "high" }, { content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" }, { content: "second task", status: "cancelled", priority: "medium" },
]) ])
await dock.expectClosed() await dock.expectClosed()
} finally { } finally {
await dock.clear() await dock.clear()
} }
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => { test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => { await withDockSession(
await withDockSeed(project.sdk, session.id, async () => { project.sdk,
await project.gotoSession(session.id) "e2e composer dock keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: [ questions: [
{ {
header: "Need input", header: "Need input",
question: "Pick one option", question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }], 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)
}) })
},
await expectQuestionBlocked(page) { trackSession: project.trackSession },
)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
}) })
}) })
@@ -58,6 +58,7 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -90,6 +91,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -138,6 +140,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withBac
const sdk = project.sdk 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) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const first = await seedConversation({ const first = await seedConversation({
+4
View File
@@ -38,6 +38,7 @@ test("session can be renamed via header menu", async ({ page, withBackendProject
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, originalTitle, async (session) => { await withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
@@ -73,6 +74,7 @@ test("session can be archived via header menu", async ({ page, withBackendProjec
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
@@ -100,6 +102,7 @@ test("session can be deleted via header menu", async ({ page, withBackendProject
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
@@ -133,6 +136,7 @@ test("session can be shared and unshared via header button", async ({ page, with
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)