mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-02 03:54:41 +00:00
Compare commits
2 Commits
kit/e2e-go
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
336d28f112 | ||
|
|
916afb5220 |
@@ -62,7 +62,7 @@ function tail(input: string[]) {
|
||||
return input.slice(-40).join("")
|
||||
}
|
||||
|
||||
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
||||
export async function startBackend(label: string): Promise<Handle> {
|
||||
const port = await freePort()
|
||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
@@ -80,7 +80,6 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
|
||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||
OPENCODE_CLIENT: "app",
|
||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
||||
} satisfies Record<string, string | undefined>
|
||||
const out: string[] = []
|
||||
const err: string[] = []
|
||||
|
||||
@@ -12,14 +12,11 @@ import {
|
||||
setHealthPhase,
|
||||
seedProjects,
|
||||
sessionIDFromUrl,
|
||||
waitSession,
|
||||
waitSessionIdle,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
waitSession,
|
||||
} from "./actions"
|
||||
import { openaiModel, withMockOpenAI } from "./prompt/mock"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, resolveDirectory, sessionPath } from "./utils"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
type LLMFixture = {
|
||||
url: string
|
||||
@@ -54,23 +51,6 @@ type LLMFixture = {
|
||||
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||
}
|
||||
|
||||
type LLMWorker = LLMFixture & {
|
||||
reset: () => Promise<void>
|
||||
}
|
||||
|
||||
type AssistantFixture = {
|
||||
reply: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||
tool: (name: string, input: unknown) => Promise<void>
|
||||
toolHang: (name: string, input: unknown) => Promise<void>
|
||||
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
||||
fail: (message?: unknown) => Promise<void>
|
||||
error: (status: number, body: unknown) => Promise<void>
|
||||
hang: () => Promise<void>
|
||||
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
||||
calls: () => Promise<number>
|
||||
pending: () => Promise<number>
|
||||
}
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
|
||||
const seedModel = (() => {
|
||||
@@ -83,10 +63,6 @@ const seedModel = (() => {
|
||||
}
|
||||
})()
|
||||
|
||||
function clean(value: string | null) {
|
||||
return (value ?? "").replace(/\u200B/g, "").trim()
|
||||
}
|
||||
|
||||
type ProjectHandle = {
|
||||
directory: string
|
||||
slug: string
|
||||
@@ -103,15 +79,8 @@ type ProjectOptions = {
|
||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||
}
|
||||
|
||||
type ProjectFixture = ProjectHandle & {
|
||||
open: (options?: ProjectOptions) => Promise<void>
|
||||
prompt: (text: string) => Promise<string>
|
||||
}
|
||||
|
||||
type TestFixtures = {
|
||||
llm: LLMFixture
|
||||
assistant: AssistantFixture
|
||||
project: ProjectFixture
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
||||
@@ -120,7 +89,6 @@ type TestFixtures = {
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
_llm: LLMWorker
|
||||
backend: {
|
||||
url: string
|
||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||
@@ -130,42 +98,9 @@ type WorkerFixtures = {
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
_llm: [
|
||||
async ({}, use) => {
|
||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||
try {
|
||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||
await use({
|
||||
url: svc.url,
|
||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||
hang: () => rt.runPromise(svc.hang),
|
||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||
reset: () => rt.runPromise(svc.reset),
|
||||
hits: () => rt.runPromise(svc.hits),
|
||||
calls: () => rt.runPromise(svc.calls),
|
||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||
inputs: () => rt.runPromise(svc.inputs),
|
||||
pending: () => rt.runPromise(svc.pending),
|
||||
misses: () => rt.runPromise(svc.misses),
|
||||
})
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
backend: [
|
||||
async ({ _llm }, use, workerInfo) => {
|
||||
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
||||
async ({}, use, workerInfo) => {
|
||||
const handle = await startBackend(`w${workerInfo.workerIndex}`)
|
||||
try {
|
||||
await use({
|
||||
url: handle.url,
|
||||
@@ -177,48 +112,35 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
llm: async ({ _llm }, use) => {
|
||||
await _llm.reset()
|
||||
await use({
|
||||
url: _llm.url,
|
||||
push: _llm.push,
|
||||
pushMatch: _llm.pushMatch,
|
||||
textMatch: _llm.textMatch,
|
||||
toolMatch: _llm.toolMatch,
|
||||
text: _llm.text,
|
||||
tool: _llm.tool,
|
||||
toolHang: _llm.toolHang,
|
||||
reason: _llm.reason,
|
||||
fail: _llm.fail,
|
||||
error: _llm.error,
|
||||
hang: _llm.hang,
|
||||
hold: _llm.hold,
|
||||
hits: _llm.hits,
|
||||
calls: _llm.calls,
|
||||
wait: _llm.wait,
|
||||
inputs: _llm.inputs,
|
||||
pending: _llm.pending,
|
||||
misses: _llm.misses,
|
||||
})
|
||||
const pending = await _llm.pending()
|
||||
if (pending > 0) {
|
||||
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
||||
llm: async ({}, use) => {
|
||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||
try {
|
||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||
await use({
|
||||
url: svc.url,
|
||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||
hang: () => rt.runPromise(svc.hang),
|
||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||
hits: () => rt.runPromise(svc.hits),
|
||||
calls: () => rt.runPromise(svc.calls),
|
||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||
inputs: () => rt.runPromise(svc.inputs),
|
||||
pending: () => rt.runPromise(svc.pending),
|
||||
misses: () => rt.runPromise(svc.misses),
|
||||
})
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
assistant: async ({ llm }, use) => {
|
||||
await use({
|
||||
reply: llm.text,
|
||||
tool: llm.tool,
|
||||
toolHang: llm.toolHang,
|
||||
reason: llm.reason,
|
||||
fail: llm.fail,
|
||||
error: llm.error,
|
||||
hang: llm.hang,
|
||||
hold: llm.hold,
|
||||
calls: llm.calls,
|
||||
pending: llm.pending,
|
||||
})
|
||||
},
|
||||
page: async ({ page }, use) => {
|
||||
let boundary: string | undefined
|
||||
setHealthPhase(page, "test")
|
||||
@@ -244,7 +166,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
directory: [
|
||||
async ({}, use) => {
|
||||
await use(await getWorktree())
|
||||
const directory = await getWorktree()
|
||||
await use(directory)
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
@@ -266,14 +189,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
project: async ({ page, llm, backend }, use) => {
|
||||
const item = makeProject(page, llm, backend)
|
||||
try {
|
||||
await use(item.project)
|
||||
} finally {
|
||||
await item.cleanup()
|
||||
}
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use((callback, options) => runProject(page, callback, options))
|
||||
},
|
||||
@@ -299,148 +214,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
})
|
||||
|
||||
function makeProject(
|
||||
page: Page,
|
||||
llm: LLMFixture,
|
||||
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
||||
) {
|
||||
let state:
|
||||
| {
|
||||
directory: string
|
||||
slug: string
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
sessions: Map<string, string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const need = () => {
|
||||
if (state) return state
|
||||
throw new Error("project.open() must be called first")
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
const cur = need()
|
||||
cur.sessions.set(sessionID, directory ?? cur.directory)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
const cur = need()
|
||||
if (directory !== cur.directory) cur.dirs.add(directory)
|
||||
}
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
const cur = need()
|
||||
await page.goto(sessionPath(cur.directory, sessionID))
|
||||
await waitSession(page, { directory: cur.directory, sessionID, serverUrl: backend.url })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const open = async (options?: ProjectOptions) => {
|
||||
if (state) return
|
||||
const directory = await createTestProject({ serverUrl: backend.url })
|
||||
const sdk = backend.sdk(directory)
|
||||
await options?.setup?.(directory)
|
||||
await seedStorage(page, {
|
||||
directory,
|
||||
extra: options?.extra,
|
||||
model: options?.model ?? openaiModel,
|
||||
serverUrl: backend.url,
|
||||
})
|
||||
state = {
|
||||
directory,
|
||||
slug: "",
|
||||
sdk,
|
||||
sessions: new Map(),
|
||||
dirs: new Set(),
|
||||
}
|
||||
await options?.beforeGoto?.({ directory, sdk })
|
||||
await gotoSession()
|
||||
need().slug = await waitSlug(page)
|
||||
}
|
||||
|
||||
const prompt = async (text: string) => {
|
||||
const cur = need()
|
||||
if ((await llm.pending()) === 0) {
|
||||
await llm.text("ok")
|
||||
}
|
||||
|
||||
const prompt = page.locator(promptSelector).first()
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await page.keyboard.type(text)
|
||||
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
|
||||
await page.keyboard.press("Enter")
|
||||
const sent = await expect
|
||||
.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 5_000 })
|
||||
.not.toBe("")
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!sent) {
|
||||
const send = page.getByRole("button", { name: "Send" }).first()
|
||||
await expect(send).toBeEnabled()
|
||||
await send.click()
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 90_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
|
||||
const current = await page
|
||||
.evaluate(() => {
|
||||
const win = window as E2EWindow
|
||||
const next = win.__opencode_e2e?.model?.current
|
||||
if (!next) return null
|
||||
return { dir: next.dir, sessionID: next.sessionID }
|
||||
})
|
||||
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||
const directory = current?.dir
|
||||
? await resolveDirectory(current.dir, backend.url).catch(() => cur.directory)
|
||||
: cur.directory
|
||||
|
||||
trackSession(sessionID, directory)
|
||||
await waitSessionSaved(directory, sessionID, 90_000, backend.url)
|
||||
await waitSessionIdle(backend.sdk(directory), sessionID, 90_000).catch(() => undefined)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
const cleanup = async () => {
|
||||
const cur = state
|
||||
if (!cur) return
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(cur.sessions, ([sessionID, directory]) =>
|
||||
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
||||
),
|
||||
)
|
||||
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(cur.directory)
|
||||
state = undefined
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
open,
|
||||
prompt,
|
||||
gotoSession,
|
||||
trackSession,
|
||||
trackDirectory,
|
||||
get directory() {
|
||||
return need().directory
|
||||
},
|
||||
get slug() {
|
||||
return need().slug
|
||||
},
|
||||
get sdk() {
|
||||
return need().sdk
|
||||
},
|
||||
},
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
async function runProject<T>(
|
||||
page: Page,
|
||||
callback: (project: ProjectHandle) => Promise<T>,
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { assistantText, withSession } from "../actions"
|
||||
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
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
|
||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
||||
test("prompt succeeds when sync message endpoint is unreachable", async ({
|
||||
page,
|
||||
llm,
|
||||
backend,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||
|
||||
const token = `E2E_ASYNC_${Date.now()}`
|
||||
await project.open()
|
||||
await assistant.reply(token)
|
||||
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const token = `E2E_ASYNC_${Date.now()}`
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(token), token)
|
||||
|
||||
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
project.trackSession(sessionID)
|
||||
|
||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { assistantText } from "../actions"
|
||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
type Sdk = ReturnType<typeof createSdk>
|
||||
@@ -42,45 +43,73 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({
|
||||
page,
|
||||
llm,
|
||||
backend,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
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 withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
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 project.open()
|
||||
await assistant.reply(firstToken)
|
||||
const sessionID = await project.prompt(first)
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, firstToken)
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(firstToken), firstToken)
|
||||
await llm.textMatch(promptMatch(secondToken), secondToken)
|
||||
|
||||
await assistant.reply(secondToken)
|
||||
await project.prompt(second)
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, secondToken)
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
project.trackSession(sessionID)
|
||||
await reply(project.sdk, sessionID, firstToken)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, secondToken)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
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, "")
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { assistantText } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
|
||||
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const pageErrors: string[] = []
|
||||
@@ -11,13 +13,41 @@ test("can send a prompt and receive a reply", async ({ page, project, assistant
|
||||
page.on("pageerror", onPageError)
|
||||
|
||||
try {
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
await project.open()
|
||||
await assistant.reply(token)
|
||||
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(token), token)
|
||||
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const sessionID = (() => {
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return id
|
||||
})()
|
||||
project.trackSession(sessionID)
|
||||
|
||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefres
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
const eagerRefreshThreshold = Duration.minutes(5)
|
||||
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
|
||||
|
||||
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
|
||||
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
@@ -219,7 +223,7 @@ export namespace Account {
|
||||
|
||||
const account = maybeAccount.value
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (account.token_expiry && account.token_expiry > now + Duration.toMillis(eagerRefreshThreshold)) {
|
||||
if (isTokenFresh(account.token_expiry, now)) {
|
||||
return account.access_token
|
||||
}
|
||||
|
||||
@@ -229,7 +233,7 @@ export namespace Account {
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now + Duration.toMillis(eagerRefreshThreshold)) {
|
||||
if (isTokenFresh(row.token_expiry, now)) {
|
||||
return row.access_token
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { EOL } from "os"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { logo as glyphs } from "./logo"
|
||||
|
||||
export namespace UI {
|
||||
const wordmark = [
|
||||
@@ -47,12 +48,60 @@ export namespace UI {
|
||||
}
|
||||
|
||||
export function logo(pad?: string) {
|
||||
const result = []
|
||||
for (const row of wordmark) {
|
||||
if (pad) result.push(pad)
|
||||
result.push(row)
|
||||
result.push(EOL)
|
||||
if (!process.stdout.isTTY && !process.stderr.isTTY) {
|
||||
const result = []
|
||||
for (const row of wordmark) {
|
||||
if (pad) result.push(pad)
|
||||
result.push(row)
|
||||
result.push(EOL)
|
||||
}
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
const reset = "\x1b[0m"
|
||||
const left = {
|
||||
fg: "\x1b[90m",
|
||||
shadow: "\x1b[38;5;235m",
|
||||
bg: "\x1b[48;5;235m",
|
||||
}
|
||||
const right = {
|
||||
fg: reset,
|
||||
shadow: "\x1b[38;5;238m",
|
||||
bg: "\x1b[48;5;238m",
|
||||
}
|
||||
const gap = " "
|
||||
const draw = (line: string, fg: string, shadow: string, bg: string) => {
|
||||
const parts: string[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
parts.push(bg, " ", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "^") {
|
||||
parts.push(fg, bg, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "~") {
|
||||
parts.push(shadow, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === " ") {
|
||||
parts.push(" ")
|
||||
continue
|
||||
}
|
||||
parts.push(fg, char, reset)
|
||||
}
|
||||
return parts.join("")
|
||||
}
|
||||
glyphs.left.forEach((row, index) => {
|
||||
if (pad) result.push(pad)
|
||||
result.push(draw(row, left.fg, left.shadow, left.bg))
|
||||
result.push(gap)
|
||||
const other = glyphs.right[index] ?? ""
|
||||
result.push(draw(other, right.fg, right.shadow, right.bg))
|
||||
result.push(EOL)
|
||||
})
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,19 @@ process.on("uncaughtException", (e) => {
|
||||
})
|
||||
})
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
const args = hideBin(process.argv)
|
||||
|
||||
function show(out: string) {
|
||||
const text = out.trimStart()
|
||||
if (!text.startsWith("opencode ")) {
|
||||
process.stderr.write(UI.logo() + EOL + EOL)
|
||||
process.stderr.write(text)
|
||||
return
|
||||
}
|
||||
process.stderr.write(out)
|
||||
}
|
||||
|
||||
const cli = yargs(args)
|
||||
.parserConfiguration({ "populate--": true })
|
||||
.scriptName("opencode")
|
||||
.wrap(100)
|
||||
@@ -130,7 +142,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
process.stderr.write("Database migration complete." + EOL)
|
||||
}
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
.usage("")
|
||||
.completion("completion", "generate shell completion script")
|
||||
.command(AcpCommand)
|
||||
.command(McpCommand)
|
||||
@@ -162,7 +174,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
msg?.startsWith("Invalid values:")
|
||||
) {
|
||||
if (err) throw err
|
||||
cli.showHelp("log")
|
||||
cli.showHelp(show)
|
||||
}
|
||||
if (err) throw err
|
||||
process.exit(1)
|
||||
@@ -170,7 +182,15 @@ const cli = yargs(hideBin(process.argv))
|
||||
.strict()
|
||||
|
||||
try {
|
||||
await cli.parse()
|
||||
if (args.includes("-h") || args.includes("--help")) {
|
||||
await cli.parse(args, (err: Error | undefined, _argv: unknown, out: string) => {
|
||||
if (err) throw err
|
||||
if (!out) return
|
||||
show(out)
|
||||
})
|
||||
} else {
|
||||
await cli.parse()
|
||||
}
|
||||
} catch (e) {
|
||||
let data: Record<string, any> = {}
|
||||
if (e instanceof NamedError) {
|
||||
|
||||
@@ -113,12 +113,6 @@ export namespace Provider {
|
||||
})
|
||||
}
|
||||
|
||||
function e2eURL() {
|
||||
const url = Env.get("OPENCODE_E2E_LLM_URL")
|
||||
if (typeof url !== "string" || url === "") return
|
||||
return url
|
||||
}
|
||||
|
||||
type BundledSDK = {
|
||||
languageModel(modelId: string): LanguageModelV3
|
||||
}
|
||||
@@ -1454,17 +1448,6 @@ export namespace Provider {
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const url = e2eURL()
|
||||
if (url) {
|
||||
const language = createOpenAI({
|
||||
name: model.providerID,
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
}).responses(model.api.id)
|
||||
s.models.set(key, language)
|
||||
return language
|
||||
}
|
||||
|
||||
const provider = s.providers[model.providerID]
|
||||
const sdk = await resolveSDK(model, s)
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ const truncate = Layer.effectDiscard(
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||
|
||||
const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
|
||||
const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
|
||||
|
||||
const live = (client: HttpClient.HttpClient) =>
|
||||
Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||
|
||||
@@ -63,7 +66,7 @@ it.live("orgsByAccount groups orgs per account", () =>
|
||||
url: "https://one.example.com",
|
||||
accessToken: AccessToken.make("at_1"),
|
||||
refreshToken: RefreshToken.make("rt_1"),
|
||||
expiry: Date.now() + 10 * 60_000,
|
||||
expiry: Date.now() + outsideEagerRefreshWindow,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
@@ -75,7 +78,7 @@ it.live("orgsByAccount groups orgs per account", () =>
|
||||
url: "https://two.example.com",
|
||||
accessToken: AccessToken.make("at_2"),
|
||||
refreshToken: RefreshToken.make("rt_2"),
|
||||
expiry: Date.now() + 10 * 60_000,
|
||||
expiry: Date.now() + outsideEagerRefreshWindow,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
@@ -159,7 +162,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
|
||||
url: "https://one.example.com",
|
||||
accessToken: AccessToken.make("at_old"),
|
||||
refreshToken: RefreshToken.make("rt_old"),
|
||||
expiry: Date.now() + 60_000,
|
||||
expiry: Date.now() + insideEagerRefreshWindow,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
@@ -267,7 +270,7 @@ it.live("config sends the selected org header", () =>
|
||||
url: "https://one.example.com",
|
||||
accessToken: AccessToken.make("at_1"),
|
||||
refreshToken: RefreshToken.make("rt_1"),
|
||||
expiry: Date.now() + 10 * 60_000,
|
||||
expiry: Date.now() + outsideEagerRefreshWindow,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -599,11 +599,6 @@ function isToolResultFollowUp(body: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function isTitleRequest(body: unknown): boolean {
|
||||
if (!body || typeof body !== "object") return false
|
||||
return JSON.stringify(body).includes("Generate a title for this conversation")
|
||||
}
|
||||
|
||||
function requestSummary(body: unknown): string {
|
||||
if (!body || typeof body !== "object") return "empty body"
|
||||
if ("messages" in body && Array.isArray(body.messages)) {
|
||||
@@ -628,7 +623,6 @@ namespace TestLLMServer {
|
||||
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
||||
readonly hang: Effect.Effect<void>
|
||||
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
||||
readonly reset: Effect.Effect<void>
|
||||
readonly hits: Effect.Effect<Hit[]>
|
||||
readonly calls: Effect.Effect<number>
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
@@ -677,29 +671,21 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||
const current = hit(req.originalUrl, body)
|
||||
if (isTitleRequest(body)) {
|
||||
hits = [...hits, current]
|
||||
yield* notify()
|
||||
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
|
||||
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||
return send(auto)
|
||||
}
|
||||
// Auto-acknowledge tool-result follow-ups so tests only need to
|
||||
// queue one response per tool call instead of two.
|
||||
if (isToolResultFollowUp(body)) {
|
||||
hits = [...hits, current]
|
||||
yield* notify()
|
||||
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
|
||||
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||
return send(auto)
|
||||
}
|
||||
const next = pull(current)
|
||||
if (!next) {
|
||||
hits = [...hits, current]
|
||||
yield* notify()
|
||||
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
|
||||
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||
return send(auto)
|
||||
// Auto-acknowledge tool-result follow-ups so tests only need to
|
||||
// queue one response per tool call instead of two.
|
||||
if (isToolResultFollowUp(body)) {
|
||||
hits = [...hits, current]
|
||||
yield* notify()
|
||||
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
|
||||
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||
return send(auto)
|
||||
}
|
||||
misses = [...misses, current]
|
||||
const summary = requestSummary(body)
|
||||
console.warn(`[TestLLMServer] unmatched request: ${req.originalUrl} (${summary}, pending=${list.length})`)
|
||||
return HttpServerResponse.text(`unexpected request: ${summary}`, { status: 500 })
|
||||
}
|
||||
hits = [...hits, current]
|
||||
yield* notify()
|
||||
@@ -769,12 +755,6 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
||||
queue(reply().wait(wait).text(value).stop().item())
|
||||
}),
|
||||
reset: Effect.sync(() => {
|
||||
hits = []
|
||||
list = []
|
||||
waits = []
|
||||
misses = []
|
||||
}),
|
||||
hits: Effect.sync(() => [...hits]),
|
||||
calls: Effect.sync(() => hits.length),
|
||||
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
|
||||
|
||||
Reference in New Issue
Block a user