@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,268 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+ import {
4
+ countFlowSteps,
5
+ describeAddressForLog,
6
+ describeLinkForLog,
7
+ describeStepForLog,
8
+ interpolateStep,
9
+ interpolateTemplate,
10
+ isPatternText,
11
+ parseMaestroStyleFlow,
12
+ resolveFlowUrl,
13
+ } from "./flow-language.js";
14
+
15
+ test("parses the web journey vocabulary a creator flow needs", () => {
16
+ const flow = parseMaestroStyleFlow(`
17
+ url: http://localhost:3111
18
+ name: creator-editor
19
+ ---
20
+ - launchApp
21
+ - openLink: "/studio/editor?show=falling-for-you"
22
+ - tapOn:
23
+ text: "Lời thoại"
24
+ index: 1
25
+ - eraseText
26
+ - inputText: "BUGMOLE"
27
+ - assertVisible: ".*BUGMOLE.*"
28
+ - assertNotVisible: "Không tìm thấy"
29
+ - extendedWaitUntil:
30
+ visible: "Falling For You"
31
+ timeout: 20000
32
+ - scrollUntilVisible:
33
+ element: "Lưu bản nháp"
34
+ - takeScreenshot: "after-save"
35
+ - wait: 250
36
+ - pressKey: "Enter"
37
+ `);
38
+ assert.equal(flow.name, "creator-editor");
39
+ assert.deepEqual(flow.steps[1], { type: "openLink", url: "/studio/editor?show=falling-for-you" });
40
+ assert.deepEqual(flow.steps[2], { type: "tapOn", text: "Lời thoại", index: 1, optional: false });
41
+ assert.deepEqual(flow.steps[5], { type: "assertVisible", text: ".*BUGMOLE.*", timeoutMs: undefined });
42
+ assert.deepEqual(flow.steps[7], { type: "assertVisible", text: "Falling For You", timeoutMs: 20000 });
43
+ assert.deepEqual(flow.steps[8], { type: "scrollUntilVisible", text: "Lưu bản nháp" });
44
+ assert.equal(flow.ignorePageErrors, false);
45
+ });
46
+
47
+ test("extendedWaitUntil supports waiting for something to disappear", () => {
48
+ const flow = parseMaestroStyleFlow(`
49
+ url: http://localhost
50
+ ---
51
+ - extendedWaitUntil:
52
+ notVisible: "Đang tải"
53
+ timeout: 5000
54
+ `);
55
+ assert.deepEqual(flow.steps[0], { type: "assertNotVisible", text: "Đang tải", timeoutMs: 5000 });
56
+ });
57
+
58
+ test("a retry block keeps its commands and counts them as real steps", () => {
59
+ const flow = parseMaestroStyleFlow(`
60
+ url: http://localhost
61
+ ---
62
+ - retry:
63
+ maxRetries: 2
64
+ commands:
65
+ - launchApp
66
+ - assertVisible: "Cinely"
67
+ `);
68
+ assert.equal(flow.steps.length, 1);
69
+ assert.equal(countFlowSteps(flow.steps), 2);
70
+ assert.deepEqual(flow.steps[0], {
71
+ type: "retry",
72
+ maxRetries: 2,
73
+ steps: [{ type: "launchApp" }, { type: "assertVisible", text: "Cinely", timeoutMs: undefined }],
74
+ });
75
+ });
76
+
77
+ test("an optional tap is preserved so a flow can skip a prompt that may not appear", () => {
78
+ const flow = parseMaestroStyleFlow(`
79
+ url: http://localhost
80
+ ---
81
+ - tapOn:
82
+ text: "Not Now"
83
+ optional: true
84
+ `);
85
+ assert.deepEqual(flow.steps[0], { type: "tapOn", text: "Not Now", index: 0, optional: true });
86
+ });
87
+
88
+ test("the driver base URL wins over a mobile appId header so device flows can run on the web", () => {
89
+ const flow = parseMaestroStyleFlow("appId: com.apple.mobilesafari\n---\n- launchApp\n", "http://localhost:3111");
90
+ assert.equal(flow.url, "http://localhost:3111");
91
+ });
92
+
93
+ test("only the .* wildcard idiom is treated as a pattern -- not incidental regex metacharacters in ordinary copy", () => {
94
+ assert.equal(isPatternText(".*Falling For You.*"), true);
95
+ assert.equal(isPatternText("Lưu bản nháp"), false);
96
+ // Real UI text is full of characters that are also regex metacharacters.
97
+ // None of these should be miscompiled into a pattern.
98
+ assert.equal(isPatternText("Giá hàng tháng (USD)"), false);
99
+ assert.equal(isPatternText("$3.99 / tháng"), false);
100
+ assert.equal(isPatternText("Tiếp tục xem."), false);
101
+ assert.equal(isPatternText("Thêm dòng (+1)"), false);
102
+ });
103
+
104
+ test("relative links resolve against the flow base URL and absolute links are left alone", () => {
105
+ assert.equal(resolveFlowUrl("/studio", "http://localhost:3111/login"), "http://localhost:3111/studio");
106
+ assert.equal(resolveFlowUrl("http://example.test/x", "http://localhost:3111"), "http://example.test/x");
107
+ });
108
+
109
+ test("a step missing its required text is rejected rather than silently skipped", () => {
110
+ assert.throws(() => parseMaestroStyleFlow("url: http://localhost\n---\n- inputText:\n"), /requires text/);
111
+ });
112
+
113
+ test("a flow can pin the viewport it asserts a layout at", () => {
114
+ const flow = parseMaestroStyleFlow("url: http://localhost\nviewport: 390x844\n---\n- launchApp\n");
115
+ assert.deepEqual(flow.viewport, { width: 390, height: 844 });
116
+ });
117
+
118
+ test("viewport also accepts an explicit width and height", () => {
119
+ const flow = parseMaestroStyleFlow("url: http://localhost\nviewport:\n width: 412\n height: 800\n---\n- launchApp\n");
120
+ assert.deepEqual(flow.viewport, { width: 412, height: 800 });
121
+ });
122
+
123
+ test("a flow without a viewport leaves the driver default alone", () => {
124
+ assert.equal(parseMaestroStyleFlow("url: http://localhost\n---\n- launchApp\n").viewport, undefined);
125
+ });
126
+
127
+ test("a malformed viewport is rejected rather than silently ignored", () => {
128
+ assert.throws(() => parseMaestroStyleFlow("url: http://localhost\nviewport: wide\n---\n- launchApp\n"), /viewport/);
129
+ assert.throws(() => parseMaestroStyleFlow("url: http://localhost\nviewport:\n width: 0\n height: 8\n---\n- launchApp\n"), /positive width and height/);
130
+ });
131
+
132
+ test("assertClickable parses plain text and an object form with an index and timeout", () => {
133
+ const flow = parseMaestroStyleFlow(`
134
+ url: http://localhost
135
+ ---
136
+ - assertClickable: "Xem tất cả"
137
+ - assertClickable:
138
+ text: "Duplicate label"
139
+ index: 1
140
+ timeout: 5000
141
+ `);
142
+ assert.deepEqual(flow.steps[0], { type: "assertClickable", text: "Xem tất cả", index: 0, timeoutMs: undefined });
143
+ assert.deepEqual(flow.steps[1], { type: "assertClickable", text: "Duplicate label", index: 1, timeoutMs: 5000 });
144
+ });
145
+
146
+ test("requestTempEmail defaults its bound name to tempEmail, both bare and object forms", () => {
147
+ const bare = parseMaestroStyleFlow("url: http://localhost\n---\n- requestTempEmail\n");
148
+ assert.deepEqual(bare.steps[0], { type: "requestTempEmail", as: "tempEmail" });
149
+
150
+ const named = parseMaestroStyleFlow('url: http://localhost\n---\n- requestTempEmail: "signupInbox"\n');
151
+ assert.deepEqual(named.steps[0], { type: "requestTempEmail", as: "signupInbox" });
152
+
153
+ const objectForm = parseMaestroStyleFlow("url: http://localhost\n---\n- requestTempEmail:\n as: signupInbox\n");
154
+ assert.deepEqual(objectForm.steps[0], { type: "requestTempEmail", as: "signupInbox" });
155
+ });
156
+
157
+ test("openEmailLink parses its filters and defaults to the tempEmail inbox", () => {
158
+ const bare = parseMaestroStyleFlow("url: http://localhost\n---\n- openEmailLink\n");
159
+ assert.deepEqual(bare.steps[0], { type: "openEmailLink", as: "tempEmail" });
160
+
161
+ const withFilters = parseMaestroStyleFlow(`
162
+ url: http://localhost
163
+ ---
164
+ - openEmailLink:
165
+ as: signupInbox
166
+ from: "cinely.net"
167
+ subject: "Confirm"
168
+ match: "confirm"
169
+ timeout: 20000
170
+ `);
171
+ assert.deepEqual(withFilters.steps[0], {
172
+ type: "openEmailLink",
173
+ as: "signupInbox",
174
+ from: "cinely.net",
175
+ subject: "Confirm",
176
+ match: "confirm",
177
+ timeoutMs: 20000,
178
+ });
179
+ });
180
+
181
+ test("interpolateTemplate substitutes a bound value and leaves plain text alone", () => {
182
+ assert.equal(interpolateTemplate("plain text with no template", {}), "plain text with no template");
183
+ assert.equal(interpolateTemplate("sign up with {{tempEmail}}", { tempEmail: "qa@example.test" }), "sign up with qa@example.test");
184
+ });
185
+
186
+ test("interpolateTemplate fails clearly when a referenced name was never bound", () => {
187
+ assert.throws(() => interpolateTemplate("{{neverRequested}}", {}), /no step has bound that name yet/);
188
+ });
189
+
190
+ test("interpolateStep only touches the free-text fields a flow author could have templated", () => {
191
+ const context = { tempEmail: "qa@example.test" };
192
+ assert.deepEqual(interpolateStep({ type: "inputText", text: "{{tempEmail}}" }, context), { type: "inputText", text: "qa@example.test" });
193
+ assert.deepEqual(interpolateStep({ type: "openLink", url: "/welcome?email={{tempEmail}}" }, context), {
194
+ type: "openLink",
195
+ url: "/welcome?email=qa@example.test",
196
+ });
197
+ // A step with no free-text field (like launchApp) passes through untouched.
198
+ assert.deepEqual(interpolateStep({ type: "launchApp" }, context), { type: "launchApp" });
199
+ });
200
+
201
+ test("moves an absolute flow URL onto the selected environment, keeping its path", () => {
202
+ const content = `url: http://localhost:3000/dashboard?tab=1
203
+ ---
204
+ - launchApp
205
+ `;
206
+ const flow = parseMaestroStyleFlow(content, "https://staging.example.com/app/", { rebaseOrigin: true });
207
+ assert.equal(flow.url, "https://staging.example.com/app/dashboard?tab=1");
208
+ assert.equal(flow.rebasedFrom, "http://localhost:3000");
209
+ // Absolute links to the authored origin follow the move; other hosts do not.
210
+ assert.equal(
211
+ resolveFlowUrl("http://localhost:3000/login", flow.url, flow.rebasedFrom, flow.rebasedOnto),
212
+ "https://staging.example.com/app/login",
213
+ );
214
+ assert.equal(
215
+ resolveFlowUrl("https://accounts.example.org/sso", flow.url, flow.rebasedFrom, flow.rebasedOnto),
216
+ "https://accounts.example.org/sso",
217
+ );
218
+ });
219
+
220
+ test("keeps the flow URL when no environment override is requested", () => {
221
+ const content = `url: http://localhost:3000/dashboard
222
+ ---
223
+ - launchApp
224
+ `;
225
+ assert.equal(parseMaestroStyleFlow(content, "https://staging.example.com").url, "http://localhost:3000/dashboard");
226
+ const relative = parseMaestroStyleFlow(`url: /welcome
227
+ ---
228
+ - launchApp
229
+ `, "https://staging.example.com/", { rebaseOrigin: true });
230
+ assert.equal(relative.url, "https://staging.example.com/welcome");
231
+ assert.equal(relative.rebasedFrom, undefined);
232
+ });
233
+
234
+ test("typed text is masked wherever a step is described", () => {
235
+ const described = describeStepForLog({ type: "inputText", text: "hunter2-password" });
236
+ assert.doesNotMatch(described, /hunter2/);
237
+ assert.match(described, /16 chars/);
238
+ const nested = describeStepForLog({
239
+ type: "retry",
240
+ maxRetries: 1,
241
+ steps: [{ type: "tapOn", text: "Password", index: 0, optional: false }, { type: "inputText", text: "s3cret" }],
242
+ });
243
+ assert.doesNotMatch(nested, /s3cret/);
244
+ assert.match(nested, /Password/);
245
+ });
246
+
247
+ test("email links and inbox addresses are logged without their secrets", () => {
248
+ assert.equal(describeLinkForLog("https://app.example.com/confirm?token=abc123#x"), "https://app.example.com/confirm?…");
249
+ assert.equal(describeLinkForLog("https://app.example.com/welcome"), "https://app.example.com/welcome");
250
+ assert.equal(describeAddressForLog("bugmole-8f2k@mail.tm"), "bu•••@mail.tm");
251
+ });
252
+
253
+ test("{{secret.NAME}} types a prefixed environment variable, only in inputText", () => {
254
+ const env = { BUGMOLE_FLOW_SECRET_APP_PASSWORD: "p@ss", AWS_SECRET_ACCESS_KEY: "worker-credential" };
255
+ assert.equal(interpolateTemplate("{{secret.APP_PASSWORD}}", {}, { allowSecrets: true, env }), "p@ss");
256
+ // Only the BUGMOLE_FLOW_SECRET_ prefix is readable, never the worker's own credentials.
257
+ assert.throws(() => interpolateTemplate("{{secret.AWS_SECRET_ACCESS_KEY}}", {}, { allowSecrets: true, env }), /BUGMOLE_FLOW_SECRET_AWS_SECRET_ACCESS_KEY is not set/);
258
+ assert.throws(() => interpolateTemplate("{{secret.APP_PASSWORD}}", {}, { env }), /only be used in inputText/);
259
+
260
+ process.env.BUGMOLE_FLOW_SECRET_TEST_ONLY = "typed-secret";
261
+ try {
262
+ assert.deepEqual(interpolateStep({ type: "inputText", text: "{{secret.TEST_ONLY}}" }, {}), { type: "inputText", text: "typed-secret" });
263
+ assert.throws(() => interpolateStep({ type: "assertVisible", text: "{{secret.TEST_ONLY}}" }, {}), /only be used in inputText/);
264
+ assert.throws(() => interpolateStep({ type: "openLink", url: "https://evil.example/?k={{secret.TEST_ONLY}}" }, {}), /only be used in inputText/);
265
+ } finally {
266
+ delete process.env.BUGMOLE_FLOW_SECRET_TEST_ONLY;
267
+ }
268
+ });
@@ -0,0 +1,414 @@
1
+ import YAML from "yaml";
2
+
3
+ /**
4
+ * The Maestro-style flow subset this project's web plans author.
5
+ *
6
+ * The vocabulary is deliberately declarative: a flow describes what a person
7
+ * does and sees, never how the page is implemented. Anything that needs page
8
+ * internals belongs in the application's own test suite, not in a QA flow.
9
+ */
10
+ export type FlowStep =
11
+ | { type: "launchApp" }
12
+ | { type: "stopApp" }
13
+ | { type: "openLink"; url: string }
14
+ | { type: "tapOn"; text: string; index: number; optional: boolean }
15
+ | { type: "inputText"; text: string }
16
+ | { type: "eraseText" }
17
+ | { type: "pressKey"; key: string }
18
+ | { type: "assertVisible"; text: string; timeoutMs?: number }
19
+ | { type: "assertNotVisible"; text: string; timeoutMs?: number }
20
+ /**
21
+ * A stronger claim than assertVisible: the element must be the actual
22
+ * hit-test target at its own center point, not merely painted somewhere on
23
+ * screen. Catches a real bug class assertVisible cannot -- an unrelated
24
+ * fixed-position element (a mascot, a floating action button, a toast)
25
+ * sitting on top of a link or button and silently swallowing the tap.
26
+ */
27
+ | { type: "assertClickable"; text: string; index: number; timeoutMs?: number }
28
+ | { type: "scrollUntilVisible"; text: string }
29
+ | { type: "wait"; ms: number }
30
+ | { type: "takeScreenshot"; name: string }
31
+ | { type: "retry"; maxRetries: number; steps: FlowStep[] }
32
+ /**
33
+ * Creates a real, receivable disposable inbox (see src/integrations/temp-email.ts)
34
+ * and binds its address to `as` for later steps to reference as `{{as}}` in
35
+ * any text/url field -- so a signup form can type a real address that a
36
+ * later `openEmailLink` step can actually receive mail at.
37
+ */
38
+ | { type: "requestTempEmail"; as: string }
39
+ /**
40
+ * Waits for a message at the inbox bound to `as`, then navigates to the
41
+ * first link in it (or the first link matching `match`). Real transactional
42
+ * email routes links through the sending provider's own click-tracking
43
+ * redirect; this follows that redirect like a person's browser would,
44
+ * rather than trying to guess the "real" destination underneath it.
45
+ */
46
+ | { type: "openEmailLink"; as: string; from?: string; subject?: string; match?: string; timeoutMs?: number };
47
+
48
+ export type FlowViewport = { width: number; height: number };
49
+
50
+ export interface ParsedFlow {
51
+ /** Entry URL used by `launchApp` and by relative `openLink` targets. */
52
+ url: string;
53
+ name?: string;
54
+ /** Emulated viewport, so a flow can pin the width a layout is asserted at. */
55
+ viewport?: FlowViewport;
56
+ /**
57
+ * Uncaught page errors fail a flow by default: a journey that "passes" on a
58
+ * crashed page is not evidence that the journey works.
59
+ */
60
+ ignorePageErrors: boolean;
61
+ steps: FlowStep[];
62
+ /**
63
+ * Origin the flow was authored against, when its header URL was moved onto
64
+ * another environment. Absolute links to this origin inside the flow follow
65
+ * the same move; links to any other host are left alone.
66
+ */
67
+ rebasedFrom?: string;
68
+ /** The environment base URL the flow was moved onto (path prefix included). */
69
+ rebasedOnto?: string;
70
+ }
71
+
72
+ export type ParseFlowOptions = {
73
+ /** Move an absolute header URL onto fallbackBaseUrl instead of letting it win. */
74
+ rebaseOrigin?: boolean;
75
+ };
76
+
77
+ /**
78
+ * Joins a URL's path, query, and hash onto another base. The base's own path
79
+ * is kept as a prefix, so an environment mounted under /app still works.
80
+ */
81
+ export function rebaseUrl(url: string, baseUrl: string): string {
82
+ const source = new URL(url);
83
+ const base = new URL(baseUrl);
84
+ const prefix = base.pathname.replace(/\/+$/, "");
85
+ const target = new URL(base.origin);
86
+ target.pathname = `${prefix}${source.pathname}` || "/";
87
+ target.search = source.search;
88
+ target.hash = source.hash;
89
+ return target.toString();
90
+ }
91
+
92
+ const defaultWaitMs = 15_000;
93
+
94
+ function asRecord(value: unknown): Record<string, unknown> | null {
95
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
96
+ }
97
+
98
+ function requireText(value: unknown, step: string): string {
99
+ const text = typeof value === "string" ? value : typeof value === "number" ? String(value) : "";
100
+ if (!text) throw new Error(`Flow step "${step}" requires text.`);
101
+ return text;
102
+ }
103
+
104
+ function parseTimeout(value: unknown): number | undefined {
105
+ const timeout = Number(value);
106
+ return Number.isFinite(timeout) && timeout > 0 ? timeout : undefined;
107
+ }
108
+
109
+ /** `viewport: 390x844`, or `{ width, height }`. */
110
+ export function parseViewport(value: unknown): FlowViewport | undefined {
111
+ if (value === undefined || value === null) return undefined;
112
+ if (typeof value === "string") {
113
+ const match = /^(\d{2,5})\s*[x×]\s*(\d{2,5})$/i.exec(value.trim());
114
+ if (!match) throw new Error(`Flow header 'viewport' must look like "390x844", received "${value}".`);
115
+ return { width: Number(match[1]), height: Number(match[2]) };
116
+ }
117
+ const record = asRecord(value);
118
+ const width = Number(record?.width);
119
+ const height = Number(record?.height);
120
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
121
+ throw new Error("Flow header 'viewport' requires a positive width and height.");
122
+ }
123
+ return { width, height };
124
+ }
125
+
126
+ function parseStep(raw: unknown): FlowStep {
127
+ if (typeof raw === "string") {
128
+ if (raw === "launchApp") return { type: "launchApp" };
129
+ if (raw === "stopApp") return { type: "stopApp" };
130
+ if (raw === "eraseText") return { type: "eraseText" };
131
+ if (raw === "requestTempEmail") return { type: "requestTempEmail", as: "tempEmail" };
132
+ if (raw === "openEmailLink") return { type: "openEmailLink", as: "tempEmail" };
133
+ // Maestro waits for animations before asserting; the web driver's
134
+ // auto-waiting locators already cover this, so it is a no-op pause.
135
+ if (raw === "waitForAnimationToEnd") return { type: "wait", ms: 300 };
136
+ throw new Error(`Unsupported flow step for the web driver: ${JSON.stringify(raw)}`);
137
+ }
138
+
139
+ const record = asRecord(raw);
140
+ if (!record) throw new Error(`Unsupported flow step for the web driver: ${JSON.stringify(raw)}`);
141
+
142
+ if ("openLink" in record) {
143
+ const value = asRecord(record.openLink);
144
+ return { type: "openLink", url: requireText(value ? value.link : record.openLink, "openLink") };
145
+ }
146
+ if ("tapOn" in record) {
147
+ const value = asRecord(record.tapOn);
148
+ const text = requireText(value ? (value.text ?? value.id ?? value.label) : record.tapOn, "tapOn");
149
+ const index = value && Number.isInteger(Number(value.index)) ? Number(value.index) : 0;
150
+ return { type: "tapOn", text, index, optional: Boolean(value?.optional) };
151
+ }
152
+ if ("inputText" in record) return { type: "inputText", text: requireText(record.inputText, "inputText") };
153
+ if ("eraseText" in record) return { type: "eraseText" };
154
+ if ("pressKey" in record) return { type: "pressKey", key: requireText(record.pressKey, "pressKey") };
155
+ if ("assertVisible" in record) {
156
+ const value = asRecord(record.assertVisible);
157
+ return {
158
+ type: "assertVisible",
159
+ text: requireText(value ? value.text : record.assertVisible, "assertVisible"),
160
+ timeoutMs: parseTimeout(value?.timeout),
161
+ };
162
+ }
163
+ if ("assertClickable" in record) {
164
+ const value = asRecord(record.assertClickable);
165
+ const index = value && Number.isInteger(Number(value.index)) ? Number(value.index) : 0;
166
+ return {
167
+ type: "assertClickable",
168
+ text: requireText(value ? value.text : record.assertClickable, "assertClickable"),
169
+ index,
170
+ timeoutMs: parseTimeout(value?.timeout),
171
+ };
172
+ }
173
+ if ("assertNotVisible" in record) {
174
+ const value = asRecord(record.assertNotVisible);
175
+ return {
176
+ type: "assertNotVisible",
177
+ text: requireText(value ? value.text : record.assertNotVisible, "assertNotVisible"),
178
+ timeoutMs: parseTimeout(value?.timeout),
179
+ };
180
+ }
181
+ if ("extendedWaitUntil" in record) {
182
+ const value = asRecord(record.extendedWaitUntil);
183
+ if (!value) throw new Error('Flow step "extendedWaitUntil" requires visible or notVisible.');
184
+ const timeoutMs = parseTimeout(value.timeout) ?? defaultWaitMs;
185
+ if (value.notVisible !== undefined) {
186
+ return { type: "assertNotVisible", text: requireText(value.notVisible, "extendedWaitUntil"), timeoutMs };
187
+ }
188
+ return { type: "assertVisible", text: requireText(value.visible, "extendedWaitUntil"), timeoutMs };
189
+ }
190
+ if ("scrollUntilVisible" in record) {
191
+ const value = asRecord(record.scrollUntilVisible);
192
+ return { type: "scrollUntilVisible", text: requireText(value ? value.element : record.scrollUntilVisible, "scrollUntilVisible") };
193
+ }
194
+ if ("wait" in record) {
195
+ const ms = parseTimeout(record.wait);
196
+ if (!ms) throw new Error('Flow step "wait" requires a positive duration in milliseconds.');
197
+ return { type: "wait", ms };
198
+ }
199
+ if ("takeScreenshot" in record) {
200
+ const value = asRecord(record.takeScreenshot);
201
+ return { type: "takeScreenshot", name: requireText(value ? value.path : record.takeScreenshot, "takeScreenshot") };
202
+ }
203
+ if ("requestTempEmail" in record) {
204
+ const value = record.requestTempEmail;
205
+ const as = typeof value === "string" ? value : asRecord(value)?.as;
206
+ return { type: "requestTempEmail", as: typeof as === "string" && as ? as : "tempEmail" };
207
+ }
208
+ if ("openEmailLink" in record) {
209
+ const value = asRecord(record.openEmailLink) ?? {};
210
+ return {
211
+ type: "openEmailLink",
212
+ as: typeof value.as === "string" && value.as ? value.as : "tempEmail",
213
+ from: typeof value.from === "string" ? value.from : undefined,
214
+ subject: typeof value.subject === "string" ? value.subject : undefined,
215
+ match: typeof value.match === "string" ? value.match : undefined,
216
+ timeoutMs: parseTimeout(value.timeout),
217
+ };
218
+ }
219
+ if ("retry" in record) {
220
+ const value = asRecord(record.retry);
221
+ if (!value || !Array.isArray(value.commands)) throw new Error('Flow step "retry" requires a commands list.');
222
+ const maxRetries = Number.isInteger(Number(value.maxRetries)) ? Math.max(0, Number(value.maxRetries)) : 1;
223
+ return { type: "retry", maxRetries, steps: value.commands.map(parseStep) };
224
+ }
225
+
226
+ throw new Error(`Unsupported flow step for the web driver: ${JSON.stringify(raw)}`);
227
+ }
228
+
229
+ export function parseMaestroStyleFlow(content: string, fallbackBaseUrl?: string, options: ParseFlowOptions = {}): ParsedFlow {
230
+ const documents = content
231
+ .split(/^---\s*$/m)
232
+ .map((doc) => doc.trim())
233
+ .filter(Boolean);
234
+ if (documents.length < 2) {
235
+ throw new Error("Flow file must have a header document and a steps document separated by '---'.");
236
+ }
237
+ const header = (YAML.parse(documents[0]) ?? {}) as { url?: string; appId?: string; name?: string; ignorePageErrors?: boolean; viewport?: unknown };
238
+ const stepsRaw: unknown = YAML.parse(documents[1]);
239
+ if (!Array.isArray(stepsRaw)) {
240
+ throw new Error("Flow steps document must be a YAML list.");
241
+ }
242
+ let url = header.url || fallbackBaseUrl || header.appId;
243
+ if (!url) {
244
+ throw new Error("Flow header must declare 'url' (or 'appId'), or the driver target must provide a base URL.");
245
+ }
246
+ let rebasedFrom: string | undefined;
247
+ if (options.rebaseOrigin && fallbackBaseUrl && header.url) {
248
+ if (/^[a-z][a-z0-9+.-]*:/i.test(header.url)) {
249
+ rebasedFrom = new URL(header.url).origin;
250
+ url = rebaseUrl(header.url, fallbackBaseUrl);
251
+ } else {
252
+ url = new URL(header.url, fallbackBaseUrl).toString();
253
+ }
254
+ }
255
+ return {
256
+ url: String(url),
257
+ ...(rebasedFrom ? { rebasedFrom, rebasedOnto: fallbackBaseUrl } : {}),
258
+ name: header.name,
259
+ viewport: parseViewport(header.viewport),
260
+ ignorePageErrors: header.ignorePageErrors === true,
261
+ steps: stepsRaw.map(parseStep),
262
+ };
263
+ }
264
+
265
+ /** Flattens `retry` blocks so callers can count the steps a flow will run. */
266
+ export function countFlowSteps(steps: readonly FlowStep[]): number {
267
+ return steps.reduce((total, step) => total + (step.type === "retry" ? countFlowSteps(step.steps) : 1), 0);
268
+ }
269
+
270
+ /**
271
+ * Maestro matches element text as a regular expression, but real UI copy is
272
+ * full of characters that are also regex metacharacters -- periods, dollar
273
+ * signs, parentheses, plus signs -- and flow authors should never have to
274
+ * escape ordinary text to assert it. Treating any of those as a pattern
275
+ * signal is actively wrong: it silently miscompiles copy like "(USD)" into a
276
+ * capture group that no longer matches the literal parentheses. `.*` (the
277
+ * "contains" wildcard) is the one idiom every flow in this project's own
278
+ * authoring convention actually uses to signal real pattern intent, so it is
279
+ * the only signal trusted here.
280
+ */
281
+ export function isPatternText(text: string): boolean {
282
+ return text.includes(".*");
283
+ }
284
+
285
+ /** Environment variables a flow may type via `{{secret.NAME}}`. */
286
+ export const FLOW_SECRET_ENV_PREFIX = "BUGMOLE_FLOW_SECRET_";
287
+
288
+ export type InterpolateOptions = {
289
+ /** Allow `{{secret.NAME}}`. Only typed text may carry a secret. */
290
+ allowSecrets?: boolean;
291
+ env?: Readonly<Record<string, string | undefined>>;
292
+ };
293
+
294
+ /**
295
+ * Substitutes `{{name}}` tokens with a value bound earlier in the same flow
296
+ * (currently, only requestTempEmail binds one). Plain text with no template
297
+ * syntax passes through untouched; a reference to a name nothing bound yet
298
+ * fails loudly rather than typing the literal "{{name}}" into a form.
299
+ *
300
+ * `{{secret.NAME}}` types the environment variable BUGMOLE_FLOW_SECRET_NAME,
301
+ * so a flow never needs a literal password. Only that prefix is readable: a
302
+ * flow must not be able to pull the worker's own credentials into a page.
303
+ */
304
+ export function interpolateTemplate(
305
+ text: string,
306
+ context: Readonly<Record<string, string>>,
307
+ options: InterpolateOptions = {},
308
+ ): string {
309
+ return text.replace(/\{\{\s*(secret\.)?([a-zA-Z0-9_]+)\s*\}\}/g, (whole, secret: string | undefined, name: string) => {
310
+ if (secret) {
311
+ if (!options.allowSecrets) {
312
+ throw new Error(`"{{secret.${name}}}" can only be used in inputText, so the secret is typed and never shown or logged.`);
313
+ }
314
+ const variable = `${FLOW_SECRET_ENV_PREFIX}${name}`;
315
+ const value = (options.env ?? process.env)[variable];
316
+ if (value === undefined || value === "") {
317
+ throw new Error(`Flow references "{{secret.${name}}}", but ${variable} is not set on this worker.`);
318
+ }
319
+ return value;
320
+ }
321
+ if (!(name in context)) {
322
+ throw new Error(`Flow references "{{${name}}}", but no step has bound that name yet (e.g. requestTempEmail: "${name}").`);
323
+ }
324
+ return context[name];
325
+ });
326
+ }
327
+
328
+ /**
329
+ * A step as it may appear in logs, step results, and uploaded evidence: typed
330
+ * text is masked, because it is where passwords and personal data go.
331
+ */
332
+ export function describeStepForLog(step: FlowStep): string {
333
+ const redact = (value: FlowStep): FlowStep => {
334
+ if (value.type === "inputText") return { ...value, text: `•••• (${value.text.length} chars)` };
335
+ if (value.type === "retry") return { ...value, steps: value.steps.map(redact) };
336
+ return value;
337
+ };
338
+ return JSON.stringify(redact(step));
339
+ }
340
+
341
+ /** A step as a short sentence for traces and timelines. Typed text is masked. */
342
+ export function describeStepLabel(step: FlowStep): string {
343
+ switch (step.type) {
344
+ case "launchApp": return "Open the app";
345
+ case "stopApp": return "Close the app";
346
+ case "openLink": return `Go to ${step.url}`;
347
+ case "tapOn": return `Tap “${step.text}”${step.optional ? " (if shown)" : ""}`;
348
+ case "inputText": return `Type ${"•".repeat(Math.min(step.text.length, 8))}`;
349
+ case "eraseText": return "Clear the field";
350
+ case "pressKey": return `Press ${step.key}`;
351
+ case "assertVisible": return `See “${step.text}”`;
352
+ case "assertNotVisible": return `No “${step.text}”`;
353
+ case "assertClickable": return `“${step.text}” is clickable`;
354
+ case "scrollUntilVisible": return `Scroll to “${step.text}”`;
355
+ case "wait": return `Wait ${step.ms}ms`;
356
+ case "takeScreenshot": return `Screenshot “${step.name}”`;
357
+ case "retry": return `Retry ${step.steps.length} step(s) up to ${step.maxRetries}×`;
358
+ case "requestTempEmail": return "Create a test inbox";
359
+ case "openEmailLink": return "Open the link from the email";
360
+ }
361
+ }
362
+
363
+ /** An email link with its query and fragment (usually a one-time token) removed. */
364
+ export function describeLinkForLog(link: string): string {
365
+ try {
366
+ const url = new URL(link);
367
+ const hidden = url.search || url.hash ? "?…" : "";
368
+ return `${url.origin}${url.pathname}${hidden}`;
369
+ } catch {
370
+ return "(unparseable link)";
371
+ }
372
+ }
373
+
374
+ /** A disposable inbox address with most of its local part hidden. */
375
+ export function describeAddressForLog(address: string): string {
376
+ const [local = "", domain = ""] = address.split("@");
377
+ return `${local.slice(0, 2)}•••@${domain}`;
378
+ }
379
+
380
+ /**
381
+ * Applies interpolateTemplate to every free-text field a flow author could
382
+ * have templated. `retry`'s nested steps are deliberately left alone here --
383
+ * the driver interpolates each one individually as it actually executes it.
384
+ */
385
+ export function interpolateStep(step: FlowStep, context: Readonly<Record<string, string>>): FlowStep {
386
+ switch (step.type) {
387
+ case "tapOn":
388
+ return { ...step, text: interpolateTemplate(step.text, context) };
389
+ case "inputText":
390
+ return { ...step, text: interpolateTemplate(step.text, context, { allowSecrets: true }) };
391
+ case "assertVisible":
392
+ return { ...step, text: interpolateTemplate(step.text, context) };
393
+ case "assertNotVisible":
394
+ return { ...step, text: interpolateTemplate(step.text, context) };
395
+ case "assertClickable":
396
+ return { ...step, text: interpolateTemplate(step.text, context) };
397
+ case "scrollUntilVisible":
398
+ return { ...step, text: interpolateTemplate(step.text, context) };
399
+ case "openLink":
400
+ return { ...step, url: interpolateTemplate(step.url, context) };
401
+ default:
402
+ return step;
403
+ }
404
+ }
405
+
406
+ export function resolveFlowUrl(target: string, baseUrl: string, rebasedFrom?: string, rebasedOnto?: string): string {
407
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) {
408
+ if (rebasedFrom && /^https?:/i.test(target) && new URL(target).origin === rebasedFrom) {
409
+ return rebaseUrl(target, rebasedOnto ?? new URL(baseUrl).origin);
410
+ }
411
+ return target;
412
+ }
413
+ return new URL(target, baseUrl).toString();
414
+ }