@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,37 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { driverForPlatform } from "./planner.js";
4
+
5
+ test("a web plan is never pinned to the mobile driver", () => {
6
+ // bugmole.config defaults execution.driver to "maestro" while the plan
7
+ // itself writes platform "web". Taking that verbatim produced 18 plans that
8
+ // could only ever fail with "requires a flow reference", blocking 53 cases
9
+ // that were never really checked.
10
+ assert.equal(driverForPlatform("maestro", "web"), "playwright");
11
+ });
12
+
13
+ test("a configured driver is honoured when it can drive the platform", () => {
14
+ assert.equal(driverForPlatform("playwright", "web"), "playwright");
15
+ assert.equal(driverForPlatform("maestro", "ios"), "maestro");
16
+ assert.equal(driverForPlatform("maestro", "android"), "maestro");
17
+ });
18
+
19
+ test("simulation stays available on every platform, since it drives none of them for real", () => {
20
+ assert.equal(driverForPlatform("simulation", "web"), "simulation");
21
+ assert.equal(driverForPlatform("simulation", "ios"), "simulation");
22
+ });
23
+
24
+ test("a mobile platform does not fall back to a browser driver", () => {
25
+ assert.equal(driverForPlatform("playwright", "ios"), "maestro");
26
+ });
27
+
28
+ test("no configured driver picks the one that fits the platform", () => {
29
+ assert.equal(driverForPlatform(undefined, "web"), "playwright");
30
+ assert.equal(driverForPlatform(undefined, "android"), "maestro");
31
+ });
32
+
33
+ test("an unknown platform is treated as web rather than defaulting to mobile", () => {
34
+ // Guessing mobile for something unrecognised is how this failed in the
35
+ // first place: the driver that cannot run without a device file.
36
+ assert.equal(driverForPlatform(undefined, "desktop"), "playwright");
37
+ });
@@ -0,0 +1,274 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { createProjectObjectStore } from "../storage/create-object-store.js";
5
+ import { projectPrefix } from "../storage/keys.js";
6
+ import { ControlPlaneClient } from "../registry/control-plane-client.js";
7
+ import { nextJourneyTask } from "./journey-editor.js";
8
+
9
+ /** Drivers that can actually operate each kind of target. */
10
+ const DRIVERS_BY_PLATFORM: Record<string, string[]> = {
11
+ web: ["playwright", "simulation"],
12
+ ios: ["maestro", "simulation"],
13
+ android: ["maestro", "simulation"],
14
+ };
15
+
16
+ /**
17
+ * Pick the driver a plan should run under.
18
+ *
19
+ * The configured driver is a preference, not an instruction: bugmole.config
20
+ * defaults it to `maestro`, which is a mobile runner, while this same plan
21
+ * writes `platform: web`. Honouring that verbatim produced 18 web plans
22
+ * pinned to maestro, and every run of them died the same way — `Driver
23
+ * "maestro" requires a flow reference (missing_flow)` — blocking 53 test
24
+ * cases that were never actually checked.
25
+ *
26
+ * A plan that cannot possibly execute is worse than one that ignores a
27
+ * contradictory setting, so the platform wins when the two disagree.
28
+ */
29
+ export function driverForPlatform(configured: string | undefined, platform: string): string {
30
+ const usable = DRIVERS_BY_PLATFORM[platform] ?? DRIVERS_BY_PLATFORM.web;
31
+ if (configured && usable.includes(configured)) return configured;
32
+ return usable[0];
33
+ }
34
+
35
+ interface PlanOptions {
36
+ journeyId: string;
37
+ actor: string;
38
+ revision?: number;
39
+ }
40
+
41
+ interface PlanResult {
42
+ status: "success" | "error";
43
+ planId?: string;
44
+ planPath?: string;
45
+ message?: string;
46
+ error?: string;
47
+ nextSteps?: string[];
48
+ }
49
+
50
+ /**
51
+ * Planner: Converts graph paths into flow plans with prerequisites and variants
52
+ */
53
+ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult> {
54
+ try {
55
+ const { journeyId, actor } = options;
56
+ const specDir = cfg.runtime.spec_dir || "./spec";
57
+ const plansDir = path.join(specDir, "plans");
58
+ const graphPath = path.join(specDir, "journeys.graph.json");
59
+
60
+ // Ensure plans directory exists
61
+ fs.mkdirSync(plansDir, { recursive: true });
62
+
63
+ // Load journey graph
64
+ if (!fs.existsSync(graphPath)) {
65
+ return {
66
+ status: "error",
67
+ error: "Journey graph not found. Run exploration first.",
68
+ };
69
+ }
70
+
71
+ const graphContent = fs.readFileSync(graphPath, "utf-8");
72
+ const graph = JSON.parse(graphContent);
73
+ const discoveredNodes = Array.isArray(graph.nodes) ? graph.nodes : [];
74
+ const discoveredEdges = Array.isArray(graph.edges) ? graph.edges : [];
75
+ const selectedJourney = Array.isArray(graph.journeys)
76
+ ? graph.journeys.find((journey: any) => journey.id === journeyId)
77
+ : undefined;
78
+ if (!selectedJourney) {
79
+ return {
80
+ status: "error",
81
+ error: `Journey "${journeyId}" is not present in the discovered flow graph.`,
82
+ nextSteps: ["Select an existing journey or record the selected journey before planning."],
83
+ };
84
+ }
85
+ if (selectedJourney.stability !== "stable" || selectedJourney.linkedToGraph !== true) {
86
+ return {
87
+ status: "error",
88
+ error: `Journey "${journeyId}" is not stabilized. Execute and adjust the canonical flow until it is stable before planning.`,
89
+ nextSteps: [
90
+ `Run the canonical flow "${journeyId}"`,
91
+ "Adjust the flow for any failures and rerun it",
92
+ "Mark the verified run as the stabilized canonical flow",
93
+ ],
94
+ };
95
+ }
96
+ const flowState = nextJourneyTask(cfg, journeyId);
97
+ if (flowState.status === "actionable") {
98
+ return {
99
+ status: "error",
100
+ error: "Planning is blocked while the selected journey still has an actionable exploration task.",
101
+ nextSteps: flowState.nextSteps,
102
+ };
103
+ }
104
+ if (discoveredNodes.length === 0 && discoveredEdges.length === 0) {
105
+ return {
106
+ status: "error",
107
+ error: "Planning is blocked because repository/application exploration has not discovered a journey yet.",
108
+ nextSteps: [
109
+ "Read bugmole://guidance/agent-testing",
110
+ "Inspect the repository entrypoints, routes, and relevant specs",
111
+ "Run bugmole_explore with the application's runnable base URL",
112
+ "Report progress and follow the returned next_steps before retrying bugmole_plan",
113
+ ],
114
+ };
115
+ }
116
+
117
+ // Load roles to validate actor
118
+ const rolesPath = path.join(specDir, "roles.yaml");
119
+ if (!fs.existsSync(rolesPath)) {
120
+ return {
121
+ status: "error",
122
+ error: "Roles file not found. Define roles first.",
123
+ nextSteps: ["Create roles.yaml from the guided testing workflow, then retry planning."],
124
+ };
125
+ }
126
+ const roles = YAML.parse(fs.readFileSync(rolesPath, "utf-8"));
127
+ const knownActors = Array.isArray(roles?.roles)
128
+ ? roles.roles.map((role: any) => role?.name).filter((name: unknown): name is string => typeof name === "string")
129
+ : [];
130
+ if (!knownActors.includes(actor)) {
131
+ return {
132
+ status: "error",
133
+ error: `Actor "${actor}" is not defined in roles.yaml.`,
134
+ nextSteps: [
135
+ `Read roles.yaml and choose one of: ${knownActors.join(", ") || "(none defined)"}`,
136
+ "Retry planning with a defined actor",
137
+ ],
138
+ };
139
+ }
140
+
141
+ // Build a plan from discovered graph evidence. A fallback action is not
142
+ // acceptable because it would publish a plan the worker cannot justify.
143
+ const selectedEdgeIds = new Set<string>(selectedJourney?.edgeIds ?? []);
144
+ const graphSteps = discoveredEdges
145
+ .filter((edge: any) => selectedJourney
146
+ ? selectedEdgeIds.has(edge.id)
147
+ : String(edge.to ?? edge.target ?? "").includes(journeyId)
148
+ || String(edge.from ?? edge.source ?? "").includes(journeyId))
149
+ .slice(0, 12)
150
+ .map((edge: any, index: number) => ({
151
+ node_id: selectedJourney?.nodeIds?.[index],
152
+ action: edge.action || `navigate:${edge.to ?? "next-screen"}`,
153
+ proofs: [
154
+ { type: "navigation", expects: edge.to ?? "next-screen" },
155
+ { type: "dom", expects: `screen source: ${edge.source ?? "discovered application code"}` },
156
+ ],
157
+ }));
158
+ if (graphSteps.length === 0) {
159
+ return {
160
+ status: "error",
161
+ error: `No discovered graph actions match journey "${journeyId}". Planning is blocked to prevent a generic fallback plan.`,
162
+ nextSteps: [
163
+ "Use the authenticated owner session to explore the dashboard screens and actions",
164
+ "Update journeys.graph.json with concrete owner transitions and proof requirements",
165
+ `Retry planning for "${journeyId}" after matching edges are present`,
166
+ ],
167
+ };
168
+ }
169
+ const steps = graphSteps;
170
+ const planId = journeyId.replace(/[^a-z0-9._-]/gi, "_").toLowerCase();
171
+ const planPath = path.join(plansDir, `${planId}.flow.yaml`);
172
+
173
+ const plan = {
174
+ version: 2,
175
+ id: planId,
176
+ name: `Journey: ${journeyId}`,
177
+ description: `Generated plan for journey ${journeyId} with actor ${actor}`,
178
+ driver: driverForPlatform(cfg.execution?.driver, cfg.project?.platform || "web"),
179
+ platform: cfg.project?.platform || "web",
180
+ target: {
181
+ base_url: cfg.project?.base_url || cfg.execution?.playwright?.base_url,
182
+ app_id: cfg.project?.app_id,
183
+ device_id: cfg.project?.device_id,
184
+ serial: cfg.project?.serial,
185
+ },
186
+ flow: cfg.execution?.maestro?.flow || null,
187
+ recovery: {
188
+ enabled: cfg.execution?.recovery?.enabled === true,
189
+ max_attempts: cfg.execution?.recovery?.max_attempts ?? 1,
190
+ timeout_ms: cfg.execution?.recovery?.timeout_ms ?? 30_000,
191
+ },
192
+ journey_revision: options.revision ?? selectedJourney?.revision ?? 1,
193
+ suites: [
194
+ {
195
+ id: "default",
196
+ runs: "on_demand",
197
+ journeys: [
198
+ {
199
+ id: journeyId,
200
+ actor: actor,
201
+ goal: `Complete journey: ${journeyId}`,
202
+ capabilities_required: [],
203
+ preconditions: ["Test tenant / sandbox is active", "Actor account exists"],
204
+ steps,
205
+ },
206
+ ],
207
+ },
208
+ ],
209
+ };
210
+
211
+ // Write plan file
212
+ const yamlContent = YAML.stringify(plan);
213
+ fs.writeFileSync(planPath, yamlContent, "utf-8");
214
+ const objectKey = `${projectPrefix(cfg.project?.id ?? "default")}/plans/${planId}.flow.yaml`;
215
+ const store = createProjectObjectStore(cfg);
216
+ await store.put(objectKey, yamlContent, "text/yaml");
217
+ // A cloud worker reads the flow from the store as well, so publish it next to the plan.
218
+ const flowReference = typeof plan.flow === "string" ? plan.flow : undefined;
219
+ if (flowReference && !path.isAbsolute(flowReference)) {
220
+ const localFlow = path.resolve(cfg.runtime?.spec_dir || "./spec", flowReference);
221
+ if (fs.existsSync(localFlow)) {
222
+ const flowKey = `${projectPrefix(cfg.project?.id ?? "default")}/spec/${flowReference.replaceAll("\\", "/").replace(/^\/+/, "")}`;
223
+ await store.put(flowKey, fs.readFileSync(localFlow), "text/yaml");
224
+ }
225
+ }
226
+ const apiKey = process.env.BUGMOLE_API_KEY?.trim();
227
+ const registryUrl = process.env.BUGMOLE_REGISTRY_URL?.trim();
228
+ if (apiKey && registryUrl) {
229
+ const client = new ControlPlaneClient({
230
+ registryUrl,
231
+ apiKey,
232
+ projectId: cfg.project?.id ?? "default",
233
+ });
234
+ await client.publishPlan({ planId, journeyId, objectKey });
235
+ const unresolvedLinks = Array.isArray(graph.metadata?.analysis?.unresolvedLinks)
236
+ ? graph.metadata.analysis.unresolvedLinks
237
+ : [];
238
+ const blockedNodes = discoveredNodes.filter((node: any) => node.runtimeProbe?.status === "blocked");
239
+ const followUps = [
240
+ ...(unresolvedLinks.length ? [{
241
+ title: `Resolve ${unresolvedLinks.length} unresolved journey links`,
242
+ description: "Validate unresolved navigation targets during the next Lumis exploration and update the sitemap.",
243
+ taskType: "investigation" as const,
244
+ priority: 8,
245
+ idempotencyKey: `${journeyId}:${options.revision ?? selectedJourney?.revision ?? 1}:unresolved-links`,
246
+ }] : []),
247
+ ...(blockedNodes.length ? [{
248
+ title: `Verify ${blockedNodes.length} authenticated journey routes`,
249
+ description: "Provide the approved authenticated environment/session and rerun route and screenshot verification.",
250
+ taskType: "ui" as const,
251
+ priority: 9,
252
+ idempotencyKey: `${journeyId}:${options.revision ?? selectedJourney?.revision ?? 1}:blocked-routes`,
253
+ }] : []),
254
+ ];
255
+ await Promise.all(followUps.map((task) => client.createTask({
256
+ ...task,
257
+ journeyId,
258
+ journeyRevision: options.revision ?? selectedJourney?.revision ?? 1,
259
+ })));
260
+ }
261
+
262
+ return {
263
+ status: "success",
264
+ planId: planId,
265
+ planPath: `plans/${planId}.flow.yaml`,
266
+ message: `Plan created at ${planPath}. Review and customize as needed.${apiKey && registryUrl ? " Published to the dashboard." : ""}`,
267
+ };
268
+ } catch (error: any) {
269
+ return {
270
+ status: "error",
271
+ error: error.message || "Planning failed",
272
+ };
273
+ }
274
+ }
@@ -0,0 +1,76 @@
1
+ // Chooses who pays for a piece of AI work: the project's own model key when
2
+ // one is configured (free), otherwise Bugmole's model account through the
3
+ // registry, charged in credits (see src/billing/plan-catalog.ts).
4
+ import type { AiAction } from "../billing/plan-catalog.js";
5
+ import { analysisModel } from "./ai-analysis.js";
6
+ import { callModelWithUsage } from "./llm.js";
7
+
8
+ export type ModelCallIds = { projectId: string; runId?: string; requestId?: string; targetId?: string };
9
+ export type ModelCall = (prompt: string, purpose: AiAction) => Promise<{ text: string; model: string; source: "own" | "platform" }>;
10
+
11
+ export class PlatformAiError extends Error {
12
+ constructor(message: string, readonly status: number, readonly code?: string) {
13
+ super(message);
14
+ this.name = "PlatformAiError";
15
+ }
16
+ }
17
+
18
+ type PlatformEnv = Record<string, string | undefined>;
19
+
20
+ export function platformAiEnabled(env: PlatformEnv = process.env): boolean {
21
+ if ((env.BUGMOLE_PLATFORM_AI ?? "").toLowerCase() === "off") return false;
22
+ return Boolean(env.BUGMOLE_REGISTRY_URL?.trim() && (env.BUGMOLE_RUN_TOKEN?.trim() || env.BUGMOLE_API_KEY?.trim()));
23
+ }
24
+
25
+ export function platformModelCall(ids: ModelCallIds, env: PlatformEnv = process.env, doFetch: typeof fetch = fetch): ModelCall | null {
26
+ if (!platformAiEnabled(env)) return null;
27
+ const registryUrl = env.BUGMOLE_REGISTRY_URL!.trim().replace(/\/$/, "");
28
+ const token = env.BUGMOLE_RUN_TOKEN?.trim() || env.BUGMOLE_API_KEY!.trim();
29
+ return async (prompt, purpose) => {
30
+ const response = await doFetch(`${registryUrl}/api/ai/complete`, {
31
+ method: "POST",
32
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
33
+ body: JSON.stringify({ purpose, prompt, ...ids }),
34
+ signal: AbortSignal.timeout(purpose === "exploration" ? 150_000 : 75_000),
35
+ });
36
+ const body = await response.json().catch(() => ({})) as { text?: string; model?: string; error?: string; code?: string };
37
+ if (!response.ok) throw new PlatformAiError(body.error ?? `Bugmole AI returned ${response.status}`, response.status, body.code);
38
+ return { text: body.text ?? "", model: body.model ?? "bugmole", source: "platform" };
39
+ };
40
+ }
41
+
42
+ /** The project's own model when configured, else Bugmole AI, else null. */
43
+ export function modelCaller(cfg: any, ids: ModelCallIds, env: PlatformEnv = process.env): ModelCall | null {
44
+ const own = analysisModel(cfg);
45
+ if (own) {
46
+ return async (prompt, purpose) => {
47
+ const reply = await callModelWithUsage(prompt, {
48
+ provider: own.provider,
49
+ model: own.model,
50
+ apiKey: own.api_key,
51
+ baseUrl: own.base_url,
52
+ timeout: purpose === "exploration" ? 150_000 : 60_000,
53
+ maxTokens: purpose === "exploration" ? 8_000 : purpose === "repair" ? 2_000 : 600,
54
+ });
55
+ return { text: reply.text, model: own.model, source: "own" };
56
+ };
57
+ }
58
+ return platformModelCall(ids, env);
59
+ }
60
+
61
+ /** The first JSON object in a model reply, tolerating code fences and prose. */
62
+ export function extractJson(text: string): unknown {
63
+ const body = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
64
+ for (const [open, close] of [["{", "}"], ["[", "]"]] as const) {
65
+ const start = body.indexOf(open);
66
+ const end = body.lastIndexOf(close);
67
+ if (start >= 0 && end > start) {
68
+ try {
69
+ return JSON.parse(body.slice(start, end + 1));
70
+ } catch {
71
+ // try the other shape
72
+ }
73
+ }
74
+ }
75
+ return null;
76
+ }
@@ -0,0 +1,93 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+ import { isMissingVideoEncoder, parseMaestroStyleFlow, PlaywrightDriver } from "./playwright-driver.js";
4
+
5
+ test("parses a Maestro-style flow with url header", () => {
6
+ const flow = parseMaestroStyleFlow(`
7
+ url: http://localhost:3010
8
+ ---
9
+ - launchApp
10
+ - tapOn: "Upload Template"
11
+ - inputText: "hello"
12
+ - assertVisible: "Consulting v2"
13
+ `);
14
+ assert.equal(flow.url, "http://localhost:3010");
15
+ assert.deepEqual(flow.steps, [
16
+ { type: "launchApp" },
17
+ { type: "tapOn", text: "Upload Template", index: 0, optional: false },
18
+ { type: "inputText", text: "hello" },
19
+ { type: "assertVisible", text: "Consulting v2", timeoutMs: undefined },
20
+ ]);
21
+ });
22
+
23
+ test("falls back to the driver target base URL when the flow header declares neither url nor appId", () => {
24
+ const flow = parseMaestroStyleFlow(
25
+ `
26
+ name: some-flow
27
+ ---
28
+ - launchApp
29
+ `,
30
+ "http://localhost:4000",
31
+ );
32
+ assert.equal(flow.url, "http://localhost:4000");
33
+ });
34
+
35
+ test("rejects a flow missing the '---' document separator", () => {
36
+ assert.throws(() => parseMaestroStyleFlow("url: http://localhost\n- launchApp"), /header document and a steps document/);
37
+ });
38
+
39
+ test("rejects an unsupported step", () => {
40
+ assert.throws(
41
+ () => parseMaestroStyleFlow('url: http://localhost\n---\n- swipeLeft: "x"\n'),
42
+ /Unsupported flow step/,
43
+ );
44
+ });
45
+
46
+ test("only supports the web platform", () => {
47
+ const driver = new PlaywrightDriver();
48
+ assert.equal(driver.supports("web"), true);
49
+ assert.equal(driver.supports("android"), false);
50
+ assert.equal(driver.supports("ios"), false);
51
+ });
52
+
53
+ test("blocks with a clear reason for a non-web platform", async () => {
54
+ const driver = new PlaywrightDriver();
55
+ const result = await driver.run({
56
+ runId: "run-1",
57
+ projectId: "demo",
58
+ journeyId: "checkout",
59
+ flowPath: "/tmp/does-not-matter.yaml",
60
+ platform: "android",
61
+ target: {},
62
+ timeoutMs: 10_000,
63
+ artifactsDir: "/tmp/bugmole-runs",
64
+ });
65
+ assert.equal(result.status, "blocked");
66
+ assert.equal(result.failureCode, "unsupported_platform");
67
+ });
68
+
69
+ test("blocks with a clear reason when the flow file is missing", async () => {
70
+ const driver = new PlaywrightDriver();
71
+ const result = await driver.run({
72
+ runId: "run-1",
73
+ projectId: "demo",
74
+ journeyId: "checkout",
75
+ flowPath: "/tmp/definitely-does-not-exist.yaml",
76
+ platform: "web",
77
+ target: { baseUrl: "http://localhost:3010" },
78
+ timeoutMs: 10_000,
79
+ artifactsDir: "/tmp/bugmole-runs",
80
+ });
81
+ assert.equal(result.status, "blocked");
82
+ assert.equal(result.failureCode, "invalid_flow");
83
+ });
84
+
85
+ test("a worker without Playwright's ffmpeg loses the video, not the run", () => {
86
+ const playwrightError = new Error(
87
+ "browserContext.newPage: Executable doesn't exist at /home/runner/.cache/ms-playwright/ffmpeg-1011/ffmpeg-linux\n" +
88
+ "╔═════════════════════════════════════╗\n║ Video rendering requires ffmpeg binary. ║\n╚═════════════════════════════════════╝",
89
+ );
90
+ assert.equal(isMissingVideoEncoder(playwrightError), true);
91
+ assert.equal(isMissingVideoEncoder(new Error("Executable doesn't exist at /ms-playwright/chromium-1234/chrome-linux/chrome")), false);
92
+ assert.equal(isMissingVideoEncoder(new Error("net::ERR_CONNECTION_REFUSED")), false);
93
+ });