Skip to content

Commit 38d2276

Browse files
authored
test(e2e): isolate prompt tests with per-worker backend (#20464)
1 parent d58004a commit 38d2276

8 files changed

Lines changed: 428 additions & 185 deletions

File tree

packages/app/e2e/actions.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -312,10 +312,11 @@ export async function openSettings(page: Page) {
312312
return dialog
313313
}
314314

315-
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
315+
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
316316
await page.addInitScript(
317317
(args: { directory: string; serverUrl: string; extra: string[] }) => {
318318
const key = "opencode.global.dat:server"
319+
const defaultKey = "opencode.settings.dat:defaultServerUrl"
319320
const raw = localStorage.getItem(key)
320321
const parsed = (() => {
321322
if (!raw) return undefined
@@ -331,6 +332,7 @@ export async function seedProjects(page: Page, input: { directory: string; extra
331332
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
332333
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
333334
const nextProjects = { ...(projects as Record<string, unknown>) }
335+
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
334336

335337
const add = (origin: string, directory: string) => {
336338
const current = nextProjects[origin]
@@ -356,17 +358,18 @@ export async function seedProjects(page: Page, input: { directory: string; extra
356358
localStorage.setItem(
357359
key,
358360
JSON.stringify({
359-
list,
361+
list: nextList,
360362
projects: nextProjects,
361363
lastProject,
362364
}),
363365
)
366+
localStorage.setItem(defaultKey, args.serverUrl)
364367
},
365-
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
368+
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
366369
)
367370
}
368371

369-
export async function createTestProject() {
372+
export async function createTestProject(input?: { serverUrl?: string }) {
370373
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
371374
const id = `e2e-${path.basename(root)}`
372375

@@ -381,7 +384,7 @@ export async function createTestProject() {
381384
stdio: "ignore",
382385
})
383386

384-
return resolveDirectory(root)
387+
return resolveDirectory(root, input?.serverUrl)
385388
}
386389

387390
export async function cleanupTestProject(directory: string) {
@@ -430,22 +433,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
430433
return next
431434
}
432435

433-
export async function resolveSlug(slug: string) {
436+
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
434437
const directory = base64Decode(slug)
435438
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
436-
const resolved = await resolveDirectory(directory)
439+
const resolved = await resolveDirectory(directory, input?.serverUrl)
437440
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
438441
}
439442

440-
export async function waitDir(page: Page, directory: string) {
441-
const target = await resolveDirectory(directory)
443+
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
444+
const target = await resolveDirectory(directory, input?.serverUrl)
442445
await expect
443446
.poll(
444447
async () => {
445448
await assertHealthy(page, "waitDir")
446449
const slug = slugFromUrl(page.url())
447450
if (!slug) return ""
448-
return resolveSlug(slug)
451+
return resolveSlug(slug, input)
449452
.then((item) => item.directory)
450453
.catch(() => "")
451454
},
@@ -455,15 +458,15 @@ export async function waitDir(page: Page, directory: string) {
455458
return { directory: target, slug: base64Encode(target) }
456459
}
457460

458-
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
459-
const target = await resolveDirectory(input.directory)
461+
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
462+
const target = await resolveDirectory(input.directory, input.serverUrl)
460463
await expect
461464
.poll(
462465
async () => {
463466
await assertHealthy(page, "waitSession")
464467
const slug = slugFromUrl(page.url())
465468
if (!slug) return false
466-
const resolved = await resolveSlug(slug).catch(() => undefined)
469+
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
467470
if (!resolved || resolved.directory !== target) return false
468471
const current = sessionIDFromUrl(page.url())
469472
if (input.sessionID && current !== input.sessionID) return false
@@ -473,7 +476,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio
473476
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
474477
if (!input.sessionID && state?.sessionID) return false
475478
if (state?.dir) {
476-
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
479+
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
477480
if (dir !== target) return false
478481
}
479482

@@ -489,9 +492,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
489492
return { directory: target, slug: base64Encode(target) }
490493
}
491494

492-
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
493-
const sdk = createSdk(directory)
494-
const target = await resolveDirectory(directory)
495+
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
496+
const sdk = createSdk(directory, serverUrl)
497+
const target = await resolveDirectory(directory, serverUrl)
495498

496499
await expect
497500
.poll(
@@ -501,7 +504,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
501504
.then((x) => x.data)
502505
.catch(() => undefined)
503506
if (!data?.directory) return ""
504-
return resolveDirectory(data.directory).catch(() => data.directory)
507+
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
505508
},
506509
{ timeout },
507510
)
@@ -666,8 +669,9 @@ export async function cleanupSession(input: {
666669
sessionID: string
667670
directory?: string
668671
sdk?: ReturnType<typeof createSdk>
672+
serverUrl?: string
669673
}) {
670-
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
674+
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
671675
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
672676
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
673677
const current = await status(sdk, input.sessionID).catch(() => undefined)
@@ -1019,3 +1023,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
10191023
await expect(menu).toBeVisible()
10201024
return menu
10211025
}
1026+
1027+
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
1028+
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
1029+
return messages
1030+
.filter((m) => m.info.role === "assistant")
1031+
.flatMap((m) => m.parts)
1032+
.filter((p) => p.type === "text")
1033+
.map((p) => p.text)
1034+
.join("\n")
1035+
}

packages/app/e2e/backend.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { spawn } from "node:child_process"
2+
import fs from "node:fs/promises"
3+
import net from "node:net"
4+
import os from "node:os"
5+
import path from "node:path"
6+
import { fileURLToPath } from "node:url"
7+
8+
type Handle = {
9+
url: string
10+
stop: () => Promise<void>
11+
}
12+
13+
function freePort() {
14+
return new Promise<number>((resolve, reject) => {
15+
const server = net.createServer()
16+
server.once("error", reject)
17+
server.listen(0, () => {
18+
const address = server.address()
19+
if (!address || typeof address === "string") {
20+
server.close(() => reject(new Error("Failed to acquire a free port")))
21+
return
22+
}
23+
server.close((err) => {
24+
if (err) reject(err)
25+
else resolve(address.port)
26+
})
27+
})
28+
})
29+
}
30+
31+
async function waitForHealth(url: string, probe = "/global/health") {
32+
const end = Date.now() + 120_000
33+
let last = ""
34+
while (Date.now() < end) {
35+
try {
36+
const res = await fetch(`${url}${probe}`)
37+
if (res.ok) return
38+
last = `status ${res.status}`
39+
} catch (err) {
40+
last = err instanceof Error ? err.message : String(err)
41+
}
42+
await new Promise((resolve) => setTimeout(resolve, 250))
43+
}
44+
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
45+
}
46+
47+
const LOG_CAP = 100
48+
49+
function cap(input: string[]) {
50+
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
51+
}
52+
53+
function tail(input: string[]) {
54+
return input.slice(-40).join("")
55+
}
56+
57+
export async function startBackend(label: string): Promise<Handle> {
58+
const port = await freePort()
59+
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
60+
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
61+
const repoDir = path.resolve(appDir, "../..")
62+
const opencodeDir = path.join(repoDir, "packages", "opencode")
63+
const env = {
64+
...process.env,
65+
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
66+
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
67+
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
68+
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
69+
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
70+
XDG_DATA_HOME: path.join(sandbox, "share"),
71+
XDG_CACHE_HOME: path.join(sandbox, "cache"),
72+
XDG_CONFIG_HOME: path.join(sandbox, "config"),
73+
XDG_STATE_HOME: path.join(sandbox, "state"),
74+
OPENCODE_CLIENT: "app",
75+
OPENCODE_STRICT_CONFIG_DEPS: "true",
76+
} satisfies Record<string, string | undefined>
77+
const out: string[] = []
78+
const err: string[] = []
79+
const proc = spawn(
80+
"bun",
81+
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
82+
{
83+
cwd: opencodeDir,
84+
env,
85+
stdio: ["ignore", "pipe", "pipe"],
86+
},
87+
)
88+
proc.stdout?.on("data", (chunk) => { out.push(String(chunk)); cap(out) })
89+
proc.stderr?.on("data", (chunk) => { err.push(String(chunk)); cap(err) })
90+
91+
const url = `http://127.0.0.1:${port}`
92+
try {
93+
await waitForHealth(url)
94+
} catch (error) {
95+
proc.kill("SIGTERM")
96+
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
97+
throw new Error(
98+
[
99+
`Failed to start isolated e2e backend for ${label}`,
100+
error instanceof Error ? error.message : String(error),
101+
tail(out),
102+
tail(err),
103+
]
104+
.filter(Boolean)
105+
.join("\n"),
106+
)
107+
}
108+
109+
return {
110+
url,
111+
async stop() {
112+
if (proc.exitCode === null) {
113+
proc.kill("SIGTERM")
114+
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
115+
}
116+
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
117+
},
118+
}
119+
}

0 commit comments

Comments
 (0)