@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,509 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import type { AppDriver, DriverContext, DriverResult } from "./driver.js";
7
+
8
+ function makeTestConfig(testDir: string) {
9
+ const specDir = path.join(testDir, "spec");
10
+ const artifactsDir = path.join(testDir, "artifacts");
11
+ fs.mkdirSync(path.join(specDir, "plans"), { recursive: true });
12
+ fs.mkdirSync(artifactsDir, { recursive: true });
13
+ return {
14
+ project: { id: "demo" },
15
+ runtime: { spec_dir: specDir, artifacts_dir: artifactsDir },
16
+ // "false" always exits 1 without touching --test-output-dir, reproducing
17
+ // a driver that fails before any step executes (e.g. "device offline")
18
+ // without depending on a real Maestro install or device.
19
+ execution: { maestro: { command: "false" } },
20
+ };
21
+ }
22
+
23
+ // Reproduces run_1786624327747 from a real Bugmole project: Maestro failed
24
+ // to connect to a device before any step ran, yet the old executor reported
25
+ // every declared step as independently "failed" with an identical message.
26
+ // The fix must stop claiming per-step verification it never performed.
27
+ test("does not fabricate per-step verification when the driver fails before any step runs", async () => {
28
+ const { execute } = await import("./executor.js");
29
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-test-"));
30
+ const cfg = makeTestConfig(testDir);
31
+ const planYaml = `
32
+ suites:
33
+ - journeys:
34
+ - id: checkout
35
+ driver: maestro
36
+ platform: android
37
+ flow: flows/checkout.yaml
38
+ steps:
39
+ - node_id: step-1
40
+ action: launch
41
+ - node_id: step-2
42
+ action: tap-sign-in
43
+ - node_id: step-3
44
+ action: enter-email
45
+ `;
46
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "checkout.yaml"), planYaml, "utf8");
47
+
48
+ const result = await execute(cfg, { planId: "checkout", journeyId: "checkout" });
49
+
50
+ assert.equal(result.status, "partial");
51
+ assert.equal(result.stepsExecuted, 3);
52
+ assert.equal(result.stepsFailed, 3);
53
+ assert.ok(result.results);
54
+ for (const step of result.results!) {
55
+ assert.equal(step.outcome, "FAIL");
56
+ // The old behavior copied a single opaque "The journey action failed."
57
+ // onto every step, presenting it as independently verified. The fix
58
+ // must say plainly that only the aggregate run outcome is known.
59
+ assert.equal(step.reason.code, "aggregate_only");
60
+ assert.match(step.reason.message, /not independently verified/);
61
+ }
62
+ fs.rmSync(testDir, { recursive: true, force: true });
63
+ });
64
+
65
+ // The companion, positive case: when the driver's per-step count matches the
66
+ // plan's declared steps, each step must carry its own real observed status
67
+ // instead of the aggregate one.
68
+ test("uses real per-step status when the driver's step count matches the plan", async () => {
69
+ const { execute } = await import("./executor.js");
70
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-test-"));
71
+ const cfg = makeTestConfig(testDir);
72
+ cfg.execution.maestro.command = path.join(import.meta.dirname, "fixtures", "fake-maestro.sh");
73
+ const planYaml = `
74
+ suites:
75
+ - journeys:
76
+ - id: checkout
77
+ driver: maestro
78
+ platform: android
79
+ flow: flows/checkout.yaml
80
+ steps:
81
+ - node_id: step-1
82
+ action: launch
83
+ - node_id: step-2
84
+ action: tap-sign-in
85
+ - node_id: step-3
86
+ action: assert-home
87
+ `;
88
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "checkout.yaml"), planYaml, "utf8");
89
+
90
+ const result = await execute(cfg, { planId: "checkout", journeyId: "checkout" });
91
+
92
+ assert.equal(result.stepsExecuted, 3);
93
+ assert.equal(result.stepsPassed, 2);
94
+ assert.equal(result.stepsFailed, 1);
95
+ assert.ok(result.results);
96
+ const [first, second, third] = result.results!;
97
+ assert.equal(first.outcome, "PASS");
98
+ assert.equal(first.reason.code, "verified");
99
+ assert.equal(second.outcome, "PASS");
100
+ assert.equal(third.outcome, "FAIL");
101
+ assert.equal(third.reason.code, "execution_failed");
102
+ // Real, distinct per-step timing from Maestro's own commands.json, not a
103
+ // single timestamp copied onto every step.
104
+ assert.notEqual(first.startedAt, third.startedAt);
105
+ fs.rmSync(testDir, { recursive: true, force: true });
106
+ });
107
+
108
+ // A flow edited after its plan was written has a different step count. The
109
+ // steps then carry only the aggregate verdict, but the run's counts must still
110
+ // be the driver's real ones: one failed command is "1 failed", not "4 failed".
111
+ test("counts the driver's own steps when they do not line up with the plan", async () => {
112
+ const { execute } = await import("./executor.js");
113
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-test-"));
114
+ const cfg = makeTestConfig(testDir);
115
+ cfg.execution.maestro.command = path.join(import.meta.dirname, "fixtures", "fake-maestro.sh");
116
+ const planYaml = `
117
+ suites:
118
+ - journeys:
119
+ - id: checkout
120
+ driver: maestro
121
+ platform: android
122
+ flow: flows/checkout.yaml
123
+ steps:
124
+ - node_id: step-1
125
+ action: launch
126
+ - node_id: step-2
127
+ action: tap-sign-in
128
+ - node_id: step-3
129
+ action: enter-email
130
+ - node_id: step-4
131
+ action: assert-home
132
+ `;
133
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "checkout.yaml"), planYaml, "utf8");
134
+
135
+ const result = await execute(cfg, { planId: "checkout", journeyId: "checkout" });
136
+
137
+ assert.equal(result.status, "partial");
138
+ assert.equal(result.stepsExecuted, 4);
139
+ assert.equal(result.stepsPassed, 2);
140
+ assert.equal(result.stepsFailed, 1);
141
+ assert.ok(result.results!.every((step) => step.reason.code === "aggregate_only"));
142
+ fs.rmSync(testDir, { recursive: true, force: true });
143
+ });
144
+
145
+ test("loads the plan and its flow from the object store when they are not on the worker's disk", async () => {
146
+ const { execute } = await import("./executor.js");
147
+ const { LocalObjectStore } = await import("../storage/object-store.js");
148
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-store-"));
149
+ const cfg = makeTestConfig(testDir);
150
+ const store = new LocalObjectStore(cfg.runtime.artifacts_dir);
151
+ await store.put("projects/demo/plans/cloud-checkout.flow.yaml", `
152
+ suites:
153
+ - journeys:
154
+ - id: cloud-checkout
155
+ driver: maestro
156
+ platform: android
157
+ flow: flows/cloud-checkout.yaml
158
+ steps:
159
+ - node_id: step-1
160
+ action: launch
161
+ `, "text/yaml");
162
+ await store.put("projects/demo/spec/flows/cloud-checkout.yaml", "appId: demo\n---\n- launchApp\n", "text/yaml");
163
+
164
+ const result = await execute(cfg, { planId: "cloud-checkout", journeyId: "cloud-checkout", runId: "run-store" });
165
+
166
+ assert.doesNotMatch(result.error ?? "", /Plan not found/);
167
+ assert.equal(result.stepsExecuted, 1);
168
+ assert.ok(fs.existsSync(path.join(cfg.runtime.artifacts_dir, "demo", "run-store", "spec", "flows", "cloud-checkout.yaml")));
169
+ assert.equal(result.manifestKey, "projects/demo/runs/run-store/manifest.json");
170
+ assert.equal(result.evidencePath, "projects/demo/runs/run-store");
171
+ });
172
+
173
+
174
+ test("fans a web run out across browser/device targets in parallel against the selected environment", async () => {
175
+ const { execute } = await import("./executor.js");
176
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-matrix-"));
177
+ const cfg = makeTestConfig(testDir);
178
+ const planYaml = `
179
+ suites:
180
+ - journeys:
181
+ - id: sign-in
182
+ driver: playwright
183
+ platform: web
184
+ flow: flows/sign-in.yaml
185
+ target:
186
+ base_url: http://localhost:3000
187
+ steps:
188
+ - node_id: open
189
+ action: open
190
+ - node_id: submit
191
+ action: submit
192
+ `;
193
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "sign-in.yaml"), planYaml, "utf8");
194
+
195
+ const contexts: DriverContext[] = [];
196
+ let inFlight = 0;
197
+ let peak = 0;
198
+ const driver: AppDriver = {
199
+ id: "playwright",
200
+ async run(context): Promise<DriverResult> {
201
+ contexts.push(context);
202
+ inFlight += 1;
203
+ peak = Math.max(peak, inFlight);
204
+ await new Promise((resolve) => setTimeout(resolve, 40));
205
+ inFlight -= 1;
206
+ const target = context.runTarget!;
207
+ if (target.browser === "msedge") {
208
+ return { status: "blocked", message: "Edge is not installed", failureCode: "browser_not_installed", artifacts: [] };
209
+ }
210
+ const screenshot = path.join(testDir, `${target.id}-final.png`);
211
+ fs.writeFileSync(screenshot, "png");
212
+ const failed = target.browser === "firefox";
213
+ return {
214
+ status: failed ? "failed" : "passed",
215
+ message: failed ? "Submit button not found" : "ok",
216
+ artifacts: [{ name: "playwright-final.png", path: screenshot, contentType: "image/png" }],
217
+ steps: [
218
+ { sequenceNumber: 0, status: "passed" },
219
+ { sequenceNumber: 1, status: failed ? "failed" : "passed" },
220
+ ],
221
+ durationMs: 40,
222
+ };
223
+ },
224
+ } as AppDriver;
225
+
226
+ const snapshots: string[][] = [];
227
+ const result = await execute(cfg, {
228
+ planId: "sign-in",
229
+ journeyId: "sign-in",
230
+ runId: "run-matrix",
231
+ baseUrl: "https://staging.example.com",
232
+ targets: [
233
+ { id: "chromium", browser: "chromium" },
234
+ { id: "firefox", browser: "firefox" },
235
+ { id: "msedge", browser: "msedge" },
236
+ { id: "webkit-iphone-15", browser: "webkit", device: "iPhone 15" },
237
+ ],
238
+ driverFactory: () => driver,
239
+ onProgress: (progress) => {
240
+ if (progress.targetResults) snapshots.push(progress.targetResults.map((entry) => entry.status));
241
+ },
242
+ });
243
+
244
+ assert.equal(contexts.length, 4);
245
+ assert.ok(peak > 1, "targets should run concurrently");
246
+ for (const context of contexts) {
247
+ assert.equal(context.target.baseUrl, "https://staging.example.com");
248
+ assert.equal(context.rebaseOrigin, true);
249
+ }
250
+ assert.equal(result.status, "partial");
251
+ assert.deepEqual(result.targetResults?.map((entry) => [entry.targetId, entry.status]), [
252
+ ["chromium", "passed"],
253
+ ["firefox", "failed"],
254
+ ["msedge", "blocked"],
255
+ ["webkit-iphone-15", "passed"],
256
+ ]);
257
+ const chromium = result.targetResults![0];
258
+ assert.equal(chromium.durationMs, 40);
259
+ assert.equal(chromium.stepsPassed, 2);
260
+ assert.equal(chromium.finalScreenshotKey, "projects/demo/runs/run-matrix/artifacts/chromium/playwright-final.png");
261
+ assert.deepEqual(result.targetResults![2].artifactKeys, []);
262
+ // The worst status per step across targets that ran.
263
+ assert.equal(result.stepsFailed, 1);
264
+ assert.deepEqual(snapshots[0], ["queued", "queued", "queued", "queued"]);
265
+ assert.ok(snapshots.some((snapshot) => snapshot.includes("running")));
266
+ assert.equal(typeof result.durationMs, "number");
267
+ const manifest = JSON.parse(fs.readFileSync(path.join(cfg.runtime.artifacts_dir, "projects", "demo", "runs", "run-matrix", "manifest.json"), "utf8"));
268
+ assert.equal(manifest.targets.length, 4);
269
+ fs.rmSync(testDir, { recursive: true, force: true });
270
+ });
271
+
272
+ test("keeps the single-driver path when no matrix is requested", async () => {
273
+ const { execute } = await import("./executor.js");
274
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-single-"));
275
+ const cfg = makeTestConfig(testDir);
276
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "home.yaml"), `
277
+ suites:
278
+ - journeys:
279
+ - id: home
280
+ driver: playwright
281
+ platform: web
282
+ flow: flows/home.yaml
283
+ steps:
284
+ - node_id: open
285
+ action: open
286
+ `, "utf8");
287
+ const contexts: DriverContext[] = [];
288
+ const driver = {
289
+ id: "playwright",
290
+ async run(context: DriverContext): Promise<DriverResult> {
291
+ contexts.push(context);
292
+ return { status: "passed", message: "ok", artifacts: [], steps: [{ sequenceNumber: 0, status: "passed" }] };
293
+ },
294
+ } as unknown as AppDriver;
295
+ const result = await execute(cfg, { planId: "home", journeyId: "home", driverFactory: () => driver });
296
+ assert.equal(contexts.length, 1);
297
+ assert.equal(contexts[0].runTarget, undefined);
298
+ assert.equal(contexts[0].rebaseOrigin, false);
299
+ assert.equal(result.targetResults, undefined);
300
+ assert.equal(result.status, "success");
301
+ fs.rmSync(testDir, { recursive: true, force: true });
302
+ });
303
+
304
+ test("a failing progress update never stops a browser target from running", async () => {
305
+ const { execute } = await import("./executor.js");
306
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-progress-"));
307
+ const cfg = makeTestConfig(testDir);
308
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "home.yaml"), `
309
+ suites:
310
+ - journeys:
311
+ - id: home
312
+ driver: playwright
313
+ platform: web
314
+ flow: flows/home.yaml
315
+ steps:
316
+ - node_id: open
317
+ action: open
318
+ `, "utf8");
319
+ const ran: string[] = [];
320
+ const driver = {
321
+ id: "playwright",
322
+ async run(context: DriverContext): Promise<DriverResult> {
323
+ ran.push(context.runTarget!.id);
324
+ await new Promise((resolve) => setTimeout(resolve, 10));
325
+ return { status: "passed", message: "ok", artifacts: [], steps: [{ sequenceNumber: 0, status: "passed" }] };
326
+ },
327
+ } as unknown as AppDriver;
328
+ // Reproduces concurrent registry updates failing (HTTP 500) while lanes report.
329
+ let calls = 0;
330
+ const result = await execute(cfg, {
331
+ planId: "home",
332
+ journeyId: "home",
333
+ targets: [
334
+ { id: "chromium", browser: "chromium" },
335
+ { id: "firefox", browser: "firefox" },
336
+ { id: "webkit", browser: "webkit" },
337
+ ],
338
+ driverFactory: () => driver,
339
+ onProgress: async () => {
340
+ calls += 1;
341
+ if (calls > 1) throw new Error("Registry update failed (500)");
342
+ },
343
+ });
344
+ assert.deepEqual([...ran].sort(), ["chromium", "firefox", "webkit"]);
345
+ assert.equal(result.status, "success");
346
+ assert.deepEqual(result.targetResults?.map((entry) => entry.status), ["passed", "passed", "passed"]);
347
+ fs.rmSync(testDir, { recursive: true, force: true });
348
+ });
349
+
350
+ test("apply fix patches the flow before running, and refuses a stale or empty fix", async () => {
351
+ const { execute } = await import("./executor.js");
352
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-fix-"));
353
+ const cfg = makeTestConfig(testDir);
354
+ fs.mkdirSync(path.join(cfg.runtime.spec_dir, "flows"), { recursive: true });
355
+ const flowFile = path.join(cfg.runtime.spec_dir, "flows", "home.yaml");
356
+ fs.writeFileSync(flowFile, 'url: http://localhost\n---\n- launchApp\n- tapOn: "Sign in"\n', "utf8");
357
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "home.yaml"), `
358
+ suites:
359
+ - journeys:
360
+ - id: home
361
+ driver: playwright
362
+ platform: web
363
+ flow: flows/home.yaml
364
+ steps:
365
+ - node_id: open
366
+ action: open
367
+ `, "utf8");
368
+ const seen: string[] = [];
369
+ const driver = {
370
+ id: "playwright",
371
+ async run(context: DriverContext): Promise<DriverResult> {
372
+ seen.push(fs.readFileSync(context.flowPath, "utf8"));
373
+ return { status: "passed", message: "ok", artifacts: [], steps: [{ sequenceNumber: 0, status: "passed" }] };
374
+ },
375
+ } as unknown as AppDriver;
376
+ const change = { op: "replace" as const, index: 1, expect: '{"type":"tapOn","text":"Sign in","index":0,"optional":false}', step: { tapOn: "Log in" } };
377
+ const fixed = await execute(cfg, {
378
+ planId: "home",
379
+ journeyId: "home",
380
+ targets: [{ id: "chromium", browser: "chromium" }],
381
+ applyFix: { changes: [change], sourceRunId: "run-old", targetId: "chromium" },
382
+ driverFactory: () => driver,
383
+ });
384
+ assert.match(seen[0], /Log in/);
385
+ assert.match(fs.readFileSync(flowFile, "utf8"), /Log in/);
386
+ assert.deepEqual(fixed.fixApplied, { sourceRunId: "run-old", targetId: "chromium", flow: "flows/home.yaml" });
387
+
388
+ // Same fix again: the step no longer says "Sign in", so it is stale.
389
+ const stale = await execute(cfg, { planId: "home", journeyId: "home", targets: [{ id: "chromium", browser: "chromium" }], applyFix: { changes: [change] }, driverFactory: () => driver });
390
+ assert.equal(stale.failureCode, "fix_not_applicable");
391
+ assert.equal(seen.length, 1);
392
+ const empty = await execute(cfg, { planId: "home", journeyId: "home", targets: [{ id: "chromium", browser: "chromium" }], applyFix: { changes: [] }, driverFactory: () => driver });
393
+ assert.equal(empty.failureCode, "fix_not_applicable");
394
+ fs.rmSync(testDir, { recursive: true, force: true });
395
+ });
396
+
397
+ test("an inserted fix adds matching nodes to a plan that lists one node per flow step", async () => {
398
+ const { execute } = await import("./executor.js");
399
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-fix-plan-"));
400
+ const cfg = makeTestConfig(testDir);
401
+ fs.mkdirSync(path.join(cfg.runtime.spec_dir, "flows"), { recursive: true });
402
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "flows", "form.yaml"), 'url: http://localhost\n---\n- launchApp\n- tapOn: "Submit"\n', "utf8");
403
+ const planFile = path.join(cfg.runtime.spec_dir, "plans", "form.yaml");
404
+ fs.writeFileSync(planFile, `
405
+ suites:
406
+ - journeys:
407
+ - id: form
408
+ driver: playwright
409
+ platform: web
410
+ flow: flows/form.yaml
411
+ steps:
412
+ - { node_id: open, action: Open }
413
+ - { node_id: submit, action: Submit }
414
+ `, "utf8");
415
+ const driver = {
416
+ id: "playwright",
417
+ async run(): Promise<DriverResult> {
418
+ return {
419
+ status: "passed",
420
+ message: "ok",
421
+ artifacts: [],
422
+ steps: [0, 1, 2, 3].map((sequenceNumber) => ({ sequenceNumber, status: "passed" as const })),
423
+ };
424
+ },
425
+ } as unknown as AppDriver;
426
+ const result = await execute(cfg, {
427
+ planId: "form",
428
+ journeyId: "form",
429
+ targets: [{ id: "chromium", browser: "chromium" }],
430
+ applyFix: { changes: [{ op: "insert", index: 1, steps: [{ tapOn: "Email" }, { inputText: "qa@example.com" }] }] },
431
+ driverFactory: () => driver,
432
+ });
433
+ const plan = fs.readFileSync(planFile, "utf8");
434
+ assert.match(plan, /node_id: fix-1[\s\S]*Tap “Email”[\s\S]*node_id: fix-2[\s\S]*node_id: submit/);
435
+ // Four plan nodes for four flow steps: every step is reported on its own.
436
+ assert.equal(result.stepsExecuted, 4);
437
+ assert.equal(result.results?.[0].reason.code, "verified");
438
+ fs.rmSync(testDir, { recursive: true, force: true });
439
+ });
440
+
441
+ test("AI repair asks the model for a checked edit, then runs the fixed flow", async () => {
442
+ const { execute } = await import("./executor.js");
443
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-executor-ai-fix-"));
444
+ const cfg = makeTestConfig(testDir);
445
+ fs.mkdirSync(path.join(cfg.runtime.spec_dir, "flows"), { recursive: true });
446
+ const flowFile = path.join(cfg.runtime.spec_dir, "flows", "home.yaml");
447
+ fs.writeFileSync(flowFile, 'url: http://localhost\n---\n- launchApp\n- tapOn: "Continue"\n- assertVisible: "Payment"\n', "utf8");
448
+ fs.writeFileSync(path.join(cfg.runtime.spec_dir, "plans", "home.yaml"), `
449
+ suites:
450
+ - journeys:
451
+ - id: home
452
+ driver: playwright
453
+ platform: web
454
+ flow: flows/home.yaml
455
+ `, "utf8");
456
+ const seen: string[] = [];
457
+ const driver = {
458
+ id: "playwright",
459
+ async run(context: DriverContext): Promise<DriverResult> {
460
+ seen.push(fs.readFileSync(context.flowPath, "utf8"));
461
+ return { status: "passed", message: "ok", artifacts: [], steps: [] };
462
+ },
463
+ } as unknown as AppDriver;
464
+ const failureContext = {
465
+ failedStepIndex: 2, error: "Timed out", pageErrors: [], consoleErrors: [],
466
+ candidates: [{ role: "textbox", text: "Email" }], alerts: ["Enter your email"], invalidFields: [],
467
+ };
468
+ const requests: any[] = [];
469
+ const originalFetch = globalThis.fetch;
470
+ const saved = { url: process.env.BUGMOLE_REGISTRY_URL, key: process.env.BUGMOLE_API_KEY };
471
+ process.env.BUGMOLE_REGISTRY_URL = "https://registry.test";
472
+ process.env.BUGMOLE_API_KEY = "vk_test";
473
+ const reply = { summary: "Enter the email first.", changes: [{ op: "insert", index: 1, steps: [{ tapOn: "Email" }, { inputText: "qa@example.com" }] }] };
474
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
475
+ requests.push({ url, body: JSON.parse(String(init.body)) });
476
+ return Response.json({ text: JSON.stringify(reply), model: "claude-sonnet-5" });
477
+ }) as unknown as typeof fetch;
478
+ try {
479
+ const result = await execute(cfg, {
480
+ planId: "home",
481
+ journeyId: "home",
482
+ runId: "run-new",
483
+ targets: [{ id: "chromium", browser: "chromium" }],
484
+ applyFix: { changes: [], mode: "ai", sourceRunId: "run-old", targetId: "chromium", failureContext },
485
+ driverFactory: () => driver,
486
+ });
487
+ assert.equal(result.fixApplied?.sourceRunId, "run-old");
488
+ assert.match(seen[0], /inputText: qa@example.com/);
489
+ assert.equal(requests.length, 1);
490
+ assert.equal(requests[0].url, "https://registry.test/api/ai/complete");
491
+ assert.equal(requests[0].body.purpose, "repair");
492
+ assert.equal(requests[0].body.runId, "run-new");
493
+ assert.match(requests[0].body.prompt, /Enter your email/);
494
+
495
+ // A model answer the flow checker rejects never reaches the test.
496
+ reply.changes = [{ op: "insert", index: 42, steps: [{ tapOn: "x" }] }];
497
+ const rejected = await execute(cfg, {
498
+ planId: "home", journeyId: "home", runId: "run-3", targets: [{ id: "chromium", browser: "chromium" }],
499
+ applyFix: { changes: [], mode: "ai", failureContext }, driverFactory: () => driver,
500
+ });
501
+ assert.equal(rejected.failureCode, "fix_not_applicable");
502
+ assert.equal(seen.length, 1);
503
+ } finally {
504
+ globalThis.fetch = originalFetch;
505
+ if (saved.url === undefined) delete process.env.BUGMOLE_REGISTRY_URL; else process.env.BUGMOLE_REGISTRY_URL = saved.url;
506
+ if (saved.key === undefined) delete process.env.BUGMOLE_API_KEY; else process.env.BUGMOLE_API_KEY = saved.key;
507
+ fs.rmSync(testDir, { recursive: true, force: true });
508
+ }
509
+ });