feat: expose background service lifecycle (#36895)
This commit is contained in:
parent
ea89a2f619
commit
ece2b16cdf
36 changed files with 2421 additions and 293 deletions
31
packages/tui/test/cli/tui/reconnecting.test.ts
Normal file
31
packages/tui/test/cli/tui/reconnecting.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { reconnectingCopy } from "../../../src/component/reconnecting"
|
||||
|
||||
test("describes service status without transport diagnostics", () => {
|
||||
expect(reconnectingCopy({ type: "starting", version: "2.0.0" })).toEqual({
|
||||
loading: true,
|
||||
message: "Starting OpenCode 2.0.0...",
|
||||
})
|
||||
expect(reconnectingCopy({ type: "stopping", targetVersion: "2.0.0" })).toEqual({
|
||||
loading: true,
|
||||
message: "Updating to 2.0.0...",
|
||||
})
|
||||
expect(
|
||||
reconnectingCopy({
|
||||
type: "failed",
|
||||
message: "Could not open the database.",
|
||||
action: "Check the service logs.",
|
||||
}),
|
||||
).toEqual({
|
||||
loading: false,
|
||||
message: "Background service failed",
|
||||
detail: "Could not open the database.",
|
||||
action: "Check the service logs.",
|
||||
})
|
||||
expect(reconnectingCopy({ type: "unresponsive" })).toEqual({
|
||||
loading: false,
|
||||
message: "Background service is not responding",
|
||||
action: "Run `opencode service restart` to recover it.",
|
||||
})
|
||||
expect(JSON.stringify(reconnectingCopy())).not.toMatch(/Attempt|ECONNREFUSED|Event stream disconnected/)
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { Service } from "@opencode-ai/client/effect"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider, useProject } from "../../../src/context/project"
|
||||
|
|
@ -53,7 +54,7 @@ function update(version: string): OpenCodeEvent {
|
|||
}
|
||||
|
||||
async function mount(
|
||||
reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>,
|
||||
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
|
||||
log?: LogSink,
|
||||
) {
|
||||
const events = createEventStream()
|
||||
|
|
@ -194,8 +195,8 @@ describe("useEvent", () => {
|
|||
const replacementEvents = createEventStream()
|
||||
const replacementCalls = createFetch(undefined, replacementEvents)
|
||||
const replacement = { api: createApi(replacementCalls.fetch) }
|
||||
const { app, events, client, seen } = await mount(async (attempt) => {
|
||||
attempts.push(attempt)
|
||||
const { app, events, client, seen } = await mount(async () => {
|
||||
attempts.push(attempts.length + 1)
|
||||
return replacement
|
||||
})
|
||||
|
||||
|
|
@ -246,4 +247,106 @@ describe("useEvent", () => {
|
|||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("backs off when a resolved event stream keeps failing", async () => {
|
||||
let calls = 0
|
||||
const encoder = new TextEncoder()
|
||||
const replacementCalls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/event") return undefined
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode('data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n'),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
})
|
||||
const replacement = {
|
||||
api: createApi(replacementCalls.fetch),
|
||||
}
|
||||
const { app, events, client } = await mount(async () => {
|
||||
calls += 1
|
||||
return replacement
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
events.disconnect()
|
||||
await Promise.race([
|
||||
wait(() => calls === 2),
|
||||
Bun.sleep(500).then(() => {
|
||||
throw new Error("resolved event stream did not retry immediately")
|
||||
}),
|
||||
])
|
||||
await Bun.sleep(200)
|
||||
expect(calls).toBe(2)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports service status while endpoint resolution is pending", async () => {
|
||||
const replacementEvents = createEventStream()
|
||||
const replacement = { api: createApi(createFetch(undefined, replacementEvents).fetch) }
|
||||
let report!: (status: Service.Status) => void
|
||||
let resolve!: (value: typeof replacement) => void
|
||||
const endpoint = new Promise<typeof replacement>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const { app, events, client } = await mount(async (onStatus) => {
|
||||
report = onStatus
|
||||
onStatus({ type: "starting", version: "2.0.0" })
|
||||
return endpoint
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
events.disconnect()
|
||||
await wait(
|
||||
() => client.connection.status() === "reconnecting" && client.connection.service()?.type === "starting",
|
||||
)
|
||||
expect(client.connection.service()).toEqual({ type: "starting", version: "2.0.0" })
|
||||
|
||||
report({ type: "failed", message: "Could not open the database.", action: "Check the service logs." })
|
||||
await wait(() => client.connection.service()?.type === "failed")
|
||||
expect(client.connection.service()).toEqual({
|
||||
type: "failed",
|
||||
message: "Could not open the database.",
|
||||
action: "Check the service logs.",
|
||||
})
|
||||
|
||||
resolve(replacement)
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
expect(client.connection.service()).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels pending endpoint resolution on cleanup", async () => {
|
||||
let aborted = false
|
||||
const { app, events, client } = await mount(
|
||||
(_onStatus, signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
events.disconnect()
|
||||
await wait(() => client.connection.status() === "reconnecting")
|
||||
app.renderer.destroy()
|
||||
await wait(() => aborted)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue