mini-host.test.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { mkdtemp, rm } from "node:fs/promises"
  3. import path from "node:path"
  4. import { Readable } from "node:stream"
  5. import { pathToFileURL } from "node:url"
  6. import {
  7. createMiniHost,
  8. INTERACTIVE_INPUT_ERROR,
  9. resolveInteractiveStdin,
  10. type InteractiveStdin,
  11. usingInteractiveStdin,
  12. } from "../src/mini-host"
  13. const temporary: string[] = []
  14. const model = { providerID: "openai", modelID: "gpt-5" }
  15. function stream(isTTY: boolean) {
  16. return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
  17. }
  18. async function root() {
  19. const directory = await mkdtemp(path.join(import.meta.dir, ".mini-host-"))
  20. temporary.push(directory)
  21. return directory
  22. }
  23. function host(terminal: InteractiveStdin, directory: string) {
  24. return createMiniHost({
  25. terminal,
  26. directory,
  27. paths: { home: directory, state: directory, log: directory },
  28. })
  29. }
  30. afterEach(async () => {
  31. await Promise.all(temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
  32. })
  33. describe("Mini CLI host", () => {
  34. test("reuses tty stdin without taking ownership", () => {
  35. const stdin = stream(true)
  36. const seen: string[] = []
  37. const terminal = resolveInteractiveStdin(
  38. stdin,
  39. (target) => {
  40. seen.push(target)
  41. return stream(true)
  42. },
  43. "linux",
  44. )
  45. expect(terminal.stdin).toBe(stdin)
  46. terminal.cleanup()
  47. expect(stdin.destroyed).toBe(false)
  48. expect(seen).toEqual([])
  49. })
  50. test("opens and cleans the controlling terminal exactly once for piped stdin", () => {
  51. const tty = stream(true)
  52. const seen: string[] = []
  53. let destroys = 0
  54. const destroy = tty.destroy.bind(tty)
  55. tty.destroy = ((...args: Parameters<typeof tty.destroy>) => {
  56. destroys++
  57. return destroy(...args)
  58. }) as typeof tty.destroy
  59. const terminal = resolveInteractiveStdin(
  60. stream(false),
  61. (target) => {
  62. seen.push(target)
  63. return tty
  64. },
  65. "linux",
  66. )
  67. terminal.cleanup()
  68. terminal.cleanup()
  69. expect(seen).toEqual(["/dev/tty"])
  70. expect(destroys).toBe(1)
  71. })
  72. test("uses the platform terminal and reports acquisition failures", () => {
  73. const seen: string[] = []
  74. resolveInteractiveStdin(
  75. stream(false),
  76. (target) => {
  77. seen.push(target)
  78. return stream(true)
  79. },
  80. "win32",
  81. ).cleanup()
  82. expect(seen).toEqual(["CONIN$"])
  83. expect(() =>
  84. resolveInteractiveStdin(
  85. stream(false),
  86. () => {
  87. throw new Error("open failed")
  88. },
  89. "linux",
  90. ),
  91. ).toThrow(INTERACTIVE_INPUT_ERROR)
  92. })
  93. test("cleans the controlling terminal when hosted frontend startup fails", async () => {
  94. let cleaned = 0
  95. const order: string[] = []
  96. const terminal = {
  97. stdin: stream(true),
  98. cleanup() {
  99. order.push("cleanup")
  100. cleaned++
  101. },
  102. }
  103. await expect(
  104. usingInteractiveStdin(
  105. async () => {
  106. order.push("run")
  107. throw new Error("frontend failed")
  108. },
  109. () => {
  110. order.push("terminal")
  111. return terminal
  112. },
  113. ),
  114. ).rejects.toThrow("frontend failed")
  115. expect(cleaned).toBe(1)
  116. expect(order).toEqual(["terminal", "run", "cleanup"])
  117. })
  118. test("subscribes and unsubscribes process signals through host capabilities", async () => {
  119. const input = host({ stdin: stream(true), cleanup() {} }, await root())
  120. const sigint = process.listenerCount("SIGINT")
  121. const sigusr2 = process.listenerCount("SIGUSR2")
  122. const offInt = input.signals.sigint.subscribe(() => {})
  123. const offTheme = input.signals.sigusr2.subscribe(() => {})
  124. expect(process.listenerCount("SIGINT")).toBe(sigint + 1)
  125. expect(process.listenerCount("SIGUSR2")).toBe(sigusr2 + 1)
  126. offInt()
  127. offInt()
  128. offTheme()
  129. offTheme()
  130. expect(process.listenerCount("SIGINT")).toBe(sigint)
  131. expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
  132. })
  133. test("passes frontend host capabilities", async () => {
  134. const directory = await root()
  135. const input = host({ stdin: stream(true), cleanup() {} }, directory)
  136. expect(input.paths).toEqual({ home: directory })
  137. expect(input.platform).toBe(process.platform)
  138. expect(typeof input.files.readText).toBe("function")
  139. const file = path.join(directory, "attachment.txt")
  140. await Bun.write(file, "attachment contents")
  141. expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
  142. expect(typeof input.startup.showTiming).toBe("boolean")
  143. expect(typeof input.startup.now()).toBe("number")
  144. })
  145. test("delegates model variant preferences", async () => {
  146. const directory = await root()
  147. const input = host({ stdin: stream(true), cleanup() {} }, directory)
  148. const file = path.join(directory, "model.json")
  149. await input.preferences.saveVariant(model, "high")
  150. expect(await input.preferences.resolveVariant(model)).toBe("high")
  151. await input.preferences.saveVariant(model, "default")
  152. expect(await input.preferences.resolveVariant(model)).toBeUndefined()
  153. await Bun.write(file, "{")
  154. await input.preferences.saveVariant(model, "high")
  155. expect(await input.preferences.resolveVariant(model)).toBe("high")
  156. })
  157. })