Files
opencode/packages/cli/test/mini-host.test.ts
Simon Klee 925c2423de mini: add reconnect, forms, and shared targets (#37811)
Centralize session target resolution for mini and noninteractive
run paths. Recover from transport drops, replace questions with
forms, and keep tool/catalog state location-scoped with live
progress and theme discovery.
2026-07-19 22:45:10 +02:00

196 lines
5.9 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import path from "node:path"
import { Readable } from "node:stream"
import { pathToFileURL } from "node:url"
import {
createMiniHost,
INTERACTIVE_INPUT_ERROR,
resolveInteractiveStdin,
type InteractiveStdin,
usingInteractiveStdin,
} from "../src/mini-host"
const temporary: string[] = []
const model = { providerID: "openai", modelID: "gpt-5" }
function stream(isTTY: boolean) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
}
async function root() {
const directory = await mkdtemp(path.join(import.meta.dir, ".mini-host-"))
temporary.push(directory)
return directory
}
function host(terminal: InteractiveStdin, directory: string) {
return createMiniHost({
terminal,
directory,
paths: { home: directory, state: directory, log: directory },
})
}
afterEach(async () => {
await Promise.all(temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
describe("Mini CLI host", () => {
test("reuses tty stdin without taking ownership", () => {
const stdin = stream(true)
const seen: string[] = []
const terminal = resolveInteractiveStdin(
stdin,
(target) => {
seen.push(target)
return stream(true)
},
"linux",
)
expect(terminal.stdin).toBe(stdin)
terminal.cleanup()
expect(stdin.destroyed).toBe(false)
expect(seen).toEqual([])
})
test("opens and cleans the controlling terminal exactly once for piped stdin", () => {
const tty = stream(true)
const seen: string[] = []
let destroys = 0
const destroy = tty.destroy.bind(tty)
tty.destroy = ((...args: Parameters<typeof tty.destroy>) => {
destroys++
return destroy(...args)
}) as typeof tty.destroy
const terminal = resolveInteractiveStdin(
stream(false),
(target) => {
seen.push(target)
return tty
},
"linux",
)
terminal.cleanup()
terminal.cleanup()
expect(seen).toEqual(["/dev/tty"])
expect(destroys).toBe(1)
})
test("uses the platform terminal and reports acquisition failures", () => {
const seen: string[] = []
resolveInteractiveStdin(
stream(false),
(target) => {
seen.push(target)
return stream(true)
},
"win32",
).cleanup()
expect(seen).toEqual(["CONIN$"])
expect(() =>
resolveInteractiveStdin(
stream(false),
() => {
throw new Error("open failed")
},
"linux",
),
).toThrow(INTERACTIVE_INPUT_ERROR)
})
test("cleans the controlling terminal when hosted frontend startup fails", async () => {
let cleaned = 0
const order: string[] = []
const terminal = {
stdin: stream(true),
cleanup() {
order.push("cleanup")
cleaned++
},
}
await expect(
usingInteractiveStdin(
async () => {
order.push("run")
throw new Error("frontend failed")
},
() => {
order.push("terminal")
return terminal
},
),
).rejects.toThrow("frontend failed")
expect(cleaned).toBe(1)
expect(order).toEqual(["terminal", "run", "cleanup"])
})
test("subscribes and unsubscribes process signals through host capabilities", async () => {
const input = host({ stdin: stream(true), cleanup() {} }, await root())
const sigint = process.listenerCount("SIGINT")
const sigusr2 = process.listenerCount("SIGUSR2")
const offInt = input.signals.sigint.subscribe(() => {})
const offTheme = input.signals.sigusr2.subscribe(() => {})
expect(process.listenerCount("SIGINT")).toBe(sigint + 1)
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2 + 1)
offInt()
offInt()
offTheme()
offTheme()
expect(process.listenerCount("SIGINT")).toBe(sigint)
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
})
test("passes frontend host capabilities", async () => {
const directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory)
expect(input.paths).toEqual({ home: directory })
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory, "attachment.txt")
await Bun.write(file, "attachment contents")
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
expect(typeof input.startup.showTiming).toBe("boolean")
expect(typeof input.startup.now()).toBe("number")
})
test("merges, clears, and repairs persisted model variants", async () => {
const directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory)
const file = path.join(directory, "model.json")
await Bun.write(
file,
JSON.stringify({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", invalid: 42 },
}),
)
await input.preferences.saveVariant(model, "high")
expect(await input.preferences.resolveVariant(model)).toBe("high")
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", "openai/gpt-5": "high" },
})
await input.preferences.saveVariant(model, undefined)
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low" },
})
await Bun.write(file, JSON.stringify({ variant: { "openai/gpt-5": "default" } }))
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
await Bun.write(file, "{")
await input.preferences.saveVariant(model, "high")
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
})
})