@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,73 @@
1
+ // BrowserStack App Automate (native apps) and Automate (mobile browsers).
2
+ import type { AppBuild, DeviceCredential, DeviceSessionHost } from "./types.js";
3
+
4
+ const HUB = "https://hub.browserstack.com/wd/hub";
5
+ const API = "https://api-cloud.browserstack.com";
6
+
7
+ function basic(credential: DeviceCredential): string {
8
+ return `Basic ${btoa(`${credential.username}:${credential.secret}`)}`;
9
+ }
10
+
11
+ async function ensureApp(credential: DeviceCredential, app: AppBuild, doFetch: typeof fetch): Promise<string> {
12
+ // custom_id lets a second run reuse the upload instead of sending the file again.
13
+ const customId = `bugmole-${app.sha256.slice(0, 32)}`;
14
+ const recent = await doFetch(`${API}/app-automate/recent_apps/${customId}`, { headers: { authorization: basic(credential) } });
15
+ if (recent.ok) {
16
+ const apps = await recent.json().catch(() => []) as Array<{ app_url?: string }>;
17
+ if (Array.isArray(apps) && apps[0]?.app_url) return apps[0].app_url;
18
+ }
19
+ const form = new FormData();
20
+ form.set("file", new Blob([Buffer.from(await app.load())]), app.filename);
21
+ form.set("custom_id", customId);
22
+ const uploaded = await doFetch(`${API}/app-automate/upload`, { method: "POST", headers: { authorization: basic(credential) }, body: form });
23
+ const body = await uploaded.json().catch(() => ({})) as { app_url?: string; error?: string };
24
+ if (!uploaded.ok || !body.app_url) throw new Error(`BrowserStack didn't accept the app${body.error ? `: ${body.error}` : ""}`);
25
+ return body.app_url;
26
+ }
27
+
28
+ export function browserstackHost(credential: DeviceCredential, doFetch: typeof fetch = fetch): DeviceSessionHost {
29
+ return {
30
+ async open({ device, name, app }) {
31
+ const native = !device.browserName;
32
+ const appUrl = native && app ? await ensureApp(credential, app, doFetch) : undefined;
33
+ if (native && !appUrl) throw new Error("A native app run needs an uploaded app build");
34
+ const capabilities: Record<string, unknown> = {
35
+ platformName: device.platform === "android" ? "Android" : "iOS",
36
+ ...(device.browserName ? { browserName: device.browserName === "chrome" ? "Chrome" : "Safari" } : { "appium:app": appUrl }),
37
+ "bstack:options": {
38
+ userName: credential.username,
39
+ accessKey: credential.secret,
40
+ deviceName: device.deviceName,
41
+ ...(device.osVersion ? { osVersion: device.osVersion } : {}),
42
+ realMobile: String(device.real),
43
+ projectName: "Bugmole",
44
+ buildName: name.split(" · ")[0],
45
+ sessionName: name,
46
+ video: true,
47
+ networkLogs: true,
48
+ },
49
+ };
50
+ const api = native ? "app-automate" : "automate";
51
+ return {
52
+ hubUrl: HUB,
53
+ capabilities,
54
+ finish: async ({ passed, webdriverSessionId, reason }) => {
55
+ if (!webdriverSessionId) return { links: [] };
56
+ const url = `https://api.browserstack.com/${api}/sessions/${webdriverSessionId}.json`;
57
+ await doFetch(url, {
58
+ method: "PUT",
59
+ headers: { authorization: basic(credential), "content-type": "application/json" },
60
+ body: JSON.stringify({ status: passed ? "passed" : "failed", ...(reason ? { reason: reason.slice(0, 255) } : {}) }),
61
+ }).catch(() => undefined);
62
+ const details = await doFetch(url, { headers: { authorization: basic(credential) } }).then((reply) => reply.json()).catch(() => null) as
63
+ { automation_session?: { public_url?: string; video_url?: string } } | null;
64
+ const session = details?.automation_session;
65
+ return {
66
+ links: session?.public_url ? [{ label: "BrowserStack session", url: session.public_url }] : [],
67
+ ...(session?.video_url ? { videoUrl: session.video_url } : {}),
68
+ };
69
+ },
70
+ };
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,52 @@
1
+ // AWS Device Farm with the customer's own account, through its managed
2
+ // Appium endpoint. Bugmole's own device cloud uses the same calls from the
3
+ // registry (see registryDeviceHost), so no AWS keys reach a runner.
4
+ import { finishedSession, listDevices, sessionArtifacts, startSession, stopSession, uploadApp, type DeviceFarmOptions } from "../../integrations/device-farm.js";
5
+ import type { CloudDevice, DeviceCredential, DeviceSessionHost } from "./types.js";
6
+
7
+ export function deviceFarmCapabilities(device: CloudDevice): Record<string, unknown> {
8
+ // Device Farm rejects device-specific capabilities such as udid and platformVersion.
9
+ return {
10
+ platformName: device.platform === "android" ? "Android" : "iOS",
11
+ "appium:automationName": device.platform === "android" ? "UiAutomator2" : "XCUITest",
12
+ ...(device.browserName ? { browserName: device.browserName === "chrome" ? "Chrome" : "Safari" } : {}),
13
+ };
14
+ }
15
+
16
+ export async function resolveDeviceArn(options: DeviceFarmOptions, device: CloudDevice): Promise<string> {
17
+ if (device.deviceArn) return device.deviceArn;
18
+ const platform = device.platform === "android" ? "ANDROID" : "IOS";
19
+ const match = (await listDevices(options)).find((candidate) =>
20
+ candidate.platform === platform
21
+ && candidate.name.toLowerCase() === device.deviceName.toLowerCase()
22
+ && (!device.osVersion || candidate.os === device.osVersion));
23
+ if (!match) throw new Error(`Device Farm has no ${device.deviceName}${device.osVersion ? ` on ${device.osVersion}` : ""}`);
24
+ return match.arn;
25
+ }
26
+
27
+ export function deviceFarmHost(credential: DeviceCredential, doFetch: typeof fetch = fetch): DeviceSessionHost {
28
+ if (!credential.projectArn) throw new Error("The Device Farm connection has no project ARN");
29
+ const options: DeviceFarmOptions = {
30
+ credentials: { accessKeyId: credential.username, secretAccessKey: credential.secret },
31
+ projectArn: credential.projectArn,
32
+ fetch: doFetch,
33
+ };
34
+ return {
35
+ async open({ device, name, app }) {
36
+ const deviceArn = await resolveDeviceArn(options, device);
37
+ const appArn = !device.browserName && app ? await uploadApp(options, { name: app.filename, platform: app.platform, bytes: await app.load() }) : undefined;
38
+ if (!device.browserName && !appArn) throw new Error("A native app run needs an uploaded app build");
39
+ const session = await startSession(options, { deviceArn, name, appArn });
40
+ return {
41
+ hubUrl: session.remoteDriverEndpoint!,
42
+ capabilities: deviceFarmCapabilities(device),
43
+ finish: async () => {
44
+ await stopSession(options, session.arn).catch(() => undefined);
45
+ const done = await finishedSession(options, session.arn);
46
+ const video = (await sessionArtifacts(options, session.arn).catch(() => [])).find((artifact) => artifact.type === "VIDEO");
47
+ return { links: [], ...(video ? { videoUrl: video.url } : {}), ...(done.meteredMinutes !== undefined ? { meteredMinutes: done.meteredMinutes } : {}) };
48
+ },
49
+ };
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,92 @@
1
+ // Which host opens a session for a device target, and loading what a run
2
+ // needs (the workspace's device-cloud credentials and app builds) from the
3
+ // registry just before it starts.
4
+ import { browserstackHost } from "./browserstack.js";
5
+ import { deviceFarmHost } from "./device-farm.js";
6
+ import { kobitonHost } from "./kobiton.js";
7
+ import type { AppBuild, CloudDevice, DeviceCredential, DeviceSessionHost, SessionReport } from "./types.js";
8
+
9
+ export * from "./types.js";
10
+
11
+ export type DeviceCloudAccess = {
12
+ credentials: Record<string, DeviceCredential>;
13
+ appBuilds: Record<string, AppBuild>;
14
+ /** Bugmole's own device cloud, opened by the registry. */
15
+ bugmole?: DeviceSessionHost;
16
+ };
17
+
18
+ export function hostFor(access: DeviceCloudAccess | undefined, device: CloudDevice, doFetch: typeof fetch = fetch): DeviceSessionHost {
19
+ if (device.provider === "bugmole") {
20
+ if (!access?.bugmole) throw new Error("Bugmole's device cloud is only available to runs on Bugmole Cloud");
21
+ return access.bugmole;
22
+ }
23
+ const credential = device.credentialId ? access?.credentials[device.credentialId] : undefined;
24
+ if (!credential) throw new Error(`The ${device.provider} connection for this device isn't available to this worker`);
25
+ if (credential.provider === "browserstack") return browserstackHost(credential, doFetch);
26
+ if (credential.provider === "kobiton") return kobitonHost(credential, doFetch);
27
+ return deviceFarmHost(credential, doFetch);
28
+ }
29
+
30
+ type RegistryAccess = {
31
+ registryUrl: string;
32
+ token: string;
33
+ runId: string;
34
+ loadObject: (key: string) => Promise<Uint8Array>;
35
+ fetch?: typeof fetch;
36
+ pollMs?: number;
37
+ maxWaitMs?: number;
38
+ };
39
+
40
+ type AccessReply = {
41
+ credentials?: Record<string, DeviceCredential>;
42
+ appBuilds?: Array<{ id: string; platform: "android" | "ios"; filename: string; sha256: string; objectKey: string }>;
43
+ bugmole?: boolean;
44
+ };
45
+
46
+ /** Fetches the run's device access from the registry. Only what the run's own targets use is returned. */
47
+ export async function loadDeviceCloudAccess(options: RegistryAccess): Promise<DeviceCloudAccess> {
48
+ const doFetch = options.fetch ?? fetch;
49
+ const base = `${options.registryUrl.replace(/\/$/, "")}/api/runs/${encodeURIComponent(options.runId)}`;
50
+ const headers = { authorization: `Bearer ${options.token}`, "content-type": "application/json" };
51
+ const reply = await doFetch(`${base}/device-access`, { headers });
52
+ const body = await reply.json().catch(() => ({})) as AccessReply & { error?: string };
53
+ if (!reply.ok) throw new Error(body.error ?? `The registry refused device access (${reply.status})`);
54
+ const appBuilds: Record<string, AppBuild> = {};
55
+ for (const build of body.appBuilds ?? []) {
56
+ appBuilds[build.id] = { id: build.id, platform: build.platform, filename: build.filename, sha256: build.sha256, load: () => options.loadObject(build.objectKey) };
57
+ }
58
+ const bugmole: DeviceSessionHost | undefined = body.bugmole ? {
59
+ async open({ device, name }) {
60
+ const opened = await doFetch(`${base}/device-sessions`, { method: "POST", headers, body: JSON.stringify({ device, name }) });
61
+ const session = await opened.json().catch(() => ({})) as { sessionId?: string; capabilities?: Record<string, unknown>; error?: string };
62
+ if (!opened.ok || !session.sessionId) throw new Error(session.error ?? `Bugmole's device cloud couldn't start a device (${opened.status})`);
63
+ // A real device takes a minute or two to become free and boot the Appium server.
64
+ const deadline = Date.now() + (options.maxWaitMs ?? 15 * 60_000);
65
+ let hubUrl: string | undefined;
66
+ for (;;) {
67
+ const polled = await doFetch(`${base}/device-sessions/${encodeURIComponent(session.sessionId)}`, { headers });
68
+ const state = await polled.json().catch(() => ({})) as { status?: string; hubUrl?: string; error?: string };
69
+ if (state.status === "running" && state.hubUrl) {
70
+ hubUrl = state.hubUrl;
71
+ break;
72
+ }
73
+ if (!polled.ok || state.status === "failed" || Date.now() > deadline) {
74
+ await doFetch(`${base}/device-sessions/${encodeURIComponent(session.sessionId)}/finish`, { method: "POST", headers, body: "{}" }).catch(() => undefined);
75
+ throw new Error(state.error ?? "No device became available in time");
76
+ }
77
+ await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? 5_000));
78
+ }
79
+ return {
80
+ hubUrl,
81
+ capabilities: session.capabilities ?? {},
82
+ finish: async (outcome) => {
83
+ const finished = await doFetch(`${base}/device-sessions/${encodeURIComponent(session.sessionId!)}/finish`, {
84
+ method: "POST", headers, body: JSON.stringify({ passed: outcome.passed }),
85
+ });
86
+ return (await finished.json().catch(() => ({ links: [] }))) as SessionReport;
87
+ },
88
+ };
89
+ },
90
+ } : undefined;
91
+ return { credentials: body.credentials ?? {}, appBuilds, ...(bugmole ? { bugmole } : {}) };
92
+ }
@@ -0,0 +1,70 @@
1
+ // Kobiton real and virtual devices.
2
+ import type { AppBuild, DeviceCredential, DeviceSessionHost } from "./types.js";
3
+
4
+ const HUB = "https://api.kobiton.com/wd/hub";
5
+ const API = "https://api.kobiton.com/v1";
6
+
7
+ function basic(credential: DeviceCredential): string {
8
+ return `Basic ${btoa(`${credential.username}:${credential.secret}`)}`;
9
+ }
10
+
11
+ async function uploadApp(credential: DeviceCredential, app: AppBuild, doFetch: typeof fetch): Promise<string> {
12
+ const headers = { authorization: basic(credential), "content-type": "application/json" };
13
+ const target = await doFetch(`${API}/apps/uploadUrl`, { method: "POST", headers, body: JSON.stringify({ filename: app.filename }) });
14
+ const slot = await target.json().catch(() => ({})) as { appPath?: string; url?: string };
15
+ if (!target.ok || !slot.url || !slot.appPath) throw new Error("Kobiton didn't provide an upload address");
16
+ const put = await doFetch(slot.url, {
17
+ method: "PUT",
18
+ headers: { "content-type": "application/octet-stream", "x-amz-tagging": "unsaved=true" },
19
+ body: await app.load() as BodyInit,
20
+ });
21
+ if (!put.ok) throw new Error(`Kobiton's storage refused the app (${put.status})`);
22
+ const created = await doFetch(`${API}/apps`, { method: "POST", headers, body: JSON.stringify({ filename: app.filename, appPath: slot.appPath }) });
23
+ const body = await created.json().catch(() => ({})) as { versionId?: number; appId?: number };
24
+ if (!created.ok || !body.versionId) throw new Error("Kobiton didn't register the app");
25
+ return `kobiton-store:v${body.versionId}`;
26
+ }
27
+
28
+ export function kobitonHost(credential: DeviceCredential, doFetch: typeof fetch = fetch): DeviceSessionHost {
29
+ return {
30
+ async open({ device, name, app }) {
31
+ const native = !device.browserName;
32
+ const appRef = native && app ? await uploadApp(credential, app, doFetch) : undefined;
33
+ if (native && !appRef) throw new Error("A native app run needs an uploaded app build");
34
+ const capabilities: Record<string, unknown> = {
35
+ platformName: device.platform === "android" ? "Android" : "iOS",
36
+ "appium:deviceName": device.deviceName,
37
+ ...(device.osVersion ? { "appium:platformVersion": device.osVersion } : {}),
38
+ "appium:automationName": device.platform === "android" ? "UiAutomator2" : "XCUITest",
39
+ ...(device.browserName ? { browserName: device.browserName } : { "appium:app": appRef }),
40
+ "kobiton:options": {
41
+ sessionName: name,
42
+ sessionDescription: "Bugmole test run",
43
+ deviceGroup: "KOBITON",
44
+ captureScreenshots: true,
45
+ deviceOrientation: "portrait",
46
+ ...(device.real ? {} : { useVirtualDevice: true }),
47
+ },
48
+ };
49
+ return {
50
+ hubUrl: HUB,
51
+ capabilities,
52
+ authorization: basic(credential),
53
+ finish: async ({ webdriverSessionId }) => {
54
+ if (!webdriverSessionId) return { links: [] };
55
+ // Kobiton names its own session id in the capabilities it returns; look it up by WebDriver id otherwise.
56
+ const lookup = await doFetch(`${API}/sessions?appiumSessionId=${encodeURIComponent(webdriverSessionId)}`, { headers: { authorization: basic(credential) } })
57
+ .then((reply) => reply.json()).catch(() => null) as { data?: Array<{ id: number }> } | null;
58
+ const id = lookup?.data?.[0]?.id;
59
+ if (!id) return { links: [] };
60
+ const details = await doFetch(`${API}/sessions/${id}`, { headers: { authorization: basic(credential) } })
61
+ .then((reply) => reply.json()).catch(() => null) as { video?: { path?: string } } | null;
62
+ return {
63
+ links: [{ label: "Kobiton session", url: `https://portal.kobiton.com/sessions/${id}` }],
64
+ ...(details?.video?.path ? { videoUrl: details.video.path } : {}),
65
+ };
66
+ },
67
+ };
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,44 @@
1
+ // Validating a device-cloud run target. Kept free of Playwright so the
2
+ // registry Worker can use it too.
3
+ import { DEVICE_PROVIDERS, type CloudDevice } from "./types.js";
4
+
5
+ /** Validates a device-cloud target from a run request. */
6
+ export function parseCloudDevice(raw: Record<string, unknown>): CloudDevice {
7
+ // "valkyrie" is this provider's pre-rename name, still sent by 0.3.x CLIs.
8
+ const provider = raw.provider === "valkyrie" ? "bugmole" : raw.provider;
9
+ if (typeof provider !== "string" || !(DEVICE_PROVIDERS as readonly string[]).includes(provider)) {
10
+ throw new Error(`Unknown device cloud "${String(provider)}"`);
11
+ }
12
+ const platform = raw.platform;
13
+ if (platform !== "android" && platform !== "ios") throw new Error("A device target needs platform android or ios");
14
+ const text = (value: unknown, max = 120) => (typeof value === "string" && value.trim() && value.length <= max ? value.trim() : undefined);
15
+ const deviceName = text(raw.deviceName);
16
+ if (!deviceName) throw new Error("A device target needs a deviceName");
17
+ const browserName = raw.browserName === undefined || raw.browserName === null ? undefined : raw.browserName;
18
+ if (browserName !== undefined && browserName !== "chrome" && browserName !== "safari") throw new Error("browserName must be chrome or safari");
19
+ const credentialId = text(raw.credentialId, 80);
20
+ if (provider !== "bugmole" && !credentialId) throw new Error("A device target needs the device-cloud connection to use");
21
+ const appBuildId = text(raw.appBuildId, 80);
22
+ if (!browserName && !appBuildId) throw new Error("A device target runs either a browser (browserName) or an uploaded app (appBuildId)");
23
+ const deviceArn = text(raw.deviceArn, 300);
24
+ if (deviceArn && !/^arn:aws:devicefarm:[a-z0-9-]+::device:[A-Za-z0-9]+$/.test(deviceArn)) throw new Error("deviceArn isn't a Device Farm device");
25
+ return {
26
+ provider: provider as CloudDevice["provider"],
27
+ ...(credentialId && provider !== "bugmole" ? { credentialId } : {}),
28
+ platform,
29
+ deviceName,
30
+ ...(text(raw.osVersion, 40) ? { osVersion: text(raw.osVersion, 40) } : {}),
31
+ ...(deviceArn ? { deviceArn } : {}),
32
+ real: raw.real !== false,
33
+ ...(browserName ? { browserName } : {}),
34
+ ...(appBuildId ? { appBuildId } : {}),
35
+ };
36
+ }
37
+
38
+ export function cloudTargetId(device: CloudDevice): string {
39
+ return ["device", device.provider, device.deviceName, device.osVersion ?? "", device.browserName ?? "app"]
40
+ .map((part) => part.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""))
41
+ .filter(Boolean)
42
+ .join("-")
43
+ .slice(0, 60);
44
+ }
@@ -0,0 +1,62 @@
1
+ // What a device cloud gives a run: a WebDriver endpoint and capabilities for
2
+ // one real device (or emulator), and what it reports when the session ends.
3
+
4
+ export type DeviceProvider = "browserstack" | "kobiton" | "aws_device_farm" | "bugmole";
5
+ export const DEVICE_PROVIDERS: readonly DeviceProvider[] = ["browserstack", "kobiton", "aws_device_farm", "bugmole"];
6
+
7
+ /** A device a run targets on a cloud. */
8
+ export type CloudDevice = {
9
+ provider: DeviceProvider;
10
+ /** The workspace's saved credential; absent for Bugmole's own device cloud. */
11
+ credentialId?: string;
12
+ platform: "android" | "ios";
13
+ deviceName: string;
14
+ osVersion?: string;
15
+ /** Device Farm's device ARN, when the target came from its catalog. */
16
+ deviceArn?: string;
17
+ /** Real hardware, as opposed to an emulator or simulator. */
18
+ real: boolean;
19
+ /** Set for mobile web: the flow runs in this browser on the device. */
20
+ browserName?: "chrome" | "safari";
21
+ /** Set for a native app: the uploaded build to install. */
22
+ appBuildId?: string;
23
+ };
24
+
25
+ export type DeviceCredential = {
26
+ provider: Exclude<DeviceProvider, "bugmole">;
27
+ /** BrowserStack user name, Kobiton user name, or AWS access key id. */
28
+ username: string;
29
+ /** BrowserStack access key, Kobiton API key, or AWS secret access key. */
30
+ secret: string;
31
+ /** AWS Device Farm project ARN. */
32
+ projectArn?: string;
33
+ };
34
+
35
+ export type AppBuild = { id: string; platform: "android" | "ios"; filename: string; sha256: string; load: () => Promise<Uint8Array> };
36
+
37
+ export type SessionReport = {
38
+ /** Where a person can watch the session back. */
39
+ links: Array<{ label: string; url: string }>;
40
+ /** A video file to keep with the run's evidence. */
41
+ videoUrl?: string;
42
+ /** Minutes the provider counted (Bugmole's own device cloud bills these). */
43
+ meteredMinutes?: number;
44
+ };
45
+
46
+ export type OpenedDeviceSession = {
47
+ hubUrl: string;
48
+ capabilities: Record<string, unknown>;
49
+ authorization?: string;
50
+ /** Reports the outcome to the provider and collects its recordings. */
51
+ finish: (outcome: { passed: boolean; webdriverSessionId: string | null; reason?: string }) => Promise<SessionReport>;
52
+ };
53
+
54
+ export interface DeviceSessionHost {
55
+ open(input: { device: CloudDevice; name: string; app?: AppBuild }): Promise<OpenedDeviceSession>;
56
+ }
57
+
58
+ export function cloudDeviceLabel(device: Pick<CloudDevice, "deviceName" | "osVersion" | "browserName" | "real">): string {
59
+ const os = device.osVersion ? ` (${device.osVersion})` : "";
60
+ const browser = device.browserName ? ` · ${device.browserName === "chrome" ? "Chrome" : "Safari"}` : "";
61
+ return `${device.deviceName}${os}${browser}${device.real ? "" : " · emulator"}`;
62
+ }
@@ -0,0 +1,84 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ interface DiffProposalOptions {
5
+ filePath: string;
6
+ evidence: any;
7
+ failures?: any[];
8
+ }
9
+
10
+ interface DiffProposalResult {
11
+ status: "success" | "error";
12
+ changeSummary?: string;
13
+ diff?: string;
14
+ riskLevel?: "low" | "medium" | "high";
15
+ changeType?: "auto-recoverable" | "auto-learnable" | "plan-breaking";
16
+ message?: string;
17
+ error?: string;
18
+ }
19
+
20
+ /**
21
+ * Diff Proposal: Analyzes evidence and proposes minimal spec changes
22
+ */
23
+ export async function proposeDiff(
24
+ cfg: any,
25
+ options: DiffProposalOptions,
26
+ ): Promise<DiffProposalResult> {
27
+ try {
28
+ const { filePath, evidence, failures = [] } = options;
29
+ const specDir = cfg.runtime.spec_dir || "./spec";
30
+ const fullPath = path.join(specDir, filePath);
31
+
32
+ if (!fs.existsSync(fullPath)) {
33
+ return {
34
+ status: "error",
35
+ error: `File not found: ${filePath}`,
36
+ };
37
+ }
38
+
39
+ const currentContent = fs.readFileSync(fullPath, "utf-8");
40
+
41
+ // TODO: Implement actual diff proposal logic using LLM
42
+ // This would:
43
+ // 1. Load the diff-proposal-template.md
44
+ // 2. Format evidence and failures
45
+ // 3. Call LLM with template + context
46
+ // 4. Parse LLM response for CHANGE_SUMMARY, DIFF, EVIDENCE sections
47
+ // 5. Validate diff against schema
48
+
49
+ // For now, generate a structured response
50
+ const changeSummary = `Proposed changes to ${filePath} based on ${failures.length} failure(s) and execution evidence.`;
51
+ const diff = `# Proposed diff for ${filePath}\n\n[Diff would be generated by LLM based on evidence]\n\nEvidence: ${JSON.stringify(evidence, null, 2)}\nFailures: ${JSON.stringify(failures, null, 2)}`;
52
+
53
+ // Analyze evidence to determine change type and risk
54
+ let changeType: "auto-recoverable" | "auto-learnable" | "plan-breaking" = "auto-learnable";
55
+ let riskLevel: "low" | "medium" | "high" = "medium";
56
+
57
+ if (failures.length === 0) {
58
+ changeType = "auto-recoverable";
59
+ riskLevel = "low";
60
+ } else if (failures.some((f: any) => f.type === "invariant_violation")) {
61
+ changeType = "plan-breaking";
62
+ riskLevel = "high";
63
+ } else if (
64
+ failures.some((f: any) => f.type === "selector_mismatch" || f.type === "text_mismatch")
65
+ ) {
66
+ changeType = "auto-learnable";
67
+ riskLevel = "low";
68
+ }
69
+
70
+ return {
71
+ status: "success",
72
+ changeSummary,
73
+ diff,
74
+ riskLevel,
75
+ changeType,
76
+ message: `Diff proposal generated. Change type: ${changeType}, Risk: ${riskLevel}`,
77
+ };
78
+ } catch (error: any) {
79
+ return {
80
+ status: "error",
81
+ error: error.message || "Diff proposal failed",
82
+ };
83
+ }
84
+ }
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { discoveryPersona, discoveryUrl, discoveryUsesAi, withPersonaMarker } from "./discovery-task.js";
4
+
5
+ test("discoveryUsesAi and discoveryUrl read the title and description a task was queued with", () => {
6
+ assert.equal(discoveryUsesAi({ title: "Explore with AI shop.test" }), true);
7
+ assert.equal(discoveryUsesAi({ title: "Explore shop.test" }), false);
8
+ assert.equal(discoveryUrl({ description: "Explore https://shop.test like a first-time visitor." }), "https://shop.test/");
9
+ assert.equal(discoveryUrl({ description: "no url here" }), null);
10
+ });
11
+
12
+ test("a persona round-trips through the description without disturbing the URL extractor", () => {
13
+ const persona = { name: "Curious", description: "Click into anything unfamiliar." };
14
+ const description = withPersonaMarker("Explore https://shop.test like a first-time visitor.", persona);
15
+ assert.deepEqual(discoveryPersona({ description }), persona);
16
+ assert.equal(discoveryUrl({ description }), "https://shop.test/");
17
+ });
18
+
19
+ test("a persona description containing markup-like text still round-trips safely", () => {
20
+ const persona = { name: "Skeptic", description: "Doubt every claim <script>alert(1)</script> and --> weird markers." };
21
+ const description = withPersonaMarker("Explore https://shop.test.", persona);
22
+ assert.deepEqual(discoveryPersona({ description }), persona);
23
+ });
24
+
25
+ test("no persona marker, or one that doesn't decode to a persona, means no persona rather than a thrown error", () => {
26
+ assert.equal(discoveryPersona({ description: "Explore https://shop.test like a first-time visitor." }), null);
27
+ const garbage = Buffer.from("not json", "utf8").toString("base64");
28
+ assert.equal(discoveryPersona({ description: `<!-- persona:${garbage} -->` }), null);
29
+ });