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.
- package/README.md +23 -13
- package/dist/cli/dispatch.js +16 -3
- package/dist/cli/src/cli/dispatch.js +16 -3
- package/dist/cli/src/cli/usage.js +1 -1
- package/dist/cli/src/core/command.js +1 -0
- package/dist/cli/src/core/init.js +5 -5
- package/dist/cli/src/plugins/external.d.ts +29 -3
- package/dist/cli/src/plugins/external.js +78 -24
- package/dist/cli/src/site/server-plugins.d.ts +2 -7
- package/dist/cli/src/site/viewer.js +1 -0
- package/dist/cli/usage.js +1 -1
- package/dist/core/command.js +1 -0
- package/dist/core/init.js +5 -5
- package/dist/core/runtime.js +357 -3207
- package/dist/package/preview.js +1232 -5113
- package/dist/plugins/external.d.ts +29 -3
- package/dist/plugins/external.js +78 -24
- package/dist/site/server-plugins.d.ts +2 -7
- package/dist/site/server-plugins.js +552 -10447
- package/dist/site/viewer.js +1 -0
- package/kernel/apps/web/package.json +1 -5
- package/kernel/apps/web/scripts/dev.mjs +53 -0
- package/kernel/apps/web/src/app.css +0 -3
- package/kernel/apps/web/src/main.tsx +0 -1
- package/kernel/apps/web/src/plugins.ts +0 -30
- package/kernel/apps/web/tsconfig.json +1 -2
- package/kernel/apps/web/vite.config.ts +31 -13
- package/kernel/packages/api/package.json +0 -4
- package/kernel/packages/api/src/server/folder-metadata-plugin.ts +13 -38
- package/kernel/packages/api/src/server/index.ts +0 -1
- package/kernel/packages/api/src/server/plugins.ts +49 -16
- package/kernel/packages/api/test/core.test.ts +13 -25
- package/kernel/packages/markdown/README.md +1 -1
- package/package.json +1 -1
- package/web/assets/{abnfDiagram-VRR7QNED-Bgfb1nEB.js → abnfDiagram-VRR7QNED-xd_g1uK3.js} +1 -1
- package/web/assets/app-CX20nNIa.js +2 -0
- package/web/assets/{app-options-D-RJiQ-X.js → app-options-CkPJE5ep.js} +1 -1
- package/web/assets/{app-view-header-BLvLq02T.js → app-view-header-CNk5KnA-.js} +1 -1
- package/web/assets/{arc-Dk_KI36B.js → arc-B0u5yBJA.js} +1 -1
- package/web/assets/{architectureDiagram-ZJ3FMSHR-NR_KhRCf.js → architectureDiagram-ZJ3FMSHR-DfxOAo8P.js} +1 -1
- package/web/assets/{ask-tray-TzeTcO6-.js → ask-tray-SscJecDY.js} +1 -1
- package/web/assets/{blockDiagram-677ZJIJ3-CkLkwx9C.js → blockDiagram-677ZJIJ3-Ci3Usxh6.js} +1 -1
- package/web/assets/{c4Diagram-LMCZKHZV-CFCUH8e_.js → c4Diagram-LMCZKHZV-BqgwIVgh.js} +1 -1
- package/web/assets/channel-X7WHyxxT.js +1 -0
- package/web/assets/{chunk-2Q5K7J3B-B_BPLWsW.js → chunk-2Q5K7J3B-CfX69l2U.js} +1 -1
- package/web/assets/{chunk-32BRIVSS-B1VB1hOy.js → chunk-32BRIVSS-CuFlzZkl.js} +1 -1
- package/web/assets/{chunk-5VM5RSS4-KFcYr8Co.js → chunk-5VM5RSS4-DidCMlJa.js} +1 -1
- package/web/assets/{chunk-EX3LRPZG-CpBX3kuK.js → chunk-EX3LRPZG-B5CDm1dS.js} +1 -1
- package/web/assets/{chunk-JWPE2WC7-BPSl40wG.js → chunk-JWPE2WC7-D5fGV_sH.js} +1 -1
- package/web/assets/{chunk-MOJQB5TN-D2RcYmoU.js → chunk-MOJQB5TN-ayhH5Q-H.js} +1 -1
- package/web/assets/{chunk-RYQCIY6F-m7Gyn-t3.js → chunk-RYQCIY6F-C4BfOOZn.js} +1 -1
- package/web/assets/{chunk-V7JOEXUC-TDvdopxS.js → chunk-V7JOEXUC-DpUEIM9V.js} +1 -1
- package/web/assets/{chunk-VR4S4FIN-DZTsG51a.js → chunk-VR4S4FIN-DoQuFFRh.js} +1 -1
- package/web/assets/{chunk-XXDRQBXY-Dp0--RE2.js → chunk-XXDRQBXY-CI9eiLwn.js} +1 -1
- package/web/assets/classDiagram-OUVF2IWQ-BjsLQ3dz.js +1 -0
- package/web/assets/classDiagram-v2-EOCWNBFH-BjsLQ3dz.js +1 -0
- package/web/assets/client-7OWw5kW2.js +6 -0
- package/web/assets/client-DYj_td4Q.js +17 -0
- package/web/assets/{client-j2kQmxsO.js → client-Dk-NBvJR.js} +4 -4
- package/web/assets/command-zkzH4hVz.js +38 -0
- package/web/assets/{copy-context-C0Uqsp-3.js → copy-context-D5oiWrYC.js} +1 -1
- package/web/assets/{cose-bilkent-JH36ORCC-CPu3PI8h.js → cose-bilkent-JH36ORCC-BHRk1mO3.js} +1 -1
- package/web/assets/{cynefin-VYW2F7L2-DWxftYAm.js → cynefin-VYW2F7L2-C1B09TRE.js} +1 -1
- package/web/assets/{cynefinDiagram-TSTJHNR4-DsmN2V-p.js → cynefinDiagram-TSTJHNR4-B27qXQbv.js} +1 -1
- package/web/assets/{dagre-VKFMJZFB-COEbApyY.js → dagre-VKFMJZFB-B1qEi32g.js} +1 -1
- package/web/assets/{diagram-FQU43EPY-0O9WGWKB.js → diagram-FQU43EPY-BMkkgADD.js} +1 -1
- package/web/assets/{diagram-G47NLZAW-CutEdMVh.js → diagram-G47NLZAW-Cb8_g1A5.js} +1 -1
- package/web/assets/{diagram-NH7WQ7WH-C9KZc3B2.js → diagram-NH7WQ7WH-B51wuc5m.js} +1 -1
- package/web/assets/{diagram-OA4YK3LP-D8kkPXbY.js → diagram-OA4YK3LP-xnsecbbI.js} +1 -1
- package/web/assets/{diagram-WEI45ONY-_YqiTaGP.js → diagram-WEI45ONY-DSZdyaAV.js} +1 -1
- package/web/assets/{document-Dgt5eZFj.js → document-Dc2dftTW.js} +3 -3
- package/web/assets/{ebnfDiagram-CCIWWBDH-JO-zQh6g.js → ebnfDiagram-CCIWWBDH-aTHmcUGy.js} +1 -1
- package/web/assets/{entities-C3wz7FCx.js → entities-CCUtpskY.js} +1 -1
- package/web/assets/{erDiagram-Q63AITRT-9UjQwJ4P.js → erDiagram-Q63AITRT-CUUPBcvo.js} +1 -1
- package/web/assets/{file-viewer-DZJ8nYnl.js → file-viewer-DJ3rsmef.js} +1 -1
- package/web/assets/{flowDiagram-23GEKE2U-Ca08tnYM.js → flowDiagram-23GEKE2U-aTQXx2Mh.js} +1 -1
- package/web/assets/{folder-image-BfGgxo7i.js → folder-image-Bnv43tzP.js} +1 -1
- package/web/assets/{folder-landing-qUKBCnG-.js → folder-landing-DCnzYXXY.js} +1 -1
- package/web/assets/{folder-list-BfFjv7rR.js → folder-list-DvVkVjiR.js} +1 -1
- package/web/assets/{folder-metadata-DCw8xzHk.js → folder-metadata-C9hMMzIH.js} +1 -1
- package/web/assets/{folder-mobile-bar-Ubz4babf.js → folder-mobile-bar-VoTL1GS0.js} +1 -1
- package/web/assets/folder-plugin-view-Cvd804ZU.js +6 -0
- package/web/assets/{ganttDiagram-NO4QXBWP-KnHzX5W3.js → ganttDiagram-NO4QXBWP-DiwdUp7u.js} +1 -1
- package/web/assets/{gitGraphDiagram-IHSO6WYX-Cm8woNtK.js → gitGraphDiagram-IHSO6WYX-BL2gG_IS.js} +1 -1
- package/web/assets/{index-CNyy98F8.js → index-CUTtMSyJ.js} +2 -2
- package/web/assets/index-CegvVQ1C.js +1 -0
- package/web/assets/index-Dwhf8F_U.css +1 -0
- package/web/assets/{infoDiagram-FWYZ7A6U-yyqmUtIb.js → infoDiagram-FWYZ7A6U-B3n0zZpt.js} +1 -1
- package/web/assets/{ishikawaDiagram-FXEZZL3T-BfvR_Zy4.js → ishikawaDiagram-FXEZZL3T-D7YytbEv.js} +1 -1
- package/web/assets/{journeyDiagram-5HDEW3XC-BKuw06HM.js → journeyDiagram-5HDEW3XC-BYwqEl3C.js} +1 -1
- package/web/assets/{kanban-definition-HUTT4EX6-n0010rmQ.js → kanban-definition-HUTT4EX6-BQHNJgXZ.js} +1 -1
- package/web/assets/{linear-C0d0lT8O.js → linear-BPiQCLNk.js} +1 -1
- package/web/assets/{mermaid.core-BDVbShyO.js → mermaid.core-CD-KvBjV.js} +4 -4
- package/web/assets/{metadata-KV0dpp4G.js → metadata-COJ7fZul.js} +1 -1
- package/web/assets/{mindmap-definition-LN4V7U3C--_0ks3GF.js → mindmap-definition-LN4V7U3C-C1L_gyoj.js} +1 -1
- package/web/assets/{pegDiagram-2B236MQR-CBoNIpKR.js → pegDiagram-2B236MQR-BDAfItqS.js} +1 -1
- package/web/assets/{pieDiagram-ENE6RG2P-MA5tkYh5.js → pieDiagram-ENE6RG2P-CkZYH3Vz.js} +1 -1
- package/web/assets/{popover-DBD7FF-W.js → popover-0oA2lLUV.js} +2 -2
- package/web/assets/{project-search-dialog-DM1IkSZu.js → project-search-dialog-B2oqMT6h.js} +1 -1
- package/web/assets/{quadrantDiagram-ABIIQ3AL-CWT1ryqF.js → quadrantDiagram-ABIIQ3AL-Dy8Al3YZ.js} +1 -1
- package/web/assets/{railroadDiagram-RFXS5EU6-ZiLB5FGi.js → railroadDiagram-RFXS5EU6-Dd9_yo7B.js} +1 -1
- package/web/assets/{requirementDiagram-TGXJPOKE-CQq9uneq.js → requirementDiagram-TGXJPOKE-DPhIUsbs.js} +1 -1
- package/web/assets/{sankeyDiagram-HTMAVEWB-qDG-0JqI.js → sankeyDiagram-HTMAVEWB-B2QkYtub.js} +1 -1
- package/web/assets/{search-DNrQA5fF.js → search-C1J0-xiB.js} +1 -1
- package/web/assets/select-CtXC_PLC.js +30 -0
- package/web/assets/{selection-popup-DmtTeRs2.js → selection-popup-qhlUFzGq.js} +1 -1
- package/web/assets/{sequenceDiagram-DBY2YBRQ-CiApurLO.js → sequenceDiagram-DBY2YBRQ-Bx7fcZDx.js} +1 -1
- package/web/assets/{sizeCapture-X5ZJPWSS-DZSR5QXm.js → sizeCapture-X5ZJPWSS-CIXIhl_A.js} +1 -1
- package/web/assets/{stateDiagram-2N3HPSRC-CjJJR9_z.js → stateDiagram-2N3HPSRC-DCrdAflB.js} +1 -1
- package/web/assets/stateDiagram-v2-6OUMAXLB-0KCFLYeQ.js +1 -0
- package/web/assets/{swimlanes-5IMT3BWC-CKmo1sg6.js → swimlanes-5IMT3BWC-DA2vc7iZ.js} +2 -2
- package/web/assets/swimlanesDiagram-G3AALYLV-CAw3AV84.js +8 -0
- package/web/assets/{timeline-definition-FHXFAJF6-D-NMwS3y.js → timeline-definition-FHXFAJF6-lKfJiV9V.js} +1 -1
- package/web/assets/{vennDiagram-L72KCM5P-CfL6LBWw.js → vennDiagram-L72KCM5P-DO1q2KzG.js} +1 -1
- package/web/assets/{view-context-gyDl_p2V.js → view-context-DXKM3-l1.js} +4 -4
- package/web/assets/{viewer-capabilities-Bpp8Uvcg.js → viewer-capabilities-Cig_xM9u.js} +1 -1
- package/web/assets/{wardleyDiagram-EHGQE667-DgldMxaj.js → wardleyDiagram-EHGQE667-Dal9LkuZ.js} +1 -1
- package/web/assets/{xychartDiagram-FW5EYKEG-CIoAIFX8.js → xychartDiagram-FW5EYKEG-B3dPLPII.js} +1 -1
- package/web/index.html +2 -2
- package/kernel/packages/api/test/partial-loading.test.ts +0 -43
- package/kernel/plugins/calendar/SKILL.md +0 -173
- package/kernel/plugins/calendar/eslint.config.js +0 -10
- package/kernel/plugins/calendar/package.json +0 -73
- package/kernel/plugins/calendar/schema.json +0 -137
- package/kernel/plugins/calendar/src/calendar.tsx +0 -328
- package/kernel/plugins/calendar/src/client.tsx +0 -139
- package/kernel/plugins/calendar/src/context.ts +0 -48
- package/kernel/plugins/calendar/src/copy-button.tsx +0 -66
- package/kernel/plugins/calendar/src/event-dialog.tsx +0 -270
- package/kernel/plugins/calendar/src/index.ts +0 -4
- package/kernel/plugins/calendar/src/manifest.ts +0 -7
- package/kernel/plugins/calendar/src/parse.ts +0 -610
- package/kernel/plugins/calendar/src/server.ts +0 -160
- package/kernel/plugins/calendar/src/utils.ts +0 -80
- package/kernel/plugins/calendar/src/views.tsx +0 -418
- package/kernel/plugins/calendar/test/context.test.mjs +0 -60
- package/kernel/plugins/calendar/test/package.test.mjs +0 -43
- package/kernel/plugins/calendar/test/parse.test.mjs +0 -392
- package/kernel/plugins/calendar/test/server.test.mjs +0 -126
- package/kernel/plugins/calendar/tsconfig.json +0 -17
- package/kernel/plugins/mail/SKILL.md +0 -97
- package/kernel/plugins/mail/eslint.config.js +0 -10
- package/kernel/plugins/mail/package.json +0 -79
- package/kernel/plugins/mail/src/api.ts +0 -359
- package/kernel/plugins/mail/src/bun-ffi.d.ts +0 -14
- package/kernel/plugins/mail/src/client.tsx +0 -14
- package/kernel/plugins/mail/src/flock.ts +0 -137
- package/kernel/plugins/mail/src/folders.ts +0 -37
- package/kernel/plugins/mail/src/history.ts +0 -485
- package/kernel/plugins/mail/src/index.ts +0 -3
- package/kernel/plugins/mail/src/mail-card.tsx +0 -123
- package/kernel/plugins/mail/src/mail-conversation.tsx +0 -153
- package/kernel/plugins/mail/src/mail-detail-page.tsx +0 -467
- package/kernel/plugins/mail/src/mail-hooks.ts +0 -252
- package/kernel/plugins/mail/src/mail-page.tsx +0 -109
- package/kernel/plugins/mail/src/mail-request.ts +0 -78
- package/kernel/plugins/mail/src/mail-send-box.tsx +0 -175
- package/kernel/plugins/mail/src/mailos.ts +0 -723
- package/kernel/plugins/mail/src/manifest.ts +0 -7
- package/kernel/plugins/mail/src/node.ts +0 -171
- package/kernel/plugins/mail/src/parse.ts +0 -139
- package/kernel/plugins/mail/src/server.ts +0 -125
- package/kernel/plugins/mail/src/types.ts +0 -10
- package/kernel/plugins/mail/src/viewer.tsx +0 -147
- package/kernel/plugins/mail/test/flock.test.mjs +0 -156
- package/kernel/plugins/mail/test/folders.test.mjs +0 -138
- package/kernel/plugins/mail/test/history.test.mjs +0 -460
- package/kernel/plugins/mail/test/mailos.test.mjs +0 -752
- package/kernel/plugins/mail/test/node.test.mjs +0 -207
- package/kernel/plugins/mail/test/package.test.mjs +0 -54
- package/kernel/plugins/mail/test/parse.test.mjs +0 -32
- package/kernel/plugins/mail/test/server.test.mjs +0 -116
- package/kernel/plugins/mail/test/viewer.test.mjs +0 -192
- package/kernel/plugins/mail/tsconfig.json +0 -17
- package/kernel/plugins/projects/SKILL.md +0 -73
- package/kernel/plugins/projects/eslint.config.js +0 -10
- package/kernel/plugins/projects/package.json +0 -70
- package/kernel/plugins/projects/src/client.tsx +0 -17
- package/kernel/plugins/projects/src/data/model.ts +0 -129
- package/kernel/plugins/projects/src/index.ts +0 -17
- package/kernel/plugins/projects/src/manifest.ts +0 -7
- package/kernel/plugins/projects/src/project-context.ts +0 -41
- package/kernel/plugins/projects/src/server.ts +0 -276
- package/kernel/plugins/projects/src/ui/project-card.tsx +0 -61
- package/kernel/plugins/projects/src/ui/projects-page.tsx +0 -102
- package/kernel/plugins/projects/test/package.test.mjs +0 -51
- package/kernel/plugins/projects/tsconfig.json +0 -17
- package/kernel/plugins/tasks/SKILL.md +0 -341
- package/kernel/plugins/tasks/eslint.config.js +0 -10
- package/kernel/plugins/tasks/package.json +0 -74
- package/kernel/plugins/tasks/schema.json +0 -939
- package/kernel/plugins/tasks/src/api.ts +0 -212
- package/kernel/plugins/tasks/src/client.tsx +0 -23
- package/kernel/plugins/tasks/src/data/parse.ts +0 -544
- package/kernel/plugins/tasks/src/data/task-id.ts +0 -56
- package/kernel/plugins/tasks/src/data/todo-model.ts +0 -795
- package/kernel/plugins/tasks/src/index.ts +0 -34
- package/kernel/plugins/tasks/src/manifest.ts +0 -7
- package/kernel/plugins/tasks/src/pages/task-detail.tsx +0 -48
- package/kernel/plugins/tasks/src/pages/task-list.tsx +0 -88
- package/kernel/plugins/tasks/src/server.ts +0 -447
- package/kernel/plugins/tasks/src/styles.css +0 -9
- package/kernel/plugins/tasks/src/ui/compact-todo-list.tsx +0 -372
- package/kernel/plugins/tasks/src/ui/error-details.tsx +0 -78
- package/kernel/plugins/tasks/src/ui/task-page.tsx +0 -392
- package/kernel/plugins/tasks/src/ui/todo-agent-filter.tsx +0 -36
- package/kernel/plugins/tasks/src/ui/todo-filter-bar.tsx +0 -66
- package/kernel/plugins/tasks/src/ui/todo-item.tsx +0 -494
- package/kernel/plugins/tasks/src/ui/todo-status-filter.tsx +0 -60
- package/kernel/plugins/tasks/src/ui/todo-tag-filter.tsx +0 -48
- package/kernel/plugins/tasks/src/ui/todo-warnings.tsx +0 -106
- package/kernel/plugins/tasks/test/api.test.mjs +0 -99
- package/kernel/plugins/tasks/test/folder-sources.test.mjs +0 -46
- package/kernel/plugins/tasks/test/package.test.mjs +0 -54
- package/kernel/plugins/tasks/test/parse.test.mjs +0 -225
- package/kernel/plugins/tasks/test/server.test.mjs +0 -386
- package/kernel/plugins/tasks/tsconfig.json +0 -17
- package/web/assets/app-DoGr2Zi7.js +0 -2
- package/web/assets/channel-uMEjV8qc.js +0 -1
- package/web/assets/classDiagram-OUVF2IWQ-fbZocVor.js +0 -1
- package/web/assets/classDiagram-v2-EOCWNBFH-fbZocVor.js +0 -1
- package/web/assets/client-C6Nxdfif.js +0 -2
- package/web/assets/client-CXYfBko5.js +0 -17
- package/web/assets/client-CoKtOq-X.js +0 -7
- package/web/assets/client-DyRQz5RU.js +0 -53
- package/web/assets/client-OY6HUzuD.js +0 -98
- package/web/assets/client-YOYWZIFi.js +0 -34
- package/web/assets/command-BugZBGXE.js +0 -38
- package/web/assets/folder-plugin-view-DsWR1zmQ.js +0 -6
- package/web/assets/index-BaRnbYRw.js +0 -1
- package/web/assets/index-BexmLxIP.css +0 -1
- package/web/assets/index-DDirZMgx.js +0 -1
- package/web/assets/note-card-DBfX6gW1.js +0 -6
- package/web/assets/select-D8cqNgcp.js +0 -30
- package/web/assets/stateDiagram-v2-6OUMAXLB-BpeGSCu9.js +0 -1
- package/web/assets/swimlanesDiagram-G3AALYLV-D9RjLKb9.js +0 -8
- 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
|
-
})
|