question.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import { afterEach, test, expect } from "bun:test"
  2. import { Question } from "../../src/question"
  3. import { Instance } from "../../src/project/instance"
  4. import { QuestionID } from "../../src/question/schema"
  5. import { tmpdir } from "../fixture/fixture"
  6. import { SessionID } from "../../src/session/schema"
  7. import { AppRuntime } from "../../src/effect/app-runtime"
  8. const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info>; tool?: Question.Tool }) =>
  9. AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
  10. const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
  11. const reply = (input: { requestID: QuestionID; answers: ReadonlyArray<Question.Answer> }) =>
  12. AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
  13. const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
  14. afterEach(async () => {
  15. await Instance.disposeAll()
  16. })
  17. /** Reject all pending questions so dangling Deferred fibers don't hang the test. */
  18. async function rejectAll() {
  19. const pending = await list()
  20. for (const req of pending) {
  21. await reject(req.id)
  22. }
  23. }
  24. test("ask - returns pending promise", async () => {
  25. await using tmp = await tmpdir({ git: true })
  26. await Instance.provide({
  27. directory: tmp.path,
  28. fn: async () => {
  29. const promise = ask({
  30. sessionID: SessionID.make("ses_test"),
  31. questions: [
  32. {
  33. question: "What would you like to do?",
  34. header: "Action",
  35. options: [
  36. { label: "Option 1", description: "First option" },
  37. { label: "Option 2", description: "Second option" },
  38. ],
  39. },
  40. ],
  41. })
  42. expect(promise).toBeInstanceOf(Promise)
  43. await rejectAll()
  44. await promise.catch(() => {})
  45. },
  46. })
  47. })
  48. test("ask - adds to pending list", async () => {
  49. await using tmp = await tmpdir({ git: true })
  50. await Instance.provide({
  51. directory: tmp.path,
  52. fn: async () => {
  53. const questions = [
  54. {
  55. question: "What would you like to do?",
  56. header: "Action",
  57. options: [
  58. { label: "Option 1", description: "First option" },
  59. { label: "Option 2", description: "Second option" },
  60. ],
  61. },
  62. ]
  63. const promise = ask({
  64. sessionID: SessionID.make("ses_test"),
  65. questions,
  66. })
  67. const pending = await list()
  68. expect(pending.length).toBe(1)
  69. expect(pending[0].questions).toEqual(questions)
  70. await rejectAll()
  71. await promise.catch(() => {})
  72. },
  73. })
  74. })
  75. // reply tests
  76. test("reply - resolves the pending ask with answers", async () => {
  77. await using tmp = await tmpdir({ git: true })
  78. await Instance.provide({
  79. directory: tmp.path,
  80. fn: async () => {
  81. const questions = [
  82. {
  83. question: "What would you like to do?",
  84. header: "Action",
  85. options: [
  86. { label: "Option 1", description: "First option" },
  87. { label: "Option 2", description: "Second option" },
  88. ],
  89. },
  90. ]
  91. const promise = ask({
  92. sessionID: SessionID.make("ses_test"),
  93. questions,
  94. })
  95. const pending = await list()
  96. const requestID = pending[0].id
  97. await reply({
  98. requestID,
  99. answers: [["Option 1"]],
  100. })
  101. const answers = await promise
  102. expect(answers).toEqual([["Option 1"]])
  103. },
  104. })
  105. })
  106. test("reply - removes from pending list", async () => {
  107. await using tmp = await tmpdir({ git: true })
  108. await Instance.provide({
  109. directory: tmp.path,
  110. fn: async () => {
  111. const promise = ask({
  112. sessionID: SessionID.make("ses_test"),
  113. questions: [
  114. {
  115. question: "What would you like to do?",
  116. header: "Action",
  117. options: [
  118. { label: "Option 1", description: "First option" },
  119. { label: "Option 2", description: "Second option" },
  120. ],
  121. },
  122. ],
  123. })
  124. const pending = await list()
  125. expect(pending.length).toBe(1)
  126. await reply({
  127. requestID: pending[0].id,
  128. answers: [["Option 1"]],
  129. })
  130. await promise
  131. const after = await list()
  132. expect(after.length).toBe(0)
  133. },
  134. })
  135. })
  136. test("reply - does nothing for unknown requestID", async () => {
  137. await using tmp = await tmpdir({ git: true })
  138. await Instance.provide({
  139. directory: tmp.path,
  140. fn: async () => {
  141. await reply({
  142. requestID: QuestionID.make("que_unknown"),
  143. answers: [["Option 1"]],
  144. })
  145. // Should not throw
  146. },
  147. })
  148. })
  149. // reject tests
  150. test("reject - throws RejectedError", async () => {
  151. await using tmp = await tmpdir({ git: true })
  152. await Instance.provide({
  153. directory: tmp.path,
  154. fn: async () => {
  155. const promise = ask({
  156. sessionID: SessionID.make("ses_test"),
  157. questions: [
  158. {
  159. question: "What would you like to do?",
  160. header: "Action",
  161. options: [
  162. { label: "Option 1", description: "First option" },
  163. { label: "Option 2", description: "Second option" },
  164. ],
  165. },
  166. ],
  167. })
  168. const pending = await list()
  169. await reject(pending[0].id)
  170. await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
  171. },
  172. })
  173. })
  174. test("reject - removes from pending list", async () => {
  175. await using tmp = await tmpdir({ git: true })
  176. await Instance.provide({
  177. directory: tmp.path,
  178. fn: async () => {
  179. const promise = ask({
  180. sessionID: SessionID.make("ses_test"),
  181. questions: [
  182. {
  183. question: "What would you like to do?",
  184. header: "Action",
  185. options: [
  186. { label: "Option 1", description: "First option" },
  187. { label: "Option 2", description: "Second option" },
  188. ],
  189. },
  190. ],
  191. })
  192. const pending = await list()
  193. expect(pending.length).toBe(1)
  194. await reject(pending[0].id)
  195. promise.catch(() => {}) // Ignore rejection
  196. const after = await list()
  197. expect(after.length).toBe(0)
  198. },
  199. })
  200. })
  201. test("reject - does nothing for unknown requestID", async () => {
  202. await using tmp = await tmpdir({ git: true })
  203. await Instance.provide({
  204. directory: tmp.path,
  205. fn: async () => {
  206. await reject(QuestionID.make("que_unknown"))
  207. // Should not throw
  208. },
  209. })
  210. })
  211. // multiple questions tests
  212. test("ask - handles multiple questions", async () => {
  213. await using tmp = await tmpdir({ git: true })
  214. await Instance.provide({
  215. directory: tmp.path,
  216. fn: async () => {
  217. const questions = [
  218. {
  219. question: "What would you like to do?",
  220. header: "Action",
  221. options: [
  222. { label: "Build", description: "Build the project" },
  223. { label: "Test", description: "Run tests" },
  224. ],
  225. },
  226. {
  227. question: "Which environment?",
  228. header: "Env",
  229. options: [
  230. { label: "Dev", description: "Development" },
  231. { label: "Prod", description: "Production" },
  232. ],
  233. },
  234. ]
  235. const promise = ask({
  236. sessionID: SessionID.make("ses_test"),
  237. questions,
  238. })
  239. const pending = await list()
  240. await reply({
  241. requestID: pending[0].id,
  242. answers: [["Build"], ["Dev"]],
  243. })
  244. const answers = await promise
  245. expect(answers).toEqual([["Build"], ["Dev"]])
  246. },
  247. })
  248. })
  249. // list tests
  250. test("list - returns all pending requests", async () => {
  251. await using tmp = await tmpdir({ git: true })
  252. await Instance.provide({
  253. directory: tmp.path,
  254. fn: async () => {
  255. const p1 = ask({
  256. sessionID: SessionID.make("ses_test1"),
  257. questions: [
  258. {
  259. question: "Question 1?",
  260. header: "Q1",
  261. options: [{ label: "A", description: "A" }],
  262. },
  263. ],
  264. })
  265. const p2 = ask({
  266. sessionID: SessionID.make("ses_test2"),
  267. questions: [
  268. {
  269. question: "Question 2?",
  270. header: "Q2",
  271. options: [{ label: "B", description: "B" }],
  272. },
  273. ],
  274. })
  275. const pending = await list()
  276. expect(pending.length).toBe(2)
  277. await rejectAll()
  278. p1.catch(() => {})
  279. p2.catch(() => {})
  280. },
  281. })
  282. })
  283. test("list - returns empty when no pending", async () => {
  284. await using tmp = await tmpdir({ git: true })
  285. await Instance.provide({
  286. directory: tmp.path,
  287. fn: async () => {
  288. const pending = await list()
  289. expect(pending.length).toBe(0)
  290. },
  291. })
  292. })
  293. test("questions stay isolated by directory", async () => {
  294. await using one = await tmpdir({ git: true })
  295. await using two = await tmpdir({ git: true })
  296. const p1 = Instance.provide({
  297. directory: one.path,
  298. fn: () =>
  299. ask({
  300. sessionID: SessionID.make("ses_one"),
  301. questions: [
  302. {
  303. question: "Question 1?",
  304. header: "Q1",
  305. options: [{ label: "A", description: "A" }],
  306. },
  307. ],
  308. }),
  309. })
  310. const p2 = Instance.provide({
  311. directory: two.path,
  312. fn: () =>
  313. ask({
  314. sessionID: SessionID.make("ses_two"),
  315. questions: [
  316. {
  317. question: "Question 2?",
  318. header: "Q2",
  319. options: [{ label: "B", description: "B" }],
  320. },
  321. ],
  322. }),
  323. })
  324. const onePending = await Instance.provide({
  325. directory: one.path,
  326. fn: () => list(),
  327. })
  328. const twoPending = await Instance.provide({
  329. directory: two.path,
  330. fn: () => list(),
  331. })
  332. expect(onePending.length).toBe(1)
  333. expect(twoPending.length).toBe(1)
  334. expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
  335. expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
  336. await Instance.provide({
  337. directory: one.path,
  338. fn: () => reject(onePending[0].id),
  339. })
  340. await Instance.provide({
  341. directory: two.path,
  342. fn: () => reject(twoPending[0].id),
  343. })
  344. await p1.catch(() => {})
  345. await p2.catch(() => {})
  346. })
  347. test("pending question rejects on instance dispose", async () => {
  348. await using tmp = await tmpdir({ git: true })
  349. const pending = Instance.provide({
  350. directory: tmp.path,
  351. fn: () => {
  352. return ask({
  353. sessionID: SessionID.make("ses_dispose"),
  354. questions: [
  355. {
  356. question: "Dispose me?",
  357. header: "Dispose",
  358. options: [{ label: "Yes", description: "Yes" }],
  359. },
  360. ],
  361. })
  362. },
  363. })
  364. const result = pending.then(
  365. () => "resolved" as const,
  366. (err) => err,
  367. )
  368. await Instance.provide({
  369. directory: tmp.path,
  370. fn: async () => {
  371. const items = await list()
  372. expect(items).toHaveLength(1)
  373. await Instance.dispose()
  374. },
  375. })
  376. expect(await result).toBeInstanceOf(Question.RejectedError)
  377. })
  378. test("pending question rejects on instance reload", async () => {
  379. await using tmp = await tmpdir({ git: true })
  380. const pending = Instance.provide({
  381. directory: tmp.path,
  382. fn: () => {
  383. return ask({
  384. sessionID: SessionID.make("ses_reload"),
  385. questions: [
  386. {
  387. question: "Reload me?",
  388. header: "Reload",
  389. options: [{ label: "Yes", description: "Yes" }],
  390. },
  391. ],
  392. })
  393. },
  394. })
  395. const result = pending.then(
  396. () => "resolved" as const,
  397. (err) => err,
  398. )
  399. await Instance.provide({
  400. directory: tmp.path,
  401. fn: async () => {
  402. const items = await list()
  403. expect(items).toHaveLength(1)
  404. await Instance.reload({ directory: tmp.path })
  405. },
  406. })
  407. expect(await result).toBeInstanceOf(Question.RejectedError)
  408. })