mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-29 05:53:24 +08:00
154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
import { createRoot } from "solid-js"
|
|
import { createClientConnection } from "../src/solid"
|
|
import { OpenCode, type OpenCodeEvent } from "../src/promise"
|
|
|
|
const connected = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
|
|
|
|
// One fake server whose event streams stay open until the test writes to them or the client aborts.
|
|
function server() {
|
|
const encoder = new TextEncoder()
|
|
const streams: {
|
|
write: (text: string) => void
|
|
close: () => void
|
|
aborted: boolean
|
|
}[] = []
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async (input, init) => {
|
|
const request = input instanceof Request ? input : new Request(input, init)
|
|
let controller!: ReadableStreamDefaultController<Uint8Array>
|
|
const entry = {
|
|
write: (text: string) => controller.enqueue(encoder.encode(text)),
|
|
close: () => controller.close(),
|
|
aborted: false,
|
|
}
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(value) {
|
|
controller = value
|
|
},
|
|
cancel() {
|
|
entry.aborted = true
|
|
},
|
|
})
|
|
request.signal.addEventListener("abort", () => {
|
|
entry.aborted = true
|
|
controller.error(request.signal.reason)
|
|
})
|
|
streams.push(entry)
|
|
return new Response(body, { headers: { "content-type": "text/event-stream" } })
|
|
},
|
|
})
|
|
return { api, streams }
|
|
}
|
|
|
|
function setup(input: ReturnType<typeof server>, idleTimeout: number) {
|
|
const events: OpenCodeEvent[] = []
|
|
return createRoot((dispose) => ({
|
|
events,
|
|
dispose,
|
|
connection: createClientConnection(input.api, {
|
|
idleTimeout,
|
|
flushInterval: 0,
|
|
onEvent: (event) => events.push(event),
|
|
}),
|
|
}))
|
|
}
|
|
|
|
async function until(check: () => boolean, timeout = 2_000) {
|
|
const deadline = Date.now() + timeout
|
|
while (!check()) {
|
|
if (Date.now() > deadline) throw new Error("Timed out waiting for condition")
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
}
|
|
}
|
|
|
|
test("a stream that goes silent past the idle timeout is replaced", async () => {
|
|
const fake = server()
|
|
const ctx = setup(fake, 60)
|
|
try {
|
|
await until(() => fake.streams.length === 1)
|
|
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
|
|
await until(() => ctx.connection.status() === "connected")
|
|
|
|
await until(() => fake.streams.length === 2)
|
|
expect(fake.streams[0].aborted).toBe(true)
|
|
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
|
|
status: "disconnected",
|
|
attempt: 1,
|
|
error: "Event stream stalled",
|
|
})
|
|
|
|
fake.streams[1].write(`data: ${JSON.stringify({ ...connected, id: "evt_connected_2" })}\n\n`)
|
|
await until(() => ctx.connection.status() === "connected" && ctx.events.length === 2)
|
|
expect(ctx.connection.error()).toBeUndefined()
|
|
} finally {
|
|
ctx.dispose()
|
|
}
|
|
})
|
|
|
|
test("keepalive comments hold a quiet stream open", async () => {
|
|
const fake = server()
|
|
const ctx = setup(fake, 60)
|
|
try {
|
|
await until(() => fake.streams.length === 1)
|
|
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
|
|
await until(() => ctx.connection.status() === "connected")
|
|
|
|
const heartbeat = setInterval(() => fake.streams[0].write(": heartbeat\n\n"), 20)
|
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
clearInterval(heartbeat)
|
|
|
|
expect(fake.streams).toHaveLength(1)
|
|
expect(fake.streams[0].aborted).toBe(false)
|
|
expect(ctx.connection.status()).toBe("connected")
|
|
expect(ctx.events).toHaveLength(1)
|
|
} finally {
|
|
ctx.dispose()
|
|
}
|
|
})
|
|
|
|
test("a forced resync replaces the stream immediately and only while connected", async () => {
|
|
const fake = server()
|
|
const ctx = setup(fake, 10_000)
|
|
try {
|
|
ctx.connection.internal.resync("too early")
|
|
await until(() => fake.streams.length === 1)
|
|
expect(fake.streams[0].aborted).toBe(false)
|
|
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
|
|
await until(() => ctx.connection.status() === "connected")
|
|
|
|
const started = Date.now()
|
|
ctx.connection.internal.resync("Network connection restored")
|
|
await until(() => fake.streams.length === 2)
|
|
expect(Date.now() - started).toBeLessThan(500)
|
|
expect(fake.streams[0].aborted).toBe(true)
|
|
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
|
|
status: "disconnected",
|
|
attempt: 1,
|
|
error: "Network connection restored",
|
|
})
|
|
fake.streams[1].write(`data: ${JSON.stringify(connected)}\n\n`)
|
|
await until(() => ctx.connection.status() === "connected")
|
|
} finally {
|
|
ctx.dispose()
|
|
}
|
|
})
|
|
|
|
test("a stream the server closes reconnects and reports the disconnect", async () => {
|
|
const fake = server()
|
|
const ctx = setup(fake, 10_000)
|
|
try {
|
|
await until(() => fake.streams.length === 1)
|
|
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
|
|
await until(() => ctx.connection.status() === "connected")
|
|
|
|
fake.streams[0].close()
|
|
await until(() => ctx.connection.status() === "reconnecting")
|
|
expect(ctx.connection.error()).toBe("Event stream disconnected")
|
|
await until(() => fake.streams.length === 2)
|
|
} finally {
|
|
ctx.dispose()
|
|
}
|
|
})
|