@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,84 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+ import { buildMaestroArgs, maestroDebugDir, MaestroDriver, parseMaestroCommands } from "./maestro-driver.js";
4
+
5
+ const context = {
6
+ runId: "run-1",
7
+ projectId: "demo",
8
+ journeyId: "checkout",
9
+ flowPath: "/tmp/checkout.yaml",
10
+ platform: "android" as const,
11
+ target: { appId: "com.example.app", serial: "emulator-5554" },
12
+ timeoutMs: 10_000,
13
+ artifactsDir: "/tmp/bugmole-runs",
14
+ };
15
+
16
+ test("builds a bounded Maestro command for a target device with an explicit debug dir", () => {
17
+ assert.deepEqual(buildMaestroArgs(context), [
18
+ "test",
19
+ "/tmp/checkout.yaml",
20
+ "--format",
21
+ "junit",
22
+ "--test-output-dir",
23
+ maestroDebugDir(context),
24
+ "--device",
25
+ "emulator-5554",
26
+ ]);
27
+ });
28
+
29
+ test("passes --platform web for web-target flows", () => {
30
+ assert.deepEqual(buildMaestroArgs({ ...context, platform: "web", target: {} }), [
31
+ "test",
32
+ "/tmp/checkout.yaml",
33
+ "--format",
34
+ "junit",
35
+ "--test-output-dir",
36
+ maestroDebugDir({ ...context, platform: "web", target: {} }),
37
+ "--platform",
38
+ "web",
39
+ ]);
40
+ });
41
+
42
+ test("supports the canonical web, Android, and iOS platforms", () => {
43
+ const driver = new MaestroDriver({ command: "maestro" });
44
+ assert.equal(driver.supports("web"), true);
45
+ assert.equal(driver.supports("android"), true);
46
+ assert.equal(driver.supports("ios"), true);
47
+ });
48
+
49
+ test("does not claim unsupported platforms", () => {
50
+ const driver = new MaestroDriver();
51
+ assert.equal(driver.supports("web"), true);
52
+ assert.equal(driver.supports("desktop" as never), false);
53
+ });
54
+
55
+ test("parses real per-step status/timing from Maestro's own commands.json", () => {
56
+ // Shape confirmed from an actual local capture:
57
+ // ~/.maestro/tests/2026-08-27_125426/member-login/commands.json
58
+ const raw = JSON.stringify([
59
+ {
60
+ command: { launchAppCommand: { appId: "com.example.app" } },
61
+ metadata: { status: "COMPLETED", timestamp: 1000, duration: 3363, sequenceNumber: 0, depth: 0 },
62
+ },
63
+ {
64
+ command: { tapOnCommand: { text: "Sign in" } },
65
+ metadata: { status: "FAILED", timestamp: 4400, duration: 50, sequenceNumber: 1, depth: 0 },
66
+ },
67
+ {
68
+ command: { assertVisibleCommand: { text: "Home" } },
69
+ // Nested inside a retry/repeat block; must not be treated as its own step.
70
+ metadata: { status: "COMPLETED", timestamp: 4500, duration: 10, sequenceNumber: 2, depth: 1 },
71
+ },
72
+ ]);
73
+ const steps = parseMaestroCommands(raw);
74
+ assert.equal(steps.length, 2);
75
+ assert.equal(steps[0].status, "passed");
76
+ assert.equal(steps[0].durationMs, 3363);
77
+ assert.equal(steps[1].status, "failed");
78
+ assert.equal(steps[1].sequenceNumber, 1);
79
+ });
80
+
81
+ test("returns no steps for malformed or missing commands.json content", () => {
82
+ assert.deepEqual(parseMaestroCommands("not json"), []);
83
+ assert.deepEqual(parseMaestroCommands(JSON.stringify({ not: "an array" })), []);
84
+ });
@@ -0,0 +1,209 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import type {
6
+ AppDriver,
7
+ DriverContext,
8
+ DriverPlatform,
9
+ DriverResult,
10
+ DriverStepResult,
11
+ } from "./driver.js";
12
+
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ export type MaestroDriverOptions = {
16
+ command?: string;
17
+ extraArgs?: string[];
18
+ };
19
+
20
+ export function maestroDebugDir(context: DriverContext): string {
21
+ return path.join(context.artifactsDir, context.projectId, context.runId, "maestro-debug");
22
+ }
23
+
24
+ export function buildMaestroArgs(context: DriverContext, extraArgs: string[] = []): string[] {
25
+ const args = [
26
+ "test",
27
+ context.flowPath,
28
+ "--format",
29
+ "junit",
30
+ // Without an explicit output directory, Maestro writes commands.json,
31
+ // screenshots/, and logs/ under a timestamped ~/.maestro/tests/ path this
32
+ // driver never reads back. An explicit --test-output-dir writes those
33
+ // artifacts directly into a run-scoped directory this driver controls.
34
+ "--test-output-dir",
35
+ maestroDebugDir(context),
36
+ ];
37
+ // Maestro's CLI selects platform via --platform, not an env var; without it,
38
+ // `maestro test` falls back to Android device auto-detection even for a web
39
+ // flow and fails with "device offline" when no emulator is running.
40
+ // Android/iOS already disambiguate via --device below, so this is scoped to
41
+ // web to avoid changing behavior for the driver's other supported platforms.
42
+ if (context.platform === "web") {
43
+ args.push("--platform", "web");
44
+ }
45
+ if (context.target.deviceId || context.target.serial) {
46
+ args.push("--device", context.target.deviceId || context.target.serial || "");
47
+ }
48
+ return [...args, ...extraArgs];
49
+ }
50
+
51
+ function artifactPath(context: DriverContext, name: string): string {
52
+ return path.join(context.artifactsDir, context.projectId, context.runId, name);
53
+ }
54
+
55
+ async function writeOutput(context: DriverContext, name: string, value: string): Promise<string> {
56
+ const outputPath = artifactPath(context, name);
57
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
58
+ await fs.writeFile(outputPath, value, "utf8");
59
+ return outputPath;
60
+ }
61
+
62
+ type MaestroCommandRecord = {
63
+ command?: Record<string, unknown>;
64
+ metadata?: {
65
+ status?: string;
66
+ timestamp?: number;
67
+ duration?: number;
68
+ sequenceNumber?: number;
69
+ depth?: number;
70
+ };
71
+ };
72
+
73
+ /**
74
+ * Parses Maestro's own --test-output-dir commands.json into real per-step
75
+ * results. Only depth:0 commands are surfaced — nested commands (inside
76
+ * repeat/retry blocks) do not correspond 1:1 with a flow's declared steps.
77
+ */
78
+ export function parseMaestroCommands(raw: string): DriverStepResult[] {
79
+ let records: MaestroCommandRecord[];
80
+ try {
81
+ records = JSON.parse(raw);
82
+ } catch {
83
+ return [];
84
+ }
85
+ if (!Array.isArray(records)) return [];
86
+ const topLevel = records.filter((record) => (record.metadata?.depth ?? 0) === 0);
87
+ return topLevel.map((record, index) => {
88
+ const status = (record.metadata?.status ?? "").toUpperCase();
89
+ const commandName = record.command ? Object.keys(record.command)[0] : undefined;
90
+ return {
91
+ sequenceNumber: record.metadata?.sequenceNumber ?? index,
92
+ status: status.includes("FAIL")
93
+ ? "failed"
94
+ : status === "COMPLETED" || status === "SUCCESS"
95
+ ? "passed"
96
+ : "blocked",
97
+ message: commandName ? `${commandName}: ${record.metadata?.status ?? "unknown"}` : record.metadata?.status,
98
+ durationMs: record.metadata?.duration,
99
+ timestamp: record.metadata?.timestamp ? new Date(record.metadata.timestamp).toISOString() : undefined,
100
+ };
101
+ });
102
+ }
103
+
104
+ async function readMaestroDebugArtifacts(
105
+ debugDir: string,
106
+ ): Promise<{ steps: DriverStepResult[]; screenshotPaths: string[] }> {
107
+ const steps = await fs
108
+ .readFile(path.join(debugDir, "commands.json"), "utf8")
109
+ .then(parseMaestroCommands)
110
+ .catch(() => []);
111
+ const screenshotPaths = await fs
112
+ .readdir(path.join(debugDir, "screenshots"))
113
+ .then((names) => names.filter((name) => /\.(png|jpe?g)$/i.test(name)).map((name) => path.join(debugDir, "screenshots", name)))
114
+ .catch(() => []);
115
+ return { steps, screenshotPaths };
116
+ }
117
+
118
+ export class MaestroDriver implements AppDriver {
119
+ readonly id = "maestro";
120
+ private readonly command: string;
121
+ private readonly extraArgs: string[];
122
+
123
+ constructor(options: MaestroDriverOptions = {}) {
124
+ this.command = options.command?.trim() || "maestro";
125
+ this.extraArgs = options.extraArgs ?? [];
126
+ }
127
+
128
+ supports(platform: DriverPlatform): boolean {
129
+ return platform === "web" || platform === "android" || platform === "ios";
130
+ }
131
+
132
+ async run(context: DriverContext): Promise<DriverResult> {
133
+ if (!this.supports(context.platform)) {
134
+ return {
135
+ status: "blocked",
136
+ message: `Maestro does not support platform "${context.platform}".`,
137
+ artifacts: [],
138
+ failureCode: "unsupported_platform",
139
+ };
140
+ }
141
+
142
+ const args = buildMaestroArgs(context, this.extraArgs);
143
+ const environment = {
144
+ ...process.env,
145
+ ...context.environment,
146
+ MAESTRO_PLATFORM: context.platform,
147
+ ...(context.target.baseUrl ? { MAESTRO_BASE_URL: context.target.baseUrl } : {}),
148
+ ...(context.target.appId ? { MAESTRO_APP_ID: context.target.appId } : {}),
149
+ };
150
+
151
+ let stdout = "";
152
+ let stderr = "";
153
+ let exitCode = 0;
154
+ try {
155
+ const result = await execFileAsync(this.command, args, {
156
+ cwd: process.cwd(),
157
+ env: environment,
158
+ timeout: context.timeoutMs,
159
+ maxBuffer: 8 * 1024 * 1024,
160
+ });
161
+ stdout = result.stdout;
162
+ stderr = result.stderr;
163
+ } catch (error) {
164
+ const failure = error as { code?: string | number; stdout?: string; stderr?: string; killed?: boolean; signal?: string };
165
+ stdout = failure.stdout ?? "";
166
+ stderr = failure.stderr ?? "";
167
+ exitCode = typeof failure.code === "number" ? failure.code : 1;
168
+ if (failure.code === "ENOENT") {
169
+ return {
170
+ status: "blocked",
171
+ message: `Maestro executable was not found: ${this.command}`,
172
+ stdout,
173
+ stderr,
174
+ artifacts: [],
175
+ failureCode: "driver_unavailable",
176
+ };
177
+ }
178
+ if (failure.killed || failure.signal === "SIGTERM") {
179
+ exitCode = 124;
180
+ }
181
+ }
182
+
183
+ const stdoutPath = await writeOutput(context, "maestro.stdout.log", stdout);
184
+ const stderrPath = await writeOutput(context, "maestro.stderr.log", stderr);
185
+ const debugDir = maestroDebugDir(context);
186
+ const { steps, screenshotPaths } = await readMaestroDebugArtifacts(debugDir);
187
+ const artifacts = [
188
+ { path: stdoutPath, contentType: "text/plain", name: "maestro.stdout.log" },
189
+ { path: stderrPath, contentType: "text/plain", name: "maestro.stderr.log" },
190
+ ...screenshotPaths.map((screenshotPath) => ({
191
+ path: screenshotPath,
192
+ contentType: /\.png$/i.test(screenshotPath) ? "image/png" : "image/jpeg",
193
+ name: `screenshots/${path.basename(screenshotPath)}`,
194
+ })),
195
+ ];
196
+ const passed = exitCode === 0;
197
+ return {
198
+ status: passed ? "passed" : "failed",
199
+ exitCode,
200
+ message: passed ? "Maestro flow completed." : `Maestro flow failed with exit code ${exitCode}.`,
201
+ stdout,
202
+ stderr,
203
+ artifacts,
204
+ recoverable: !passed && exitCode !== 124,
205
+ failureCode: passed ? undefined : exitCode === 124 ? "driver_timeout" : "flow_failed",
206
+ steps: steps.length ? steps : undefined,
207
+ };
208
+ }
209
+ }
@@ -0,0 +1,21 @@
1
+ // How Bugmole talks about results: its agents are moles, failures it catches
2
+ // are bugs they found, and failures that later pass are bugs they ate. The
3
+ // theme is personality only: workspace, project, test run and issue keep
4
+ // their ordinary names everywhere.
5
+ // apps/qa/src/lib/mole-voice.ts is the dashboard's copy; keep them in step.
6
+
7
+ export const bugCount = (count: number): string => `${count} bug${count === 1 ? "" : "s"}`;
8
+
9
+ /** "Our moles found 3 bugs" / "No bugs hiding here". */
10
+ export const molesFound = (count: number): string =>
11
+ count > 0 ? `Our moles found ${bugCount(count)}` : NO_BUGS;
12
+
13
+ export const NO_BUGS = "No bugs hiding here";
14
+
15
+ /** What a run in progress is doing: "Digging for bugs in shop.example.com…". */
16
+ export const digging = (where?: string | null): string =>
17
+ where ? `Digging for bugs in ${where}…` : "Digging for bugs…";
18
+
19
+ /** "Our moles ate 3 bugs"; without a count, "Our moles ate the bugs". */
20
+ export const molesAte = (count?: number | null): string =>
21
+ count && count > 0 ? `Our moles ate ${bugCount(count)}` : "Our moles ate the bugs";
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { extractLinks, normalizeCrawledHref } from "./nav-crawl.js";
4
+ import { entranceRank } from "./explorer.js";
5
+
6
+ const BASE = "http://localhost:3020";
7
+
8
+ test("keeps in-app navigation and drops the query string, since it is the same screen", () => {
9
+ assert.equal(normalizeCrawledHref("/plans?project=bugmole&environment=all", BASE), "/plans");
10
+ assert.equal(normalizeCrawledHref("/parity/issues?project=x", BASE), "/parity/issues");
11
+ assert.equal(normalizeCrawledHref("http://localhost:3020/tasks", BASE), "/tasks");
12
+ });
13
+
14
+ test("the root route normalises to /", () => {
15
+ assert.equal(normalizeCrawledHref("/", BASE), "/");
16
+ assert.equal(normalizeCrawledHref("/?project=bugmole", BASE), "/");
17
+ assert.equal(normalizeCrawledHref("/plans/", BASE), "/plans");
18
+ });
19
+
20
+ test("build output and static assets are not screens", () => {
21
+ for (const href of [
22
+ "/_next/static/chunks/webpack.js?v=1",
23
+ "/_next/static/css/app/layout.css",
24
+ "/favicon.png",
25
+ "/bugmole-logo.png",
26
+ "/report.pdf",
27
+ ]) {
28
+ assert.equal(normalizeCrawledHref(href, BASE), null, `${href} must not be treated as a route`);
29
+ }
30
+ });
31
+
32
+ test("cross-origin and non-navigational hrefs are dropped", () => {
33
+ assert.equal(normalizeCrawledHref("https://example.com/plans", BASE), null);
34
+ assert.equal(normalizeCrawledHref("mailto:a@b.com", BASE), null);
35
+ assert.equal(normalizeCrawledHref("javascript:void(0)", BASE), null);
36
+ assert.equal(normalizeCrawledHref("#section", BASE), null);
37
+ assert.equal(normalizeCrawledHref("", BASE), null);
38
+ });
39
+
40
+ test("reads the app's own label for each destination", () => {
41
+ // Shaped like the dashboard's real rendered nav, including the computed
42
+ // hrefs the source scan cannot see.
43
+ const html = `
44
+ <nav>
45
+ <a class="active" href="/?project=bugmole&amp;environment=all">Overview</a>
46
+ <a href="/plans?project=bugmole&amp;environment=all">Test plans</a>
47
+ <a href="/tasks?project=bugmole&amp;environment=all"><span>Agent</span> tasks</a>
48
+ <a href="/_next/static/chunks/webpack.js">ignored</a>
49
+ <a href="https://storybook.js.org/">external</a>
50
+ </nav>`;
51
+ const links = extractLinks(html, BASE);
52
+ const byRoute = new Map(links.map((link) => [link.route, link.label]));
53
+ assert.deepEqual([...byRoute.keys()].sort(), ["/", "/plans", "/tasks"]);
54
+ assert.equal(byRoute.get("/"), "Overview");
55
+ assert.equal(byRoute.get("/plans"), "Test plans");
56
+ // Nested markup still yields the human label.
57
+ assert.equal(byRoute.get("/tasks"), "Agent tasks");
58
+ });
59
+
60
+ test("the same destination is reported once, preferring a real label", () => {
61
+ const html = `
62
+ <a href="/plans"></a>
63
+ <a href="/plans?project=x">Test plans</a>`;
64
+ const links = extractLinks(html, BASE);
65
+ assert.equal(links.length, 1);
66
+ assert.equal(links[0].label, "Test plans");
67
+ });
68
+
69
+ test("a declared entrance outranks everything — the project stating its own start", () => {
70
+ const declared = new Set(["/dashboard"]);
71
+ const dashboard = entranceRank({ id: "d", route: "/dashboard" }, { declaredEntranceRoutes: declared });
72
+ const root = entranceRank({ id: "r", route: "/" }, { declaredEntranceRoutes: declared });
73
+ assert.ok(dashboard < root, "declared entrance should win");
74
+ });
75
+
76
+ test("the app root beats a leaf page — this is the reported bug", () => {
77
+ // Previously /api-keys won purely because nothing linked to it, so the
78
+ // journeys started at a settings page and ended at the home page.
79
+ const inbound = new Map([["root", 6]]);
80
+ const root = entranceRank({ id: "root", route: "/" }, { inboundCounts: inbound });
81
+ const apiKeys = entranceRank({ id: "keys", route: "/api-keys" }, { inboundCounts: inbound });
82
+ assert.ok(root < apiKeys, "the application root must outrank a settings page");
83
+ });
84
+
85
+ test("the mobile app's own conventions still win when present, so it is not regressed", () => {
86
+ const onboarding = entranceRank({ id: "o", route: "/onboarding", variant: "auth" });
87
+ const tabs = entranceRank({ id: "t", route: "/", variant: "tabs" });
88
+ const plainRoot = entranceRank({ id: "r2", route: "/" });
89
+ assert.ok(onboarding < tabs, "auth onboarding stays the first entry");
90
+ assert.ok(tabs < plainRoot, "the tabs root stays ahead of a generic root");
91
+ });
92
+
93
+ test("a hub outranks a leaf when neither is the root", () => {
94
+ const inbound = new Map([["hub", 5], ["leaf", 1]]);
95
+ const hub = entranceRank({ id: "hub", route: "/hub" }, { inboundCounts: inbound });
96
+ const leaf = entranceRank({ id: "leaf", route: "/leaf" }, { inboundCounts: inbound });
97
+ const orphan = entranceRank({ id: "orphan", route: "/orphan" }, { inboundCounts: inbound });
98
+ assert.ok(hub < leaf, "more inbound navigation means a stronger entrance");
99
+ assert.ok(leaf < orphan, "a linked page still beats one nothing links to");
100
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Navigation read from the running application.
3
+ *
4
+ * The source scan can only see link literals. This dashboard builds its
5
+ * primary navigation from a route table (`href={hrefFor(value)}`), so the
6
+ * scan found none of it and fell back to the handful of hardcoded links in
7
+ * the account dropdown — which is why every discovered flow began at a
8
+ * settings page. Once rendered, those hrefs are real strings, so the DOM
9
+ * is where navigation can honestly be read.
10
+ *
11
+ * This runs last, after the project's declared intent has been read: docs
12
+ * say what matters, the crawl says what exists.
13
+ */
14
+ import { dumpRenderedDom, mapWithConcurrency, resolveCaptureConcurrency } from "./journey-evidence.js";
15
+
16
+ /** Assets are not navigation, and would otherwise become phantom screens. */
17
+ const ASSET_PATTERN = /\.(png|jpe?g|gif|svg|webp|avif|ico|css|js|mjs|map|woff2?|ttf|otf|json|txt|xml|pdf)$/i;
18
+
19
+ export type CrawledLink = {
20
+ /** Route the anchor points at, normalised. */
21
+ route: string;
22
+ /** Anchor text, which is the app's own name for the destination. */
23
+ label?: string;
24
+ };
25
+
26
+ export type NavCrawlResult = {
27
+ /** Links found per starting route. */
28
+ linksByRoute: Map<string, CrawledLink[]>;
29
+ /** Routes that were successfully rendered. */
30
+ visited: string[];
31
+ /** Routes that could not be rendered, with why. */
32
+ failed: Array<{ route: string; reason: string }>;
33
+ };
34
+
35
+ /**
36
+ * Normalise an href to a comparable route, or null if it is not in-app
37
+ * navigation. Query strings and fragments are dropped: `/plans?project=x`
38
+ * and `/plans` are the same screen.
39
+ */
40
+ export function normalizeCrawledHref(href: string, baseUrl: string): string | null {
41
+ const raw = href?.trim();
42
+ if (!raw) return null;
43
+ if (/^(mailto:|tel:|javascript:|data:|blob:)/i.test(raw)) return null;
44
+ if (raw.startsWith("#")) return null;
45
+
46
+ let url: URL;
47
+ try {
48
+ url = new URL(raw, baseUrl);
49
+ } catch {
50
+ return null;
51
+ }
52
+ // Only same-origin links are this app's navigation.
53
+ try {
54
+ if (url.origin !== new URL(baseUrl).origin) return null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ const pathname = url.pathname;
59
+ // Framework build output and static files are not screens.
60
+ if (pathname.startsWith("/_next/") || pathname.startsWith("/__")) return null;
61
+ if (ASSET_PATTERN.test(pathname)) return null;
62
+ const trimmed = pathname.length > 1 ? pathname.replace(/\/+$/, "") : "/";
63
+ return trimmed || "/";
64
+ }
65
+
66
+ /**
67
+ * Build the URL to crawl, carrying over any query the base URL sets.
68
+ *
69
+ * An app can need context before it will render anything real: this
70
+ * dashboard shows "No projects available" until `?project=` is present.
71
+ * Crawling bare routes therefore produced an empty shell for the home page
72
+ * and attributed the whole navigation to whichever page happened to render
73
+ * — so the base URL's query is propagated to every route rather than
74
+ * dropped.
75
+ */
76
+ export function resolveCrawlUrl(route: string, baseUrl: string): string {
77
+ const base = new URL(baseUrl);
78
+ const url = new URL(route, base);
79
+ for (const [key, value] of base.searchParams) {
80
+ if (!url.searchParams.has(key)) url.searchParams.set(key, value);
81
+ }
82
+ return url.toString();
83
+ }
84
+
85
+ /** Anchors from rendered HTML, with their visible text. */
86
+ export function extractLinks(html: string, baseUrl: string): CrawledLink[] {
87
+ const found = new Map<string, CrawledLink>();
88
+ for (const match of html.matchAll(/<a\b[^>]*href\s*=\s*"([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)) {
89
+ const route = normalizeCrawledHref(decodeHtml(match[1]), baseUrl);
90
+ if (!route) continue;
91
+ const label = decodeHtml(match[2].replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
92
+ const existing = found.get(route);
93
+ if (!existing || (!existing.label && label)) {
94
+ found.set(route, { route, label: label || existing?.label });
95
+ }
96
+ }
97
+ return [...found.values()];
98
+ }
99
+
100
+ function decodeHtml(value: string): string {
101
+ return value
102
+ .replace(/&amp;/g, "&")
103
+ .replace(/&lt;/g, "<")
104
+ .replace(/&gt;/g, ">")
105
+ .replace(/&quot;/g, '"')
106
+ .replace(/&#0?39;|&apos;/g, "'");
107
+ }
108
+
109
+ /**
110
+ * Render each route and read its navigation.
111
+ *
112
+ * Bounded on purpose: every dump launches a headless Chrome, and each of
113
+ * those spawns a dozen OS processes. An unbounded pass over a graph of
114
+ * this size previously produced hundreds of simultaneous Chrome processes,
115
+ * so this reuses the same worker pool and budget as screenshot capture
116
+ * rather than introducing a second unbounded launcher.
117
+ */
118
+ export async function crawlNavigation(
119
+ baseUrl: string,
120
+ routes: string[],
121
+ options: { concurrency?: number; maxRoutes?: number; timeoutMs?: number } = {},
122
+ ): Promise<NavCrawlResult> {
123
+ const limit = options.concurrency ?? resolveCaptureConcurrency();
124
+ const maxRoutes = options.maxRoutes ?? 40;
125
+ // Concrete routes only: a parameterised route has no real URL to visit
126
+ // without inventing an id, and a guessed id is not evidence.
127
+ const targets = routes
128
+ .filter((route) => !/[[:*]/.test(route))
129
+ .slice(0, maxRoutes);
130
+
131
+ const linksByRoute = new Map<string, CrawledLink[]>();
132
+ const visited: string[] = [];
133
+ const failed: Array<{ route: string; reason: string }> = [];
134
+
135
+ await mapWithConcurrency(targets, limit, async (route) => {
136
+ let url: string;
137
+ try {
138
+ url = resolveCrawlUrl(route, baseUrl);
139
+ } catch {
140
+ failed.push({ route, reason: "Route could not be resolved against the base URL" });
141
+ return;
142
+ }
143
+ try {
144
+ const html = await dumpRenderedDom(url, options.timeoutMs);
145
+ linksByRoute.set(route, extractLinks(html, baseUrl));
146
+ visited.push(route);
147
+ } catch (error) {
148
+ failed.push({ route, reason: error instanceof Error ? error.message : "Rendered DOM could not be read" });
149
+ }
150
+ });
151
+
152
+ return { linksByRoute, visited, failed };
153
+ }