Skip to content

Commit c8ecd64

Browse files
authored
test(app): add mock llm e2e fixture (#20375)
1 parent ca376a4 commit c8ecd64

2 files changed

Lines changed: 139 additions & 35 deletions

File tree

packages/app/e2e/fixtures.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { test as base, expect, type Page } from "@playwright/test"
2+
import { ManagedRuntime } from "effect"
23
import type { E2EWindow } from "../src/testing/terminal"
4+
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
5+
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
36
import {
47
healthPhase,
58
cleanupSession,
@@ -13,6 +16,24 @@ import {
1316
} from "./actions"
1417
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
1518

19+
type LLMFixture = {
20+
url: string
21+
push: (...input: (Item | Reply)[]) => Promise<void>
22+
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
23+
tool: (name: string, input: unknown) => Promise<void>
24+
toolHang: (name: string, input: unknown) => Promise<void>
25+
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
26+
fail: (message?: unknown) => Promise<void>
27+
error: (status: number, body: unknown) => Promise<void>
28+
hang: () => Promise<void>
29+
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
30+
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
31+
calls: () => Promise<number>
32+
wait: (count: number) => Promise<void>
33+
inputs: () => Promise<Record<string, unknown>[]>
34+
pending: () => Promise<number>
35+
}
36+
1637
export const settingsKey = "settings.v3"
1738

1839
const seedModel = (() => {
@@ -26,6 +47,7 @@ const seedModel = (() => {
2647
})()
2748

2849
type TestFixtures = {
50+
llm: LLMFixture
2951
sdk: ReturnType<typeof createSdk>
3052
gotoSession: (sessionID?: string) => Promise<void>
3153
withProject: <T>(
@@ -36,7 +58,11 @@ type TestFixtures = {
3658
trackSession: (sessionID: string, directory?: string) => void
3759
trackDirectory: (directory: string) => void
3860
}) => Promise<T>,
39-
options?: { extra?: string[] },
61+
options?: {
62+
extra?: string[]
63+
model?: { providerID: string; modelID: string }
64+
setup?: (directory: string) => Promise<void>
65+
},
4066
) => Promise<T>
4167
}
4268

@@ -46,6 +72,31 @@ type WorkerFixtures = {
4672
}
4773

4874
export const test = base.extend<TestFixtures, WorkerFixtures>({
75+
llm: async ({}, use) => {
76+
const rt = ManagedRuntime.make(TestLLMServer.layer)
77+
try {
78+
const svc = await rt.runPromise(TestLLMServer.asEffect())
79+
await use({
80+
url: svc.url,
81+
push: (...input) => rt.runPromise(svc.push(...input)),
82+
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
83+
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
84+
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
85+
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
86+
fail: (message) => rt.runPromise(svc.fail(message)),
87+
error: (status, body) => rt.runPromise(svc.error(status, body)),
88+
hang: () => rt.runPromise(svc.hang),
89+
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
90+
hits: () => rt.runPromise(svc.hits),
91+
calls: () => rt.runPromise(svc.calls),
92+
wait: (count) => rt.runPromise(svc.wait(count)),
93+
inputs: () => rt.runPromise(svc.inputs),
94+
pending: () => rt.runPromise(svc.pending),
95+
})
96+
} finally {
97+
await rt.dispose()
98+
}
99+
},
49100
page: async ({ page }, use) => {
50101
let boundary: string | undefined
51102
setHealthPhase(page, "test")
@@ -99,7 +150,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
99150
const root = await createTestProject()
100151
const sessions = new Map<string, string>()
101152
const dirs = new Set<string>()
102-
await seedStorage(page, { directory: root, extra: options?.extra })
153+
await options?.setup?.(root)
154+
await seedStorage(page, { directory: root, extra: options?.extra, model: options?.model })
103155

104156
const gotoSession = async (sessionID?: string) => {
105157
await page.goto(sessionPath(root, sessionID))
@@ -133,7 +185,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
133185
},
134186
})
135187

136-
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
188+
async function seedStorage(
189+
page: Page,
190+
input: {
191+
directory: string
192+
extra?: string[]
193+
model?: { providerID: string; modelID: string }
194+
},
195+
) {
137196
await seedProjects(page, input)
138197
await page.addInitScript((model: { providerID: string; modelID: string }) => {
139198
const win = window as E2EWindow
@@ -158,7 +217,7 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
158217
variant: {},
159218
}),
160219
)
161-
}, seedModel)
220+
}, input.model ?? seedModel)
162221
}
163222

164223
export { expect }

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

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,44 @@
1+
import fs from "node:fs/promises"
2+
import path from "node:path"
13
import { test, expect } from "../fixtures"
24
import { promptSelector } from "../selectors"
3-
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
5+
import { sessionIDFromUrl } from "../actions"
6+
import { createSdk } from "../utils"
47

5-
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
8+
async function config(dir: string, url: string) {
9+
await fs.writeFile(
10+
path.join(dir, "opencode.json"),
11+
JSON.stringify({
12+
$schema: "https://opencode.ai/config.json",
13+
enabled_providers: ["e2e-llm"],
14+
provider: {
15+
"e2e-llm": {
16+
name: "E2E LLM",
17+
npm: "@ai-sdk/openai-compatible",
18+
env: [],
19+
models: {
20+
"test-model": {
21+
name: "Test Model",
22+
tool_call: true,
23+
limit: { context: 128000, output: 32000 },
24+
},
25+
},
26+
options: {
27+
apiKey: "test-key",
28+
baseURL: url,
29+
},
30+
},
31+
},
32+
agent: {
33+
build: {
34+
model: "e2e-llm/test-model",
35+
},
36+
},
37+
}),
38+
)
39+
}
40+
41+
test("can send a prompt and receive a reply", async ({ page, llm, withProject }) => {
642
test.setTimeout(120_000)
743

844
const pageErrors: string[] = []
@@ -11,42 +47,51 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
1147
}
1248
page.on("pageerror", onPageError)
1349

14-
await gotoSession()
15-
16-
const token = `E2E_OK_${Date.now()}`
50+
try {
51+
await withProject(
52+
async (project) => {
53+
const sdk = createSdk(project.directory)
54+
const token = `E2E_OK_${Date.now()}`
1755

18-
const prompt = page.locator(promptSelector)
19-
await prompt.click()
20-
await page.keyboard.type(`Reply with exactly: ${token}`)
21-
await page.keyboard.press("Enter")
56+
await llm.text(token)
57+
await project.gotoSession()
2258

23-
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
59+
const prompt = page.locator(promptSelector)
60+
await prompt.click()
61+
await page.keyboard.type(`Reply with exactly: ${token}`)
62+
await page.keyboard.press("Enter")
2463

25-
const sessionID = (() => {
26-
const id = sessionIDFromUrl(page.url())
27-
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
28-
return id
29-
})()
64+
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
3065

31-
try {
32-
await expect
33-
.poll(
34-
async () => {
35-
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
36-
return messages
37-
.filter((m) => m.info.role === "assistant")
38-
.flatMap((m) => m.parts)
39-
.filter((p) => p.type === "text")
40-
.map((p) => p.text)
41-
.join("\n")
42-
},
43-
{ timeout: 90_000 },
44-
)
66+
const sessionID = (() => {
67+
const id = sessionIDFromUrl(page.url())
68+
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
69+
return id
70+
})()
71+
project.trackSession(sessionID)
4572

46-
.toContain(token)
73+
await expect
74+
.poll(
75+
async () => {
76+
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
77+
return messages
78+
.filter((m) => m.info.role === "assistant")
79+
.flatMap((m) => m.parts)
80+
.filter((p) => p.type === "text")
81+
.map((p) => p.text)
82+
.join("\n")
83+
},
84+
{ timeout: 30_000 },
85+
)
86+
.toContain(token)
87+
},
88+
{
89+
model: { providerID: "e2e-llm", modelID: "test-model" },
90+
setup: (dir) => config(dir, llm.url),
91+
},
92+
)
4793
} finally {
4894
page.off("pageerror", onPageError)
49-
await cleanupSession({ sdk, sessionID })
5095
}
5196

5297
if (pageErrors.length > 0) {

0 commit comments

Comments
 (0)