@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,155 @@
1
+ // AWS Device Farm through its managed Appium endpoint: a remote access
2
+ // session on a real device, driven over WebDriver, metered by AWS in minutes.
3
+ // https://docs.aws.amazon.com/devicefarm/latest/developerguide/appium-endpoint.html
4
+ import { signAwsRequest, type AwsCredentials } from "./aws-sigv4.js";
5
+
6
+ /** Device Farm only runs in us-west-2. */
7
+ export const DEVICE_FARM_REGION = "us-west-2";
8
+ const ENDPOINT = `https://devicefarm.${DEVICE_FARM_REGION}.amazonaws.com/`;
9
+
10
+ export type DeviceFarmOptions = { credentials: AwsCredentials; projectArn: string; fetch?: typeof fetch };
11
+
12
+ export class DeviceFarmError extends Error {
13
+ constructor(readonly status: number, readonly code: string, message: string) {
14
+ super(message);
15
+ this.name = "DeviceFarmError";
16
+ }
17
+ }
18
+
19
+ export type DeviceFarmDevice = {
20
+ arn: string;
21
+ name: string;
22
+ platform: "ANDROID" | "IOS";
23
+ os: string;
24
+ formFactor?: string;
25
+ availability?: string;
26
+ manufacturer?: string;
27
+ };
28
+
29
+ export type RemoteSession = {
30
+ arn: string;
31
+ status: string;
32
+ result?: string;
33
+ remoteDriverEndpoint?: string;
34
+ meteredMinutes?: number;
35
+ message?: string;
36
+ };
37
+
38
+ export async function deviceFarmCall<T>(options: DeviceFarmOptions, action: string, payload: Record<string, unknown>): Promise<T> {
39
+ const body = JSON.stringify(payload);
40
+ const headers = await signAwsRequest({
41
+ credentials: options.credentials,
42
+ region: DEVICE_FARM_REGION,
43
+ service: "devicefarm",
44
+ method: "POST",
45
+ url: ENDPOINT,
46
+ headers: { "content-type": "application/x-amz-json-1.1", "x-amz-target": `DeviceFarm_20150623.${action}` },
47
+ body,
48
+ });
49
+ const response = await (options.fetch ?? fetch)(ENDPOINT, { method: "POST", headers, body });
50
+ const text = await response.text();
51
+ const parsed = text ? JSON.parse(text) : {};
52
+ if (!response.ok) {
53
+ const code = String(parsed.__type ?? "").split("#").pop() || `http_${response.status}`;
54
+ throw new DeviceFarmError(response.status, code, `Device Farm ${action} failed: ${code}${parsed.message ? ` (${parsed.message})` : ""}`);
55
+ }
56
+ return parsed as T;
57
+ }
58
+
59
+ export async function listDevices(options: DeviceFarmOptions): Promise<DeviceFarmDevice[]> {
60
+ const devices: DeviceFarmDevice[] = [];
61
+ let nextToken: string | undefined;
62
+ do {
63
+ const page = await deviceFarmCall<{ devices?: DeviceFarmDevice[]; nextToken?: string }>(options, "ListDevices", {
64
+ arn: options.projectArn,
65
+ ...(nextToken ? { nextToken } : {}),
66
+ });
67
+ devices.push(...(page.devices ?? []));
68
+ nextToken = page.nextToken;
69
+ } while (nextToken && devices.length < 2_000);
70
+ return devices;
71
+ }
72
+
73
+ /** Uploads an app build (APK or IPA) and waits until Device Farm has processed it. */
74
+ export async function uploadApp(options: DeviceFarmOptions, app: { name: string; platform: "android" | "ios"; bytes: Uint8Array }, pollMs = 3_000): Promise<string> {
75
+ const created = await deviceFarmCall<{ upload: { arn: string; url: string } }>(options, "CreateUpload", {
76
+ projectArn: options.projectArn,
77
+ name: app.name,
78
+ type: app.platform === "android" ? "ANDROID_APP" : "IOS_APP",
79
+ contentType: "application/octet-stream",
80
+ });
81
+ const put = await (options.fetch ?? fetch)(created.upload.url, {
82
+ method: "PUT",
83
+ headers: { "content-type": "application/octet-stream" },
84
+ body: app.bytes as BodyInit,
85
+ });
86
+ if (!put.ok) throw new DeviceFarmError(put.status, "UploadFailed", `Device Farm didn't accept the app upload (${put.status})`);
87
+ for (let attempt = 0; attempt < 100; attempt++) {
88
+ const { upload } = await deviceFarmCall<{ upload: { status: string; message?: string } }>(options, "GetUpload", { arn: created.upload.arn });
89
+ if (upload.status === "SUCCEEDED") return created.upload.arn;
90
+ if (upload.status === "FAILED") throw new DeviceFarmError(400, "UploadFailed", `Device Farm couldn't process the app: ${upload.message ?? "unknown reason"}`);
91
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
92
+ }
93
+ throw new DeviceFarmError(504, "UploadTimeout", "Device Farm took too long to process the app");
94
+ }
95
+
96
+ function toSession(raw: any): RemoteSession {
97
+ return {
98
+ arn: raw.arn,
99
+ status: raw.status,
100
+ result: raw.result,
101
+ remoteDriverEndpoint: raw.endpoints?.remoteDriverEndpoint,
102
+ meteredMinutes: typeof raw.deviceMinutes?.metered === "number" ? raw.deviceMinutes.metered : undefined,
103
+ message: raw.message,
104
+ };
105
+ }
106
+
107
+ export async function startSession(
108
+ options: DeviceFarmOptions,
109
+ params: { deviceArn: string; name: string; appArn?: string; appiumVersion?: "2" | "3" },
110
+ pollMs = 5_000,
111
+ maxWaitMs = 15 * 60_000,
112
+ ): Promise<RemoteSession> {
113
+ const created = await deviceFarmCall<{ remoteAccessSession: any }>(options, "CreateRemoteAccessSession", {
114
+ projectArn: options.projectArn,
115
+ deviceArn: params.deviceArn,
116
+ name: params.name.slice(0, 250),
117
+ ...(params.appArn ? { appArn: params.appArn } : {}),
118
+ configuration: { parameters: { "appium:version": params.appiumVersion ?? "3" } },
119
+ });
120
+ let session = toSession(created.remoteAccessSession);
121
+ const deadline = Date.now() + maxWaitMs;
122
+ while (!(session.status === "RUNNING" && session.remoteDriverEndpoint)) {
123
+ if (["COMPLETED", "STOPPING"].includes(session.status) || session.result === "ERRORED" || session.result === "FAILED") {
124
+ throw new DeviceFarmError(409, "SessionEnded", `The device session ended before it started${session.message ? `: ${session.message}` : ""}`);
125
+ }
126
+ if (Date.now() > deadline) {
127
+ await stopSession(options, session.arn).catch(() => undefined);
128
+ throw new DeviceFarmError(504, "SessionTimeout", "No device became available in time");
129
+ }
130
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
131
+ session = toSession((await deviceFarmCall<{ remoteAccessSession: any }>(options, "GetRemoteAccessSession", { arn: session.arn })).remoteAccessSession);
132
+ }
133
+ return session;
134
+ }
135
+
136
+ export async function stopSession(options: DeviceFarmOptions, arn: string): Promise<void> {
137
+ await deviceFarmCall(options, "StopRemoteAccessSession", { arn });
138
+ }
139
+
140
+ /** Waits for a stopped session's final minutes. */
141
+ export async function finishedSession(options: DeviceFarmOptions, arn: string, pollMs = 5_000, maxWaitMs = 5 * 60_000): Promise<RemoteSession> {
142
+ const deadline = Date.now() + maxWaitMs;
143
+ for (;;) {
144
+ const session = toSession((await deviceFarmCall<{ remoteAccessSession: any }>(options, "GetRemoteAccessSession", { arn })).remoteAccessSession);
145
+ if (session.status === "COMPLETED" || Date.now() > deadline) return session;
146
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
147
+ }
148
+ }
149
+
150
+ export type DeviceFarmArtifact = { name: string; type: string; extension: string; url: string };
151
+
152
+ export async function sessionArtifacts(options: DeviceFarmOptions, arn: string): Promise<DeviceFarmArtifact[]> {
153
+ const result = await deviceFarmCall<{ artifacts?: DeviceFarmArtifact[] }>(options, "ListArtifacts", { arn, type: "FILE" });
154
+ return result.artifacts ?? [];
155
+ }
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createVerify, generateKeyPairSync } from "node:crypto";
4
+ import { createCheckRun, githubAppJwt, installationToken, pkcs1ToPkcs8 } from "./github-app.js";
5
+
6
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
7
+ const pkcs1Pem = privateKey.export({ type: "pkcs1", format: "pem" }).toString();
8
+ const pkcs8Pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
9
+
10
+ function verifyJwt(jwt: string) {
11
+ const [header, payload, signature] = jwt.split(".");
12
+ const verifier = createVerify("RSA-SHA256");
13
+ verifier.update(`${header}.${payload}`);
14
+ return {
15
+ valid: verifier.verify(publicKey, Buffer.from(signature, "base64url")),
16
+ header: JSON.parse(Buffer.from(header, "base64url").toString()),
17
+ payload: JSON.parse(Buffer.from(payload, "base64url").toString()),
18
+ };
19
+ }
20
+
21
+ test("the app JWT is signed with GitHub's PKCS#1 key as downloaded, escaped newlines and all", async () => {
22
+ const now = Date.parse("2026-09-17T10:00:00Z");
23
+ for (const pem of [pkcs1Pem, pkcs8Pem, pkcs1Pem.replace(/\n/g, "\\n")]) {
24
+ const jwt = await githubAppJwt({ GITHUB_APP_ID: "12345", GITHUB_APP_PRIVATE_KEY: pem }, now);
25
+ const { valid, header, payload } = verifyJwt(jwt);
26
+ assert.equal(valid, true);
27
+ assert.deepEqual(header, { alg: "RS256", typ: "JWT" });
28
+ assert.deepEqual(payload, { iat: now / 1000 - 60, exp: now / 1000 + 540, iss: "12345" });
29
+ }
30
+ await assert.rejects(githubAppJwt({}), /isn't configured/);
31
+ });
32
+
33
+ test("PKCS#1 is wrapped into the PKCS#8 structure byte for byte", () => {
34
+ const pkcs1 = privateKey.export({ type: "pkcs1", format: "der" });
35
+ const pkcs8 = privateKey.export({ type: "pkcs8", format: "der" });
36
+ assert.deepEqual(Buffer.from(pkcs1ToPkcs8(new Uint8Array(pkcs1))), pkcs8);
37
+ });
38
+
39
+ test("installation tokens are cached until close to expiry, and calls go out as the installation", async () => {
40
+ const calls: Array<{ url: string; auth: string | null; body?: string }> = [];
41
+ const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
42
+ const url = String(input);
43
+ calls.push({ url, auth: new Headers(init?.headers).get("authorization"), body: init?.body as string | undefined });
44
+ if (url.endsWith("/access_tokens")) return Response.json({ token: "ghs_install", expires_at: new Date(Date.now() + 3_600_000).toISOString() }, { status: 201 });
45
+ return Response.json({ id: 77, html_url: "https://github.com/acme/shop/runs/77" }, { status: 201 });
46
+ }) as unknown as typeof fetch;
47
+ const env = { GITHUB_APP_ID: "999", GITHUB_APP_PRIVATE_KEY: pkcs8Pem, GITHUB_FETCH: doFetch };
48
+ assert.equal(await installationToken(env, 42), "ghs_install");
49
+ assert.equal(await installationToken(env, 42), "ghs_install");
50
+ const check = await createCheckRun(env, 42, "acme/shop", { name: "Bugmole: checkout", head_sha: "abc", status: "queued" });
51
+ assert.equal(check.id, 77);
52
+ assert.equal(calls.filter((call) => call.url.endsWith("/access_tokens")).length, 1);
53
+ const create = calls.at(-1)!;
54
+ assert.equal(create.url, "https://api.github.com/repos/acme/shop/check-runs");
55
+ assert.equal(create.auth, "Bearer ghs_install");
56
+ assert.deepEqual(JSON.parse(create.body!), { name: "Bugmole: checkout", head_sha: "abc", status: "queued" });
57
+ });
@@ -0,0 +1,143 @@
1
+ // A GitHub App client that runs anywhere WebCrypto does (Cloudflare Workers,
2
+ // Node): the app JWT, installation tokens, and the few REST calls Bugmole
3
+ // makes as the app (check runs, issues).
4
+
5
+ export type GithubAppEnv = {
6
+ GITHUB_APP_ID?: string;
7
+ /** PEM, PKCS#1 ("BEGIN RSA PRIVATE KEY", GitHub's download) or PKCS#8. Newlines may be escaped as \n. */
8
+ GITHUB_APP_PRIVATE_KEY?: string;
9
+ /** Test seam. */
10
+ GITHUB_FETCH?: typeof fetch;
11
+ };
12
+
13
+ export class GithubAppError extends Error {
14
+ constructor(readonly status: number, message: string) {
15
+ super(message);
16
+ }
17
+ }
18
+
19
+ const API = "https://api.github.com";
20
+
21
+ export function githubAppConfigured(env: GithubAppEnv): boolean {
22
+ return Boolean(env.GITHUB_APP_ID?.trim() && env.GITHUB_APP_PRIVATE_KEY?.trim());
23
+ }
24
+
25
+ function base64UrlBytes(bytes: Uint8Array): string {
26
+ let binary = "";
27
+ for (const byte of bytes) binary += String.fromCharCode(byte);
28
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
29
+ }
30
+
31
+ function base64UrlJson(value: unknown): string {
32
+ return base64UrlBytes(new TextEncoder().encode(JSON.stringify(value)));
33
+ }
34
+
35
+ function derLength(length: number): number[] {
36
+ if (length < 0x80) return [length];
37
+ const bytes: number[] = [];
38
+ let rest = length;
39
+ while (rest > 0) {
40
+ bytes.unshift(rest & 0xff);
41
+ rest >>= 8;
42
+ }
43
+ return [0x80 | bytes.length, ...bytes];
44
+ }
45
+
46
+ function der(tag: number, content: Uint8Array): Uint8Array {
47
+ return new Uint8Array([tag, ...derLength(content.length), ...content]);
48
+ }
49
+
50
+ /** Wraps a PKCS#1 RSAPrivateKey in the PKCS#8 structure WebCrypto imports. */
51
+ export function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array {
52
+ const version = new Uint8Array([0x02, 0x01, 0x00]);
53
+ // SEQUENCE { OID 1.2.840.113549.1.1.1 (rsaEncryption), NULL }
54
+ const algorithm = new Uint8Array([0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]);
55
+ const key = der(0x04, pkcs1);
56
+ return der(0x30, new Uint8Array([...version, ...algorithm, ...key]));
57
+ }
58
+
59
+ function pemBody(pem: string): Uint8Array {
60
+ const base64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
61
+ return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
62
+ }
63
+
64
+ const keyCache = new Map<string, Promise<CryptoKey>>();
65
+
66
+ function signingKey(pem: string): Promise<CryptoKey> {
67
+ const cached = keyCache.get(pem);
68
+ if (cached) return cached;
69
+ const normalized = pem.replace(/\\n/g, "\n").trim();
70
+ const bytes = pemBody(normalized);
71
+ const pkcs8 = normalized.includes("BEGIN RSA PRIVATE KEY") ? pkcs1ToPkcs8(bytes) : bytes;
72
+ const key = crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
73
+ keyCache.set(pem, key);
74
+ return key;
75
+ }
76
+
77
+ /** The app's own JWT: valid for ten minutes, backdated a minute for clock drift (GitHub's guidance). */
78
+ export async function githubAppJwt(env: GithubAppEnv, now = Date.now()): Promise<string> {
79
+ if (!githubAppConfigured(env)) throw new GithubAppError(503, "The GitHub App isn't configured");
80
+ const seconds = Math.floor(now / 1000);
81
+ const header = base64UrlJson({ alg: "RS256", typ: "JWT" });
82
+ const payload = base64UrlJson({ iat: seconds - 60, exp: seconds + 540, iss: env.GITHUB_APP_ID!.trim() });
83
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", await signingKey(env.GITHUB_APP_PRIVATE_KEY!), new TextEncoder().encode(`${header}.${payload}`));
84
+ return `${header}.${payload}.${base64UrlBytes(new Uint8Array(signature))}`;
85
+ }
86
+
87
+ const tokenCache = new Map<string, { token: string; expiresAt: number }>();
88
+
89
+ export async function installationToken(env: GithubAppEnv, installationId: number): Promise<string> {
90
+ const cacheKey = `${env.GITHUB_APP_ID}:${installationId}`;
91
+ const cached = tokenCache.get(cacheKey);
92
+ if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token;
93
+ const response = await (env.GITHUB_FETCH ?? fetch)(`${API}/app/installations/${installationId}/access_tokens`, {
94
+ method: "POST",
95
+ headers: githubHeaders(await githubAppJwt(env)),
96
+ });
97
+ if (!response.ok) throw new GithubAppError(response.status, `GitHub refused an installation token (${response.status})`);
98
+ const body = await response.json() as { token: string; expires_at: string };
99
+ tokenCache.set(cacheKey, { token: body.token, expiresAt: Date.parse(body.expires_at) });
100
+ return body.token;
101
+ }
102
+
103
+ function githubHeaders(token: string): Record<string, string> {
104
+ return {
105
+ accept: "application/vnd.github+json",
106
+ authorization: `Bearer ${token}`,
107
+ "x-github-api-version": "2022-11-28",
108
+ "user-agent": "bugmole",
109
+ };
110
+ }
111
+
112
+ /** A REST call as the installation. Throws GithubAppError with GitHub's status. */
113
+ export async function githubRequest<T>(env: GithubAppEnv, installationId: number, method: string, path: string, body?: unknown): Promise<T> {
114
+ const token = await installationToken(env, installationId);
115
+ const response = await (env.GITHUB_FETCH ?? fetch)(`${API}${path}`, {
116
+ method,
117
+ headers: { ...githubHeaders(token), ...(body === undefined ? {} : { "content-type": "application/json" }) },
118
+ body: body === undefined ? undefined : JSON.stringify(body),
119
+ });
120
+ if (!response.ok) {
121
+ const text = await response.text().catch(() => "");
122
+ throw new GithubAppError(response.status, `GitHub ${method} ${path} failed (${response.status}) ${text.slice(0, 200)}`);
123
+ }
124
+ return (response.status === 204 ? undefined : await response.json()) as T;
125
+ }
126
+
127
+ export type CheckRunUpdate = {
128
+ status?: "queued" | "in_progress" | "completed";
129
+ conclusion?: "success" | "failure" | "neutral" | "cancelled" | "timed_out" | "action_required";
130
+ details_url?: string;
131
+ external_id?: string;
132
+ started_at?: string;
133
+ completed_at?: string;
134
+ output?: { title: string; summary: string; text?: string };
135
+ };
136
+
137
+ export function createCheckRun(env: GithubAppEnv, installationId: number, repo: string, check: CheckRunUpdate & { name: string; head_sha: string }) {
138
+ return githubRequest<{ id: number; html_url: string }>(env, installationId, "POST", `/repos/${repo}/check-runs`, check);
139
+ }
140
+
141
+ export function updateCheckRun(env: GithubAppEnv, installationId: number, repo: string, checkRunId: number, update: CheckRunUpdate) {
142
+ return githubRequest<{ id: number }>(env, installationId, "PATCH", `/repos/${repo}/check-runs/${checkRunId}`, update);
143
+ }
@@ -0,0 +1,81 @@
1
+ // A GitLab client for the one thing Bugmole needs as itself: posting a
2
+ // commit status. Unlike the GitHub App, there is no Bugmole-operated app to
3
+ // register — each project supplies its own access token (Project or Group
4
+ // access token, scope `api`), stored encrypted, so there is nothing for an
5
+ // operator to configure globally.
6
+
7
+ export type GitlabFetchEnv = {
8
+ /** Test seam. */
9
+ GITLAB_FETCH?: typeof fetch;
10
+ };
11
+
12
+ export class GitlabApiError extends Error {
13
+ constructor(readonly status: number, message: string) {
14
+ super(message);
15
+ }
16
+ }
17
+
18
+ const DEFAULT_HOST = "gitlab.com";
19
+ const HOST = /^[A-Za-z0-9.-]{1,255}$/;
20
+
21
+ export function validGitlabHost(host: string): boolean {
22
+ return HOST.test(host) && !host.includes("..");
23
+ }
24
+
25
+ export function normalizeGitlabHost(host: string | null | undefined): string {
26
+ const trimmed = (host ?? "").trim().toLowerCase();
27
+ return trimmed || DEFAULT_HOST;
28
+ }
29
+
30
+ function api(host: string): string {
31
+ return `https://${host}/api/v4`;
32
+ }
33
+
34
+ /** A REST call to the given GitLab instance, authenticated with a project/group access token. */
35
+ export async function gitlabRequest<T>(
36
+ env: GitlabFetchEnv,
37
+ host: string,
38
+ token: string,
39
+ method: string,
40
+ path: string,
41
+ body?: unknown,
42
+ ): Promise<T> {
43
+ const response = await (env.GITLAB_FETCH ?? fetch)(`${api(host)}${path}`, {
44
+ method,
45
+ headers: {
46
+ "PRIVATE-TOKEN": token,
47
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
48
+ },
49
+ body: body === undefined ? undefined : JSON.stringify(body),
50
+ });
51
+ if (!response.ok) {
52
+ const text = await response.text().catch(() => "");
53
+ throw new GitlabApiError(response.status, `GitLab ${method} ${path} failed (${response.status}) ${text.slice(0, 200)}`);
54
+ }
55
+ return (response.status === 204 ? undefined : await response.json()) as T;
56
+ }
57
+
58
+ export type GitlabProject = { id: number; path_with_namespace: string; web_url: string };
59
+
60
+ /** Confirms the token can see this project, and returns GitLab's own id and path for it. */
61
+ export function fetchGitlabProject(env: GitlabFetchEnv, host: string, token: string, projectPath: string): Promise<GitlabProject> {
62
+ return gitlabRequest<GitlabProject>(env, host, token, "GET", `/projects/${encodeURIComponent(projectPath)}`);
63
+ }
64
+
65
+ export type CommitStatusState = "pending" | "running" | "success" | "failed" | "canceled";
66
+
67
+ /**
68
+ * Posting a new status with the same `name` for the same commit replaces the
69
+ * previous one in GitLab's UI — there's no separate "check run id" to track
70
+ * and update, unlike GitHub Checks.
71
+ */
72
+ export function postCommitStatus(
73
+ env: GitlabFetchEnv,
74
+ host: string,
75
+ token: string,
76
+ gitlabProjectId: number,
77
+ sha: string,
78
+ status: { state: CommitStatusState; name: string; target_url?: string; description?: string },
79
+ ): Promise<unknown> {
80
+ return gitlabRequest(env, host, token, "POST", `/projects/${gitlabProjectId}/statuses/${sha}`, status);
81
+ }
@@ -0,0 +1,123 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+ import { createTempEmailInbox, deleteTempEmailInbox, extractFirstLink, waitForTempEmailMessage } from "./temp-email.js";
4
+
5
+ function mockFetchSequence(handlers: Array<(url: string, init?: RequestInit) => unknown>) {
6
+ let call = 0;
7
+ const original = globalThis.fetch;
8
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
9
+ const handler = handlers[Math.min(call, handlers.length - 1)];
10
+ call += 1;
11
+ const body = handler(String(input), init);
12
+ return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
13
+ }) as typeof fetch;
14
+ return () => {
15
+ globalThis.fetch = original;
16
+ };
17
+ }
18
+
19
+ test("createTempEmailInbox picks a served domain, creates an account, and authenticates", async () => {
20
+ const restore = mockFetchSequence([
21
+ () => ({ "hydra:member": [{ domain: "example-temp.test" }] }),
22
+ (_url, init) => {
23
+ const body = JSON.parse(String(init?.body));
24
+ assert.ok(body.address.endsWith("@example-temp.test"));
25
+ assert.ok(body.password.length > 0);
26
+ return { id: "acct-1", address: body.address };
27
+ },
28
+ () => ({ token: "jwt-token" }),
29
+ ]);
30
+ try {
31
+ const inbox = await createTempEmailInbox();
32
+ assert.equal(inbox.accountId, "acct-1");
33
+ assert.equal(inbox.token, "jwt-token");
34
+ assert.ok(inbox.address.endsWith("@example-temp.test"));
35
+ } finally {
36
+ restore();
37
+ }
38
+ });
39
+
40
+ test("createTempEmailInbox fails clearly when mail.tm has no active domain", async () => {
41
+ const restore = mockFetchSequence([() => ({ "hydra:member": [] })]);
42
+ try {
43
+ await assert.rejects(createTempEmailInbox(), /no active domain/);
44
+ } finally {
45
+ restore();
46
+ }
47
+ });
48
+
49
+ const inbox = { address: "qa@example-temp.test", password: "x", accountId: "acct-1", token: "jwt-token" };
50
+
51
+ test("waitForTempEmailMessage polls until a message matching from/subject arrives", async () => {
52
+ let listCalls = 0;
53
+ const restore = mockFetchSequence([
54
+ () => {
55
+ listCalls += 1;
56
+ return { "hydra:member": [] };
57
+ },
58
+ () => {
59
+ listCalls += 1;
60
+ return {
61
+ "hydra:member": [
62
+ { id: "msg-wrong", from: { address: "someone-else@cinely.net" }, subject: "Unrelated", createdAt: "now" },
63
+ { id: "msg-right", from: { address: "no-reply@cinely.net" }, subject: "Confirm your email address", createdAt: "now" },
64
+ ],
65
+ };
66
+ },
67
+ () => ({ id: "msg-right", from: { address: "no-reply@cinely.net" }, subject: "Confirm your email address", text: "", html: ["<a href='https://x.test/confirm'>Confirm</a>"], createdAt: "now" }),
68
+ ]);
69
+ try {
70
+ const message = await waitForTempEmailMessage(inbox, { from: "cinely.net", subject: "confirm", pollIntervalMs: 1 });
71
+ assert.equal(message.id, "msg-right");
72
+ assert.ok(message.html.includes("confirm"));
73
+ assert.ok(listCalls >= 2);
74
+ } finally {
75
+ restore();
76
+ }
77
+ });
78
+
79
+ test("waitForTempEmailMessage times out with a clear message naming the criteria", async () => {
80
+ const restore = mockFetchSequence([() => ({ "hydra:member": [] })]);
81
+ try {
82
+ await assert.rejects(
83
+ waitForTempEmailMessage(inbox, { from: "cinely.net", subject: "confirm", timeoutMs: 5, pollIntervalMs: 1 }),
84
+ /from containing "cinely\.net" and subject containing "confirm"/,
85
+ );
86
+ } finally {
87
+ restore();
88
+ }
89
+ });
90
+
91
+ test("extractFirstLink returns the first link by default", () => {
92
+ const href = extractFirstLink({ html: `<a href="https://a.test">First</a><a href="https://b.test">Second</a>`, text: "" });
93
+ assert.equal(href, "https://a.test");
94
+ });
95
+
96
+ test("extractFirstLink prefers a link whose text or URL matches", () => {
97
+ const href = extractFirstLink({
98
+ html: `<a href="https://a.test/unsubscribe">Unsubscribe</a><a href="https://a.test/confirm-email">Confirm email address</a>`,
99
+ text: "",
100
+ }, "confirm");
101
+ assert.equal(href, "https://a.test/confirm-email");
102
+ });
103
+
104
+ test("extractFirstLink falls back to a bare URL in the text body when there's no html link", () => {
105
+ const href = extractFirstLink({ html: "", text: "Confirm here: https://a.test/confirm?token=abc" });
106
+ assert.equal(href, "https://a.test/confirm?token=abc");
107
+ });
108
+
109
+ test("extractFirstLink fails clearly when nothing matches", () => {
110
+ assert.throws(() => extractFirstLink({ html: `<a href="https://a.test">Unsubscribe</a>`, text: "" }, "confirm"), /No link matching "confirm"/);
111
+ });
112
+
113
+ test("deleteTempEmailInbox is best-effort and never throws", async () => {
114
+ const original = globalThis.fetch;
115
+ globalThis.fetch = (async () => {
116
+ throw new Error("network down");
117
+ }) as typeof fetch;
118
+ try {
119
+ await deleteTempEmailInbox(inbox);
120
+ } finally {
121
+ globalThis.fetch = original;
122
+ }
123
+ });