mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-28 21:45:51 +08:00
251 lines
8.7 KiB
TypeScript
251 lines
8.7 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
import { createRoot } from "solid-js"
|
|
import { createData, settleMs, type CreateDataInput } from "../src/solid"
|
|
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
|
|
|
test("config reads and update refreshes are opt-in", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const requests: string[] = []
|
|
const location = { directory: "/project" }
|
|
let model = "provider/first"
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async (input, init) => {
|
|
const request = input instanceof Request ? input : new Request(input, init)
|
|
const url = new URL(request.url)
|
|
requests.push(url.pathname)
|
|
if (url.pathname === "/api/location") return Response.json(location)
|
|
if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }])
|
|
if (url.pathname === "/api/mcp/resource")
|
|
return Response.json({ location, data: { resources: [], templates: [] } })
|
|
return Response.json({ location, data: [] })
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: location.directory,
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
}),
|
|
dispose,
|
|
}))
|
|
const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} }
|
|
try {
|
|
await setup.data.location.sync()
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
expect(requests).not.toContain("/api/config")
|
|
|
|
await setup.data.location.config.sync()
|
|
expect(requests.filter((path) => path === "/api/config")).toHaveLength(1)
|
|
model = "provider/second"
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
await setup.data.location.config.sync()
|
|
expect(requests.filter((path) => path === "/api/config")).toHaveLength(2)
|
|
expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }])
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const reported = Promise.withResolvers<unknown>()
|
|
const errors: unknown[] = []
|
|
const state = { offline: true, requests: 0 }
|
|
const session: SessionInfo = {
|
|
id: "ses_refresh_failure",
|
|
projectID: "project",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0, idle: 2 },
|
|
location: { directory: "/project" },
|
|
}
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async () => {
|
|
state.requests++
|
|
if (state.offline) throw new TypeError("Failed to fetch")
|
|
return Response.json({ data: { ...session, title: "Recovered" } })
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: "/project",
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
onError(error) {
|
|
errors.push(error)
|
|
reported.resolve(error)
|
|
},
|
|
}),
|
|
dispose,
|
|
}))
|
|
const event: OpenCodeEvent = {
|
|
id: "evt_refresh_failure",
|
|
created: 2,
|
|
type: "session.viewed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, idle: 2 },
|
|
}
|
|
try {
|
|
setup.data.session.remember(session)
|
|
setup.data.session.invalidate(session.id)
|
|
await expect(setup.data.session.sync(session.id)).rejects.toThrow("Transport")
|
|
expect(errors).toEqual([])
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
expect(String(await reported.promise)).toContain("Transport")
|
|
expect(errors).toHaveLength(1)
|
|
expect(setup.data.session.get(session.id)?.title).toBeUndefined()
|
|
state.offline = false
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
await setup.data.session.sync(session.id)
|
|
expect(setup.data.session.get(session.id)?.title).toBe("Recovered")
|
|
expect(state.requests).toBe(3)
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test.each(["reconnecting", "disposed"] as const)("background reads respect %s owners", async (mode) => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const pending = Promise.withResolvers<Response>()
|
|
const errors: unknown[] = []
|
|
const state = { requests: 0 }
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: () => {
|
|
state.requests++
|
|
return pending.promise
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: "/project",
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
connection: { status: () => (mode === "reconnecting" ? "reconnecting" : "connected") },
|
|
onError: (error) => errors.push(error),
|
|
}),
|
|
dispose,
|
|
}))
|
|
const event: OpenCodeEvent = {
|
|
id: "evt_refresh_owner",
|
|
type: "command.updated",
|
|
location: { directory: "/project" },
|
|
data: {},
|
|
}
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
if (mode === "reconnecting") {
|
|
expect(state.requests).toBe(0)
|
|
setup.dispose()
|
|
return
|
|
}
|
|
const joined = setup.data.location.command.sync()
|
|
setup.dispose()
|
|
pending.reject(new TypeError("Failed to fetch"))
|
|
await expect(joined).rejects.toThrow("Transport")
|
|
expect(state.requests).toBe(1)
|
|
expect(errors).toEqual([])
|
|
})
|
|
|
|
test("a burst of mcp.status.changed events refetches the server list once it settles", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const requests: string[] = []
|
|
const location = { directory: "/project" }
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async (input, init) => {
|
|
const request = input instanceof Request ? input : new Request(input, init)
|
|
requests.push(new URL(request.url).pathname)
|
|
return Response.json({ location, data: [] })
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: location.directory,
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
}),
|
|
dispose,
|
|
}))
|
|
try {
|
|
await setup.data.location.mcp.server.sync()
|
|
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
|
for (const server of ["a", "b", "c", "d", "e"]) {
|
|
const event: OpenCodeEvent = { id: `evt_${server}`, created: 1, type: "mcp.status.changed", location, data: { server } }
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
}
|
|
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
|
await new Promise((resolve) => setTimeout(resolve, settleMs * 2))
|
|
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(2)
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test("invalidations that land before a queued reload starts are absorbed by it", async () => {
|
|
const pending: Array<PromiseWithResolvers<Response>> = []
|
|
const location = { directory: "/project" }
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: () => {
|
|
const deferred = Promise.withResolvers<Response>()
|
|
pending.push(deferred)
|
|
return deferred.promise
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: location.directory,
|
|
event: { on: () => () => {}, listen: () => () => {} },
|
|
}),
|
|
dispose,
|
|
}))
|
|
try {
|
|
const first = setup.data.location.agent.sync()
|
|
expect(pending).toHaveLength(1)
|
|
setup.data.location.agent.invalidate()
|
|
const second = setup.data.location.agent.sync()
|
|
setup.data.location.agent.invalidate()
|
|
const third = setup.data.location.agent.sync()
|
|
setup.data.location.agent.invalidate()
|
|
expect(pending).toHaveLength(1)
|
|
pending[0].resolve(Response.json({ location, data: [] }))
|
|
await first
|
|
await Promise.resolve()
|
|
expect(pending).toHaveLength(2)
|
|
pending[1].resolve(Response.json({ location, data: [{ name: "reviewer" }] }))
|
|
await Promise.all([second, third])
|
|
expect(pending).toHaveLength(2)
|
|
await setup.data.location.agent.sync()
|
|
expect(pending).toHaveLength(2)
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|