@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,107 @@
1
+ // Emails owners and billing managers once per period when usage crosses 80%
2
+ // and 100% of an allowance, or of the spending limit.
3
+ import type { UsageKind } from "../../billing/plan-catalog.js";
4
+ import type { Env } from "../core.js";
5
+ import { emailSender, escapeHtml } from "../email/sender.js";
6
+ import { usageSummary } from "./ledger.js";
7
+
8
+ const LABELS: Record<UsageKind, string> = {
9
+ browser_minute: "cloud minutes",
10
+ ai_credit: "AI credits",
11
+ device_minute: "real-device minutes",
12
+ };
13
+
14
+ export type ThresholdAlert = { key: string; subject: string; body: string };
15
+
16
+ /** Alerts due for the current usage, highest threshold first per meter. */
17
+ export function dueAlerts(summary: Awaited<ReturnType<typeof usageSummary>>["summary"]): ThresholdAlert[] {
18
+ const alerts: ThresholdAlert[] = [];
19
+ for (const kind of ["browser_minute", "ai_credit"] as const) {
20
+ const line = summary.lines[kind];
21
+ if (!line.included) continue;
22
+ const share = line.used / line.included;
23
+ for (const threshold of [100, 80]) {
24
+ if (share * 100 >= threshold) {
25
+ alerts.push({
26
+ key: `${kind}:${threshold}`,
27
+ subject: threshold === 100 ? `You've used all of this month's ${LABELS[kind]}` : `You've used ${threshold}% of this month's ${LABELS[kind]}`,
28
+ body: `${line.used.toLocaleString("en-US")} of ${line.included.toLocaleString("en-US")} ${LABELS[kind]} used this period.` +
29
+ (threshold === 100
30
+ ? line.unitCents === null
31
+ ? " Cloud usage is paused until the next period; runs on your own machines and CI keep working."
32
+ : ` Additional usage is billed at $${(line.unitCents / 100).toFixed(2)} each.`
33
+ : ""),
34
+ });
35
+ break;
36
+ }
37
+ }
38
+ }
39
+ if (summary.spendingLimitCents !== null && summary.spendingLimitCents > 0) {
40
+ const share = summary.overageCents / summary.spendingLimitCents;
41
+ for (const threshold of [100, 80]) {
42
+ if (share * 100 >= threshold) {
43
+ alerts.push({
44
+ key: `spending_limit:${threshold}`,
45
+ subject: threshold === 100 ? "Your spending limit has been reached" : `You've used ${threshold}% of your spending limit`,
46
+ body: `Additional usage this period is $${(summary.overageCents / 100).toFixed(2)} of your $${(summary.spendingLimitCents / 100).toFixed(2)} limit.` +
47
+ (threshold === 100 ? " Cloud runs are paused until you raise the limit or the next period starts." : ""),
48
+ });
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ return alerts;
54
+ }
55
+
56
+ export async function billingRecipients(env: Env, workspaceId: string): Promise<Array<{ email: string }>> {
57
+ const rows = await env.DB.prepare(`
58
+ SELECT DISTINCT u.email FROM workspace_memberships m
59
+ JOIN aegis_users u ON u.id = m.aegis_id
60
+ WHERE m.workspace_id = ? AND m.status = 'active' AND m.role IN ('owner', 'billing') AND u.email IS NOT NULL
61
+ `).bind(workspaceId).all<{ email: string }>();
62
+ return rows.results.map(({ email }) => ({ email }));
63
+ }
64
+
65
+ export type AlertHook = (env: Env, workspaceId: string, alert: ThresholdAlert) => Promise<void>;
66
+ const hooks: AlertHook[] = [];
67
+ /** Lets other channels (Slack) hear about the same alerts. */
68
+ export function onUsageAlert(hook: AlertHook): void {
69
+ hooks.push(hook);
70
+ }
71
+
72
+ export async function checkUsageThresholds(env: Env, workspaceId: string, now = new Date()): Promise<void> {
73
+ const { summary } = await usageSummary(env, workspaceId, now);
74
+ const due = dueAlerts(summary);
75
+ if (!due.length) return;
76
+ const workspace = await env.DB.prepare("SELECT name FROM workspaces WHERE id = ?").bind(workspaceId).first<{ name: string }>();
77
+ const billingUrl = `${(env.DASHBOARD_URL ?? "https://app.bugmole.com").replace(/\/$/, "")}/billing?workspace=${encodeURIComponent(workspaceId)}`;
78
+ for (const alert of due) {
79
+ const claimed = await env.DB.prepare(`
80
+ INSERT INTO usage_alerts_sent (workspace_id, period_start, alert) VALUES (?, ?, ?)
81
+ ON CONFLICT DO NOTHING
82
+ `).bind(workspaceId, summary.period.start, alert.key).run();
83
+ if (!claimed.meta?.changes) continue;
84
+ const recipients = await billingRecipients(env, workspaceId);
85
+ if (recipients.length) {
86
+ const name = workspace?.name ?? workspaceId;
87
+ try {
88
+ await emailSender(env).send({
89
+ to: recipients,
90
+ subject: `${alert.subject} — ${name}`,
91
+ tag: "usage-alert",
92
+ text: `${alert.body}\n\nManage your plan and spending limit: ${billingUrl}`,
93
+ html: `<p>${escapeHtml(alert.body)}</p><p><a href="${billingUrl}">Manage your plan and spending limit</a></p>`,
94
+ });
95
+ } catch (error) {
96
+ console.error("[usage] alert email failed", error);
97
+ }
98
+ }
99
+ for (const hook of hooks) {
100
+ try {
101
+ await hook(env, workspaceId, alert);
102
+ } catch (error) {
103
+ console.error("[usage] alert hook failed", error);
104
+ }
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,308 @@
1
+ // Shared registry primitives: environment, principals, responses, crypto.
2
+ // Route modules import these instead of reaching into index.ts.
3
+ import type { EmailEnv } from "./email/sender.js";
4
+ import { RUNNER_TOKEN_PREFIX, verifyRunnerToken, type RunnerIdentity } from "./runner/tokens.js";
5
+ export interface Env extends EmailEnv {
6
+ DB: D1Database;
7
+ AEGIS_CLIENT_ID: string;
8
+ AEGIS_CLIENT_SECRET: string;
9
+ RUNS_COORDINATOR?: DurableObjectNamespace;
10
+ /** Bucket the cloud worker writes run evidence to. The artifact routes return 503 without it. */
11
+ ARTIFACTS?: R2Bucket;
12
+ REGISTRY_REALM_OWNER_TENANT_ID?: string;
13
+ /** Public dashboard origin, used in emails and links (e.g. https://app.bugmole.com). */
14
+ DASHBOARD_URL?: string;
15
+ /** Signs the short-lived tokens hosted runners use; see runner/tokens.ts. */
16
+ RUNNER_TOKEN_SECRET?: string;
17
+ /** Service binding to the valkyrie-runner Worker that starts cloud-run containers. */
18
+ RUNNER?: Fetcher;
19
+ /** Cloud runs executing at once across every workspace (default 20). */
20
+ CLOUD_POOL_MAX?: string;
21
+ /** Longest a cloud run may take before it is stopped, in minutes (default 60). */
22
+ CLOUD_RUN_MAX_MINUTES?: string;
23
+ /** This registry's public origin, handed to runners (e.g. https://api.bugmole.com). */
24
+ REGISTRY_PUBLIC_URL?: string;
25
+ /** Rollout flags; see flags.ts. */
26
+ [key: `FEATURE_${string}`]: string | undefined;
27
+ }
28
+
29
+ export type Principal = {
30
+ aegisId: string;
31
+ /** Sign-in email passed along by the dashboard, when known. */
32
+ email?: string;
33
+ /** Aegis confirmed that email (only asserted by the dashboard). */
34
+ emailVerified?: boolean;
35
+ projectIds: string[];
36
+ scopes: string[];
37
+ apiKeyId?: string;
38
+ /** Set for a hosted runner: it may act on this one run and nothing else. */
39
+ runner?: RunnerIdentity;
40
+ /**
41
+ * Enterprise sign-in: the Aegis organization (only asserted by the dashboard).
42
+ * Groups are sent at sign-in only; null on other requests.
43
+ */
44
+ org?: { id: string; groups: string[] | null };
45
+ };
46
+
47
+ /** Reads the dashboard's `x-aegis-org` / `x-aegis-groups` headers (groups as URI-encoded JSON). */
48
+ export function orgFromHeaders(headers: Headers): Principal["org"] {
49
+ const id = headers.get("x-aegis-org")?.trim();
50
+ if (!id || !/^[0-9a-f-]{36}$/i.test(id)) return undefined;
51
+ const raw = headers.get("x-aegis-groups");
52
+ if (raw === null) return { id, groups: null };
53
+ let groups: string[] = [];
54
+ try {
55
+ const parsed = JSON.parse(decodeURIComponent(raw)) as unknown;
56
+ if (Array.isArray(parsed)) groups = parsed.filter((g): g is string => typeof g === "string").slice(0, 500);
57
+ } catch {
58
+ groups = [];
59
+ }
60
+ return { id, groups };
61
+ }
62
+
63
+
64
+
65
+ export const jsonHeaders = { "content-type": "application/json; charset=utf-8" };
66
+
67
+ export function response(body: unknown, status = 200): Response {
68
+ return new Response(JSON.stringify(body), { status, headers: jsonHeaders });
69
+ }
70
+
71
+ export function bearer(request: Request): string | null {
72
+ const value = request.headers.get("authorization")?.trim();
73
+ return value?.toLowerCase().startsWith("bearer ") ? value.slice(7).trim() || null : null;
74
+ }
75
+
76
+ export async function sha256(value: string): Promise<string> {
77
+ const bytes = new TextEncoder().encode(value);
78
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
79
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
80
+ }
81
+
82
+ export async function subscriptionPrincipal(request: Request, env: Env): Promise<Principal | null> {
83
+ const token = new URL(request.url).searchParams.get("token");
84
+ if (!token || !env.AEGIS_CLIENT_SECRET) return null;
85
+ try {
86
+ const decoded = new TextDecoder().decode(fromBase64(token));
87
+ const [aegisId, expires, signature] = decoded.split(".");
88
+ if (!aegisId || !expires || !signature || Number(expires) <= Date.now()) return null;
89
+ const key = await crypto.subtle.importKey(
90
+ "raw",
91
+ new TextEncoder().encode(env.AEGIS_CLIENT_SECRET),
92
+ { name: "HMAC", hash: "SHA-256" },
93
+ false,
94
+ ["sign"],
95
+ );
96
+ const digest = await crypto.subtle.sign(
97
+ "HMAC",
98
+ key,
99
+ new TextEncoder().encode(`${aegisId}.${expires}`),
100
+ );
101
+ const expected = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
102
+ if (!constantTimeEqual(signature, expected)) return null;
103
+ return { aegisId, projectIds: [], scopes: ["runs:read"] };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export async function apiPrincipal(request: Request, env: Env): Promise<Principal | null> {
110
+ const token = bearer(request);
111
+ if (token?.startsWith(RUNNER_TOKEN_PREFIX)) {
112
+ const runner = await verifyRunnerToken(env, token);
113
+ if (!runner) return null;
114
+ return { aegisId: `runner:${runner.kind}:${runner.id}`, projectIds: [runner.projectId], scopes: ["runs:write", "tasks:write"], runner };
115
+ }
116
+ if (token) {
117
+ const hash = await sha256(token);
118
+ const row = await env.DB.prepare(`
119
+ SELECT id, aegis_id, project_ids_json, scopes_json, expires_at
120
+ FROM api_keys
121
+ WHERE token_hash = ? AND revoked_at IS NULL
122
+ `).bind(hash).first<{
123
+ id: string;
124
+ aegis_id: string;
125
+ project_ids_json: string;
126
+ scopes_json: string;
127
+ expires_at: string | null;
128
+ }>();
129
+ if (!row || (row.expires_at && Date.parse(row.expires_at) <= Date.now())) return null;
130
+ return {
131
+ aegisId: row.aegis_id,
132
+ projectIds: JSON.parse(row.project_ids_json || "[]"),
133
+ scopes: JSON.parse(row.scopes_json || "[]"),
134
+ apiKeyId: row.id,
135
+ };
136
+ }
137
+
138
+ const aegisId = request.headers.get("x-aegis-id")?.trim();
139
+ const secret = request.headers.get("x-aegis-secret");
140
+ if (!aegisId || !secret || !env.AEGIS_CLIENT_SECRET) return null;
141
+ if (!constantTimeEqual(secret, env.AEGIS_CLIENT_SECRET)) return null;
142
+ const email = request.headers.get("x-aegis-email")?.trim().toLowerCase();
143
+ const validEmail = email && /^[^\s@]+@[^\s@]+$/.test(email) ? email : undefined;
144
+ const org = orgFromHeaders(request.headers);
145
+ return {
146
+ aegisId,
147
+ projectIds: [],
148
+ scopes: ["*"],
149
+ ...(validEmail ? { email: validEmail, emailVerified: request.headers.get("x-aegis-email-verified") === "true" } : {}),
150
+ ...(org ? { org } : {}),
151
+ };
152
+ }
153
+
154
+ export function constantTimeEqual(left: string, right: string): boolean {
155
+ const leftBytes = new TextEncoder().encode(left);
156
+ const rightBytes = new TextEncoder().encode(right);
157
+ if (leftBytes.length !== rightBytes.length) return false;
158
+ let result = 0;
159
+ for (let index = 0; index < leftBytes.length; index++) result |= leftBytes[index] ^ rightBytes[index];
160
+ return result === 0;
161
+ }
162
+
163
+ export function can(principal: Principal, scope: string): boolean {
164
+ return principal.scopes.includes("*") || principal.scopes.includes(scope);
165
+ }
166
+
167
+ export function projectAllowed(principal: Principal, projectId: string): boolean {
168
+ return principal.projectIds.length === 0 || principal.projectIds.includes(projectId);
169
+ }
170
+
171
+ export const WORKSPACE_ROLES = ["owner", "admin", "member", "billing"] as const;
172
+ export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number];
173
+
174
+ /**
175
+ * What each role may do. Billing managers see everything but only change
176
+ * billing; members run tests and edit plans; admins also manage the workspace
177
+ * and its people; owners do all of it, including billing.
178
+ */
179
+ export const PERMISSIONS = {
180
+ read: ["owner", "admin", "member", "billing"],
181
+ "project.write": ["owner", "admin", "member"],
182
+ "project.manage": ["owner", "admin"],
183
+ "workspace.write": ["owner", "admin", "member"],
184
+ "workspace.manage": ["owner", "admin"],
185
+ "members.manage": ["owner", "admin"],
186
+ "billing.manage": ["owner", "billing"],
187
+ "audit.read": ["owner", "admin"],
188
+ } as const satisfies Record<string, readonly WorkspaceRole[]>;
189
+ export type Permission = keyof typeof PERMISSIONS;
190
+
191
+ export function isWorkspaceRole(value: unknown): value is WorkspaceRole {
192
+ return typeof value === "string" && (WORKSPACE_ROLES as readonly string[]).includes(value);
193
+ }
194
+
195
+ export function roleAllows(role: string | null | undefined, permission: Permission): boolean {
196
+ return isWorkspaceRole(role) && (PERMISSIONS[permission] as readonly string[]).includes(role);
197
+ }
198
+
199
+ /** The caller's role in a workspace, or null when they aren't an active member. */
200
+ export async function workspaceRole(env: Env, aegisId: string, workspaceId: string): Promise<WorkspaceRole | null> {
201
+ const row = await env.DB.prepare(`
202
+ SELECT role FROM workspace_memberships
203
+ WHERE workspace_id = ? AND aegis_id = ? AND status = 'active'
204
+ `).bind(workspaceId, aegisId).first<{ role: string }>();
205
+ return isWorkspaceRole(row?.role) ? row.role : null;
206
+ }
207
+
208
+ /**
209
+ * The caller's role for a project: their workspace role, or for projects
210
+ * registered before workspaces existed, their direct project membership.
211
+ */
212
+ export async function projectRole(env: Env, aegisId: string, projectId: string): Promise<WorkspaceRole | null> {
213
+ const row = await env.DB.prepare(`
214
+ SELECT wm.role AS workspaceRole, pm.role AS projectRole
215
+ FROM projects p
216
+ LEFT JOIN workspace_memberships wm
217
+ ON wm.workspace_id = p.workspace_id AND wm.aegis_id = ? AND wm.status = 'active'
218
+ LEFT JOIN project_memberships pm
219
+ ON pm.project_id = p.id AND pm.aegis_id = ? AND pm.status = 'active'
220
+ WHERE p.id = ? AND p.deleted_at IS NULL
221
+ `).bind(aegisId, aegisId, projectId).first<{ workspaceRole: string | null; projectRole: string | null }>();
222
+ if (!row) return null;
223
+ if (isWorkspaceRole(row.workspaceRole)) return row.workspaceRole;
224
+ return isWorkspaceRole(row.projectRole) ? row.projectRole : null;
225
+ }
226
+
227
+ /**
228
+ * Whether the caller may act on a project. API keys are limited to the
229
+ * projects they were issued for *and* to what their creator can still do, so
230
+ * removing someone from a workspace also disables their keys.
231
+ */
232
+ export async function memberOfProject(
233
+ env: Env,
234
+ principal: Principal,
235
+ projectId: string,
236
+ permission: Permission = "read",
237
+ ): Promise<boolean> {
238
+ if (principal.projectIds.length > 0 && !principal.projectIds.includes(projectId)) return false;
239
+ // A runner reads its project's plans and writes its own run, nothing more.
240
+ if (principal.runner) return principal.runner.projectId === projectId && (permission === "read" || permission === "project.write");
241
+ if (!roleAllows(await projectRole(env, principal.aegisId, projectId), permission)) return false;
242
+ // Projects beyond a downgraded plan's limit are kept, read-only.
243
+ if (permission !== "read" && (await projectLocked(env, projectId))) return false;
244
+ return true;
245
+ }
246
+
247
+ export async function projectLocked(env: Env, projectId: string): Promise<boolean> {
248
+ const row = await env.DB.prepare("SELECT locked_at AS lockedAt FROM projects WHERE id = ?").bind(projectId).first<{ lockedAt: string | null }>();
249
+ return Boolean(row?.lockedAt);
250
+ }
251
+
252
+ export async function memberOfWorkspace(
253
+ env: Env,
254
+ principal: Principal,
255
+ workspaceId: string,
256
+ permission: Permission = "read",
257
+ ): Promise<boolean> {
258
+ if (principal.runner) return false;
259
+ return roleAllows(await workspaceRole(env, principal.aegisId, workspaceId), permission);
260
+ }
261
+
262
+ export function randomToken(bytes = 32): string {
263
+ const data = new Uint8Array(bytes);
264
+ crypto.getRandomValues(data);
265
+ return btoa(String.fromCharCode(...data)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
266
+ }
267
+
268
+ export function base64(value: Uint8Array): string {
269
+ return btoa(String.fromCharCode(...value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
270
+ }
271
+
272
+ export function fromBase64(value: string): Uint8Array {
273
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4);
274
+ return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
275
+ }
276
+
277
+ export async function encryptForSecret(value: string, secret: string): Promise<{ ciphertext: string; iv: string }> {
278
+ const secretHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
279
+ const key = await crypto.subtle.importKey("raw", secretHash, "AES-GCM", false, ["encrypt"]);
280
+ const iv = crypto.getRandomValues(new Uint8Array(12));
281
+ const encrypted = await crypto.subtle.encrypt(
282
+ { name: "AES-GCM", iv },
283
+ key,
284
+ new TextEncoder().encode(value),
285
+ );
286
+ return { ciphertext: base64(new Uint8Array(encrypted)), iv: base64(iv) };
287
+ }
288
+
289
+ export async function decryptForSecret(ciphertext: string, iv: string, secret: string): Promise<string> {
290
+ const secretHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
291
+ const key = await crypto.subtle.importKey("raw", secretHash, "AES-GCM", false, ["decrypt"]);
292
+ const decrypted = await crypto.subtle.decrypt(
293
+ { name: "AES-GCM", iv: fromBase64(iv) },
294
+ key,
295
+ fromBase64(ciphertext),
296
+ );
297
+ return new TextDecoder().decode(decrypted);
298
+ }
299
+
300
+
301
+ export function newId(prefix: string): string {
302
+ return `${prefix}_${randomToken(12)}`;
303
+ }
304
+
305
+ export async function requestJson(request: Request): Promise<Record<string, unknown> | null> {
306
+ const body = await request.json().catch(() => null);
307
+ return body && typeof body === "object" && !Array.isArray(body) ? body as Record<string, unknown> : null;
308
+ }
@@ -0,0 +1,185 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { Env } from "../core.js";
4
+ import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
5
+ import { runnerLauncher, type LaunchRequest } from "../runner/dispatch.js";
6
+ import { sweepDeviceSessions } from "./routes.js";
7
+
8
+ const ZIP = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3]);
9
+ const PROJECT_ARN = "arn:aws:devicefarm:us-west-2:123456789012:project:abc-1";
10
+
11
+ function fakeClouds() {
12
+ const state = { deviceFarm: [] as string[], sessionStatus: "RUNNING", metered: 3, stopped: false };
13
+ const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
14
+ const url = String(input);
15
+ const auth = new Headers(init?.headers).get("authorization") ?? "";
16
+ if (url.includes("browserstack.com/app-automate/devices.json")) {
17
+ if (auth !== `Basic ${btoa("ada:good-key")}`) return new Response("", { status: 401 });
18
+ return Response.json([{ os: "ios", os_version: "17", device: "iPhone 15", realMobile: true }, { os: "android", os_version: "14.0", device: "Google Pixel 8", realMobile: true }]);
19
+ }
20
+ if (url.includes("devicefarm")) {
21
+ const action = new Headers(init?.headers).get("x-amz-target")!.split(".")[1];
22
+ state.deviceFarm.push(action);
23
+ if (action === "ListDevices") return Response.json({ devices: [{ arn: "arn:aws:devicefarm:us-west-2::device:PIXEL8", name: "Google Pixel 8", platform: "ANDROID", os: "14" }] });
24
+ if (action === "CreateRemoteAccessSession") return Response.json({ remoteAccessSession: { arn: "arn:session:1", status: "PENDING" } });
25
+ if (action === "GetRemoteAccessSession") {
26
+ return Response.json({ remoteAccessSession: state.stopped
27
+ ? { arn: "arn:session:1", status: "COMPLETED", deviceMinutes: { metered: state.metered } }
28
+ : { arn: "arn:session:1", status: state.sessionStatus, endpoints: { remoteDriverEndpoint: "https://appium.test/wd" } } });
29
+ }
30
+ if (action === "StopRemoteAccessSession") {
31
+ state.stopped = true;
32
+ return Response.json({});
33
+ }
34
+ if (action === "ListArtifacts") return Response.json({ artifacts: [{ name: "Video", type: "VIDEO", extension: "mp4", url: "https://s3.test/video.mp4" }] });
35
+ }
36
+ return new Response("not found", { status: 404 });
37
+ }) as unknown as typeof fetch;
38
+ return { state, doFetch };
39
+ }
40
+
41
+ async function business(clouds: ReturnType<typeof fakeClouds>, plan = "business") {
42
+ const setup = createTestEnv({
43
+ FEATURE_DEVICE_CLOUDS: "on",
44
+ FEATURE_CLOUD_RUNNERS: "on",
45
+ SECRETS_ENCRYPTION_KEY: "k",
46
+ RUNNER_TOKEN_SECRET: "r",
47
+ DEVICE_FARM_FETCH: clouds.doFetch,
48
+ BUGMOLE_DEVICE_FARM_ACCESS_KEY_ID: "AKIABUGMOLE",
49
+ BUGMOLE_DEVICE_FARM_SECRET_ACCESS_KEY: "bugmole-secret",
50
+ BUGMOLE_DEVICE_FARM_PROJECT_ARN: PROJECT_ARN,
51
+ } as Partial<Env>);
52
+ const project = await seedProject(setup.env, { storageProvider: "bugmole" });
53
+ setup.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, plan);
54
+ await seedPlan(setup.env, project.projectId, project.owner);
55
+ return { ...setup, ...project };
56
+ }
57
+
58
+ test("a device cloud is checked before it's saved, and its key never comes back", async () => {
59
+ const clouds = fakeClouds();
60
+ const { env, db, workspaceId, owner } = await business(clouds);
61
+ const add = (body: Record<string, unknown>) => call(env, "POST", `/api/workspaces/${workspaceId}/device-clouds`, { as: owner, body });
62
+ const rejected = await add({ provider: "browserstack", username: "ada", secret: "wrong" });
63
+ assert.equal(rejected.status, 422);
64
+ assert.match(rejected.json.error, /didn't accept/);
65
+ assert.equal((await add({ provider: "aws_device_farm", username: "AKIA", secret: "s", projectArn: "arn:aws:s3:::x" })).status, 422);
66
+ const added = await add({ provider: "browserstack", username: "ada", secret: "good-key", label: "Main" });
67
+ assert.equal(added.status, 201);
68
+ const stored = db.db.prepare("SELECT secret_ciphertext AS c FROM device_cloud_credentials").get() as any;
69
+ assert.ok(!String(stored.c).includes("good-key"));
70
+ const listed = await call(env, "GET", `/api/workspaces/${workspaceId}/device-clouds`, { as: owner });
71
+ assert.ok(!JSON.stringify(listed.json).includes("good-key"));
72
+ assert.equal(listed.json.bugmole.available, true);
73
+ const devices = await call(env, "GET", `/api/workspaces/${workspaceId}/device-clouds/${added.json.connection.id}/devices`, { as: owner });
74
+ assert.deepEqual(devices.json.devices.map((device: { name: string }) => device.name), ["Google Pixel 8", "iPhone 15"]);
75
+ const own = await call(env, "GET", `/api/workspaces/${workspaceId}/device-clouds/bugmole/devices`, { as: owner });
76
+ assert.equal(own.json.devices[0].deviceArn, "arn:aws:devicefarm:us-west-2::device:PIXEL8");
77
+ assert.equal((await call(env, "DELETE", `/api/workspaces/${workspaceId}/device-clouds/${added.json.connection.id}`, { as: owner })).status, 200);
78
+ assert.equal(db.db.prepare("SELECT secret_ciphertext AS c FROM device_cloud_credentials").get()?.c, "", "the key is wiped when disconnected");
79
+ });
80
+
81
+ test("device clouds are a Business feature", async () => {
82
+ const clouds = fakeClouds();
83
+ const { env, workspaceId, owner, projectId } = await business(clouds, "team");
84
+ assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/device-clouds`, { as: owner, body: { provider: "browserstack", username: "ada", secret: "good-key" } })).status, 402);
85
+ const run = await call(env, "POST", `/api/projects/${projectId}/runs`, {
86
+ as: owner, body: { planId: "checkout", idempotencyKey: "t", targets: [{ kind: "device", provider: "browserstack", credentialId: "x", platform: "ios", deviceName: "iPhone 15", browserName: "safari" }] },
87
+ });
88
+ assert.equal(run.status, 402);
89
+ });
90
+
91
+ test("app builds are checked, stored, and only usable in their own project", async () => {
92
+ const clouds = fakeClouds();
93
+ const { env, artifacts, projectId, owner } = await business(clouds);
94
+ const upload = (query: string, body: BodyInit) => call(env, "PUT", `/api/projects/${projectId}/app-builds?${query}`, { as: owner, body: body as string, headers: { "content-type": "application/octet-stream" } });
95
+ assert.equal((await upload("platform=android&filename=shop.ipa", Buffer.from(ZIP) as unknown as string)).status, 422);
96
+ assert.equal((await upload("platform=android&filename=shop.apk", "not a zip")).status, 422);
97
+ const ok = await call(env, "PUT", `/api/projects/${projectId}/app-builds?platform=android&filename=shop.apk`, {
98
+ as: owner, body: new TextDecoder("latin1").decode(ZIP), headers: { "content-type": "application/octet-stream" },
99
+ });
100
+ assert.equal(ok.status, 201, JSON.stringify(ok.json));
101
+ assert.ok([...artifacts.objects.keys()].some((key) => key.startsWith(`projects/${projectId}/app-builds/`)));
102
+ const listed = await call(env, "GET", `/api/projects/${projectId}/app-builds`, { as: owner });
103
+ assert.equal(listed.json.builds[0].filename, "shop.apk");
104
+
105
+ const wrongPlatform = await call(env, "POST", `/api/projects/${projectId}/runs`, {
106
+ as: owner, body: { planId: "checkout", idempotencyKey: "w", targets: [{ kind: "device", provider: "bugmole", platform: "ios", deviceName: "iPhone 15", appBuildId: ok.json.build.id }], executionMode: "cloud" },
107
+ });
108
+ assert.equal(wrongPlatform.json.code, "app_build_platform");
109
+ });
110
+
111
+ test("device targets are checked when a run is created", async () => {
112
+ const clouds = fakeClouds();
113
+ const { env, projectId, owner } = await business(clouds);
114
+ const create = (target: Record<string, unknown>, executionMode = "self") => call(env, "POST", `/api/projects/${projectId}/runs`, {
115
+ as: owner, headers: { "x-aegis-email": "o@acme.test", "x-aegis-email-verified": "true" },
116
+ body: { planId: "checkout", idempotencyKey: crypto.randomUUID(), executionMode, targets: [target] },
117
+ });
118
+ const bugmoleWeb = { kind: "device", provider: "bugmole", platform: "android", deviceName: "Google Pixel 8", browserName: "chrome" };
119
+ assert.equal((await create(bugmoleWeb)).json.code, "bugmole_devices_need_cloud");
120
+ assert.equal((await create({ ...bugmoleWeb, real: false }, "cloud")).json.code, "bugmole_real_only");
121
+ assert.equal((await create({ kind: "device", provider: "kobiton", credentialId: "nope", platform: "android", deviceName: "Pixel", browserName: "chrome" })).json.code, "device_credential_missing");
122
+ assert.equal((await create({ kind: "device", provider: "kobiton", platform: "android", deviceName: "Pixel", browserName: "chrome" })).status, 422);
123
+ const ok = await create(bugmoleWeb, "cloud");
124
+ assert.equal(ok.status, 201, JSON.stringify(ok.json));
125
+ assert.equal(ok.json.run.targets[0].provider, "bugmole");
126
+ });
127
+
128
+ test("a cloud run gets Bugmole's devices through the registry, and pays for the minutes it used", async () => {
129
+ const clouds = fakeClouds();
130
+ const { env, db, projectId, owner, workspaceId } = await business(clouds);
131
+ const launches: LaunchRequest[] = [];
132
+ const original = runnerLauncher.start;
133
+ runnerLauncher.start = async (_env, launch) => {
134
+ launches.push(launch);
135
+ };
136
+ try {
137
+ const target = { kind: "device", provider: "bugmole", platform: "android", deviceName: "Google Pixel 8", browserName: "chrome" };
138
+ const created = await call(env, "POST", `/api/projects/${projectId}/runs`, {
139
+ as: owner, headers: { "x-aegis-email": "o@acme.test", "x-aegis-email-verified": "true" },
140
+ body: { planId: "checkout", idempotencyKey: "d1", executionMode: "cloud", targets: [target] },
141
+ });
142
+ const runId = created.json.run.id;
143
+ const token = launches[0].token;
144
+
145
+ const access = await call(env, "GET", `/api/runs/${runId}/device-access`, { token });
146
+ assert.deepEqual(access.json, { credentials: {}, appBuilds: [], bugmole: true });
147
+ const asOwner = await call(env, "GET", `/api/runs/${runId}/device-access`, { as: owner });
148
+ assert.equal(asOwner.json.bugmole, false, "only the cloud runner opens Bugmole's devices");
149
+
150
+ assert.equal((await call(env, "POST", `/api/runs/${runId}/device-sessions`, { token, body: { device: { ...target, deviceName: "iPhone 15" } } })).status, 422);
151
+ const opened = await call(env, "POST", `/api/runs/${runId}/device-sessions`, { token, body: { device: target, name: "checkout" } });
152
+ assert.equal(opened.status, 201, JSON.stringify(opened.json));
153
+ assert.ok(!JSON.stringify(opened.json).includes("bugmole-secret"), "AWS keys stay in the registry");
154
+ const status = await call(env, "GET", `/api/runs/${runId}/device-sessions/${opened.json.sessionId}`, { token });
155
+ assert.deepEqual(status.json, { status: "running", hubUrl: "https://appium.test/wd" });
156
+ assert.equal((await call(env, "POST", `/api/runs/${runId}/device-sessions`, { as: owner, body: { device: target } })).status, 403);
157
+
158
+ const finished = await call(env, "POST", `/api/runs/${runId}/device-sessions/${opened.json.sessionId}/finish`, { token, body: { passed: true } });
159
+ assert.deepEqual(finished.json, { links: [], videoUrl: "https://s3.test/video.mp4", meteredMinutes: 3 });
160
+ await call(env, "POST", `/api/runs/${runId}/device-sessions/${opened.json.sessionId}/finish`, { token, body: { passed: true } });
161
+ const usage = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
162
+ assert.equal(usage.json.usage.lines.device_minute.used, 3, "billed once");
163
+ assert.equal(usage.json.usage.overageCents, 75, "3 minutes × $0.25");
164
+
165
+ // Once the run is over, credentials are no longer handed out.
166
+ db.db.prepare("UPDATE runs SET status = 'success' WHERE id = ?").run(runId);
167
+ assert.equal((await call(env, "GET", `/api/runs/${runId}/device-access`, { token })).status, 409);
168
+ } finally {
169
+ runnerLauncher.start = original;
170
+ }
171
+ });
172
+
173
+ test("a session its runner abandoned is stopped and billed by the sweep", async () => {
174
+ const clouds = fakeClouds();
175
+ const { env, db, projectId, workspaceId } = await business(clouds);
176
+ db.db.prepare("INSERT INTO runs (id, project_id, tenant_id, plan_id, journey_id, requested_by, idempotency_key, status, workspace_id) VALUES ('run_x', ?, ?, 'checkout', 'checkout', 'u', 'k', 'error', ?)")
177
+ .run(projectId, workspaceId, workspaceId);
178
+ db.db.prepare("INSERT INTO bugmole_device_sessions (id, run_id, project_id, workspace_id, device_name, session_arn, status) VALUES ('vds_1', 'run_x', ?, ?, 'Pixel', 'arn:session:1', 'running')")
179
+ .run(projectId, workspaceId);
180
+ assert.equal(await sweepDeviceSessions(env), 1);
181
+ const row = db.db.prepare("SELECT status, metered_minutes AS minutes FROM bugmole_device_sessions WHERE id = 'vds_1'").get() as any;
182
+ assert.deepEqual({ ...row }, { status: "finished", minutes: 3 });
183
+ assert.ok(clouds.state.deviceFarm.includes("StopRemoteAccessSession"));
184
+ assert.equal(await sweepDeviceSessions(env), 0);
185
+ });