From c4fd677785392aa27d0bd3e01cc185d24d7bc7fd Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:08:59 +0100 Subject: [PATCH] tests(app): e2e tests part 67 (#16406) --- packages/app/e2e/prompt/prompt-async.spec.ts | 35 +++- .../app/e2e/prompt/prompt-history.spec.ts | 181 ++++++++++++++++++ packages/app/e2e/prompt/prompt-shell.spec.ts | 61 ++++++ .../app/e2e/prompt/prompt-slash-share.spec.ts | 64 +++++++ .../app/e2e/terminal/terminal-tabs.spec.ts | 120 ++++++++++++ packages/app/e2e/utils.ts | 8 +- 6 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/prompt/prompt-history.spec.ts create mode 100644 packages/app/e2e/prompt/prompt-shell.spec.ts create mode 100644 packages/app/e2e/prompt/prompt-slash-share.spec.ts create mode 100644 packages/app/e2e/terminal/terminal-tabs.spec.ts diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts index ce9b1a7a3..10e3fc312 100644 --- a/packages/app/e2e/prompt/prompt-async.spec.ts +++ b/packages/app/e2e/prompt/prompt-async.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" -import { sessionIDFromUrl } from "../actions" +import { sessionIDFromUrl, withSession } from "../actions" + +const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() // Regression test for Issue #12453: the synchronous POST /message endpoint holds // the connection open while the agent works, causing "Failed to fetch" over @@ -41,3 +43,34 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page, await sdk.session.delete({ sessionID }).catch(() => undefined) } }) + +test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => { + const prompt = page.locator(promptSelector) + const value = `restore ${Date.now()}` + + await page.route(`**/session/${session.id}/prompt_async`, (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ message: "e2e prompt failure" }), + }), + ) + + await gotoSession(session.id) + await prompt.click() + await page.keyboard.type(value) + await page.keyboard.press("Enter") + + await expect.poll(async () => text(await prompt.textContent())).toBe(value) + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 15_000 }, + ) + .toBe(0) + }) +}) diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts new file mode 100644 index 000000000..ec6899814 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -0,0 +1,181 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2/client" +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { withSession } from "../actions" +import { promptSelector } from "../selectors" + +const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() + +const isBash = (part: unknown): part is ToolPart => { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || part.tool !== "bash") return false + 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: Parameters[0], sessionID: string, token: string) { + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + return messages + .filter((item) => item.info.role === "assistant") + .flatMap((item) => item.parts) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n") + }, + { timeout: 90_000 }, + ) + .toContain(token) +} + +async function shell(sdk: Parameters[0], sessionID: string, cmd: string, token: string) { + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + const part = messages + .filter((item) => item.info.role === "assistant") + .flatMap((item) => item.parts) + .filter(isBash) + .find((item) => item.state.input?.command === cmd && item.state.status === "completed") + + if (!part || part.state.status !== "completed") return + return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output + }, + { timeout: 90_000 }, + ) + .toContain(token) +} + +test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => { + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + const firstToken = `E2E_HISTORY_ONE_${Date.now()}` + const secondToken = `E2E_HISTORY_TWO_${Date.now()}` + const first = `Reply with exactly: ${firstToken}` + const second = `Reply with exactly: ${secondToken}` + const draft = `draft ${Date.now()}` + + await prompt.click() + await page.keyboard.type(first) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, firstToken) + + await prompt.click() + await page.keyboard.type(second) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, secondToken) + + await prompt.click() + await page.keyboard.type(draft) + await wait(page, draft) + + await edge(page, "start") + await page.keyboard.press("ArrowUp") + await wait(page, second) + + await page.keyboard.press("ArrowUp") + await wait(page, first) + + await page.keyboard.press("ArrowDown") + await wait(page, second) + + await page.keyboard.press("ArrowDown") + await wait(page, draft) + }) +}) + +test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => { + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + const firstToken = `E2E_SHELL_ONE_${Date.now()}` + const secondToken = `E2E_SHELL_TWO_${Date.now()}` + const normalToken = `E2E_NORMAL_${Date.now()}` + const first = `echo ${firstToken}` + const second = `echo ${secondToken}` + const normal = `Reply with exactly: ${normalToken}` + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.type(first) + await page.keyboard.press("Enter") + await wait(page, "") + await shell(sdk, session.id, first, firstToken) + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.type(second) + await page.keyboard.press("Enter") + await wait(page, "") + await shell(sdk, session.id, second, secondToken) + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.press("ArrowUp") + await wait(page, second) + + await page.keyboard.press("ArrowUp") + await wait(page, first) + + await page.keyboard.press("ArrowDown") + await wait(page, second) + + await page.keyboard.press("ArrowDown") + await wait(page, "") + + await page.keyboard.press("Escape") + await wait(page, "") + + await prompt.click() + await page.keyboard.type(normal) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, normalToken) + + await prompt.click() + await page.keyboard.press("ArrowUp") + await wait(page, normal) + }) +}) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts new file mode 100644 index 000000000..c9880bf20 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -0,0 +1,61 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2/client" +import { test, expect } from "../fixtures" +import { sessionIDFromUrl } from "../actions" +import { promptSelector } from "../selectors" +import { createSdk } from "../utils" + +const isBash = (part: unknown): part is ToolPart => { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || part.tool !== "bash") return false + return "state" in part +} + +test("shell mode runs a command in the project directory", async ({ page, withProject }) => { + test.setTimeout(120_000) + + await withProject(async ({ directory, gotoSession }) => { + const sdk = createSdk(directory) + const prompt = page.locator(promptSelector) + const cmd = process.platform === "win32" ? "dir" : "ls" + + await gotoSession() + await prompt.click() + await page.keyboard.type("!") + await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) + + await page.keyboard.type(cmd) + await page.keyboard.press("Enter") + + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + + const id = sessionIDFromUrl(page.url()) + if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) + + await expect + .poll( + async () => { + const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? []) + const msg = list.findLast( + (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory, + ) + if (!msg) return + + const part = msg.parts + .filter(isBash) + .find((item) => item.state.input?.command === cmd && item.state.status === "completed") + + if (!part || part.state.status !== "completed") return + const output = + typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output + if (!output.includes("README.md")) return + + return { cwd: directory, output } + }, + { timeout: 90_000 }, + ) + .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") })) + + await expect(prompt).toHaveText("") + }) +}) diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts new file mode 100644 index 000000000..817b353a7 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-slash-share.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { withSession } from "../actions" + +const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" + +async function seed(sdk: Parameters[0], sessionID: string) { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: "e2e share seed" }], + }) + + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0) +} + +test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => { + test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") + + await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => { + const prompt = page.locator(promptSelector) + + await seed(sdk, session.id) + await gotoSession(session.id) + + await prompt.click() + await page.keyboard.type("/share") + await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() + + await prompt.click() + await page.keyboard.type("/unshare") + await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() + }) +}) diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts new file mode 100644 index 000000000..f76a86cf7 --- /dev/null +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -0,0 +1,120 @@ +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { terminalSelector } from "../selectors" +import { terminalToggleKey, workspacePersistKey } from "../utils" + +type State = { + active?: string + all: Array<{ + id: string + title: string + titleNumber: number + buffer?: string + }> +} + +async function open(page: Page) { + const terminal = page.locator(terminalSelector) + const visible = await terminal.isVisible().catch(() => false) + if (!visible) await page.keyboard.press(terminalToggleKey) + await expect(terminal).toBeVisible() + await expect(terminal.locator("textarea")).toHaveCount(1) +} + +async function run(page: Page, cmd: string) { + const terminal = page.locator(terminalSelector) + await expect(terminal).toBeVisible() + await terminal.click() + await page.keyboard.type(cmd) + await page.keyboard.press("Enter") +} + +async function store(page: Page, key: string) { + return page.evaluate((key) => { + const raw = localStorage.getItem(key) + if (raw) return JSON.parse(raw) as State + + for (let i = 0; i < localStorage.length; i++) { + const next = localStorage.key(i) + if (!next?.endsWith(":workspace:terminal")) continue + const value = localStorage.getItem(next) + if (!value) continue + return JSON.parse(value) as State + } + }, key) +} + +test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + const key = workspacePersistKey(directory, "terminal") + const one = `E2E_TERM_ONE_${Date.now()}` + const two = `E2E_TERM_TWO_${Date.now()}` + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + + await gotoSession() + await open(page) + + await run(page, `echo ${one}`) + + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) + + await run(page, `echo ${two}`) + + await tabs + .filter({ hasText: /Terminal 1/ }) + .first() + .click() + + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return first.includes(one) && second.includes(two) + }, + { timeout: 30_000 }, + ) + .toBe(true) + }) +}) + +test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + const key = workspacePersistKey(directory, "terminal") + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + + await gotoSession() + await open(page) + + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) + + const second = tabs.filter({ hasText: /Terminal 2/ }).first() + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") + + await second.hover() + await page + .getByRole("button", { name: /close terminal/i }) + .nth(1) + .click({ force: true }) + + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + await expect(tabs).toHaveCount(1) + await expect(first).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + return { + count: state?.all.length ?? 0, + first: state?.all.some((item) => item.titleNumber === 1) ?? false, + } + }, + { timeout: 15_000 }, + ) + .toEqual({ count: 1, first: true }) + }) +}) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index e015a1e9b..e2d61984d 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -1,5 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode, checksum } from "@opencode-ai/util/encode" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" @@ -33,3 +33,9 @@ export function dirPath(directory: string) { export function sessionPath(directory: string, sessionID?: string) { return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` } + +export function workspacePersistKey(directory: string, key: string) { + const head = directory.slice(0, 12) || "workspace" + const sum = checksum(directory) ?? "0" + return `opencode.workspace.${head}.${sum}.dat:workspace:${key}` +}