summaryrefslogtreecommitdiffhomepage
path: root/packages/app/e2e/prompt
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/e2e/prompt')
-rw-r--r--packages/app/e2e/prompt/prompt-async.spec.ts35
-rw-r--r--packages/app/e2e/prompt/prompt-history.spec.ts181
-rw-r--r--packages/app/e2e/prompt/prompt-shell.spec.ts61
-rw-r--r--packages/app/e2e/prompt/prompt-slash-share.spec.ts64
4 files changed, 340 insertions, 1 deletions
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<typeof withSession>[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<typeof withSession>[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<typeof withSession>[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()
+ })
+})