@bugmole/cli 0.4.0

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 (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,1220 @@
1
+ /**
2
+ * Comprehensive unit tests for MCP server tools
3
+ *
4
+ * These tests ensure all MCP tools handle errors gracefully and never crash the server.
5
+ * Every tool must be tested for:
6
+ * - Valid inputs
7
+ * - Invalid inputs
8
+ * - Missing files
9
+ * - Path resolution issues
10
+ * - Error handling
11
+ */
12
+
13
+ import { describe, it, before, after } from "node:test";
14
+ import { strict as assert } from "node:assert";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ // Import the functions we need to test
21
+ // Note: We'll need to export these from server.ts for testing
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+
24
+ // Mock config for testing
25
+ // The "dashboard" journey / "home" node fixture that bugmole_write_ui_audit
26
+ // tests below assert against. Re-seeded immediately before that describe
27
+ // block, not just once in the outer before(): every test in this file
28
+ // shares one testDir, and the bugmole_explore test in between legitimately
29
+ // rebuilds journeys.graph.json's nodes from a scanned source tree ("Rebuild
30
+ // the sitemap from the current source tree on every run" in explorer.ts) --
31
+ // which has always dropped this fixture's "home" node by the time these
32
+ // tests run. That dangling "dashboard" -> "home" reference used to go
33
+ // unnoticed because nothing validated it; readJourneyGraph now repairs it
34
+ // (see AGENTS.md's "Journey graph reads and mutations must tolerate legacy
35
+ // corruption"), correctly pruning it -- which surfaced this pre-existing
36
+ // test-isolation gap instead of a bug in that repair.
37
+ function seedDashboardJourney(testDir: string) {
38
+ fs.writeFileSync(
39
+ path.join(testDir, "spec", "journeys.graph.json"),
40
+ JSON.stringify({
41
+ version: 2,
42
+ projectId: "default",
43
+ journeys: [{
44
+ id: "dashboard",
45
+ name: "Dashboard",
46
+ actor: "user",
47
+ nodeIds: ["home"],
48
+ edgeIds: [],
49
+ revision: 1,
50
+ status: "draft",
51
+ }],
52
+ nodes: [{ id: "home", title: "Home", kind: "screen", route: "/" }],
53
+ edges: [],
54
+ }),
55
+ );
56
+ }
57
+
58
+ function createTestConfig(testDir: string) {
59
+ return {
60
+ name: "Bugmole MCP Test",
61
+ runtime: {
62
+ env: "test",
63
+ tenant: "test",
64
+ artifacts_dir: path.join(testDir, "artifacts"),
65
+ spec_dir: path.join(testDir, "spec"),
66
+ },
67
+ mcp: {
68
+ enabled: true,
69
+ host: "127.0.0.1",
70
+ port: 3187,
71
+ },
72
+ };
73
+ }
74
+
75
+ describe("MCP Server Tools", () => {
76
+ let testDir: string;
77
+ let cfg: any;
78
+
79
+ it("formats tool results for MCP clients", async () => {
80
+ const { formatMcpToolResult } = await import("./server.js");
81
+ assert.deepEqual(
82
+ formatMcpToolResult({ status: "success", auditId: "parity-rerun" }),
83
+ {
84
+ content: [{
85
+ type: "text",
86
+ text: '{"status":"success","auditId":"parity-rerun"}',
87
+ }],
88
+ },
89
+ );
90
+ });
91
+
92
+ it("serves wrapped tool results over HTTP and rejects invalid JSON-RPC requests", async () => {
93
+ const { startMcpServer } = await import("./server.js");
94
+ const httpServer = await startMcpServer({
95
+ ...createTestConfig(testDir),
96
+ mcp: { ...createTestConfig(testDir).mcp, port: 0 },
97
+ }, { forceHttp: true });
98
+ assert.ok(httpServer);
99
+ try {
100
+ const toolResponse = await fetch(`${httpServer.origin}/mcp`, {
101
+ method: "POST",
102
+ headers: { "content-type": "application/json" },
103
+ body: JSON.stringify({
104
+ jsonrpc: "2.0",
105
+ id: "http-tool",
106
+ method: "tools/call",
107
+ params: {
108
+ name: "bugmole_read_spec",
109
+ arguments: { filePath: "journeys.graph.json" },
110
+ },
111
+ }),
112
+ });
113
+ assert.equal(toolResponse.status, 200);
114
+ const toolBody = await toolResponse.json() as {
115
+ id: string;
116
+ result?: { content?: Array<{ type?: string; text?: string }> };
117
+ };
118
+ assert.equal(toolBody.id, "http-tool");
119
+ assert.equal(toolBody.result?.content?.[0]?.type, "text");
120
+ assert.equal(
121
+ (JSON.parse(toolBody.result?.content?.[0]?.text ?? "{}") as { content?: string }).content,
122
+ fs.readFileSync(path.join(testDir, "spec", "journeys.graph.json"), "utf8"),
123
+ );
124
+
125
+ const invalidResponse = await fetch(`${httpServer.origin}/mcp`, {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json" },
128
+ body: "null",
129
+ });
130
+ assert.equal(invalidResponse.status, 200);
131
+ assert.deepEqual(await invalidResponse.json(), {
132
+ jsonrpc: "2.0",
133
+ id: null,
134
+ error: { code: -32600, message: "Invalid Request" },
135
+ });
136
+ } finally {
137
+ await httpServer.close();
138
+ }
139
+ });
140
+
141
+ before(() => {
142
+ // Create temporary test directory
143
+ testDir = path.join(tmpdir(), `bugmole-test-${Date.now()}`);
144
+ fs.mkdirSync(testDir, { recursive: true });
145
+ fs.mkdirSync(path.join(testDir, "spec"), { recursive: true });
146
+ fs.mkdirSync(path.join(testDir, "spec", "plans"), { recursive: true });
147
+ fs.mkdirSync(path.join(testDir, "artifacts"), { recursive: true });
148
+ seedDashboardJourney(testDir);
149
+
150
+ cfg = createTestConfig(testDir);
151
+ });
152
+
153
+ after(() => {
154
+ // Cleanup: remove test directory
155
+ if (fs.existsSync(testDir)) {
156
+ fs.rmSync(testDir, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ describe("bugmole_read_spec", () => {
161
+ it("should read existing spec file", async () => {
162
+ // Create test file
163
+ const testFile = path.join(testDir, "spec", "test.yaml");
164
+ const testContent = "test: content";
165
+ fs.writeFileSync(testFile, testContent);
166
+
167
+ // Import and test
168
+ const { readSpecFile } = await import("./server.js");
169
+ const result = readSpecFile("test.yaml", cfg);
170
+
171
+ assert.strictEqual(result.content, testContent);
172
+ assert.strictEqual(result.path, "test.yaml");
173
+ assert.strictEqual(result.size, testContent.length);
174
+ });
175
+
176
+ it("should handle file not found gracefully", async () => {
177
+ const { readSpecFile } = await import("./server.js");
178
+
179
+ try {
180
+ readSpecFile("nonexistent.yaml", cfg);
181
+ assert.fail("Should have thrown error");
182
+ } catch (error: any) {
183
+ // Should throw an error with helpful message
184
+ assert(error instanceof Error);
185
+ assert(error.message);
186
+ assert(
187
+ error.message.includes("Spec file not found") ||
188
+ error.message.includes("nonexistent.yaml") ||
189
+ error.message.includes("Tried"),
190
+ );
191
+ }
192
+ });
193
+
194
+ it("should resolve paths with spec/ prefix", async () => {
195
+ const testFile = path.join(testDir, "spec", "plans", "test.yaml");
196
+ const testContent = "plan: test";
197
+ fs.writeFileSync(testFile, testContent);
198
+
199
+ const { readSpecFile } = await import("./server.js");
200
+ const result = readSpecFile("plans/test.yaml", cfg);
201
+
202
+ assert.strictEqual(result.content, testContent);
203
+ });
204
+
205
+ it("should handle absolute paths", async () => {
206
+ const testFile = path.join(testDir, "spec", "absolute.yaml");
207
+ const testContent = "absolute: test";
208
+ fs.writeFileSync(testFile, testContent);
209
+
210
+ const { readSpecFile } = await import("./server.js");
211
+ const result = readSpecFile(testFile, cfg);
212
+
213
+ assert.strictEqual(result.content, testContent);
214
+ });
215
+
216
+ it("should try multiple path resolution strategies", async () => {
217
+ // Test that it tries multiple paths when file not found
218
+ const { readSpecFile } = await import("./server.js");
219
+
220
+ try {
221
+ readSpecFile("missing.yaml", cfg);
222
+ assert.fail("Should have thrown error");
223
+ } catch (error: any) {
224
+ // Error message should show multiple paths were tried
225
+ const errorMsg = error.message;
226
+ assert(errorMsg.includes("Tried"));
227
+ assert(errorMsg.includes("paths"));
228
+ }
229
+ });
230
+ });
231
+
232
+ describe("bugmole_write_spec", () => {
233
+ it("should write spec file successfully", async () => {
234
+ const { writeSpecFile } = await import("./server.js");
235
+ const content = "test: write content";
236
+
237
+ const result = writeSpecFile("write_test.yaml", content, cfg);
238
+
239
+ assert.strictEqual(result.success, true);
240
+ assert.strictEqual(result.path, "write_test.yaml");
241
+
242
+ // Verify file was created
243
+ const filePath = path.join(testDir, "spec", "write_test.yaml");
244
+ assert(fs.existsSync(filePath));
245
+ assert.strictEqual(fs.readFileSync(filePath, "utf-8"), content);
246
+ });
247
+
248
+ it("should create directory if it does not exist", async () => {
249
+ const { writeSpecFile } = await import("./server.js");
250
+ const content = "nested: content";
251
+
252
+ const result = writeSpecFile("nested/deep/test.yaml", content, cfg);
253
+
254
+ assert.strictEqual(result.success, true);
255
+
256
+ // Verify nested directory was created
257
+ const filePath = path.join(testDir, "spec", "nested", "deep", "test.yaml");
258
+ assert(fs.existsSync(filePath));
259
+ });
260
+
261
+ it("should handle invalid file paths", async () => {
262
+ const { writeSpecFile } = await import("./server.js");
263
+
264
+ // Should not crash on invalid paths
265
+ try {
266
+ writeSpecFile("", "content", cfg);
267
+ // Empty path might be handled or throw - either is acceptable
268
+ } catch (error: any) {
269
+ // If it throws, error should be clear
270
+ assert(error.message.length > 0);
271
+ }
272
+ });
273
+ });
274
+
275
+ describe("bugmole_report_progress", () => {
276
+ it("should create progress report file", async () => {
277
+ // We need to import callToolInternal or the actual function
278
+ // For now, test the directory structure
279
+ const progressDir = path.join(testDir, "artifacts", "progress_reports");
280
+ fs.mkdirSync(progressDir, { recursive: true });
281
+
282
+ const progressId = `progress_${Date.now()}`;
283
+ const progressPath = path.join(progressDir, `${progressId}.json`);
284
+ const progressData = {
285
+ id: progressId,
286
+ timestamp: new Date().toISOString(),
287
+ status: "working",
288
+ message: "Test progress",
289
+ };
290
+
291
+ fs.writeFileSync(progressPath, JSON.stringify(progressData, null, 2));
292
+
293
+ assert(fs.existsSync(progressPath));
294
+ const saved = JSON.parse(fs.readFileSync(progressPath, "utf-8"));
295
+ assert.strictEqual(saved.status, "working");
296
+ });
297
+
298
+ it("should handle all status types", async () => {
299
+ const statuses = ["working", "blocked", "completed"];
300
+
301
+ for (const status of statuses) {
302
+ // Test that each status is accepted
303
+ assert(["working", "blocked", "completed"].includes(status));
304
+ }
305
+ });
306
+ });
307
+
308
+ describe("bugmole_commit_response", () => {
309
+ it("should save commit response with all fields", async () => {
310
+ const commitDir = path.join(testDir, "artifacts", "cursor_commits");
311
+ fs.mkdirSync(commitDir, { recursive: true });
312
+
313
+ const commitId = `commit_${Date.now()}`;
314
+ const commitPath = path.join(commitDir, `${commitId}.json`);
315
+ const commitData = {
316
+ id: commitId,
317
+ timestamp: new Date().toISOString(),
318
+ payload: {
319
+ status: "ok",
320
+ summary: "Test commit",
321
+ next_steps: ["Step 1", "Step 2"],
322
+ },
323
+ };
324
+
325
+ fs.writeFileSync(commitPath, JSON.stringify(commitData, null, 2));
326
+
327
+ assert(fs.existsSync(commitPath));
328
+ const saved = JSON.parse(fs.readFileSync(commitPath, "utf-8"));
329
+ assert.strictEqual(saved.payload.status, "ok");
330
+ assert(Array.isArray(saved.payload.next_steps));
331
+ assert.strictEqual(saved.payload.next_steps.length, 2);
332
+ });
333
+
334
+ it("should always include next_steps as array", async () => {
335
+ // Test that next_steps is never undefined
336
+ const commitDir = path.join(testDir, "artifacts", "cursor_commits");
337
+ const commitId = `commit_${Date.now()}`;
338
+ const commitPath = path.join(commitDir, `${commitId}.json`);
339
+
340
+ const commitData = {
341
+ id: commitId,
342
+ timestamp: new Date().toISOString(),
343
+ payload: {
344
+ status: "ok",
345
+ summary: "Test",
346
+ next_steps: [], // Should always be array
347
+ },
348
+ };
349
+
350
+ fs.writeFileSync(commitPath, JSON.stringify(commitData, null, 2));
351
+ const saved = JSON.parse(fs.readFileSync(commitPath, "utf-8"));
352
+
353
+ assert("next_steps" in saved.payload);
354
+ assert(Array.isArray(saved.payload.next_steps));
355
+ });
356
+ });
357
+
358
+ describe("bugmole_explore", () => {
359
+ it("should discover application screens and update the journey graph", async () => {
360
+ const appDir = path.join(testDir, "app");
361
+ fs.mkdirSync(path.join(appDir, "settings"), { recursive: true });
362
+ fs.writeFileSync(
363
+ path.join(appDir, "page.tsx"),
364
+ `export default function Home() { return <a href="/settings">Settings</a>; }`,
365
+ );
366
+ fs.writeFileSync(
367
+ path.join(appDir, "settings", "page.tsx"),
368
+ `export default function Settings() { return <main>Settings</main>; }`,
369
+ );
370
+ const previousProjectDirectory = cfg.cursor_driver?.project_directory;
371
+ cfg.cursor_driver = { project_directory: testDir };
372
+
373
+ // Import the explore function
374
+ const { explore } = await import("../runtime/explorer.js");
375
+ const result = await explore(cfg, {
376
+ baseUrl: "http://localhost:8000",
377
+ maxDepth: 3,
378
+ });
379
+
380
+ assert.strictEqual(result.status, "success");
381
+ assert.strictEqual(result.screensDiscovered, 2);
382
+ assert.strictEqual(result.actionsDiscovered, 1);
383
+ assert.strictEqual(result.journeyGraphUpdated, true);
384
+ assert(result.message?.includes("Review the graph"));
385
+ assert(fs.existsSync(path.join(cfg.runtime.spec_dir, "journeys.graph.json")));
386
+ if (previousProjectDirectory === undefined) delete cfg.cursor_driver.project_directory;
387
+ else cfg.cursor_driver.project_directory = previousProjectDirectory;
388
+ });
389
+
390
+ it("should not crash on invalid baseUrl", async () => {
391
+ const { explore } = await import("../runtime/explorer.js");
392
+
393
+ // Should handle invalid URLs gracefully
394
+ const result = await explore(cfg, {
395
+ baseUrl: "invalid-url",
396
+ maxDepth: 1,
397
+ });
398
+
399
+ // Should return error, not crash
400
+ assert(result.status === "error" || result.status === "success");
401
+ assert(result.error || result.message);
402
+ });
403
+ });
404
+
405
+ describe("bugmole_plan", () => {
406
+ it("should handle missing journey gracefully", async () => {
407
+ const { plan } = await import("../runtime/planner.js");
408
+
409
+ try {
410
+ await plan(cfg, {
411
+ journeyId: "nonexistent_journey",
412
+ actor: "staff",
413
+ });
414
+ // Might succeed or fail, but should not crash
415
+ } catch (error: any) {
416
+ // If it throws, should be a clear error
417
+ assert(error.message.length > 0);
418
+ }
419
+ });
420
+ });
421
+
422
+ describe("bugmole_run", () => {
423
+ it("should handle missing plan gracefully", async () => {
424
+ const { execute } = await import("../runtime/executor.js");
425
+
426
+ try {
427
+ await execute(cfg, {
428
+ planId: "nonexistent_plan",
429
+ journeyId: "test",
430
+ });
431
+ // Might succeed or fail, but should not crash
432
+ } catch (error: any) {
433
+ // If it throws, should be a clear error
434
+ assert(error.message.length > 0);
435
+ }
436
+ });
437
+ });
438
+
439
+ describe("bugmole_propose_diff", () => {
440
+ it("should handle missing file gracefully", async () => {
441
+ const { proposeDiff } = await import("../runtime/diff-proposal.js");
442
+
443
+ try {
444
+ await proposeDiff(cfg, {
445
+ filePath: "nonexistent.yaml",
446
+ evidence: { test: "data" },
447
+ failures: [],
448
+ });
449
+ // Might succeed or fail, but should not crash
450
+ } catch (error: any) {
451
+ // If it throws, should be a clear error
452
+ assert(error.message.length > 0);
453
+ }
454
+ });
455
+
456
+ it("should handle invalid evidence gracefully", async () => {
457
+ const { proposeDiff } = await import("../runtime/diff-proposal.js");
458
+
459
+ try {
460
+ await proposeDiff(cfg, {
461
+ filePath: "test.yaml",
462
+ evidence: null as any,
463
+ failures: [],
464
+ });
465
+ // Might succeed or fail, but should not crash
466
+ } catch (error: any) {
467
+ // If it throws, should be a clear error
468
+ assert(error.message.length > 0);
469
+ }
470
+ });
471
+ });
472
+
473
+ describe("bugmole_review", () => {
474
+ it("should handle missing file gracefully", async () => {
475
+ const { review } = await import("../runtime/reviewer.js");
476
+
477
+ try {
478
+ await review(cfg, {
479
+ filePath: "nonexistent.yaml",
480
+ changeSummary: "Test change",
481
+ diff: "test diff",
482
+ evidence: { test: "data" },
483
+ });
484
+ // Might succeed or fail, but should not crash
485
+ } catch (error: any) {
486
+ // If it throws, should be a clear error
487
+ assert(error.message.length > 0);
488
+ }
489
+ });
490
+
491
+ it("should handle missing arguments gracefully", async () => {
492
+ const { review } = await import("../runtime/reviewer.js");
493
+
494
+ try {
495
+ await review(cfg, {
496
+ filePath: "test.yaml",
497
+ changeSummary: "",
498
+ diff: "",
499
+ evidence: {},
500
+ });
501
+ // Might succeed or fail, but should not crash
502
+ } catch (error: any) {
503
+ // If it throws, should be a clear error
504
+ assert(error.message.length > 0);
505
+ }
506
+ });
507
+ });
508
+
509
+ describe("bugmole_report", () => {
510
+ it("should handle missing runId gracefully", async () => {
511
+ const { generateReport } = await import("../runtime/reporter.js");
512
+
513
+ try {
514
+ await generateReport(cfg, {
515
+ runId: "nonexistent_run",
516
+ format: "json",
517
+ });
518
+ // Might succeed or fail, but should not crash
519
+ } catch (error: any) {
520
+ // If it throws, should be a clear error
521
+ assert(error.message.length > 0);
522
+ }
523
+ });
524
+
525
+ it("should handle different formats gracefully", async () => {
526
+ const { generateReport } = await import("../runtime/reporter.js");
527
+
528
+ const formats = ["json", "markdown"];
529
+ for (const format of formats) {
530
+ try {
531
+ await generateReport(cfg, {
532
+ runId: "test_run",
533
+ format: format as "json" | "markdown",
534
+ });
535
+ // Might succeed or fail, but should not crash
536
+ } catch (error: any) {
537
+ // If it throws, should be a clear error
538
+ assert(error.message.length > 0);
539
+ }
540
+ }
541
+ });
542
+ });
543
+
544
+ describe("bugmole_write_ui_audit", () => {
545
+ before(() => seedDashboardJourney(testDir));
546
+
547
+ it("writes a dashboard-consumable JSON audit with stable totals", async () => {
548
+ const { callTool } = await import("./server.js");
549
+ // Runtime evidence must reference a real artifact; write one before
550
+ // the audit claims it, exercising the same existence check a bogus
551
+ // location would fail.
552
+ const evidenceKey = "projects/default/ui-audits-evidence/open-project.png";
553
+ const evidencePath = path.join(testDir, "artifacts", evidenceKey);
554
+ fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
555
+ fs.writeFileSync(evidencePath, Buffer.from([0]));
556
+ const audit = {
557
+ schemaVersion: 1,
558
+ auditId: "dashboard-home",
559
+ requestedFlow: "Open the dashboard",
560
+ journeyId: "dashboard",
561
+ journeyRevision: 1,
562
+ flowStatus: "exhausted",
563
+ exploredNodeIds: ["home"],
564
+ exploredEdgeIds: [],
565
+ summary: {
566
+ total: 1,
567
+ implemented: 0,
568
+ partiallyImplemented: 1,
569
+ notImplemented: 0,
570
+ },
571
+ screens: [
572
+ {
573
+ screenId: "home",
574
+ route: "/",
575
+ controls: [
576
+ {
577
+ controlId: "open-project",
578
+ label: "Open project",
579
+ kind: "button",
580
+ status: "Partially implemented",
581
+ destinationOrHandler: "openProject()",
582
+ evidence: [{ type: "runtime", description: "Button responds to click", location: evidenceKey }],
583
+ missingBehavior: ["Project selection is not persisted"],
584
+ },
585
+ ],
586
+ },
587
+ ],
588
+ highestPriorityMissingImplementations: [],
589
+ blockedVerification: [],
590
+ };
591
+
592
+ const result = await callTool({ name: "bugmole_write_ui_audit", arguments: { audit } }, cfg);
593
+ assert.equal(result.status, "success");
594
+ assert.equal(result.schema, "bugmole://spec/app-ui-audit.schema.json");
595
+ const filePath = path.join(
596
+ testDir,
597
+ "artifacts",
598
+ "projects",
599
+ "default",
600
+ "ui-audits",
601
+ "dashboard-home.json",
602
+ );
603
+ assert.deepEqual(JSON.parse(fs.readFileSync(filePath, "utf8")), {
604
+ ...audit,
605
+ projectId: "default",
606
+ });
607
+ });
608
+
609
+ it("rejects runtime evidence without a location", async () => {
610
+ const { callTool } = await import("./server.js");
611
+ await assert.rejects(
612
+ callTool(
613
+ {
614
+ name: "bugmole_write_ui_audit",
615
+ arguments: {
616
+ audit: {
617
+ schemaVersion: 1,
618
+ auditId: "missing-location",
619
+ journeyId: "dashboard",
620
+ journeyRevision: 1,
621
+ flowStatus: "exhausted",
622
+ exploredNodeIds: ["home"],
623
+ exploredEdgeIds: [],
624
+ summary: { total: 1, implemented: 1, partiallyImplemented: 0, notImplemented: 0 },
625
+ screens: [
626
+ {
627
+ screenId: "home",
628
+ controls: [
629
+ {
630
+ controlId: "open-project",
631
+ label: "Open project",
632
+ kind: "button",
633
+ status: "Implemented",
634
+ destinationOrHandler: "openProject()",
635
+ evidence: [{ type: "runtime", description: "Trust me, it works" }],
636
+ missingBehavior: [],
637
+ },
638
+ ],
639
+ },
640
+ ],
641
+ highestPriorityMissingImplementations: [],
642
+ blockedVerification: [],
643
+ },
644
+ },
645
+ },
646
+ cfg,
647
+ ),
648
+ /runtime evidence without a location/,
649
+ );
650
+ });
651
+
652
+ it("rejects runtime evidence whose referenced artifact does not exist", async () => {
653
+ const { callTool } = await import("./server.js");
654
+ await assert.rejects(
655
+ callTool(
656
+ {
657
+ name: "bugmole_write_ui_audit",
658
+ arguments: {
659
+ audit: {
660
+ schemaVersion: 1,
661
+ auditId: "phantom-evidence",
662
+ journeyId: "dashboard",
663
+ journeyRevision: 1,
664
+ flowStatus: "exhausted",
665
+ exploredNodeIds: ["home"],
666
+ exploredEdgeIds: [],
667
+ summary: { total: 1, implemented: 1, partiallyImplemented: 0, notImplemented: 0 },
668
+ screens: [
669
+ {
670
+ screenId: "home",
671
+ controls: [
672
+ {
673
+ controlId: "open-project",
674
+ label: "Open project",
675
+ kind: "button",
676
+ status: "Implemented",
677
+ destinationOrHandler: "openProject()",
678
+ evidence: [
679
+ {
680
+ type: "runtime",
681
+ description: "Captured a screenshot",
682
+ location: "projects/default/ui-audits-evidence/does-not-exist.png",
683
+ },
684
+ ],
685
+ missingBehavior: [],
686
+ },
687
+ ],
688
+ },
689
+ ],
690
+ highestPriorityMissingImplementations: [],
691
+ blockedVerification: [],
692
+ },
693
+ },
694
+ },
695
+ cfg,
696
+ ),
697
+ /no artifact exists at that key/,
698
+ );
699
+ });
700
+
701
+ it("rejects inconsistent status totals", async () => {
702
+ const { callTool } = await import("./server.js");
703
+ await assert.rejects(
704
+ callTool(
705
+ {
706
+ name: "bugmole_write_ui_audit",
707
+ arguments: {
708
+ audit: {
709
+ schemaVersion: 1,
710
+ auditId: "invalid",
711
+ journeyId: "dashboard",
712
+ journeyRevision: 1,
713
+ flowStatus: "exhausted",
714
+ exploredNodeIds: ["home"],
715
+ exploredEdgeIds: [],
716
+ summary: {
717
+ total: 0,
718
+ implemented: 0,
719
+ partiallyImplemented: 0,
720
+ notImplemented: 0,
721
+ },
722
+ screens: [
723
+ {
724
+ screenId: "home",
725
+ controls: [
726
+ {
727
+ controlId: "button",
728
+ label: "Button",
729
+ status: "Implemented",
730
+ destinationOrHandler: "buttonHandler",
731
+ evidence: [],
732
+ missingBehavior: [],
733
+ },
734
+ ],
735
+ },
736
+ ],
737
+ blockedVerification: [],
738
+ highestPriorityMissingImplementations: [],
739
+ },
740
+ },
741
+ },
742
+ cfg,
743
+ ),
744
+ /summary must match control status totals/,
745
+ );
746
+ });
747
+ });
748
+
749
+ describe("bugmole_write_ui_parity_audit", () => {
750
+ it("writes a scored reference/current parity artifact", async () => {
751
+ const { callTool } = await import("./server.js");
752
+ const audit = {
753
+ schemaVersion: 1,
754
+ auditId: "home-parity",
755
+ reference: { kind: "url", location: "https://reference.example/home" },
756
+ current: { kind: "url", location: "http://localhost:3020" },
757
+ summary: {
758
+ screensTotal: 1,
759
+ actionsTotal: 1,
760
+ visualDifferencePercent: 8,
761
+ interactionParityScore: 100,
762
+ apiParityScore: 88,
763
+ authenticationParityScore: 100,
764
+ fixedIssues: 2,
765
+ unresolvedIssues: 1,
766
+ },
767
+ flowMatrix: [
768
+ {
769
+ flowId: "home",
770
+ referenceScreen: "Home",
771
+ currentRoute: "/",
772
+ authState: "guest",
773
+ actions: ["Open project"],
774
+ status: "matched",
775
+ },
776
+ ],
777
+ comparisons: [
778
+ {
779
+ screenId: "home",
780
+ differencePercent: 8,
781
+ changedPixels: 10,
782
+ totalPixels: 100,
783
+ heatmap: "parity-output/heatmap.png",
784
+ mismatches: ["Card spacing differs by 4px"],
785
+ },
786
+ ],
787
+ issues: [
788
+ {
789
+ issueId: "home-card-spacing",
790
+ category: "visual",
791
+ status: "unresolved",
792
+ description: "Card spacing differs by 4px",
793
+ },
794
+ ],
795
+ fixPrompt: "Adjust home card spacing to match the reference.",
796
+ blockedVerification: [],
797
+ };
798
+ const result = await callTool(
799
+ { name: "bugmole_write_ui_parity_audit", arguments: { audit } },
800
+ cfg,
801
+ );
802
+
803
+ assert.equal(result.status, "success");
804
+ assert.equal(result.schema, "bugmole://spec/ui-parity-audit.schema.json");
805
+ assert.equal(
806
+ JSON.parse(
807
+ fs.readFileSync(
808
+ path.join(testDir, "artifacts/projects/default/ui-parity-audits/home-parity.json"),
809
+ "utf8",
810
+ ),
811
+ ).summary.visualDifferencePercent,
812
+ 8,
813
+ );
814
+ });
815
+ });
816
+
817
+ describe("Error Handling", () => {
818
+ it("should wrap all tool calls in error handler", async () => {
819
+ // Test that callToolInternal is wrapped
820
+ // This ensures no tool can crash the server
821
+ const { callTool } = await import("./server.js");
822
+
823
+ // Call with invalid tool name
824
+ try {
825
+ await callTool(
826
+ {
827
+ name: "nonexistent_tool",
828
+ arguments: {},
829
+ },
830
+ cfg,
831
+ );
832
+ // Should throw error, not crash
833
+ } catch (error: any) {
834
+ assert(error.message.includes("Unknown tool") || error.message.length > 0);
835
+ }
836
+ });
837
+
838
+ it("should return JSON-RPC error format", async () => {
839
+ // Errors should be in JSON-RPC format
840
+ const { callTool } = await import("./server.js");
841
+
842
+ try {
843
+ await callTool(
844
+ {
845
+ name: "bugmole_read_spec",
846
+ arguments: { filePath: "nonexistent.yaml" },
847
+ },
848
+ cfg,
849
+ );
850
+ assert.fail("Should have thrown error");
851
+ } catch (error: any) {
852
+ // Error should have message
853
+ assert(error.message);
854
+ // Should not be a crash/exception that kills the server
855
+ assert(typeof error.message === "string");
856
+ }
857
+ });
858
+ });
859
+
860
+ describe("Resources", () => {
861
+ it("exposes first-class journey editing and continuation tools", async () => {
862
+ const { getTools } = await import("./server.js");
863
+ const names = getTools(cfg).map((tool) => tool.name);
864
+ for (const name of [
865
+ "bugmole_journey_list",
866
+ "bugmole_journey_get",
867
+ "bugmole_journey_create",
868
+ "bugmole_journey_add_step",
869
+ "bugmole_journey_add_transition",
870
+ "bugmole_journey_update",
871
+ "bugmole_journey_remove",
872
+ "bugmole_journey_next",
873
+ "bugmole_resolve_blocker",
874
+ ]) {
875
+ assert(names.includes(name), `missing MCP tool ${name}`);
876
+ }
877
+ });
878
+
879
+ it("should list available resources", async () => {
880
+ const { getResources } = await import("./server.js");
881
+ const resources = getResources(cfg);
882
+
883
+ assert(Array.isArray(resources));
884
+ assert(resources.length > 0);
885
+ // Should have standard resources
886
+ const resourceUris = resources.map((r) => r.uri);
887
+ assert(resourceUris.some((uri) => uri.includes("roles.yaml")));
888
+ assert(resourceUris.some((uri) => uri.includes("domain_rules.yaml")));
889
+ assert(resourceUris.includes("bugmole://guidance/app-ui-audit"));
890
+ assert(resourceUris.includes("bugmole://guidance/ui-parity-audit"));
891
+ assert(resourceUris.includes("bugmole://guidance/ui-reverse-engineering"));
892
+ assert(resourceUris.includes("bugmole://spec/ui-reverse-engineering.schema.json"));
893
+ assert(resourceUris.includes("bugmole://spec/design-dna.schema.json"));
894
+ assert(resourceUris.includes("bugmole://spec/ui-parity-audit.schema.json"));
895
+ });
896
+
897
+ it("should read the application UI audit guidance", async () => {
898
+ const { readResource } = await import("./server.js");
899
+ const result = await readResource({ uri: "bugmole://guidance/app-ui-audit" }, cfg);
900
+ const text = result.contents[0].text;
901
+
902
+ assert.match(text, /reproduce the requested user flow/);
903
+ assert.match(text, /screen-by-screen grouping/);
904
+ assert.match(text, /Implemented: the intended action works end-to-end/);
905
+ // The audit is the only task that populates the component library, so
906
+ // the component scan has to be part of its instructions.
907
+ assert.match(text, /bugmole_ui_re_start/);
908
+ assert.match(text, /stage `atoms`/);
909
+ assert.match(text, /do not invent components/i);
910
+ });
911
+
912
+ it("should read UI parity guidance without control-audit overlap", async () => {
913
+ const { readResource } = await import("./server.js");
914
+ const result = await readResource({ uri: "bugmole://guidance/ui-parity-audit" }, cfg);
915
+ const text = result.contents[0].text;
916
+
917
+ assert.match(text, /reference experience/);
918
+ assert.match(text, /flow matrix/);
919
+ assert.match(text, /bugmole_write_ui_parity_audit/);
920
+ assert.match(text, /does not replace the application UI audit/);
921
+ });
922
+
923
+ it("should read existing resource", async () => {
924
+ // Create a test resource file
925
+ const testFile = path.join(testDir, "spec", "roles.yaml");
926
+ const testContent = "roles: test";
927
+ fs.writeFileSync(testFile, testContent);
928
+
929
+ const { readResource } = await import("./server.js");
930
+ const result = await readResource({ uri: "bugmole://spec/roles.yaml" }, cfg);
931
+
932
+ assert(result.contents);
933
+ assert(Array.isArray(result.contents));
934
+ assert.strictEqual(result.contents[0].text, testContent);
935
+ });
936
+
937
+ it("should handle missing resource gracefully", async () => {
938
+ const { readResource } = await import("./server.js");
939
+
940
+ try {
941
+ await readResource({ uri: "bugmole://spec/nonexistent.yaml" }, cfg);
942
+ assert.fail("Should have thrown error");
943
+ } catch (error: any) {
944
+ assert(error.message.includes("not found") || error.message.includes("Resource"));
945
+ }
946
+ });
947
+
948
+ it("should handle invalid URI format", async () => {
949
+ const { readResource } = await import("./server.js");
950
+
951
+ try {
952
+ await readResource({ uri: "invalid://uri" }, cfg);
953
+ assert.fail("Should have thrown error");
954
+ } catch (error: any) {
955
+ assert(error.message.includes("Unknown resource URI"));
956
+ }
957
+ });
958
+ });
959
+
960
+ describe("Prompts", () => {
961
+ it("should list available prompts", async () => {
962
+ const { getPrompts } = await import("./server.js");
963
+ const prompts = getPrompts(cfg);
964
+
965
+ assert(Array.isArray(prompts));
966
+ // Should have at least one prompt
967
+ assert(prompts.length >= 0);
968
+ assert(prompts.some((prompt) => prompt.name === "app_ui_audit"));
969
+ assert(prompts.some((prompt) => prompt.name === "ui_parity_audit"));
970
+ assert(prompts.some((prompt) => prompt.name === "ui_reverse_engineering"));
971
+ assert(prompts.some((prompt) => prompt.name === "agent_task_execution"));
972
+ });
973
+
974
+ it("should return the application UI audit prompt", async () => {
975
+ const { getPrompt } = await import("./server.js");
976
+ const result = await getPrompt({ name: "app_ui_audit" }, cfg);
977
+ const text = result.messages[0].content.text;
978
+
979
+ assert.match(text, /every user-facing button, link, row, card/);
980
+ assert.match(text, /runtime or source-code evidence/);
981
+ assert.match(text, /highest-priority missing implementations/);
982
+ });
983
+
984
+ it("should return the UI parity audit prompt", async () => {
985
+ const { getPrompt } = await import("./server.js");
986
+ const result = await getPrompt({ name: "ui_parity_audit" }, cfg);
987
+ const text = result.messages[0].content.text;
988
+
989
+ assert.match(text, /flow matrix/);
990
+ assert.match(text, /visual, behavioral, backend, and authentication parity/);
991
+ assert.match(text, /bugmole_write_ui_parity_audit/);
992
+ });
993
+
994
+ it("should expose the agent task queue guidance", async () => {
995
+ const { readResource } = await import("./server.js");
996
+ const result = await readResource({ uri: "bugmole://guidance/agent-task-queue" }, cfg);
997
+ const text = result.contents[0].text;
998
+ assert.match(text, /bugmole_task_next/);
999
+ assert.match(text, /fresh parity artifact/);
1000
+ });
1001
+
1002
+ it("should return the agent task execution prompt", async () => {
1003
+ const { getPrompt } = await import("./server.js");
1004
+ const result = await getPrompt({ name: "agent_task_execution" }, cfg);
1005
+ const text = result.messages[0].content.text;
1006
+ assert.match(text, /bugmole_task_next/);
1007
+ assert.match(text, /fresh parity audit id and match score/);
1008
+ });
1009
+
1010
+ it("should get prompt with arguments", async () => {
1011
+ const { getPrompt } = await import("./server.js");
1012
+
1013
+ try {
1014
+ await getPrompt(
1015
+ {
1016
+ name: "file_authoring",
1017
+ arguments: {
1018
+ filePath: "test.yaml",
1019
+ filePurpose: "Test file",
1020
+ },
1021
+ },
1022
+ cfg,
1023
+ );
1024
+ // Might succeed or fail depending on template existence
1025
+ } catch (error: any) {
1026
+ // If it throws, should be a clear error
1027
+ assert(error.message.length > 0);
1028
+ }
1029
+ });
1030
+
1031
+ it("should handle unknown prompt gracefully", async () => {
1032
+ const { getPrompt } = await import("./server.js");
1033
+
1034
+ try {
1035
+ await getPrompt({ name: "nonexistent_prompt" }, cfg);
1036
+ assert.fail("Should have thrown error");
1037
+ } catch (error: any) {
1038
+ assert(error.message.includes("Unknown prompt"));
1039
+ }
1040
+ });
1041
+ });
1042
+
1043
+ describe("bugmole_commit_response edge cases", () => {
1044
+ it("should extract next_steps from answer_markdown when missing", async () => {
1045
+ // This tests the fallback extraction logic
1046
+ const { callTool } = await import("./server.js");
1047
+ const commitDir = path.join(testDir, "artifacts", "cursor_commits");
1048
+ fs.mkdirSync(commitDir, { recursive: true });
1049
+
1050
+ // Call with answer_markdown containing "Next Steps" but no next_steps field
1051
+ const result = await callTool(
1052
+ {
1053
+ name: "bugmole_commit_response",
1054
+ arguments: {
1055
+ status: "ok",
1056
+ summary: "Test commit",
1057
+ answer_markdown: "## Next Steps\n\n1. Do something\n2. Do something else",
1058
+ // Intentionally missing next_steps
1059
+ },
1060
+ },
1061
+ cfg,
1062
+ );
1063
+
1064
+ assert.strictEqual(result.success, true);
1065
+
1066
+ // Verify next_steps was extracted and saved
1067
+ const commitFiles = fs.readdirSync(commitDir).filter((f) => f.endsWith(".json"));
1068
+ assert(commitFiles.length > 0);
1069
+
1070
+ const latestCommit = JSON.parse(
1071
+ fs.readFileSync(path.join(commitDir, commitFiles[0]), "utf-8"),
1072
+ );
1073
+ assert("next_steps" in latestCommit.payload);
1074
+ assert(Array.isArray(latestCommit.payload.next_steps));
1075
+ });
1076
+
1077
+ it("should normalize next_steps from string to array", async () => {
1078
+ const { callTool } = await import("./server.js");
1079
+ const commitDir = path.join(testDir, "artifacts", "cursor_commits");
1080
+ fs.mkdirSync(commitDir, { recursive: true });
1081
+
1082
+ // Call with next_steps as string (should be normalized to array)
1083
+ const result = await callTool(
1084
+ {
1085
+ name: "bugmole_commit_response",
1086
+ arguments: {
1087
+ status: "ok",
1088
+ summary: "Test",
1089
+ next_steps: "Single step" as any, // Wrong type, should be normalized
1090
+ },
1091
+ },
1092
+ cfg,
1093
+ );
1094
+
1095
+ assert.strictEqual(result.success, true);
1096
+
1097
+ const commitFiles = fs.readdirSync(commitDir).filter((f) => f.endsWith(".json"));
1098
+ const latestCommit = JSON.parse(
1099
+ fs.readFileSync(path.join(commitDir, commitFiles[commitFiles.length - 1]), "utf-8"),
1100
+ );
1101
+ assert(Array.isArray(latestCommit.payload.next_steps));
1102
+ });
1103
+
1104
+ it("should handle null/undefined next_steps", async () => {
1105
+ const { callTool } = await import("./server.js");
1106
+ const commitDir = path.join(testDir, "artifacts", "cursor_commits");
1107
+ fs.mkdirSync(commitDir, { recursive: true });
1108
+
1109
+ // Call with null next_steps (should become empty array)
1110
+ const result = await callTool(
1111
+ {
1112
+ name: "bugmole_commit_response",
1113
+ arguments: {
1114
+ status: "ok",
1115
+ summary: "Test",
1116
+ next_steps: null as any,
1117
+ },
1118
+ },
1119
+ cfg,
1120
+ );
1121
+
1122
+ assert.strictEqual(result.success, true);
1123
+
1124
+ const commitFiles = fs.readdirSync(commitDir).filter((f) => f.endsWith(".json"));
1125
+ const latestCommit = JSON.parse(
1126
+ fs.readFileSync(path.join(commitDir, commitFiles[commitFiles.length - 1]), "utf-8"),
1127
+ );
1128
+ assert(Array.isArray(latestCommit.payload.next_steps));
1129
+ assert.strictEqual(latestCommit.payload.next_steps.length, 0);
1130
+ });
1131
+ });
1132
+
1133
+ describe("Tool Input Validation", () => {
1134
+ it("should handle missing required arguments", async () => {
1135
+ const { callTool } = await import("./server.js");
1136
+
1137
+ try {
1138
+ await callTool(
1139
+ {
1140
+ name: "bugmole_read_spec",
1141
+ arguments: {}, // Missing required filePath
1142
+ },
1143
+ cfg,
1144
+ );
1145
+ // Should either throw or return error
1146
+ } catch (error: any) {
1147
+ assert(error.message.length > 0);
1148
+ }
1149
+ });
1150
+
1151
+ it("should handle invalid argument types", async () => {
1152
+ const { callTool } = await import("./server.js");
1153
+
1154
+ try {
1155
+ await callTool(
1156
+ {
1157
+ name: "bugmole_read_spec",
1158
+ arguments: {
1159
+ filePath: 123 as any, // Wrong type
1160
+ },
1161
+ },
1162
+ cfg,
1163
+ );
1164
+ // Should handle gracefully
1165
+ } catch (error: any) {
1166
+ // If it throws, should be a clear error
1167
+ assert(error.message.length > 0);
1168
+ }
1169
+ });
1170
+ });
1171
+
1172
+ describe("Path Resolution", () => {
1173
+ it("should handle relative paths from different directories", async () => {
1174
+ const { readSpecFile } = await import("./server.js");
1175
+
1176
+ // Create file in spec/plans/
1177
+ const testFile = path.join(testDir, "spec", "plans", "relative.yaml");
1178
+ fs.writeFileSync(testFile, "relative: test");
1179
+
1180
+ // Try different path formats
1181
+ const paths = [
1182
+ "plans/relative.yaml",
1183
+ "spec/plans/relative.yaml",
1184
+ path.join(testDir, "spec", "plans", "relative.yaml"),
1185
+ ];
1186
+
1187
+ for (const testPath of paths) {
1188
+ try {
1189
+ const result = readSpecFile(testPath, cfg);
1190
+ assert.strictEqual(result.content, "relative: test");
1191
+ break; // If one works, that's good
1192
+ } catch {
1193
+ // Continue trying other paths
1194
+ }
1195
+ }
1196
+ });
1197
+
1198
+ it("should use environment variables for project directory", async () => {
1199
+ // Set environment variable
1200
+ const originalDir = process.env.BUGMOLE_PROJECT_DIR;
1201
+ process.env.BUGMOLE_PROJECT_DIR = testDir;
1202
+
1203
+ try {
1204
+ const { readSpecFile } = await import("./server.js");
1205
+ const testFile = path.join(testDir, "spec", "env_test.yaml");
1206
+ fs.writeFileSync(testFile, "env: test");
1207
+
1208
+ const result = readSpecFile("env_test.yaml", cfg);
1209
+ assert.strictEqual(result.content, "env: test");
1210
+ } finally {
1211
+ // Restore
1212
+ if (originalDir) {
1213
+ process.env.BUGMOLE_PROJECT_DIR = originalDir;
1214
+ } else {
1215
+ delete process.env.BUGMOLE_PROJECT_DIR;
1216
+ }
1217
+ }
1218
+ });
1219
+ });
1220
+ });