@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,175 @@
1
+ /**
2
+ * A real, disposable email inbox for QA flows -- so a signup/confirmation
3
+ * journey can be tested against a real message a real mail provider
4
+ * actually delivered, not just a database row that says "confirmed."
5
+ *
6
+ * Backed by mail.tm's public REST API (https://api.mail.tm), not browser
7
+ * automation against a temp-mail website: no CAPTCHA to solve (several
8
+ * disposable-inbox sites gate message content behind one), a stable typed
9
+ * response instead of scraping rendered HTML, and no dependency on which
10
+ * inbox UI happens to be reachable this week.
11
+ */
12
+
13
+ const API_BASE = "https://api.mail.tm";
14
+
15
+ export type TempEmailInbox = {
16
+ address: string;
17
+ password: string;
18
+ accountId: string;
19
+ token: string;
20
+ };
21
+
22
+ export type TempEmailMessage = {
23
+ id: string;
24
+ from: string;
25
+ subject: string;
26
+ text: string;
27
+ html: string;
28
+ receivedAt: string;
29
+ };
30
+
31
+ type MailTmDomain = { domain: string };
32
+ type MailTmDomainList = { "hydra:member": MailTmDomain[] };
33
+ type MailTmAccount = { id: string; address: string };
34
+ type MailTmTokenResponse = { token: string };
35
+ type MailTmMessageSummary = { id: string; from: { address: string }; subject: string; createdAt: string };
36
+ type MailTmMessageList = { "hydra:member": MailTmMessageSummary[] };
37
+ type MailTmMessageDetail = { id: string; from: { address: string }; subject: string; text?: string; html?: string[]; createdAt: string };
38
+
39
+ async function mailTmFetch<T>(path: string, init?: RequestInit): Promise<T> {
40
+ const response = await fetch(`${API_BASE}${path}`, {
41
+ ...init,
42
+ headers: { "content-type": "application/json", ...init?.headers },
43
+ });
44
+ if (!response.ok) {
45
+ const body = await response.text().catch(() => "");
46
+ throw new Error(`mail.tm ${init?.method ?? "GET"} ${path} failed: ${response.status} ${body.slice(0, 200)}`);
47
+ }
48
+ return (await response.json()) as T;
49
+ }
50
+
51
+ function randomLocalPart(): string {
52
+ return Array.from({ length: 12 }, () => "abcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(Math.random() * 36)]).join("");
53
+ }
54
+
55
+ /**
56
+ * Creates a fresh, real, receivable inbox. Each call is a brand-new address
57
+ * on a domain mail.tm currently serves -- callers never need to manage
58
+ * inbox lifecycles across runs, since a new one is cheap and disposable by
59
+ * design.
60
+ */
61
+ export async function createTempEmailInbox(): Promise<TempEmailInbox> {
62
+ const domains = await mailTmFetch<MailTmDomainList>("/domains");
63
+ const domain = domains["hydra:member"].find((entry) => entry.domain)?.domain;
64
+ if (!domain) throw new Error("mail.tm has no active domain to create an inbox on right now.");
65
+
66
+ const address = `${randomLocalPart()}@${domain}`;
67
+ // A per-inbox random password, never reused or exposed to the flow author;
68
+ // this account exists only to receive one QA message and then be deleted.
69
+ const password = randomLocalPart() + randomLocalPart();
70
+
71
+ const account = await mailTmFetch<MailTmAccount>("/accounts", {
72
+ method: "POST",
73
+ body: JSON.stringify({ address, password }),
74
+ });
75
+ const tokenResponse = await mailTmFetch<MailTmTokenResponse>("/token", {
76
+ method: "POST",
77
+ body: JSON.stringify({ address, password }),
78
+ });
79
+
80
+ return { address, password, accountId: account.id, token: tokenResponse.token };
81
+ }
82
+
83
+ export type WaitForTempEmailOptions = {
84
+ /** Substring match against the message's From address, case-insensitive. */
85
+ from?: string;
86
+ /** Substring match against the message's subject, case-insensitive. */
87
+ subject?: string;
88
+ timeoutMs?: number;
89
+ pollIntervalMs?: number;
90
+ };
91
+
92
+ /**
93
+ * Polls the inbox until a message matching the given criteria arrives, then
94
+ * fetches its full body. Matching happens on the lightweight summary list
95
+ * first so a busy inbox isn't fetching full bodies for messages that were
96
+ * never going to match.
97
+ */
98
+ export async function waitForTempEmailMessage(inbox: TempEmailInbox, options: WaitForTempEmailOptions = {}): Promise<TempEmailMessage> {
99
+ const timeoutMs = options.timeoutMs ?? 30_000;
100
+ const pollIntervalMs = options.pollIntervalMs ?? 2_000;
101
+ const fromFilter = options.from?.toLowerCase();
102
+ const subjectFilter = options.subject?.toLowerCase();
103
+ const deadline = Date.now() + timeoutMs;
104
+
105
+ for (;;) {
106
+ const list = await mailTmFetch<MailTmMessageList>("/messages", {
107
+ headers: { Authorization: `Bearer ${inbox.token}` },
108
+ });
109
+ const match = list["hydra:member"].find(
110
+ (summary) =>
111
+ (!fromFilter || summary.from.address.toLowerCase().includes(fromFilter)) &&
112
+ (!subjectFilter || summary.subject.toLowerCase().includes(subjectFilter)),
113
+ );
114
+ if (match) {
115
+ const detail = await mailTmFetch<MailTmMessageDetail>(`/messages/${match.id}`, {
116
+ headers: { Authorization: `Bearer ${inbox.token}` },
117
+ });
118
+ return {
119
+ id: detail.id,
120
+ from: detail.from.address,
121
+ subject: detail.subject,
122
+ text: detail.text ?? "",
123
+ html: (detail.html ?? []).join("\n"),
124
+ receivedAt: detail.createdAt,
125
+ };
126
+ }
127
+ if (Date.now() >= deadline) {
128
+ const criteria = [fromFilter && `from containing "${fromFilter}"`, subjectFilter && `subject containing "${subjectFilter}"`]
129
+ .filter(Boolean)
130
+ .join(" and ");
131
+ throw new Error(`No message${criteria ? ` (${criteria})` : ""} arrived at ${inbox.address} within ${timeoutMs}ms.`);
132
+ }
133
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Pulls the first link out of a message body, optionally requiring the link
139
+ * text or the URL itself to contain a substring (e.g. "confirm"). Real
140
+ * transactional email routes links through the sending provider's own
141
+ * click-tracking redirect, so this deliberately returns that URL as-is
142
+ * rather than trying to guess the "real" destination underneath it --
143
+ * following the redirect is the whole point of the test.
144
+ */
145
+ export function extractFirstLink(message: Pick<TempEmailMessage, "html" | "text">, match?: string): string {
146
+ const hrefPattern = /<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
147
+ const candidates: { href: string; text: string }[] = [];
148
+ for (const found of message.html.matchAll(hrefPattern)) {
149
+ candidates.push({ href: found[1], text: found[2].replace(/<[^>]*>/g, "").trim() });
150
+ }
151
+ if (candidates.length === 0) {
152
+ const bareUrl = message.text.match(/https?:\/\/\S+/);
153
+ if (bareUrl) candidates.push({ href: bareUrl[0], text: "" });
154
+ }
155
+
156
+ const needle = match?.toLowerCase();
157
+ const chosen = needle
158
+ ? candidates.find((candidate) => candidate.href.toLowerCase().includes(needle) || candidate.text.toLowerCase().includes(needle))
159
+ : candidates[0];
160
+
161
+ if (!chosen) {
162
+ throw new Error(
163
+ `No link${match ? ` matching "${match}"` : ""} found in message "${message.html ? "(html body)" : "(text body)"}".`,
164
+ );
165
+ }
166
+ return chosen.href;
167
+ }
168
+
169
+ /** Deletes the inbox. Best-effort: a QA run's cleanup should never fail the run over it. */
170
+ export async function deleteTempEmailInbox(inbox: TempEmailInbox): Promise<void> {
171
+ await mailTmFetch(`/accounts/${inbox.accountId}`, {
172
+ method: "DELETE",
173
+ headers: { Authorization: `Bearer ${inbox.token}` },
174
+ }).catch(() => undefined);
175
+ }
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHmac } from "node:crypto";
3
+ import test from "node:test";
4
+ import {
5
+ normalizeTestFlightFeedback,
6
+ TestFlightFeedbackValidationError,
7
+ verifyTestFlightSignature,
8
+ } from "./testflight-feedback.js";
9
+
10
+ test("normalizes common TestFlight feedback fields", () => {
11
+ const feedback = normalizeTestFlightFeedback({
12
+ feedback: "The checkout button is clipped.",
13
+ tester: { name: "Test User", email: "tester@example.invalid" },
14
+ app: { name: "Bugmole", bundleId: "com.example.bugmole" },
15
+ appVersion: "1.4.0",
16
+ buildNumber: 42,
17
+ device: { model: "iPhone 15", os: "iOS", osVersion: "18.5" },
18
+ timestamp: "2026-07-13T05:00:00.000Z",
19
+ attachments: [{ url: "https://example.invalid/screenshot.png" }],
20
+ });
21
+
22
+ assert.equal(feedback.feedbackText, "The checkout button is clipped.");
23
+ assert.equal(feedback.tester?.email, "tester@example.invalid");
24
+ assert.equal(feedback.app.build, "42");
25
+ assert.equal(feedback.device?.osVersion, "18.5");
26
+ assert.deepEqual(feedback.attachments, ["https://example.invalid/screenshot.png"]);
27
+ assert.equal(feedback.source, "testflight");
28
+ assert.equal(feedback.status, "new");
29
+ });
30
+
31
+ test("rejects malformed feedback", () => {
32
+ assert.throws(
33
+ () => normalizeTestFlightFeedback({ appVersion: "1.0" }),
34
+ (error: unknown) =>
35
+ error instanceof TestFlightFeedbackValidationError &&
36
+ error.message === "Payload must include feedback text.",
37
+ );
38
+ assert.throws(() => normalizeTestFlightFeedback("not-an-object"));
39
+ });
40
+
41
+ test("verifies sha256 and sha256= HMAC signatures", () => {
42
+ const body = JSON.stringify({ feedback: "Hello" });
43
+ const secret = "testflight-placeholder-secret";
44
+ const digest = createHmac("sha256", secret).update(body).digest("hex");
45
+
46
+ assert.equal(verifyTestFlightSignature(body, digest, secret), true);
47
+ assert.equal(verifyTestFlightSignature(body, `sha256=${digest}`, secret), true);
48
+ assert.equal(verifyTestFlightSignature(body, `${digest.slice(0, -1)}0`, secret), false);
49
+ assert.equal(verifyTestFlightSignature(body, digest, "wrong-secret"), false);
50
+ assert.equal(verifyTestFlightSignature(body, null, secret), false);
51
+ });
@@ -0,0 +1,173 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ export type TestFlightFeedbackPayload = {
4
+ feedback?: string;
5
+ comment?: string;
6
+ feedbackText?: string;
7
+ tester?: {
8
+ name?: string;
9
+ email?: string;
10
+ contact?: string;
11
+ };
12
+ testerName?: string;
13
+ testerEmail?: string;
14
+ contactEmail?: string;
15
+ app?: {
16
+ name?: string;
17
+ bundleId?: string;
18
+ };
19
+ appName?: string;
20
+ bundleId?: string;
21
+ build?: string | number;
22
+ buildNumber?: string | number;
23
+ version?: string;
24
+ appVersion?: string;
25
+ device?: {
26
+ model?: string;
27
+ name?: string;
28
+ os?: string;
29
+ osVersion?: string;
30
+ };
31
+ deviceModel?: string;
32
+ deviceName?: string;
33
+ os?: string;
34
+ osVersion?: string;
35
+ submittedAt?: string;
36
+ timestamp?: string;
37
+ createdAt?: string;
38
+ attachments?: Array<string | { url?: string; name?: string }>;
39
+ links?: string[];
40
+ source?: string;
41
+ environment?: string;
42
+ };
43
+
44
+ export type QaFeedbackStatus = "new" | "triaged" | "linked";
45
+
46
+ export type QaFeedback = {
47
+ id: string;
48
+ source: "testflight";
49
+ feedbackText: string;
50
+ tester?: {
51
+ name?: string;
52
+ email?: string;
53
+ contact?: string;
54
+ };
55
+ app: {
56
+ name?: string;
57
+ bundleId?: string;
58
+ version?: string;
59
+ build?: string;
60
+ };
61
+ device?: {
62
+ model?: string;
63
+ name?: string;
64
+ os?: string;
65
+ osVersion?: string;
66
+ };
67
+ submittedAt?: string;
68
+ receivedAt: string;
69
+ attachments: string[];
70
+ environment?: string;
71
+ status: QaFeedbackStatus;
72
+ links: {
73
+ testCase?: string;
74
+ defect?: string;
75
+ evidence?: string;
76
+ };
77
+ };
78
+
79
+ export class TestFlightFeedbackValidationError extends Error {
80
+ constructor(message: string) {
81
+ super(message);
82
+ this.name = "TestFlightFeedbackValidationError";
83
+ }
84
+ }
85
+
86
+ function optionalString(value: unknown): string | undefined {
87
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
88
+ }
89
+
90
+ function stringFrom(value: unknown): string | undefined {
91
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
92
+ return optionalString(value);
93
+ }
94
+
95
+ function objectFrom(value: unknown): Record<string, unknown> {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value)
97
+ ? (value as Record<string, unknown>)
98
+ : {};
99
+ }
100
+
101
+ function firstString(...values: unknown[]): string | undefined {
102
+ return values.map(stringFrom).find(Boolean);
103
+ }
104
+
105
+ export function normalizeTestFlightFeedback(
106
+ input: unknown,
107
+ options: { id?: string; receivedAt?: string } = {},
108
+ ): QaFeedback {
109
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
110
+ throw new TestFlightFeedbackValidationError("Payload must be a JSON object.");
111
+ }
112
+
113
+ const payload = input as TestFlightFeedbackPayload;
114
+ const feedbackText = firstString(payload.feedbackText, payload.feedback, payload.comment);
115
+ if (!feedbackText) {
116
+ throw new TestFlightFeedbackValidationError("Payload must include feedback text.");
117
+ }
118
+
119
+ const tester = objectFrom(payload.tester);
120
+ const app = objectFrom(payload.app);
121
+ const device = objectFrom(payload.device);
122
+ const attachmentValues = Array.isArray(payload.attachments) ? payload.attachments : [];
123
+ const attachments = [
124
+ ...attachmentValues
125
+ .map((item) => (typeof item === "string" ? item : objectFrom(item).url))
126
+ .map(optionalString)
127
+ .filter((item): item is string => Boolean(item)),
128
+ ...(Array.isArray(payload.links) ? payload.links : [])
129
+ .map(optionalString)
130
+ .filter((item): item is string => Boolean(item)),
131
+ ];
132
+
133
+ return {
134
+ id: options.id ?? `testflight-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
135
+ source: "testflight",
136
+ feedbackText,
137
+ tester: {
138
+ name: firstString(tester.name, payload.testerName),
139
+ email: firstString(tester.email, payload.testerEmail, payload.contactEmail),
140
+ contact: firstString(tester.contact),
141
+ },
142
+ app: {
143
+ name: firstString(app.name, payload.appName),
144
+ bundleId: firstString(app.bundleId, payload.bundleId),
145
+ version: firstString(payload.appVersion, payload.version),
146
+ build: firstString(payload.buildNumber, payload.build),
147
+ },
148
+ device: {
149
+ model: firstString(device.model, payload.deviceModel),
150
+ name: firstString(device.name, payload.deviceName),
151
+ os: firstString(device.os, payload.os),
152
+ osVersion: firstString(device.osVersion, payload.osVersion),
153
+ },
154
+ submittedAt: firstString(payload.submittedAt, payload.timestamp, payload.createdAt),
155
+ receivedAt: options.receivedAt ?? new Date().toISOString(),
156
+ attachments,
157
+ environment: optionalString(payload.environment),
158
+ status: "new",
159
+ links: {},
160
+ };
161
+ }
162
+
163
+ export function verifyTestFlightSignature(
164
+ rawBody: string,
165
+ suppliedSignature: string | null,
166
+ secret: string,
167
+ ): boolean {
168
+ if (!suppliedSignature || !secret) return false;
169
+ const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
170
+ const provided = suppliedSignature.trim().replace(/^sha256=/i, "");
171
+ if (!/^[a-f0-9]{64}$/i.test(provided)) return false;
172
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(provided, "hex"));
173
+ }
@@ -0,0 +1,131 @@
1
+ // A small W3C WebDriver client: enough to drive Appium sessions on device
2
+ // clouds (BrowserStack, Kobiton, AWS Device Farm) without a heavy dependency.
3
+
4
+ export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
5
+
6
+ export class WebDriverError extends Error {
7
+ constructor(readonly status: number, readonly error: string, message: string) {
8
+ super(message);
9
+ this.name = "WebDriverError";
10
+ }
11
+ }
12
+
13
+ export type SessionOptions = {
14
+ /** Hub URL, e.g. https://hub.browserstack.com/wd/hub. Credentials may be in the URL or in `authorization`. */
15
+ hubUrl: string;
16
+ capabilities: Record<string, unknown>;
17
+ authorization?: string;
18
+ fetch?: typeof fetch;
19
+ /** Per-command timeout. */
20
+ timeoutMs?: number;
21
+ };
22
+
23
+ export type Locator = { using: string; value: string };
24
+
25
+ function splitCredentials(hubUrl: string): { url: string; authorization?: string } {
26
+ const url = new URL(hubUrl);
27
+ if (!url.username) return { url: url.toString().replace(/\/$/, "") };
28
+ const authorization = `Basic ${btoa(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`)}`;
29
+ url.username = "";
30
+ url.password = "";
31
+ return { url: url.toString().replace(/\/$/, ""), authorization };
32
+ }
33
+
34
+ export class WebDriverSession {
35
+ private constructor(
36
+ readonly id: string,
37
+ readonly capabilities: Record<string, unknown>,
38
+ private readonly base: string,
39
+ private readonly authorization: string | undefined,
40
+ private readonly doFetch: typeof fetch,
41
+ private readonly timeoutMs: number,
42
+ ) {}
43
+
44
+ static async create(options: SessionOptions): Promise<WebDriverSession> {
45
+ const { url, authorization } = splitCredentials(options.hubUrl);
46
+ const doFetch = options.fetch ?? fetch;
47
+ const auth = options.authorization ?? authorization;
48
+ const reply = await send(doFetch, `${url}/session`, "POST", auth, { capabilities: { alwaysMatch: options.capabilities, firstMatch: [{}] } }, 5 * 60_000);
49
+ const value = reply as { sessionId?: string; capabilities?: Record<string, unknown> };
50
+ if (!value.sessionId) throw new WebDriverError(500, "session not created", "The device cloud didn't start a session");
51
+ return new WebDriverSession(value.sessionId, value.capabilities ?? {}, `${url}/session/${value.sessionId}`, auth, doFetch, options.timeoutMs ?? 60_000);
52
+ }
53
+
54
+ command<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
55
+ return send(this.doFetch, `${this.base}${path}`, method, this.authorization, body, this.timeoutMs) as Promise<T>;
56
+ }
57
+
58
+ navigate(url: string) {
59
+ return this.command("POST", "/url", { url });
60
+ }
61
+
62
+ async findElements(locator: Locator): Promise<string[]> {
63
+ const found = await this.command<Array<Record<string, string>>>("POST", "/elements", locator);
64
+ return found.map((element) => element[ELEMENT_KEY] ?? Object.values(element)[0]);
65
+ }
66
+
67
+ click(element: string) {
68
+ return this.command("POST", `/element/${element}/click`, {});
69
+ }
70
+
71
+ sendKeys(element: string, text: string) {
72
+ return this.command("POST", `/element/${element}/value`, { text, value: [...text] });
73
+ }
74
+
75
+ clear(element: string) {
76
+ return this.command("POST", `/element/${element}/clear`, {});
77
+ }
78
+
79
+ async isDisplayed(element: string): Promise<boolean> {
80
+ try {
81
+ return Boolean(await this.command("GET", `/element/${element}/displayed`));
82
+ } catch (error) {
83
+ if (error instanceof WebDriverError && error.error === "stale element reference") return false;
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ async activeElement(): Promise<string | null> {
89
+ try {
90
+ const value = await this.command<Record<string, string>>("GET", "/element/active");
91
+ return value[ELEMENT_KEY] ?? Object.values(value)[0] ?? null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** Sends keys to whatever has focus. */
98
+ keys(text: string) {
99
+ return this.command("POST", "/actions", {
100
+ actions: [{ type: "key", id: "keyboard", actions: [...text].flatMap((value) => [{ type: "keyDown", value }, { type: "keyUp", value }]) }],
101
+ });
102
+ }
103
+
104
+ execute<T = unknown>(script: string, args: unknown[] = []): Promise<T> {
105
+ return this.command<T>("POST", "/execute/sync", { script, args });
106
+ }
107
+
108
+ async screenshot(): Promise<Uint8Array> {
109
+ const base64 = await this.command<string>("GET", "/screenshot");
110
+ return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
111
+ }
112
+
113
+ async delete(): Promise<void> {
114
+ await this.command("DELETE", "").catch(() => undefined);
115
+ }
116
+ }
117
+
118
+ async function send(doFetch: typeof fetch, url: string, method: string, authorization: string | undefined, body: unknown, timeoutMs: number): Promise<unknown> {
119
+ const response = await doFetch(url, {
120
+ method,
121
+ headers: { "content-type": "application/json; charset=utf-8", ...(authorization ? { authorization } : {}) },
122
+ body: body === undefined || method === "GET" || method === "DELETE" ? undefined : JSON.stringify(body),
123
+ signal: AbortSignal.timeout(timeoutMs),
124
+ });
125
+ const payload = await response.json().catch(() => ({})) as { value?: any };
126
+ const value = payload.value;
127
+ if (!response.ok || (value && typeof value === "object" && typeof value.error === "string")) {
128
+ throw new WebDriverError(response.status, value?.error ?? `http ${response.status}`, value?.message ?? `WebDriver ${method} failed (${response.status})`);
129
+ }
130
+ return value;
131
+ }