mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"
|
|
import { createRoot } from "solid-js"
|
|
import type { FormInfo } from "@opencode/client/promise"
|
|
import { toaster } from "@opencode/ui/toast"
|
|
import type { ServerScope } from "@/runtime/server/scope"
|
|
import { createElicitationDock, elicitationCache } from "@/session/requests/elicitation-controller"
|
|
import { ELICITATION_REQUEST_TIMEOUT_MS } from "@/session/requests/elicitation-machine"
|
|
|
|
// SAFETY: a scope is any string without a null byte.
|
|
const scope = "test-elicitation-dock" as ServerScope
|
|
|
|
const request: FormInfo = {
|
|
id: "frm_elicitation",
|
|
sessionID: "ses_elicitation",
|
|
title: "Deploy",
|
|
metadata: { kind: "mcp-elicitation" },
|
|
fields: [{ key: "env", type: "string", title: "Environment" }],
|
|
}
|
|
|
|
const cacheKey = `${scope}\u0000frm_elicitation`
|
|
|
|
// A data layer whose reply and dismissal never settle until the test settles them.
|
|
function setup() {
|
|
const listeners = new Map<string, (event: { data: { id: string } }) => void>()
|
|
const toasts: string[] = []
|
|
const pending: { reject: (error: Error) => void }[] = []
|
|
|
|
const send = () => new Promise<void>((_, reject) => void pending.push({ reject }))
|
|
|
|
const sdk = {
|
|
scope,
|
|
event: {
|
|
// SAFETY: the driver subscribes by type and reads only the form id of these events.
|
|
on: ((type: string, handler: (event: { data: { id: string } }) => void) => {
|
|
listeners.set(type, handler)
|
|
|
|
return () => listeners.delete(type)
|
|
}) as never,
|
|
},
|
|
}
|
|
|
|
const owned = createRoot((dispose) => ({
|
|
dock: createElicitationDock({
|
|
sdk,
|
|
forms: { reply: send, cancel: send },
|
|
// The failure toast's title is the only text the driver translates, so this records each failure toast.
|
|
language: { t: (key) => (toasts.push(key), key) },
|
|
request: () => request,
|
|
onSubmit: () => undefined,
|
|
}),
|
|
dispose,
|
|
}))
|
|
|
|
return { ...owned, listeners, toasts, pending }
|
|
}
|
|
|
|
// Lets a settled request's handlers run.
|
|
const settled = () =>
|
|
Promise.resolve()
|
|
.then(() => undefined)
|
|
.then(() => undefined)
|
|
.then(() => undefined)
|
|
|
|
describe("createElicitationDock", () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers()
|
|
toaster.dismiss()
|
|
elicitationCache.delete(cacheKey)
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.clearAllTimers()
|
|
jest.useRealTimers()
|
|
toaster.dismiss()
|
|
})
|
|
|
|
test("a reply the server settles after the dock unmounted ends without a failure or a kept draft", async () => {
|
|
const owner = setup()
|
|
owner.dock.setText("env", "prod")
|
|
owner.dock.reply()
|
|
owner.dispose()
|
|
|
|
expect([...owner.listeners.keys()]).toEqual(["form.replied", "form.cancelled"])
|
|
owner.listeners.get("form.replied")?.({ data: { id: "frm_elicitation" } })
|
|
jest.advanceTimersByTime(ELICITATION_REQUEST_TIMEOUT_MS)
|
|
owner.pending[0]?.reject(new Error("late"))
|
|
await settled()
|
|
|
|
expect(owner.toasts).toEqual([])
|
|
expect(elicitationCache.get(cacheKey)).toBeUndefined()
|
|
expect(owner.listeners.size).toBe(0)
|
|
})
|
|
|
|
test("a dismissal that hangs after the dock unmounted reports one failure and keeps the draft", async () => {
|
|
const owner = setup()
|
|
owner.dock.setText("env", "prod")
|
|
owner.dock.dismiss()
|
|
owner.dispose()
|
|
|
|
jest.advanceTimersByTime(ELICITATION_REQUEST_TIMEOUT_MS)
|
|
// The data layer's own request ends later; the attempt already timed out.
|
|
owner.pending[0]?.reject(new Error("late"))
|
|
await settled()
|
|
|
|
expect(owner.pending).toHaveLength(1)
|
|
expect(owner.toasts).toEqual(["common.requestFailed"])
|
|
expect(elicitationCache.get(cacheKey)?.text).toEqual({ env: "prod" })
|
|
expect(owner.listeners.size).toBe(0)
|
|
})
|
|
})
|