Skip to content

Commit ba40cea

Browse files
Merge remote-tracking branch 'upstream/dev' into ocv
2 parents 899535f + f3f728e commit ba40cea

39 files changed

+1986
-1056
lines changed

.github/VOUCHED.td

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ adamdotdevin
1111
-agusbasari29 AI PR slop
1212
ariane-emory
1313
-atharvau AI review spamming literally every PR
14+
-borealbytes
1415
-danieljoshuanazareth
1516
-danieljoshuanazareth
1617
edemaine

bun.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nix/hashes.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"nodeModules": {
3-
"x86_64-linux": "sha256-C7y5FMI1pGEgMw/vcPoBhK9tw5uGg1bk0gPXPUUVhgU=",
4-
"aarch64-linux": "sha256-cUlQ9jp4WIaJkd4GRoHMWc+REG/OnnGCmsQUNmvg4is=",
5-
"aarch64-darwin": "sha256-3GXmqG7yihJ91wS/jlW19qxGI62b1bFJnpGB4LcMlpY=",
6-
"x86_64-darwin": "sha256-cUF0TfYg2nXnU80kWFpr9kNHlu9txiatIgrHTltgx4g="
3+
"x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
4+
"aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
5+
"aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
6+
"x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
77
}
88
}

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"packages/slack"
2626
],
2727
"catalog": {
28-
"@effect/platform-node": "4.0.0-beta.42",
28+
"@effect/platform-node": "4.0.0-beta.43",
2929
"@types/bun": "1.3.11",
3030
"@octokit/rest": "22.0.0",
3131
"@hono/zod-validator": "0.4.2",
@@ -45,7 +45,7 @@
4545
"dompurify": "3.3.1",
4646
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
4747
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
48-
"effect": "4.0.0-beta.42",
48+
"effect": "4.0.0-beta.43",
4949
"ai": "6.0.138",
5050
"hono": "4.10.7",
5151
"hono-openapi": "1.1.2",

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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
48+
if (proc.exitCode !== null) return
49+
await Promise.race([
50+
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
51+
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
52+
])
53+
}
54+
55+
const LOG_CAP = 100
56+
57+
function cap(input: string[]) {
58+
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
59+
}
60+
61+
function tail(input: string[]) {
62+
return input.slice(-40).join("")
63+
}
64+
65+
export async function startBackend(label: string): Promise<Handle> {
66+
const port = await freePort()
67+
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
68+
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
69+
const repoDir = path.resolve(appDir, "../..")
70+
const opencodeDir = path.join(repoDir, "packages", "opencode")
71+
const env = {
72+
...process.env,
73+
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
74+
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
75+
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
76+
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
77+
XDG_DATA_HOME: path.join(sandbox, "share"),
78+
XDG_CACHE_HOME: path.join(sandbox, "cache"),
79+
XDG_CONFIG_HOME: path.join(sandbox, "config"),
80+
XDG_STATE_HOME: path.join(sandbox, "state"),
81+
OPENCODE_CLIENT: "app",
82+
OPENCODE_STRICT_CONFIG_DEPS: "true",
83+
} satisfies Record<string, string | undefined>
84+
const out: string[] = []
85+
const err: string[] = []
86+
const proc = spawn(
87+
"bun",
88+
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
89+
{
90+
cwd: opencodeDir,
91+
env,
92+
stdio: ["ignore", "pipe", "pipe"],
93+
},
94+
)
95+
proc.stdout?.on("data", (chunk) => {
96+
out.push(String(chunk))
97+
cap(out)
98+
})
99+
proc.stderr?.on("data", (chunk) => {
100+
err.push(String(chunk))
101+
cap(err)
102+
})
103+
104+
const url = `http://127.0.0.1:${port}`
105+
try {
106+
await waitForHealth(url)
107+
} catch (error) {
108+
proc.kill("SIGTERM")
109+
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
110+
throw new Error(
111+
[
112+
`Failed to start isolated e2e backend for ${label}`,
113+
error instanceof Error ? error.message : String(error),
114+
tail(out),
115+
tail(err),
116+
]
117+
.filter(Boolean)
118+
.join("\n"),
119+
)
120+
}
121+
122+
return {
123+
url,
124+
async stop() {
125+
if (proc.exitCode === null) {
126+
proc.kill("SIGTERM")
127+
await waitExit(proc)
128+
}
129+
if (proc.exitCode === null) {
130+
proc.kill("SIGKILL")
131+
await waitExit(proc)
132+
}
133+
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
134+
},
135+
}
136+
}

0 commit comments

Comments
 (0)