kennel 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +23 -13
  2. package/dist/cli/dispatch.js +16 -3
  3. package/dist/cli/src/cli/dispatch.js +16 -3
  4. package/dist/cli/src/cli/usage.js +1 -1
  5. package/dist/cli/src/core/command.js +1 -0
  6. package/dist/cli/src/core/init.js +5 -5
  7. package/dist/cli/src/plugins/external.d.ts +29 -3
  8. package/dist/cli/src/plugins/external.js +78 -24
  9. package/dist/cli/src/site/server-plugins.d.ts +2 -7
  10. package/dist/cli/src/site/viewer.js +1 -0
  11. package/dist/cli/usage.js +1 -1
  12. package/dist/core/command.js +1 -0
  13. package/dist/core/init.js +5 -5
  14. package/dist/core/runtime.js +357 -3207
  15. package/dist/package/preview.js +1232 -5113
  16. package/dist/plugins/external.d.ts +29 -3
  17. package/dist/plugins/external.js +78 -24
  18. package/dist/site/server-plugins.d.ts +2 -7
  19. package/dist/site/server-plugins.js +552 -10447
  20. package/dist/site/viewer.js +1 -0
  21. package/kernel/apps/web/package.json +1 -5
  22. package/kernel/apps/web/scripts/dev.mjs +53 -0
  23. package/kernel/apps/web/src/app.css +0 -3
  24. package/kernel/apps/web/src/main.tsx +0 -1
  25. package/kernel/apps/web/src/plugins.ts +0 -30
  26. package/kernel/apps/web/tsconfig.json +1 -2
  27. package/kernel/apps/web/vite.config.ts +31 -13
  28. package/kernel/packages/api/package.json +0 -4
  29. package/kernel/packages/api/src/server/folder-metadata-plugin.ts +13 -38
  30. package/kernel/packages/api/src/server/index.ts +0 -1
  31. package/kernel/packages/api/src/server/plugins.ts +49 -16
  32. package/kernel/packages/api/test/core.test.ts +13 -25
  33. package/kernel/packages/markdown/README.md +1 -1
  34. package/package.json +1 -1
  35. package/web/assets/{abnfDiagram-VRR7QNED-Bgfb1nEB.js → abnfDiagram-VRR7QNED-xd_g1uK3.js} +1 -1
  36. package/web/assets/app-CX20nNIa.js +2 -0
  37. package/web/assets/{app-options-D-RJiQ-X.js → app-options-CkPJE5ep.js} +1 -1
  38. package/web/assets/{app-view-header-BLvLq02T.js → app-view-header-CNk5KnA-.js} +1 -1
  39. package/web/assets/{arc-Dk_KI36B.js → arc-B0u5yBJA.js} +1 -1
  40. package/web/assets/{architectureDiagram-ZJ3FMSHR-NR_KhRCf.js → architectureDiagram-ZJ3FMSHR-DfxOAo8P.js} +1 -1
  41. package/web/assets/{ask-tray-TzeTcO6-.js → ask-tray-SscJecDY.js} +1 -1
  42. package/web/assets/{blockDiagram-677ZJIJ3-CkLkwx9C.js → blockDiagram-677ZJIJ3-Ci3Usxh6.js} +1 -1
  43. package/web/assets/{c4Diagram-LMCZKHZV-CFCUH8e_.js → c4Diagram-LMCZKHZV-BqgwIVgh.js} +1 -1
  44. package/web/assets/channel-X7WHyxxT.js +1 -0
  45. package/web/assets/{chunk-2Q5K7J3B-B_BPLWsW.js → chunk-2Q5K7J3B-CfX69l2U.js} +1 -1
  46. package/web/assets/{chunk-32BRIVSS-B1VB1hOy.js → chunk-32BRIVSS-CuFlzZkl.js} +1 -1
  47. package/web/assets/{chunk-5VM5RSS4-KFcYr8Co.js → chunk-5VM5RSS4-DidCMlJa.js} +1 -1
  48. package/web/assets/{chunk-EX3LRPZG-CpBX3kuK.js → chunk-EX3LRPZG-B5CDm1dS.js} +1 -1
  49. package/web/assets/{chunk-JWPE2WC7-BPSl40wG.js → chunk-JWPE2WC7-D5fGV_sH.js} +1 -1
  50. package/web/assets/{chunk-MOJQB5TN-D2RcYmoU.js → chunk-MOJQB5TN-ayhH5Q-H.js} +1 -1
  51. package/web/assets/{chunk-RYQCIY6F-m7Gyn-t3.js → chunk-RYQCIY6F-C4BfOOZn.js} +1 -1
  52. package/web/assets/{chunk-V7JOEXUC-TDvdopxS.js → chunk-V7JOEXUC-DpUEIM9V.js} +1 -1
  53. package/web/assets/{chunk-VR4S4FIN-DZTsG51a.js → chunk-VR4S4FIN-DoQuFFRh.js} +1 -1
  54. package/web/assets/{chunk-XXDRQBXY-Dp0--RE2.js → chunk-XXDRQBXY-CI9eiLwn.js} +1 -1
  55. package/web/assets/classDiagram-OUVF2IWQ-BjsLQ3dz.js +1 -0
  56. package/web/assets/classDiagram-v2-EOCWNBFH-BjsLQ3dz.js +1 -0
  57. package/web/assets/client-7OWw5kW2.js +6 -0
  58. package/web/assets/client-DYj_td4Q.js +17 -0
  59. package/web/assets/{client-j2kQmxsO.js → client-Dk-NBvJR.js} +4 -4
  60. package/web/assets/command-zkzH4hVz.js +38 -0
  61. package/web/assets/{copy-context-C0Uqsp-3.js → copy-context-D5oiWrYC.js} +1 -1
  62. package/web/assets/{cose-bilkent-JH36ORCC-CPu3PI8h.js → cose-bilkent-JH36ORCC-BHRk1mO3.js} +1 -1
  63. package/web/assets/{cynefin-VYW2F7L2-DWxftYAm.js → cynefin-VYW2F7L2-C1B09TRE.js} +1 -1
  64. package/web/assets/{cynefinDiagram-TSTJHNR4-DsmN2V-p.js → cynefinDiagram-TSTJHNR4-B27qXQbv.js} +1 -1
  65. package/web/assets/{dagre-VKFMJZFB-COEbApyY.js → dagre-VKFMJZFB-B1qEi32g.js} +1 -1
  66. package/web/assets/{diagram-FQU43EPY-0O9WGWKB.js → diagram-FQU43EPY-BMkkgADD.js} +1 -1
  67. package/web/assets/{diagram-G47NLZAW-CutEdMVh.js → diagram-G47NLZAW-Cb8_g1A5.js} +1 -1
  68. package/web/assets/{diagram-NH7WQ7WH-C9KZc3B2.js → diagram-NH7WQ7WH-B51wuc5m.js} +1 -1
  69. package/web/assets/{diagram-OA4YK3LP-D8kkPXbY.js → diagram-OA4YK3LP-xnsecbbI.js} +1 -1
  70. package/web/assets/{diagram-WEI45ONY-_YqiTaGP.js → diagram-WEI45ONY-DSZdyaAV.js} +1 -1
  71. package/web/assets/{document-Dgt5eZFj.js → document-Dc2dftTW.js} +3 -3
  72. package/web/assets/{ebnfDiagram-CCIWWBDH-JO-zQh6g.js → ebnfDiagram-CCIWWBDH-aTHmcUGy.js} +1 -1
  73. package/web/assets/{entities-C3wz7FCx.js → entities-CCUtpskY.js} +1 -1
  74. package/web/assets/{erDiagram-Q63AITRT-9UjQwJ4P.js → erDiagram-Q63AITRT-CUUPBcvo.js} +1 -1
  75. package/web/assets/{file-viewer-DZJ8nYnl.js → file-viewer-DJ3rsmef.js} +1 -1
  76. package/web/assets/{flowDiagram-23GEKE2U-Ca08tnYM.js → flowDiagram-23GEKE2U-aTQXx2Mh.js} +1 -1
  77. package/web/assets/{folder-image-BfGgxo7i.js → folder-image-Bnv43tzP.js} +1 -1
  78. package/web/assets/{folder-landing-qUKBCnG-.js → folder-landing-DCnzYXXY.js} +1 -1
  79. package/web/assets/{folder-list-BfFjv7rR.js → folder-list-DvVkVjiR.js} +1 -1
  80. package/web/assets/{folder-metadata-DCw8xzHk.js → folder-metadata-C9hMMzIH.js} +1 -1
  81. package/web/assets/{folder-mobile-bar-Ubz4babf.js → folder-mobile-bar-VoTL1GS0.js} +1 -1
  82. package/web/assets/folder-plugin-view-Cvd804ZU.js +6 -0
  83. package/web/assets/{ganttDiagram-NO4QXBWP-KnHzX5W3.js → ganttDiagram-NO4QXBWP-DiwdUp7u.js} +1 -1
  84. package/web/assets/{gitGraphDiagram-IHSO6WYX-Cm8woNtK.js → gitGraphDiagram-IHSO6WYX-BL2gG_IS.js} +1 -1
  85. package/web/assets/{index-CNyy98F8.js → index-CUTtMSyJ.js} +2 -2
  86. package/web/assets/index-CegvVQ1C.js +1 -0
  87. package/web/assets/index-Dwhf8F_U.css +1 -0
  88. package/web/assets/{infoDiagram-FWYZ7A6U-yyqmUtIb.js → infoDiagram-FWYZ7A6U-B3n0zZpt.js} +1 -1
  89. package/web/assets/{ishikawaDiagram-FXEZZL3T-BfvR_Zy4.js → ishikawaDiagram-FXEZZL3T-D7YytbEv.js} +1 -1
  90. package/web/assets/{journeyDiagram-5HDEW3XC-BKuw06HM.js → journeyDiagram-5HDEW3XC-BYwqEl3C.js} +1 -1
  91. package/web/assets/{kanban-definition-HUTT4EX6-n0010rmQ.js → kanban-definition-HUTT4EX6-BQHNJgXZ.js} +1 -1
  92. package/web/assets/{linear-C0d0lT8O.js → linear-BPiQCLNk.js} +1 -1
  93. package/web/assets/{mermaid.core-BDVbShyO.js → mermaid.core-CD-KvBjV.js} +4 -4
  94. package/web/assets/{metadata-KV0dpp4G.js → metadata-COJ7fZul.js} +1 -1
  95. package/web/assets/{mindmap-definition-LN4V7U3C--_0ks3GF.js → mindmap-definition-LN4V7U3C-C1L_gyoj.js} +1 -1
  96. package/web/assets/{pegDiagram-2B236MQR-CBoNIpKR.js → pegDiagram-2B236MQR-BDAfItqS.js} +1 -1
  97. package/web/assets/{pieDiagram-ENE6RG2P-MA5tkYh5.js → pieDiagram-ENE6RG2P-CkZYH3Vz.js} +1 -1
  98. package/web/assets/{popover-DBD7FF-W.js → popover-0oA2lLUV.js} +2 -2
  99. package/web/assets/{project-search-dialog-DM1IkSZu.js → project-search-dialog-B2oqMT6h.js} +1 -1
  100. package/web/assets/{quadrantDiagram-ABIIQ3AL-CWT1ryqF.js → quadrantDiagram-ABIIQ3AL-Dy8Al3YZ.js} +1 -1
  101. package/web/assets/{railroadDiagram-RFXS5EU6-ZiLB5FGi.js → railroadDiagram-RFXS5EU6-Dd9_yo7B.js} +1 -1
  102. package/web/assets/{requirementDiagram-TGXJPOKE-CQq9uneq.js → requirementDiagram-TGXJPOKE-DPhIUsbs.js} +1 -1
  103. package/web/assets/{sankeyDiagram-HTMAVEWB-qDG-0JqI.js → sankeyDiagram-HTMAVEWB-B2QkYtub.js} +1 -1
  104. package/web/assets/{search-DNrQA5fF.js → search-C1J0-xiB.js} +1 -1
  105. package/web/assets/select-CtXC_PLC.js +30 -0
  106. package/web/assets/{selection-popup-DmtTeRs2.js → selection-popup-qhlUFzGq.js} +1 -1
  107. package/web/assets/{sequenceDiagram-DBY2YBRQ-CiApurLO.js → sequenceDiagram-DBY2YBRQ-Bx7fcZDx.js} +1 -1
  108. package/web/assets/{sizeCapture-X5ZJPWSS-DZSR5QXm.js → sizeCapture-X5ZJPWSS-CIXIhl_A.js} +1 -1
  109. package/web/assets/{stateDiagram-2N3HPSRC-CjJJR9_z.js → stateDiagram-2N3HPSRC-DCrdAflB.js} +1 -1
  110. package/web/assets/stateDiagram-v2-6OUMAXLB-0KCFLYeQ.js +1 -0
  111. package/web/assets/{swimlanes-5IMT3BWC-CKmo1sg6.js → swimlanes-5IMT3BWC-DA2vc7iZ.js} +2 -2
  112. package/web/assets/swimlanesDiagram-G3AALYLV-CAw3AV84.js +8 -0
  113. package/web/assets/{timeline-definition-FHXFAJF6-D-NMwS3y.js → timeline-definition-FHXFAJF6-lKfJiV9V.js} +1 -1
  114. package/web/assets/{vennDiagram-L72KCM5P-CfL6LBWw.js → vennDiagram-L72KCM5P-DO1q2KzG.js} +1 -1
  115. package/web/assets/{view-context-gyDl_p2V.js → view-context-DXKM3-l1.js} +4 -4
  116. package/web/assets/{viewer-capabilities-Bpp8Uvcg.js → viewer-capabilities-Cig_xM9u.js} +1 -1
  117. package/web/assets/{wardleyDiagram-EHGQE667-DgldMxaj.js → wardleyDiagram-EHGQE667-Dal9LkuZ.js} +1 -1
  118. package/web/assets/{xychartDiagram-FW5EYKEG-CIoAIFX8.js → xychartDiagram-FW5EYKEG-B3dPLPII.js} +1 -1
  119. package/web/index.html +2 -2
  120. package/kernel/packages/api/test/partial-loading.test.ts +0 -43
  121. package/kernel/plugins/calendar/SKILL.md +0 -173
  122. package/kernel/plugins/calendar/eslint.config.js +0 -10
  123. package/kernel/plugins/calendar/package.json +0 -73
  124. package/kernel/plugins/calendar/schema.json +0 -137
  125. package/kernel/plugins/calendar/src/calendar.tsx +0 -328
  126. package/kernel/plugins/calendar/src/client.tsx +0 -139
  127. package/kernel/plugins/calendar/src/context.ts +0 -48
  128. package/kernel/plugins/calendar/src/copy-button.tsx +0 -66
  129. package/kernel/plugins/calendar/src/event-dialog.tsx +0 -270
  130. package/kernel/plugins/calendar/src/index.ts +0 -4
  131. package/kernel/plugins/calendar/src/manifest.ts +0 -7
  132. package/kernel/plugins/calendar/src/parse.ts +0 -610
  133. package/kernel/plugins/calendar/src/server.ts +0 -160
  134. package/kernel/plugins/calendar/src/utils.ts +0 -80
  135. package/kernel/plugins/calendar/src/views.tsx +0 -418
  136. package/kernel/plugins/calendar/test/context.test.mjs +0 -60
  137. package/kernel/plugins/calendar/test/package.test.mjs +0 -43
  138. package/kernel/plugins/calendar/test/parse.test.mjs +0 -392
  139. package/kernel/plugins/calendar/test/server.test.mjs +0 -126
  140. package/kernel/plugins/calendar/tsconfig.json +0 -17
  141. package/kernel/plugins/mail/SKILL.md +0 -97
  142. package/kernel/plugins/mail/eslint.config.js +0 -10
  143. package/kernel/plugins/mail/package.json +0 -79
  144. package/kernel/plugins/mail/src/api.ts +0 -359
  145. package/kernel/plugins/mail/src/bun-ffi.d.ts +0 -14
  146. package/kernel/plugins/mail/src/client.tsx +0 -14
  147. package/kernel/plugins/mail/src/flock.ts +0 -137
  148. package/kernel/plugins/mail/src/folders.ts +0 -37
  149. package/kernel/plugins/mail/src/history.ts +0 -485
  150. package/kernel/plugins/mail/src/index.ts +0 -3
  151. package/kernel/plugins/mail/src/mail-card.tsx +0 -123
  152. package/kernel/plugins/mail/src/mail-conversation.tsx +0 -153
  153. package/kernel/plugins/mail/src/mail-detail-page.tsx +0 -467
  154. package/kernel/plugins/mail/src/mail-hooks.ts +0 -252
  155. package/kernel/plugins/mail/src/mail-page.tsx +0 -109
  156. package/kernel/plugins/mail/src/mail-request.ts +0 -78
  157. package/kernel/plugins/mail/src/mail-send-box.tsx +0 -175
  158. package/kernel/plugins/mail/src/mailos.ts +0 -723
  159. package/kernel/plugins/mail/src/manifest.ts +0 -7
  160. package/kernel/plugins/mail/src/node.ts +0 -171
  161. package/kernel/plugins/mail/src/parse.ts +0 -139
  162. package/kernel/plugins/mail/src/server.ts +0 -125
  163. package/kernel/plugins/mail/src/types.ts +0 -10
  164. package/kernel/plugins/mail/src/viewer.tsx +0 -147
  165. package/kernel/plugins/mail/test/flock.test.mjs +0 -156
  166. package/kernel/plugins/mail/test/folders.test.mjs +0 -138
  167. package/kernel/plugins/mail/test/history.test.mjs +0 -460
  168. package/kernel/plugins/mail/test/mailos.test.mjs +0 -752
  169. package/kernel/plugins/mail/test/node.test.mjs +0 -207
  170. package/kernel/plugins/mail/test/package.test.mjs +0 -54
  171. package/kernel/plugins/mail/test/parse.test.mjs +0 -32
  172. package/kernel/plugins/mail/test/server.test.mjs +0 -116
  173. package/kernel/plugins/mail/test/viewer.test.mjs +0 -192
  174. package/kernel/plugins/mail/tsconfig.json +0 -17
  175. package/kernel/plugins/projects/SKILL.md +0 -73
  176. package/kernel/plugins/projects/eslint.config.js +0 -10
  177. package/kernel/plugins/projects/package.json +0 -70
  178. package/kernel/plugins/projects/src/client.tsx +0 -17
  179. package/kernel/plugins/projects/src/data/model.ts +0 -129
  180. package/kernel/plugins/projects/src/index.ts +0 -17
  181. package/kernel/plugins/projects/src/manifest.ts +0 -7
  182. package/kernel/plugins/projects/src/project-context.ts +0 -41
  183. package/kernel/plugins/projects/src/server.ts +0 -276
  184. package/kernel/plugins/projects/src/ui/project-card.tsx +0 -61
  185. package/kernel/plugins/projects/src/ui/projects-page.tsx +0 -102
  186. package/kernel/plugins/projects/test/package.test.mjs +0 -51
  187. package/kernel/plugins/projects/tsconfig.json +0 -17
  188. package/kernel/plugins/tasks/SKILL.md +0 -341
  189. package/kernel/plugins/tasks/eslint.config.js +0 -10
  190. package/kernel/plugins/tasks/package.json +0 -74
  191. package/kernel/plugins/tasks/schema.json +0 -939
  192. package/kernel/plugins/tasks/src/api.ts +0 -212
  193. package/kernel/plugins/tasks/src/client.tsx +0 -23
  194. package/kernel/plugins/tasks/src/data/parse.ts +0 -544
  195. package/kernel/plugins/tasks/src/data/task-id.ts +0 -56
  196. package/kernel/plugins/tasks/src/data/todo-model.ts +0 -795
  197. package/kernel/plugins/tasks/src/index.ts +0 -34
  198. package/kernel/plugins/tasks/src/manifest.ts +0 -7
  199. package/kernel/plugins/tasks/src/pages/task-detail.tsx +0 -48
  200. package/kernel/plugins/tasks/src/pages/task-list.tsx +0 -88
  201. package/kernel/plugins/tasks/src/server.ts +0 -447
  202. package/kernel/plugins/tasks/src/styles.css +0 -9
  203. package/kernel/plugins/tasks/src/ui/compact-todo-list.tsx +0 -372
  204. package/kernel/plugins/tasks/src/ui/error-details.tsx +0 -78
  205. package/kernel/plugins/tasks/src/ui/task-page.tsx +0 -392
  206. package/kernel/plugins/tasks/src/ui/todo-agent-filter.tsx +0 -36
  207. package/kernel/plugins/tasks/src/ui/todo-filter-bar.tsx +0 -66
  208. package/kernel/plugins/tasks/src/ui/todo-item.tsx +0 -494
  209. package/kernel/plugins/tasks/src/ui/todo-status-filter.tsx +0 -60
  210. package/kernel/plugins/tasks/src/ui/todo-tag-filter.tsx +0 -48
  211. package/kernel/plugins/tasks/src/ui/todo-warnings.tsx +0 -106
  212. package/kernel/plugins/tasks/test/api.test.mjs +0 -99
  213. package/kernel/plugins/tasks/test/folder-sources.test.mjs +0 -46
  214. package/kernel/plugins/tasks/test/package.test.mjs +0 -54
  215. package/kernel/plugins/tasks/test/parse.test.mjs +0 -225
  216. package/kernel/plugins/tasks/test/server.test.mjs +0 -386
  217. package/kernel/plugins/tasks/tsconfig.json +0 -17
  218. package/web/assets/app-DoGr2Zi7.js +0 -2
  219. package/web/assets/channel-uMEjV8qc.js +0 -1
  220. package/web/assets/classDiagram-OUVF2IWQ-fbZocVor.js +0 -1
  221. package/web/assets/classDiagram-v2-EOCWNBFH-fbZocVor.js +0 -1
  222. package/web/assets/client-C6Nxdfif.js +0 -2
  223. package/web/assets/client-CXYfBko5.js +0 -17
  224. package/web/assets/client-CoKtOq-X.js +0 -7
  225. package/web/assets/client-DyRQz5RU.js +0 -53
  226. package/web/assets/client-OY6HUzuD.js +0 -98
  227. package/web/assets/client-YOYWZIFi.js +0 -34
  228. package/web/assets/command-BugZBGXE.js +0 -38
  229. package/web/assets/folder-plugin-view-DsWR1zmQ.js +0 -6
  230. package/web/assets/index-BaRnbYRw.js +0 -1
  231. package/web/assets/index-BexmLxIP.css +0 -1
  232. package/web/assets/index-DDirZMgx.js +0 -1
  233. package/web/assets/note-card-DBfX6gW1.js +0 -6
  234. package/web/assets/select-D8cqNgcp.js +0 -30
  235. package/web/assets/stateDiagram-v2-6OUMAXLB-BpeGSCu9.js +0 -1
  236. package/web/assets/swimlanesDiagram-G3AALYLV-D9RjLKb9.js +0 -8
  237. package/web/assets/use-folder-filter-LQyXns5Q.js +0 -1
@@ -1,752 +0,0 @@
1
- import { beforeEach, describe, expect, test } from "bun:test"
2
-
3
- import {
4
- conversationLimits,
5
- createMailosService,
6
- MailosCommandError,
7
- MailSendFailure,
8
- resetConversationCache,
9
- runMailos,
10
- validateSendContract,
11
- } from "../src/mailos.ts"
12
-
13
- const REV = "a".repeat(64)
14
- const BIN = "/tmp/mailos-test-bin"
15
- const DRAFT_DIR = "/tmp/mailos-test-drafts"
16
-
17
- function readMarkdown(id) {
18
- return [
19
- `# Subject ${id}`,
20
- "",
21
- "**From:** sender@example.com ",
22
- "**To:** to@example.com ",
23
- "**Date:** September 21, 2026 3:04 PM ",
24
- "**ID:** 42 ",
25
- "",
26
- "---",
27
- "",
28
- `Body of ${id}`,
29
- "",
30
- ].join("\n")
31
- }
32
-
33
- const defaultHandler = () => ({
34
- stdout: readMarkdown("x"),
35
- stderr: "",
36
- })
37
-
38
- function recordingRunner(handlers = [], fallback = defaultHandler) {
39
- const calls = []
40
- let index = 0
41
- const runner = async (file, args, options) => {
42
- const call = {
43
- args,
44
- cwd: options.cwd,
45
- file,
46
- signal: options.signal,
47
- timeout: options.timeout,
48
- }
49
- calls.push(call)
50
- const handler = handlers[index] ?? fallback
51
- index += 1
52
- return handler(call)
53
- }
54
- return { calls, runner }
55
- }
56
-
57
- function service(handlers = [], options = {}) {
58
- const { calls, runner } = recordingRunner(handlers)
59
- const svc = createMailosService({
60
- execFile: runner,
61
- mailosBin: BIN,
62
- globalDraftDir: DRAFT_DIR,
63
- ...options,
64
- })
65
- return { calls, svc }
66
- }
67
-
68
- function notFoundError(id) {
69
- return new MailosCommandError("not-found", `Message ${id} not found`)
70
- }
71
-
72
- beforeEach(() => {
73
- resetConversationCache()
74
- })
75
-
76
- describe("runMailos lifecycle", () => {
77
- test("maps killed children to timeout with the exit code", async () => {
78
- const runner = async () => {
79
- throw Object.assign(new Error("boom"), { killed: true, code: 1 })
80
- }
81
- await expect(
82
- runMailos(runner, BIN, ["x"], { cwd: "/f", timeoutMs: 100 }),
83
- ).rejects.toMatchObject({ code: "timeout", timedOut: true, exitCode: 1 })
84
- })
85
-
86
- test("maps AbortError to aborted", async () => {
87
- const runner = async () => {
88
- const error = new Error("aborted")
89
- error.name = "AbortError"
90
- throw error
91
- }
92
- await expect(
93
- runMailos(runner, BIN, ["x"], { cwd: "/f", timeoutMs: 100 }),
94
- ).rejects.toMatchObject({ code: "aborted", aborted: true })
95
- })
96
-
97
- test("redacts command output from error messages", async () => {
98
- const runner = async () => {
99
- throw new Error("stderr contained secret-token-xyz and body content")
100
- }
101
- await expect(
102
- runMailos(runner, BIN, ["x"], { cwd: "/f", timeoutMs: 100 }),
103
- ).rejects.toMatchObject({
104
- code: "failed",
105
- message: "MailOS command failed to complete",
106
- })
107
- })
108
-
109
- test("passes the abort signal to the child", async () => {
110
- const seen = {}
111
- const runner = async (_file, _args, options) => {
112
- seen.signal = options.signal
113
- return { stdout: "", stderr: "" }
114
- }
115
- const controller = new AbortController()
116
- await runMailos(runner, BIN, ["x"], {
117
- cwd: "/f",
118
- signal: controller.signal,
119
- timeoutMs: 100,
120
- })
121
- expect(seen.signal).toBe(controller.signal)
122
- })
123
-
124
- test("rejects immediately when the signal is already aborted", async () => {
125
- const controller = new AbortController()
126
- controller.abort()
127
- const runner = async () => ({ stdout: "", stderr: "" })
128
- await expect(
129
- runMailos(runner, BIN, ["x"], {
130
- cwd: "/f",
131
- signal: controller.signal,
132
- timeoutMs: 100,
133
- }),
134
- ).rejects.toMatchObject({ code: "aborted" })
135
- })
136
- })
137
-
138
- describe("identity", () => {
139
- test("parses Effective From and From Name, never errors the row", async () => {
140
- const { svc, calls } = service([
141
- () => ({
142
- stdout: "From Name: Jane Doe\nEffective From: jane@example.com\n",
143
- stderr: "",
144
- }),
145
- ])
146
- const identity = await svc.identity("/f")
147
- expect(identity).toEqual({
148
- from: "jane@example.com",
149
- name: "Jane Doe",
150
- ok: true,
151
- })
152
- expect(calls[0].args).toEqual(["whoami"])
153
- expect(calls[0].cwd).toBe("/f")
154
- })
155
-
156
- test('normalizes "(not set)" placeholders to null instead of a fake identity', async () => {
157
- const { svc } = service([
158
- () => ({
159
- stdout: "From Name: (not set)\nEffective From: (not set)\n",
160
- stderr: "",
161
- }),
162
- ])
163
- expect(await svc.identity("/f")).toEqual({
164
- from: null,
165
- name: null,
166
- ok: false,
167
- })
168
- })
169
-
170
- test("treats a blank identity as unset", async () => {
171
- const { svc } = service([
172
- () => ({ stdout: "From Name:\nEffective From:\n", stderr: "" }),
173
- ])
174
- expect(await svc.identity("/f")).toEqual({
175
- from: null,
176
- name: null,
177
- ok: false,
178
- })
179
- })
180
-
181
- test("CLI failure yields a graceful null identity", async () => {
182
- const { svc } = service([
183
- () => {
184
- throw new Error("boom")
185
- },
186
- ])
187
- expect(await svc.identity("/f")).toEqual({
188
- from: null,
189
- name: null,
190
- ok: false,
191
- })
192
- })
193
- })
194
-
195
- describe("signature", () => {
196
- test("resolves the folder signature with exact args", async () => {
197
- const { svc, calls } = service([
198
- () => ({
199
- stdout: "**Example User**\nDirector, Example Company\n",
200
- stderr: "",
201
- }),
202
- ])
203
- expect(await svc.signature("/f/clients")).toBe(
204
- "**Example User**\nDirector, Example Company",
205
- )
206
- expect(calls[0].args).toEqual(["sig", "/f/clients"])
207
- expect(calls[0].cwd).toBe("/f/clients")
208
- })
209
-
210
- test("returns null when the folder has no signature configured", async () => {
211
- const { svc } = service([
212
- () => ({ stdout: "(no signature configured)\n", stderr: "" }),
213
- ])
214
- expect(await svc.signature("/f")).toBeNull()
215
- })
216
-
217
- test("returns null for an empty signature", async () => {
218
- const { svc } = service([
219
- () => ({ stdout: "(signature is empty)\n", stderr: "" }),
220
- ])
221
- expect(await svc.signature("/f")).toBeNull()
222
- })
223
-
224
- test("blank output degrades to null", async () => {
225
- const { svc } = service([() => ({ stdout: "", stderr: "" })])
226
- expect(await svc.signature("/f")).toBeNull()
227
- })
228
-
229
- test("CLI failure yields a graceful null signature", async () => {
230
- const { svc } = service([
231
- () => {
232
- throw new Error("boom")
233
- },
234
- ])
235
- expect(await svc.signature("/f")).toBeNull()
236
- })
237
- })
238
-
239
- describe("conversation", () => {
240
- test("reads references with exact args and returns ok items in order", async () => {
241
- const { svc, calls } = service([
242
- () => ({ stdout: readMarkdown("r1"), stderr: "" }),
243
- () => ({ stdout: readMarkdown("r2"), stderr: "" }),
244
- ])
245
- const result = await svc.conversation("/f", ["r1", "r2"])
246
- expect(result.outcome).toBe("ok")
247
- expect(result.items.map((item) => item.id)).toEqual(["r1", "r2"])
248
- expect(result.items[0]).toMatchObject({
249
- subject: "Subject r1",
250
- from: "sender@example.com",
251
- to: "to@example.com",
252
- date: "September 21, 2026 3:04 PM",
253
- })
254
- expect(result.items[0].body).toBe("Body of r1")
255
- expect(calls.map((call) => call.args)).toEqual([
256
- ["read", "r1", "--markdown"],
257
- ["read", "r2", "--markdown"],
258
- ])
259
- })
260
-
261
- test("empty references do not invoke the CLI", async () => {
262
- const { svc, calls } = service([])
263
- const result = await svc.conversation("/f", [])
264
- expect(result).toEqual({ items: [], missing: [], outcome: "empty" })
265
- expect(calls.length).toBe(0)
266
- })
267
-
268
- test("bounds references to the configured maximum", async () => {
269
- const ids = Array.from({ length: 20 }, (_, index) => `r${index}`)
270
- const { svc, calls } = service([])
271
- const result = await svc.conversation("/f", ids)
272
- expect(result.items.length).toBe(conversationLimits.maxReferences)
273
- expect(calls.length).toBe(conversationLimits.maxReferences)
274
- })
275
-
276
- test("deduplicates duplicate references within one request", async () => {
277
- const { svc, calls } = service([])
278
- const result = await svc.conversation("/f", ["r1", "r1", "r1"])
279
- expect(result.items.length).toBe(1)
280
- expect(calls.length).toBe(1)
281
- })
282
-
283
- test("classifies a partially resolved thread", async () => {
284
- const { svc } = service([
285
- () => ({ stdout: readMarkdown("a"), stderr: "" }),
286
- () => {
287
- throw notFoundError("b")
288
- },
289
- () => ({ stdout: readMarkdown("c"), stderr: "" }),
290
- ])
291
- const result = await svc.conversation("/f", ["a", "b", "c"])
292
- expect(result.outcome).toBe("partial")
293
- expect(result.items.map((item) => item.id)).toEqual(["a", "c"])
294
- expect(result.missing).toEqual(["b"])
295
- })
296
-
297
- test("classifies an entirely unresolved thread as not-found", async () => {
298
- const { svc } = service([
299
- () => {
300
- throw notFoundError("a")
301
- },
302
- () => {
303
- throw notFoundError("b")
304
- },
305
- ])
306
- const result = await svc.conversation("/f", ["a", "b"])
307
- expect(result.outcome).toBe("not-found")
308
- expect(result.items).toEqual([])
309
- expect(result.missing).toEqual(["a", "b"])
310
- })
311
-
312
- test("classifies a timed-out read as timeout without retrying", async () => {
313
- const { svc, calls } = service([
314
- () => ({ stdout: readMarkdown("a"), stderr: "" }),
315
- () => {
316
- throw new MailosCommandError("timeout", "read timed out", {
317
- timedOut: true,
318
- })
319
- },
320
- ])
321
- const result = await svc.conversation("/f", ["a", "b"])
322
- expect(result.outcome).toBe("timeout")
323
- expect(result.items.map((item) => item.id)).toEqual(["a"])
324
- // One attempt per reference; never a retry loop.
325
- expect(calls.length).toBe(2)
326
- })
327
-
328
- test(
329
- "long-delay fixture: a read slower than the old 8s cap still succeeds on real timers",
330
- { timeout: 60_000 },
331
- async () => {
332
- const startedAt = performance.now()
333
- const slowDelayMs = 9_500
334
- const { svc } = service([
335
- () => {
336
- return Bun.sleep(slowDelayMs).then(() => ({
337
- stdout: readMarkdown("slow-real"),
338
- stderr: "",
339
- }))
340
- },
341
- ])
342
- const result = await svc.conversation("/f", ["slow-real"])
343
- const elapsed = performance.now() - startedAt
344
- // The per-read cap (60s) sits above the measured 22-26s success envelope
345
- // and above this 9.5s fixture; the old 8s cap would have killed it.
346
- expect(result.outcome).toBe("ok")
347
- expect(result.items.map((item) => item.id)).toEqual(["slow-real"])
348
- expect(elapsed).toBeGreaterThanOrEqual(slowDelayMs)
349
- },
350
- )
351
-
352
- test("keeps slow-but-successful reads above the old 8s cap (20s per read)", async () => {
353
- const realNow = Date.now
354
- let now = 0
355
- Date.now = () => now
356
- try {
357
- const { svc } = service([
358
- () => {
359
- now += 20_500 // a slow-but-successful MailOS read (~20.5s)
360
- return { stdout: readMarkdown("slow-a"), stderr: "" }
361
- },
362
- () => {
363
- now += 20_500
364
- return { stdout: readMarkdown("slow-b"), stderr: "" }
365
- },
366
- ])
367
- const result = await svc.conversation("/f", ["slow-a", "slow-b"])
368
- // Two ~20.5s reads fit inside the 75s budget with 60s per-read caps.
369
- expect(result.outcome).toBe("ok")
370
- expect(result.items.map((item) => item.id)).toEqual(["slow-a", "slow-b"])
371
- } finally {
372
- Date.now = realNow
373
- }
374
- })
375
-
376
- test("stops once the total time budget is exhausted", async () => {
377
- const realNow = Date.now
378
- let now = 0
379
- Date.now = () => now
380
- try {
381
- const { svc, calls } = service([
382
- () => {
383
- now += 40_000 // within the 60s per-read cap
384
- return { stdout: readMarkdown("a"), stderr: "" }
385
- },
386
- () => {
387
- now += 40_000 // 80s total now exceeds the 75s budget
388
- return { stdout: readMarkdown("b"), stderr: "" }
389
- },
390
- () => ({ stdout: readMarkdown("c"), stderr: "" }),
391
- ])
392
- const result = await svc.conversation("/f", ["a", "b", "c"])
393
- expect(result.outcome).toBe("timeout")
394
- expect(result.items.length).toBeGreaterThanOrEqual(1)
395
- // No blind retry loop: each reference gets at most one attempt.
396
- expect(calls.length).toBe(3)
397
- } finally {
398
- Date.now = realNow
399
- }
400
- })
401
-
402
- test("per-read cap and budget sit above the measured 22-26s success envelope", () => {
403
- expect(conversationLimits.perReadTimeoutMs).toBeGreaterThanOrEqual(60_000)
404
- expect(conversationLimits.totalBudgetMs).toBeGreaterThanOrEqual(75_000)
405
- expect(conversationLimits.perReadTimeoutMs).toBeLessThan(
406
- conversationLimits.totalBudgetMs,
407
- )
408
- })
409
-
410
- test("deduplicates in-flight reads across concurrent callers", async () => {
411
- const { svc, calls } = service([])
412
- const [left, right] = await Promise.all([
413
- svc.conversation("/f", ["shared", "left"]),
414
- svc.conversation("/f", ["shared", "right"]),
415
- ])
416
- expect(left.outcome).toBe("ok")
417
- expect(right.outcome).toBe("ok")
418
- const sharedCalls = calls.filter((call) => call.args[1] === "shared")
419
- expect(sharedCalls.length).toBe(1)
420
- expect(calls.length).toBe(3)
421
- })
422
-
423
- test("serves repeated requests from the bounded success cache per scope", async () => {
424
- const { svc, calls } = service([])
425
- await svc.conversation("/f", ["hot"])
426
- const afterFirst = calls.length
427
- const cached = await svc.conversation("/f", ["hot"])
428
- expect(cached.outcome).toBe("ok")
429
- expect(calls.length).toBe(afterFirst)
430
-
431
- // A different folder scope must not reuse the cached read.
432
- await svc.conversation("/g", ["hot"])
433
- expect(calls.length).toBe(afterFirst + 1)
434
- })
435
-
436
- test("evicts oldest successes when the cache is full", async () => {
437
- const { svc, calls } = service([])
438
- const batches = Array.from({ length: 5 }, (_, batch) =>
439
- Array.from(
440
- { length: conversationLimits.maxReferences },
441
- (_, index) => `k${batch}-${index}`,
442
- ),
443
- )
444
- for (const batch of batches) await svc.conversation("/f", batch)
445
- const afterBulk = calls.length
446
- expect(afterBulk).toBeGreaterThan(conversationLimits.successCacheSize)
447
- // The oldest success was evicted: it must hit the CLI again.
448
- await svc.conversation("/f", ["k0-0"])
449
- expect(calls.length).toBe(afterBulk + 1)
450
- // A recently cached id is still served from memory.
451
- await svc.conversation("/f", ["k4-11"])
452
- expect(calls.length).toBe(afterBulk + 1)
453
- })
454
-
455
- test("cancels the child on caller disconnect without touching shared consumers", async () => {
456
- const { svc } = service([])
457
- const cancelled = new AbortController()
458
- cancelled.abort()
459
- await expect(
460
- svc.conversation("/f", ["x"], cancelled.signal),
461
- ).rejects.toMatchObject({
462
- code: "aborted",
463
- })
464
- })
465
-
466
- test("one canceled subscriber never kills another consumer of the same read", async () => {
467
- // A gated runner keeps the shared read in flight until the test decides:
468
- // with an instantly-resolving fake the read would be done (and cached)
469
- // before the abort, so this must deliberately hold the child open.
470
- const calls = []
471
- let gateResolve = null
472
- const gate = new Promise((resolveGate) => {
473
- gateResolve = resolveGate
474
- })
475
- const runner = async (_file, args) => {
476
- calls.push(args)
477
- if (args[1] === "shared") await gate
478
- return { stdout: readMarkdown(args[1]), stderr: "" }
479
- }
480
- const svc = createMailosService({
481
- execFile: runner,
482
- mailosBin: BIN,
483
- globalDraftDir: DRAFT_DIR,
484
- })
485
- const first = new AbortController()
486
- const second = new AbortController()
487
- const pending = svc.conversation("/f", ["shared"], first.signal)
488
- const sharedCall = new Promise((resolveCall) => {
489
- const started = () => resolveCall(undefined)
490
- const check = () => {
491
- if (calls.some((args) => args[1] === "shared")) started()
492
- }
493
- check()
494
- const timer = setInterval(check, 1)
495
- setTimeout(() => clearInterval(timer), 1000)
496
- })
497
- await sharedCall
498
- const other = svc.conversation("/f", ["shared", "other"], second.signal)
499
- // Let the second worker subscribe to the shared in-flight read.
500
- await Bun.sleep(5)
501
-
502
- // Aborting just the first consumer must not abort the shared child.
503
- first.abort()
504
- await expect(pending).rejects.toMatchObject({ code: "aborted" })
505
-
506
- // Release the shared read: the second subscriber still completes and
507
- // both consumers shared one read call.
508
- gateResolve()
509
- const result = await other
510
- expect(result.outcome).toBe("ok")
511
- // Two workers resolve the two ids independently; order is not part of
512
- // the guarantee, both messages must be present.
513
- expect(result.items.map((item) => item.id).sort()).toEqual([
514
- "other",
515
- "shared",
516
- ])
517
- const sharedCalls = calls.filter((args) => args[1] === "shared")
518
- expect(sharedCalls.length).toBe(1)
519
- })
520
-
521
- test("production path (with signal) still reuses cache and in-flight reads", async () => {
522
- const { svc, calls } = service([])
523
- const controller = new AbortController()
524
- const first = await svc.conversation("/f", ["hot"], controller.signal)
525
- expect(first.outcome).toBe("ok")
526
- const afterFirst = calls.length
527
-
528
- // A second production-style request with its own signal hits the cache.
529
- const second = await svc.conversation(
530
- "/f",
531
- ["hot"],
532
- new AbortController().signal,
533
- )
534
- expect(second.outcome).toBe("ok")
535
- expect(calls.length).toBe(afterFirst)
536
- })
537
- })
538
-
539
- describe("send preview and confirm", () => {
540
- function previewOutput() {
541
- return [
542
- "📋 PREVIEW - Would send draft: d_abc.md",
543
- `Revision: ${REV}`,
544
- "=== EMAIL PREVIEW ===",
545
- "To: to@example.com",
546
- "Subject: Hi",
547
- "",
548
- "--- Body ---",
549
- "Hello",
550
- "=== END PREVIEW ===",
551
- "Send now: mailos send draft d_abc --draft-revision ...",
552
- "",
553
- ].join("\n")
554
- }
555
-
556
- test("preview targets the authorized draft directory with exact args", async () => {
557
- const { svc, calls } = service([
558
- () => ({ stdout: previewOutput(), stderr: "" }),
559
- ])
560
- const preview = await svc.sendPreview("/f", "d_abc", {
561
- draftDir: DRAFT_DIR,
562
- })
563
- expect(preview.dryRun).toBe(true)
564
- expect(preview.revision).toBe(REV)
565
- expect(calls[0].args).toEqual([
566
- "send",
567
- "draft",
568
- "d_abc",
569
- "--draft-dir",
570
- DRAFT_DIR,
571
- "--preview",
572
- ])
573
- expect(calls[0].cwd).toBe("/f")
574
- })
575
-
576
- test("preview refuses to run without an explicit authorized draft directory", async () => {
577
- const { svc, calls } = service([])
578
- await expect(svc.sendPreview("/f", "d_abc")).rejects.toMatchObject({
579
- name: "MailSendFailure",
580
- reason: "invalid-draft",
581
- })
582
- expect(calls.length).toBe(0)
583
- })
584
-
585
- test("preview without a revision reports an unsendable draft", async () => {
586
- const { svc } = service([
587
- () => ({ stdout: "no revision here", stderr: "" }),
588
- ])
589
- await expect(
590
- svc.sendPreview("/f", "d_abc", { draftDir: DRAFT_DIR }),
591
- ).rejects.toMatchObject({
592
- name: "MailSendFailure",
593
- reason: "invalid-draft",
594
- })
595
- })
596
-
597
- test("confirm send uses the revision, the draft dir, and retains the draft", async () => {
598
- const { svc, calls } = service([
599
- () => ({
600
- stdout:
601
- "📤 Sending draft: d_abc.md ✅ Sent\n\n📊 Draft sending complete:\n ✅ Sent: 1\n 📁 Processed: 1 total drafts\n",
602
- stderr: "",
603
- }),
604
- ])
605
- const result = await svc.confirmSend("/f", "d_abc", REV, {
606
- draftDir: DRAFT_DIR,
607
- })
608
- expect(result.dryRun).toBe(false)
609
- expect(calls[0].args).toEqual([
610
- "send",
611
- "draft",
612
- "d_abc",
613
- "--draft-dir",
614
- DRAFT_DIR,
615
- "--draft-revision",
616
- REV,
617
- "--delete-after=false",
618
- ])
619
- })
620
-
621
- test("a malformed revision is rejected before any command runs", async () => {
622
- const { svc, calls } = service([])
623
- await expect(
624
- svc.confirmSend("/f", "d_abc", "not-a-hash", { draftDir: DRAFT_DIR }),
625
- ).rejects.toMatchObject({
626
- name: "MailSendFailure",
627
- reason: "revision-match",
628
- })
629
- expect(calls.length).toBe(0)
630
- })
631
-
632
- test("a send-time revision change is a revision conflict, not a generic failure", async () => {
633
- const { svc, calls } = service([
634
- () => ({
635
- stdout:
636
- "❌ Draft d_abc.md changed after preview\n Expected revision: aaa…\n Current revision: bbb…\n\n📊 Draft sending complete:\n ✅ Sent: 0\n ❌ Errors: 1\n 📁 Processed: 1 total drafts\n",
637
- stderr: "",
638
- }),
639
- ])
640
- await expect(
641
- svc.confirmSend("/f", "d_abc", REV, { draftDir: DRAFT_DIR }),
642
- ).rejects.toMatchObject({
643
- name: "MailSendFailure",
644
- reason: "revision-match",
645
- })
646
- expect(calls.length).toBe(1)
647
- })
648
-
649
- test("exit-0 output that reports skips or failures fails the contract", async () => {
650
- const { svc } = service([
651
- () => ({
652
- stdout:
653
- "⏭️ Skipped sent draft: d_abc.md\n\n📊 Draft sending complete:\n ✅ Sent: 0\n 📁 Processed: 1 total drafts\n",
654
- stderr: "",
655
- }),
656
- ])
657
- await expect(
658
- svc.confirmSend("/f", "d_abc", REV, { draftDir: DRAFT_DIR }),
659
- ).rejects.toMatchObject({
660
- name: "MailSendFailure",
661
- reason: "contract",
662
- })
663
- })
664
-
665
- test("cancellation aborts a send", async () => {
666
- const { svc } = service([])
667
- const controller = new AbortController()
668
- controller.abort()
669
- await expect(
670
- svc.confirmSend("/f", "d_abc", REV, {
671
- draftDir: DRAFT_DIR,
672
- signal: controller.signal,
673
- }),
674
- ).rejects.toMatchObject({
675
- code: "aborted",
676
- })
677
- })
678
- })
679
-
680
- describe("archive", () => {
681
- test("archives by canonical draft id in the global draft dir", async () => {
682
- const { svc, calls } = service([() => ({ stdout: "", stderr: "" })])
683
- await svc.archive("/f", "d_abc", { draftDir: DRAFT_DIR })
684
- expect(calls[0].args).toEqual(["drafts", "archive", "d_abc"])
685
- expect(calls[0].cwd).toBe("/f")
686
- })
687
-
688
- test("rejects a non-canonical archive target before any spawn", async () => {
689
- const { svc, calls } = service([])
690
- await expect(
691
- svc.archive("/f", "d_abc", { draftDir: "/tmp/elsewhere/drafts" }),
692
- ).rejects.toMatchObject({
693
- name: "MailSendFailure",
694
- reason: "unsupported-draft-dir",
695
- })
696
- // The CLI never ran: it would have mutated a same-named home draft.
697
- expect(calls.length).toBe(0)
698
- })
699
- })
700
-
701
- describe("validateSendContract", () => {
702
- test("accepts a confirmed single-draft send", () => {
703
- const output = [
704
- "📤 Sending draft: d_abc.md ✅ Sent",
705
- "",
706
- "📊 Draft sending complete:",
707
- " ✅ Sent: 1",
708
- " 📁 Processed: 1 total drafts",
709
- ].join("\n")
710
- expect(validateSendContract(output)).toEqual({ ok: true })
711
- })
712
-
713
- test("accepts a previewed run", () => {
714
- const output =
715
- "📊 Draft sending complete:\n ✅ Previewed: 1\n 📁 Processed: 1 total drafts\n"
716
- expect(validateSendContract(output)).toEqual({ ok: true })
717
- })
718
-
719
- test("rejects failure markers even with exit 0", () => {
720
- const output =
721
- "❌ Invalid draft d_abc: missing recipients\n📊 Draft sending complete:\n ✅ Sent: 0\n ❌ Errors: 1\n"
722
- expect(validateSendContract(output)).toMatchObject({ ok: false })
723
- })
724
-
725
- test("rejects skipped drafts", () => {
726
- expect(
727
- validateSendContract("⏭️ Skipped sent draft: d_abc.md\n ✅ Sent: 0\n"),
728
- ).toMatchObject({ ok: false })
729
- })
730
-
731
- test("rejects when no draft was found", () => {
732
- expect(
733
- validateSendContract("No draft files found to send\n ✅ Sent: 0\n"),
734
- ).toMatchObject({
735
- ok: false,
736
- })
737
- })
738
-
739
- test("rejects zero sent drafts", () => {
740
- expect(
741
- validateSendContract("📊 Draft sending complete:\n ✅ Sent: 0\n"),
742
- ).toMatchObject({
743
- ok: false,
744
- })
745
- })
746
- })
747
-
748
- test("MailSendFailure is an Error subclass", () => {
749
- const failure = new MailSendFailure("contract", "nope")
750
- expect(failure).toBeInstanceOf(Error)
751
- expect(failure.message).toBe("nope")
752
- })