@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,111 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { analyzeFailure, applyFlowChanges, labelSimilarity, rawFlowSteps, type FailureContext } from "./failure-analysis.js";
4
+ import { explainWithModel, analysisModel } from "./ai-analysis.js";
5
+ import { parseMaestroStyleFlow } from "./flow-language.js";
6
+
7
+ const checkoutFlow = `url: http://localhost:3000/checkout
8
+ name: checkout
9
+ ---
10
+ - launchApp
11
+ - tapOn: "Full name"
12
+ - inputText: "Ada Lovelace"
13
+ - tapOn: "Continue to payment"
14
+ - assertVisible: "Payment"
15
+ `;
16
+
17
+ const baseContext = (overrides: Partial<FailureContext>): FailureContext => ({
18
+ failedStepIndex: 4,
19
+ error: 'Timed out after 15000ms waiting for "Payment" to be visible.',
20
+ pageErrors: [],
21
+ consoleErrors: [],
22
+ candidates: [],
23
+ alerts: [],
24
+ invalidFields: [],
25
+ ...overrides,
26
+ });
27
+
28
+ test("an empty required field behind a validation message is a test-data problem, with a fix", () => {
29
+ const flow = parseMaestroStyleFlow(checkoutFlow);
30
+ const analysis = analyzeFailure(flow.steps, rawFlowSteps(checkoutFlow), baseContext({
31
+ alerts: ["Please enter a valid email"],
32
+ invalidFields: [{ label: "Email", type: "email", empty: true }],
33
+ }));
34
+ assert.equal(analysis.category, "test");
35
+ assert.match(analysis.title, /email field was left empty/);
36
+ assert.match(analysis.rootCause, /Please enter a valid email/);
37
+ assert.match(analysis.suggestedFix!.summary, /valid email address/);
38
+ // The fill goes in before the submitting tap.
39
+ const fixed = parseMaestroStyleFlow(applyFlowChanges(checkoutFlow, analysis.suggestedFix!.changes));
40
+ assert.deepEqual(fixed.steps.slice(3, 6), [
41
+ { type: "tapOn", text: "Email", index: 0, optional: false },
42
+ { type: "inputText", text: "qa@example.com" },
43
+ { type: "tapOn", text: "Continue to payment", index: 0, optional: false },
44
+ ]);
45
+ assert.equal(fixed.url, "http://localhost:3000/checkout");
46
+ });
47
+
48
+ test("renamed UI copy is matched to the closest control and replaced in place", () => {
49
+ const flow = parseMaestroStyleFlow(checkoutFlow);
50
+ const analysis = analyzeFailure(flow.steps, rawFlowSteps(checkoutFlow), baseContext({
51
+ failedStepIndex: 3,
52
+ error: 'No element matching "Continue to payment" at index 0.',
53
+ candidates: [{ role: "button", text: "Continue to checkout" }, { role: "link", text: "Help" }],
54
+ }));
55
+ assert.equal(analysis.category, "test");
56
+ assert.equal(analysis.title, "“Continue to payment” is now “Continue to checkout”");
57
+ assert.deepEqual(analysis.suggestedFix!.preview.map((line) => line.kind), ["remove", "add"]);
58
+ const fixed = parseMaestroStyleFlow(applyFlowChanges(checkoutFlow, analysis.suggestedFix!.changes));
59
+ assert.equal((fixed.steps[3] as { text: string }).text, "Continue to checkout");
60
+ // A fix written for another version of the flow is refused.
61
+ const edited = checkoutFlow.replace('tapOn: "Continue to payment"', 'tapOn: "Pay now"');
62
+ assert.throws(() => applyFlowChanges(edited, analysis.suggestedFix!.changes), /has changed since this fix was suggested/);
63
+ });
64
+
65
+ test("a missing element with no close match is reported as an app problem, without a fix", () => {
66
+ const flow = parseMaestroStyleFlow(checkoutFlow);
67
+ const analysis = analyzeFailure(flow.steps, rawFlowSteps(checkoutFlow), baseContext({
68
+ candidates: [{ role: "heading", text: "Order history" }],
69
+ }));
70
+ assert.equal(analysis.category, "app");
71
+ assert.equal(analysis.suggestedFix, undefined);
72
+ });
73
+
74
+ test("crashes and unreachable environments are classified, and typed text never leaks", () => {
75
+ const flow = parseMaestroStyleFlow(checkoutFlow);
76
+ const crash = analyzeFailure(flow.steps, rawFlowSteps(checkoutFlow), baseContext({ error: "boom", pageErrors: ["TypeError: cart is undefined"] }));
77
+ assert.equal(crash.category, "app");
78
+ const down = analyzeFailure(flow.steps, rawFlowSteps(checkoutFlow), baseContext({ error: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:3000/" }));
79
+ assert.equal(down.category, "environment");
80
+ const typed = analyzeFailure(flow.steps, rawFlowSteps(checkoutFlow), baseContext({ failedStepIndex: 2, error: 'No element matching "x"' }));
81
+ assert.doesNotMatch(JSON.stringify(typed), /Ada Lovelace/);
82
+ });
83
+
84
+ test("label similarity tolerates case, punctuation, and small edits", () => {
85
+ assert.equal(labelSimilarity("Sign in", "sign in!"), 1);
86
+ assert.ok(labelSimilarity("Continue to payment", "Continue to checkout") >= 0.5);
87
+ assert.ok(labelSimilarity("Sign in", "Delete account") < 0.5);
88
+ });
89
+
90
+ test("the AI model only rewrites the explanation, never the fix", async () => {
91
+ const flow = parseMaestroStyleFlow(checkoutFlow);
92
+ const context = baseContext({ alerts: ["Please enter a valid email"], invalidFields: [{ label: "Email", type: "email", empty: true }] });
93
+ const rules = analyzeFailure(flow.steps, rawFlowSteps(checkoutFlow), context);
94
+ assert.equal(analysisModel({ llm: { provider: "manual" } }), null);
95
+ assert.equal(analysisModel({ llm: { provider: "openai", api_key: "not-set" } }), null);
96
+ const cfg = { llm: { provider: "openai", model: "gpt-test", api_key: "sk-test" } };
97
+ let prompt = "";
98
+ const explained = await explainWithModel(cfg, rules, context, async (text) => {
99
+ prompt = text;
100
+ return '```json\n{"title":"Checkout blocked by the email check","rootCause":"The email field was empty, so validation stopped the form.","fixSummary":"Add a valid email address before submitting the form."}\n```';
101
+ });
102
+ assert.equal(explained.source, "ai");
103
+ assert.equal(explained.model, "gpt-test");
104
+ assert.equal(explained.title, "Checkout blocked by the email check");
105
+ assert.equal(explained.suggestedFix!.summary, "Add a valid email address before submitting the form.");
106
+ assert.deepEqual(explained.suggestedFix!.changes, rules.suggestedFix!.changes);
107
+ assert.doesNotMatch(prompt, /Ada Lovelace/);
108
+ const fallback = await explainWithModel(cfg, rules, context, async () => { throw new Error("rate limited"); });
109
+ assert.equal(fallback, rules);
110
+ assert.equal(await explainWithModel(cfg, rules, context, async () => "not json"), rules);
111
+ });
@@ -0,0 +1,272 @@
1
+ import YAML from "yaml";
2
+ import { describeStepForLog, parseMaestroStyleFlow, type FlowStep } from "./flow-language.js";
3
+
4
+ /**
5
+ * What the page looked like when a step failed, captured by the driver. It is
6
+ * the evidence the analysis reasons from, and it is uploaded with the run so
7
+ * a person can check the reasoning.
8
+ */
9
+ export type FailureContext = {
10
+ url?: string;
11
+ title?: string;
12
+ failedStepIndex: number;
13
+ /** The failing step as logged: typed text masked. */
14
+ failedStep?: string;
15
+ error: string;
16
+ pageErrors: string[];
17
+ consoleErrors: string[];
18
+ /** Things a person could see and act on: buttons, links, labels, headings. */
19
+ candidates: Array<{ role: string; text: string }>;
20
+ /** Visible error and validation messages. */
21
+ alerts: string[];
22
+ /** Fields that are empty or marked invalid, by their label. */
23
+ invalidFields: Array<{ label: string; type: string; empty: boolean }>;
24
+ };
25
+
26
+ /** One edit to a flow's top-level step list. `expect` guards against stale fixes. */
27
+ export type FlowChange =
28
+ | { op: "replace"; index: number; expect: string; step: unknown }
29
+ | { op: "insert"; index: number; steps: unknown[] };
30
+
31
+ export type FailureCategory = "test" | "app" | "environment";
32
+
33
+ export type FailureAnalysis = {
34
+ version: 1;
35
+ source: "rules" | "ai";
36
+ model?: string;
37
+ category: FailureCategory;
38
+ title: string;
39
+ rootCause: string;
40
+ evidence: string[];
41
+ confidence: "high" | "medium" | "low";
42
+ suggestedFix?: { summary: string; changes: FlowChange[]; preview: Array<{ kind: "add" | "remove"; text: string }> };
43
+ };
44
+
45
+ const normalize = (value: string) => value.toLowerCase().replace(/[^\p{Letter}\p{Number}]+/gu, " ").trim();
46
+
47
+ function levenshtein(a: string, b: string): number {
48
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
49
+ for (let i = 1; i <= a.length; i += 1) {
50
+ let previous = row[0];
51
+ row[0] = i;
52
+ for (let j = 1; j <= b.length; j += 1) {
53
+ const current = row[j];
54
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
55
+ previous = current;
56
+ }
57
+ }
58
+ return row[b.length];
59
+ }
60
+
61
+ /** 0–1 similarity between two labels, tolerant of case, punctuation, and small edits. */
62
+ export function labelSimilarity(left: string, right: string): number {
63
+ const a = normalize(left);
64
+ const b = normalize(right);
65
+ if (!a || !b) return 0;
66
+ if (a === b) return 1;
67
+ const edit = 1 - levenshtein(a, b) / Math.max(a.length, b.length);
68
+ const aWords = new Set(a.split(" "));
69
+ const bWords = new Set(b.split(" "));
70
+ const shared = [...aWords].filter((word) => bWords.has(word)).length;
71
+ const overlap = shared / Math.max(aWords.size, bWords.size);
72
+ const contains = a.includes(b) || b.includes(a) ? 0.75 : 0;
73
+ return Math.max(edit, overlap, contains);
74
+ }
75
+
76
+ /** A raw step as it appears in the flow file, for fix previews. Never used for typed text. */
77
+ function describeRaw(raw: unknown): string {
78
+ return YAML.stringify([raw]).trim().replace(/^- /, "");
79
+ }
80
+
81
+ function sampleValue(field: { label: string; type: string }): string {
82
+ const hint = `${field.type} ${field.label}`.toLowerCase();
83
+ if (hint.includes("email")) return "qa@example.com";
84
+ if (hint.includes("tel") || hint.includes("phone")) return "+1 555 010 0100";
85
+ if (hint.includes("zip") || hint.includes("postal")) return "94107";
86
+ if (hint.includes("number") || hint.includes("quantity")) return "1";
87
+ if (hint.includes("password")) return "{{secret.APP_PASSWORD}}";
88
+ return "Bugmole";
89
+ }
90
+
91
+ const isMissingElement = (error: string) =>
92
+ /No element matching|waiting for "[^"]+" to be visible|to scroll to|did not become clickable/i.test(error);
93
+
94
+ /**
95
+ * Explains a failed flow from the captured page state, and proposes a flow
96
+ * change when the failure is the test's fault. Rules only: this never guesses
97
+ * without evidence, and every proposed change is checkable against the flow.
98
+ */
99
+ export function analyzeFailure(steps: FlowStep[], rawSteps: unknown[], context: FailureContext): FailureAnalysis {
100
+ const { error, failedStepIndex } = context;
101
+ const step = steps[failedStepIndex];
102
+ const evidence: string[] = [`Step ${failedStepIndex + 1} failed: ${error}`];
103
+ if (context.url) evidence.push(`Page at failure: ${context.url}${context.title ? ` (“${context.title}”)` : ""}`);
104
+
105
+ if (/ERR_CONNECTION|ERR_NAME_NOT_RESOLVED|ECONNREFUSED|net::ERR_|getaddrinfo/i.test(error)) {
106
+ return {
107
+ version: 1,
108
+ source: "rules",
109
+ category: "environment",
110
+ title: "The app could not be reached",
111
+ rootCause: "The browser could not connect to the environment, so no step could be checked. The server may be down, or the environment's base URL may be wrong.",
112
+ evidence,
113
+ confidence: "high",
114
+ };
115
+ }
116
+
117
+ if (context.pageErrors.length > 0 && !isMissingElement(error)) {
118
+ evidence.push(...context.pageErrors.slice(0, 3).map((message) => `Uncaught page error: ${message}`));
119
+ return {
120
+ version: 1,
121
+ source: "rules",
122
+ category: "app",
123
+ title: "The page crashed",
124
+ rootCause: `The application threw an uncaught error: ${context.pageErrors[0]}. The flow is behaving correctly; the app needs a fix.`,
125
+ evidence,
126
+ confidence: "high",
127
+ };
128
+ }
129
+
130
+ // A form refused to submit: the next assertion never came true, and the
131
+ // page is showing validation messages for fields the flow left empty.
132
+ const emptyFields = context.invalidFields.filter((field) => field.empty);
133
+ if (context.alerts.length > 0 && emptyFields.length > 0) {
134
+ evidence.push(...context.alerts.slice(0, 3).map((alert) => `Visible message: “${alert}”`));
135
+ const submitIndex = [...steps.keys()].slice(0, failedStepIndex).reverse().find((index) => steps[index].type === "tapOn");
136
+ const field = emptyFields[0];
137
+ const value = sampleValue(field);
138
+ const insertAt = submitIndex ?? failedStepIndex;
139
+ const inserted = [{ tapOn: field.label }, { inputText: value }];
140
+ return {
141
+ version: 1,
142
+ source: "rules",
143
+ category: "test",
144
+ title: `The ${field.label.toLowerCase()} field was left empty`,
145
+ rootCause: `The form showed “${context.alerts[0]}” and did not continue, because the flow never filled in ${field.label}. The page behaved correctly; the test data was incomplete.`,
146
+ evidence,
147
+ confidence: "high",
148
+ suggestedFix: {
149
+ summary: `Fill in ${field.label} with a valid ${field.type === "email" ? "email address" : "value"} before submitting the form.`,
150
+ changes: [{ op: "insert", index: insertAt, steps: inserted }],
151
+ // Sample values are placeholders, never secrets, so the preview shows them.
152
+ preview: inserted.map((raw) => ({ kind: "add" as const, text: describeRaw(raw) })),
153
+ },
154
+ };
155
+ }
156
+
157
+ // The element the flow looked for is gone, but something very similar is
158
+ // on the page: the UI copy changed and the flow did not.
159
+ // Typed text can be a secret; it never becomes part of the analysis.
160
+ const expected = step && step.type !== "inputText" && "text" in step ? step.text : undefined;
161
+ if (expected && isMissingElement(error)) {
162
+ const ranked = context.candidates
163
+ .map((candidate) => ({ ...candidate, score: labelSimilarity(expected, candidate.text) }))
164
+ .filter((candidate) => normalize(candidate.text) !== normalize(expected))
165
+ .sort((left, right) => right.score - left.score);
166
+ const best = ranked[0];
167
+ if (best && best.score >= 0.5 && step && rawSteps[failedStepIndex] !== undefined) {
168
+ const replacement = replaceText(rawSteps[failedStepIndex], expected, best.text);
169
+ evidence.push(`Closest match on the page: ${best.role} “${best.text}”`);
170
+ return {
171
+ version: 1,
172
+ source: "rules",
173
+ category: "test",
174
+ title: `“${expected}” is now “${best.text}”`,
175
+ rootCause: `The flow looks for “${expected}”, but the page shows a ${best.role} labelled “${best.text}”. The UI copy changed and the test was not updated.`,
176
+ evidence,
177
+ confidence: best.score >= 0.75 ? "high" : "medium",
178
+ suggestedFix: {
179
+ summary: `Update step ${failedStepIndex + 1} to use “${best.text}”.`,
180
+ changes: [{ op: "replace", index: failedStepIndex, expect: stepSignature(step), step: replacement }],
181
+ preview: [
182
+ { kind: "remove", text: describeRaw(rawSteps[failedStepIndex]) },
183
+ { kind: "add", text: describeRaw(replacement) },
184
+ ],
185
+ },
186
+ };
187
+ }
188
+ return {
189
+ version: 1,
190
+ source: "rules",
191
+ category: "app",
192
+ title: `“${expected}” is missing from the page`,
193
+ rootCause: `The flow expected “${expected}”, and nothing similar is on the page. Either the feature is broken or the flow reached the wrong page.`,
194
+ evidence,
195
+ confidence: "medium",
196
+ };
197
+ }
198
+
199
+ return {
200
+ version: 1,
201
+ source: "rules",
202
+ category: "test",
203
+ title: "The flow stopped",
204
+ rootCause: error,
205
+ evidence,
206
+ confidence: "low",
207
+ };
208
+ }
209
+
210
+ /** A stable identity for a step, used to reject a fix written for another version of the flow. */
211
+ export function stepSignature(step: FlowStep): string {
212
+ return describeStepForLog(step);
213
+ }
214
+
215
+ function replaceText(raw: unknown, from: string, to: string): unknown {
216
+ if (typeof raw === "string") return raw;
217
+ const record = raw as Record<string, unknown>;
218
+ const [key] = Object.keys(record);
219
+ const value = record[key];
220
+ if (typeof value === "string") return { [key]: value === from ? to : value };
221
+ if (value && typeof value === "object") {
222
+ const nested = { ...(value as Record<string, unknown>) };
223
+ for (const field of ["text", "label", "id", "element"]) {
224
+ if (nested[field] === from) nested[field] = to;
225
+ }
226
+ return { [key]: nested };
227
+ }
228
+ return raw;
229
+ }
230
+
231
+ /** Splits a flow file into its header text and raw step list. */
232
+ function splitFlow(content: string): { header: string; steps: unknown[] } {
233
+ const documents = content.split(/^---\s*$/m);
234
+ if (documents.length < 2) throw new Error("Flow file must have a header document and a steps document separated by '---'.");
235
+ const steps: unknown = YAML.parse(documents.slice(1).join("---"));
236
+ if (!Array.isArray(steps)) throw new Error("Flow steps document must be a YAML list.");
237
+ return { header: documents[0].trimEnd(), steps };
238
+ }
239
+
240
+ export function rawFlowSteps(content: string): unknown[] {
241
+ return splitFlow(content).steps;
242
+ }
243
+
244
+ /**
245
+ * Applies a suggested fix to a flow file's text. Every replacement must still
246
+ * match the step it was written for; otherwise the fix is stale and refused.
247
+ */
248
+ export function applyFlowChanges(content: string, changes: readonly FlowChange[]): string {
249
+ const { header, steps } = splitFlow(content);
250
+ // Flows may take their URL from the environment; only the steps matter here.
251
+ const validationBase = "http://localhost";
252
+ const parsed = parseMaestroStyleFlow(content, validationBase).steps;
253
+ const next = [...steps];
254
+ // Later indexes first, so earlier inserts do not shift them.
255
+ const ordered = [...changes].sort((left, right) => right.index - left.index);
256
+ for (const change of ordered) {
257
+ if (change.index < 0 || change.index > next.length) throw new Error(`Fix refers to step ${change.index + 1}, which the flow does not have.`);
258
+ if (change.op === "replace") {
259
+ const current = parsed[change.index];
260
+ if (!current || stepSignature(current) !== change.expect) {
261
+ throw new Error(`Step ${change.index + 1} has changed since this fix was suggested; re-run the test for a new suggestion.`);
262
+ }
263
+ next[change.index] = change.step;
264
+ } else {
265
+ next.splice(change.index, 0, ...change.steps);
266
+ }
267
+ }
268
+ const updated = `${header}\n---\n${YAML.stringify(next)}`;
269
+ // Refuse to write a flow the driver would reject.
270
+ parseMaestroStyleFlow(updated, validationBase);
271
+ return updated;
272
+ }
@@ -0,0 +1,36 @@
1
+ #!/bin/sh
2
+ # Test fixture standing in for the real `maestro` binary: writes a
3
+ # commands.json shaped like Maestro's own --test-output-dir output (one
4
+ # passed step, one passed step, one failed step) and exits non-zero, the way
5
+ # a real flow with a failed assertion would.
6
+ set -e
7
+ outdir=""
8
+ prev=""
9
+ for arg in "$@"; do
10
+ if [ "$prev" = "--test-output-dir" ]; then
11
+ outdir="$arg"
12
+ fi
13
+ prev="$arg"
14
+ done
15
+ if [ -z "$outdir" ]; then
16
+ echo "fake-maestro: missing --test-output-dir" >&2
17
+ exit 2
18
+ fi
19
+ mkdir -p "$outdir"
20
+ cat > "$outdir/commands.json" <<'JSON'
21
+ [
22
+ {
23
+ "command": { "launchAppCommand": { "appId": "com.example.app" } },
24
+ "metadata": { "status": "COMPLETED", "timestamp": 1000, "duration": 3363, "sequenceNumber": 0, "depth": 0 }
25
+ },
26
+ {
27
+ "command": { "tapOnCommand": { "text": "Sign in" } },
28
+ "metadata": { "status": "COMPLETED", "timestamp": 4400, "duration": 50, "sequenceNumber": 1, "depth": 0 }
29
+ },
30
+ {
31
+ "command": { "assertVisibleCommand": { "text": "Home" } },
32
+ "metadata": { "status": "FAILED", "timestamp": 4500, "duration": 200, "sequenceNumber": 2, "depth": 0 }
33
+ }
34
+ ]
35
+ JSON
36
+ exit 1