Skip to content

Commit c4fd677

Browse files
authored
tests(app): e2e tests part 67 (anomalyco#16406)
1 parent 770cb66 commit c4fd677

File tree

6 files changed

+467
-2
lines changed

6 files changed

+467
-2
lines changed

packages/app/e2e/prompt/prompt-async.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { test, expect } from "../fixtures"
22
import { promptSelector } from "../selectors"
3-
import { sessionIDFromUrl } from "../actions"
3+
import { sessionIDFromUrl, withSession } from "../actions"
4+
5+
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
46

57
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
68
// 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,
4143
await sdk.session.delete({ sessionID }).catch(() => undefined)
4244
}
4345
})
46+
47+
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
48+
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
49+
const prompt = page.locator(promptSelector)
50+
const value = `restore ${Date.now()}`
51+
52+
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
53+
route.fulfill({
54+
status: 500,
55+
contentType: "application/json",
56+
body: JSON.stringify({ message: "e2e prompt failure" }),
57+
}),
58+
)
59+
60+
await gotoSession(session.id)
61+
await prompt.click()
62+
await page.keyboard.type(value)
63+
await page.keyboard.press("Enter")
64+
65+
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
66+
await expect
67+
.poll(
68+
async () => {
69+
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
70+
return messages.length
71+
},
72+
{ timeout: 15_000 },
73+
)
74+
.toBe(0)
75+
})
76+
})
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
2+
import type { Page } from "@playwright/test"
3+
import { test, expect } from "../fixtures"
4+
import { withSession } from "../actions"
5+
import { promptSelector } from "../selectors"
6+
7+
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
8+
9+
const isBash = (part: unknown): part is ToolPart => {
10+
if (!part || typeof part !== "object") return false
11+
if (!("type" in part) || part.type !== "tool") return false
12+
if (!("tool" in part) || part.tool !== "bash") return false
13+
return "state" in part
14+
}
15+
16+
async function edge(page: Page, pos: "start" | "end") {
17+
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
18+
const selection = window.getSelection()
19+
if (!selection) return
20+
21+
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
22+
const nodes: Text[] = []
23+
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
24+
nodes.push(node as Text)
25+
}
26+
27+
if (nodes.length === 0) {
28+
const node = document.createTextNode("")
29+
el.appendChild(node)
30+
nodes.push(node)
31+
}
32+
33+
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
34+
const range = document.createRange()
35+
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
36+
range.collapse(true)
37+
selection.removeAllRanges()
38+
selection.addRange(range)
39+
}, pos)
40+
}
41+
42+
async function wait(page: Page, value: string) {
43+
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
44+
}
45+
46+
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
47+
await expect
48+
.poll(
49+
async () => {
50+
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
51+
return messages
52+
.filter((item) => item.info.role === "assistant")
53+
.flatMap((item) => item.parts)
54+
.filter((item) => item.type === "text")
55+
.map((item) => item.text)
56+
.join("\n")
57+
},
58+
{ timeout: 90_000 },
59+
)
60+
.toContain(token)
61+
}
62+
63+
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
64+
await expect
65+
.poll(
66+
async () => {
67+
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
68+
const part = messages
69+
.filter((item) => item.info.role === "assistant")
70+
.flatMap((item) => item.parts)
71+
.filter(isBash)
72+
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
73+
74+
if (!part || part.state.status !== "completed") return
75+
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
76+
},
77+
{ timeout: 90_000 },
78+
)
79+
.toContain(token)
80+
}
81+
82+
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
83+
test.setTimeout(120_000)
84+
85+
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
86+
await gotoSession(session.id)
87+
88+
const prompt = page.locator(promptSelector)
89+
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
90+
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
91+
const first = `Reply with exactly: ${firstToken}`
92+
const second = `Reply with exactly: ${secondToken}`
93+
const draft = `draft ${Date.now()}`
94+
95+
await prompt.click()
96+
await page.keyboard.type(first)
97+
await page.keyboard.press("Enter")
98+
await wait(page, "")
99+
await reply(sdk, session.id, firstToken)
100+
101+
await prompt.click()
102+
await page.keyboard.type(second)
103+
await page.keyboard.press("Enter")
104+
await wait(page, "")
105+
await reply(sdk, session.id, secondToken)
106+
107+
await prompt.click()
108+
await page.keyboard.type(draft)
109+
await wait(page, draft)
110+
111+
await edge(page, "start")
112+
await page.keyboard.press("ArrowUp")
113+
await wait(page, second)
114+
115+
await page.keyboard.press("ArrowUp")
116+
await wait(page, first)
117+
118+
await page.keyboard.press("ArrowDown")
119+
await wait(page, second)
120+
121+
await page.keyboard.press("ArrowDown")
122+
await wait(page, draft)
123+
})
124+
})
125+
126+
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
127+
test.setTimeout(120_000)
128+
129+
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
130+
await gotoSession(session.id)
131+
132+
const prompt = page.locator(promptSelector)
133+
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
134+
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
135+
const normalToken = `E2E_NORMAL_${Date.now()}`
136+
const first = `echo ${firstToken}`
137+
const second = `echo ${secondToken}`
138+
const normal = `Reply with exactly: ${normalToken}`
139+
140+
await prompt.click()
141+
await page.keyboard.type("!")
142+
await page.keyboard.type(first)
143+
await page.keyboard.press("Enter")
144+
await wait(page, "")
145+
await shell(sdk, session.id, first, firstToken)
146+
147+
await prompt.click()
148+
await page.keyboard.type("!")
149+
await page.keyboard.type(second)
150+
await page.keyboard.press("Enter")
151+
await wait(page, "")
152+
await shell(sdk, session.id, second, secondToken)
153+
154+
await prompt.click()
155+
await page.keyboard.type("!")
156+
await page.keyboard.press("ArrowUp")
157+
await wait(page, second)
158+
159+
await page.keyboard.press("ArrowUp")
160+
await wait(page, first)
161+
162+
await page.keyboard.press("ArrowDown")
163+
await wait(page, second)
164+
165+
await page.keyboard.press("ArrowDown")
166+
await wait(page, "")
167+
168+
await page.keyboard.press("Escape")
169+
await wait(page, "")
170+
171+
await prompt.click()
172+
await page.keyboard.type(normal)
173+
await page.keyboard.press("Enter")
174+
await wait(page, "")
175+
await reply(sdk, session.id, normalToken)
176+
177+
await prompt.click()
178+
await page.keyboard.press("ArrowUp")
179+
await wait(page, normal)
180+
})
181+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
2+
import { test, expect } from "../fixtures"
3+
import { sessionIDFromUrl } from "../actions"
4+
import { promptSelector } from "../selectors"
5+
import { createSdk } from "../utils"
6+
7+
const isBash = (part: unknown): part is ToolPart => {
8+
if (!part || typeof part !== "object") return false
9+
if (!("type" in part) || part.type !== "tool") return false
10+
if (!("tool" in part) || part.tool !== "bash") return false
11+
return "state" in part
12+
}
13+
14+
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
15+
test.setTimeout(120_000)
16+
17+
await withProject(async ({ directory, gotoSession }) => {
18+
const sdk = createSdk(directory)
19+
const prompt = page.locator(promptSelector)
20+
const cmd = process.platform === "win32" ? "dir" : "ls"
21+
22+
await gotoSession()
23+
await prompt.click()
24+
await page.keyboard.type("!")
25+
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
26+
27+
await page.keyboard.type(cmd)
28+
await page.keyboard.press("Enter")
29+
30+
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
31+
32+
const id = sessionIDFromUrl(page.url())
33+
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
34+
35+
await expect
36+
.poll(
37+
async () => {
38+
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
39+
const msg = list.findLast(
40+
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
41+
)
42+
if (!msg) return
43+
44+
const part = msg.parts
45+
.filter(isBash)
46+
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
47+
48+
if (!part || part.state.status !== "completed") return
49+
const output =
50+
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
51+
if (!output.includes("README.md")) return
52+
53+
return { cwd: directory, output }
54+
},
55+
{ timeout: 90_000 },
56+
)
57+
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
58+
59+
await expect(prompt).toHaveText("")
60+
})
61+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { test, expect } from "../fixtures"
2+
import { promptSelector } from "../selectors"
3+
import { withSession } from "../actions"
4+
5+
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
6+
7+
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
8+
await sdk.session.promptAsync({
9+
sessionID,
10+
noReply: true,
11+
parts: [{ type: "text", text: "e2e share seed" }],
12+
})
13+
14+
await expect
15+
.poll(
16+
async () => {
17+
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
18+
return messages.length
19+
},
20+
{ timeout: 30_000 },
21+
)
22+
.toBeGreaterThan(0)
23+
}
24+
25+
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
26+
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
27+
28+
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
29+
const prompt = page.locator(promptSelector)
30+
31+
await seed(sdk, session.id)
32+
await gotoSession(session.id)
33+
34+
await prompt.click()
35+
await page.keyboard.type("/share")
36+
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
37+
await page.keyboard.press("Enter")
38+
39+
await expect
40+
.poll(
41+
async () => {
42+
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
43+
return data?.share?.url || undefined
44+
},
45+
{ timeout: 30_000 },
46+
)
47+
.not.toBeUndefined()
48+
49+
await prompt.click()
50+
await page.keyboard.type("/unshare")
51+
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
52+
await page.keyboard.press("Enter")
53+
54+
await expect
55+
.poll(
56+
async () => {
57+
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
58+
return data?.share?.url || undefined
59+
},
60+
{ timeout: 30_000 },
61+
)
62+
.toBeUndefined()
63+
})
64+
})

0 commit comments

Comments
 (0)