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})` : ""}`)
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (proc.exitCode !== null) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
])
}
const LOG_CAP = 100
function cap(input: string[]) {
@@ -62,7 +70,6 @@ export async function startBackend(label: string): Promise<Handle> {
const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = {
...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
@@ -117,7 +124,11 @@ export async function startBackend(label: string): Promise<Handle> {
async stop() {
if (proc.exitCode === null) {
proc.kill("SIGTERM")
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
await waitExit(proc)
}
if (proc.exitCode === null) {
proc.kill("SIGKILL")
await waitExit(proc)
}
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
},
+13 -55
View File
@@ -3,9 +3,11 @@ import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk>
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
@@ -14,47 +16,15 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
sessionID: string,
token: string,
) {
await expect
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
.toContain(token)
async function reply(sdk: Sdk, sessionID: string, token: string) {
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
@@ -142,13 +112,9 @@ test("prompt history restores unsent draft with arrow navigation", async ({
})
})
test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => {
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
@@ -156,11 +122,8 @@ test("shell history stays separate from normal prompt history", async ({ page, l
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(normalToken), normalToken)
await gotoSession()
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click()
@@ -171,15 +134,17 @@ test("shell history stays separate from normal prompt history", async ({ page, l
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await shell(project.sdk, sessionID, first, firstToken)
await shell(sdk, sessionID, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(project.sdk, sessionID, second, secondToken)
await shell(sdk, sessionID, second, secondToken)
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type("!")
@@ -202,16 +167,9 @@ test("shell history stays separate from normal prompt history", async ({ page, l
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, normalToken)
await reply(sdk, sessionID, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
},
{
model: openaiModel,
},
)
},
})
})
@@ -27,6 +27,7 @@ test("/share and /unshare update session share state", async ({ page, withBacken
await withBackendProject(async (project) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id)
const prompt = page.locator(promptSelector)
await seed(project.sdk, session.id)
@@ -1,5 +1,6 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
test.setTimeout(120_000)
@@ -10,9 +11,9 @@ test("task tool child-session link does not trigger stale show errors", async ({
}
page.on("pageerror", onError)
try {
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
trackSession(session.id)
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
@@ -20,7 +21,6 @@ test("task tool child-session link does not trigger stale show errors", async ({
})
trackSession(child.sessionID)
try {
await gotoSession(session.id)
const link = page
@@ -31,11 +31,11 @@ test("task tool child-session link does not trigger stale show errors", async ({
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
})
})
} finally {
page.off("pageerror", onError)
}
})
})
})
@@ -22,12 +22,13 @@ async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
opts?.trackSession?.(session.id)
try {
return await fn(session)
} finally {
@@ -258,7 +259,10 @@ async function withMockPermission<T>(
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock default", async (session) => {
await withDockSession(
project.sdk,
"e2e composer dock default",
async (session) => {
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
@@ -268,7 +272,9 @@ test("default dock shows prompt input", async ({ page, withBackendProject }) =>
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
},
{ trackSession: project.trackSession },
)
})
})
@@ -287,7 +293,10 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
await withDockSession(
project.sdk,
"e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
@@ -313,13 +322,18 @@ test("blocked question flow unblocks after submit", async ({ page, withBackendPr
await expectQuestionOpen(page)
})
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
await withDockSession(
project.sdk,
"e2e composer dock question keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
@@ -351,13 +365,18 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, withBac
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 withDockSession(
project.sdk,
"e2e composer dock question escape",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
@@ -384,13 +403,18 @@ test("blocked question flow supports escape dismiss", async ({ page, withBackend
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 withDockSession(
project.sdk,
"e2e composer dock permission once",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
@@ -413,13 +437,18 @@ test("blocked permission flow supports allow once", async ({ page, withBackendPr
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 withDockSession(
project.sdk,
"e2e composer dock permission reject",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
@@ -441,13 +470,18 @@ test("blocked permission flow supports reject", async ({ page, withBackendProjec
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 withDockSession(
project.sdk,
"e2e composer dock permission always",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
@@ -470,7 +504,9 @@ test("blocked permission flow supports allow always", async ({ page, withBackend
await expectPermissionOpen(page)
},
)
})
},
{ trackSession: project.trackSession },
)
})
})
@@ -479,7 +515,10 @@ test("child session question request blocks parent dock and unblocks after submi
withBackendProject,
}) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => {
await withDockSession(
project.sdk,
"e2e composer dock child question parent",
async (session) => {
await project.gotoSession(session.id)
const child = await project.sdk.session
@@ -489,6 +528,7 @@ test("child session question request blocks parent dock and unblocks after submi
})
.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 () => {
@@ -517,7 +557,9 @@ test("child session question request blocks parent dock and unblocks after submi
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
})
},
{ trackSession: project.trackSession },
)
})
})
@@ -526,7 +568,10 @@ test("child session permission request blocks parent dock and supports allow onc
withBackendProject,
}) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => {
await withDockSession(
project.sdk,
"e2e composer dock child permission parent",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
@@ -537,6 +582,7 @@ test("child session permission request blocks parent dock and supports allow onc
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try {
await withMockPermission(
@@ -563,13 +609,18 @@ test("child session permission request blocks parent dock and supports allow onc
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
})
},
{ trackSession: project.trackSession },
)
})
})
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => {
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()
@@ -595,13 +646,18 @@ test("todo dock transitions and collapse behavior", async ({ page, withBackendPr
} finally {
await dock.clear()
}
})
},
{ trackSession: project.trackSession },
)
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
await withDockSession(
project.sdk,
"e2e composer dock keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
@@ -622,6 +678,8 @@ test("keyboard focus stays off prompt while blocked", async ({ page, withBackend
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
},
{ trackSession: project.trackSession },
)
})
})
@@ -58,6 +58,7 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -90,6 +91,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -138,6 +140,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withBac
const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
const first = await seedConversation({
+4
View File
@@ -38,6 +38,7 @@ test("session can be renamed via header menu", async ({ page, withBackendProject
await withBackendProject(async (project) => {
await withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
@@ -73,6 +74,7 @@ test("session can be archived via header menu", async ({ page, withBackendProjec
await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
@@ -100,6 +102,7 @@ test("session can be deleted via header menu", async ({ page, withBackendProject
await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
@@ -133,6 +136,7 @@ test("session can be shared and unshared via header button", async ({ page, with
await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)