question.test.ts 11 KB

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