@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,354 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import readline from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import YAML from "yaml";
7
+
8
+ export type StorageProvider = "local" | "gcs" | "s3" | "r2" | "bugmole";
9
+
10
+ export type InitOptions = {
11
+ cwd?: string;
12
+ nonInteractive?: boolean;
13
+ force?: boolean;
14
+ configPath?: string;
15
+ envPath?: string;
16
+ projectId?: string;
17
+ projectName?: string;
18
+ appId?: string;
19
+ appName?: string;
20
+ workspaceId?: string;
21
+ workspaceName?: string;
22
+ environment?: string;
23
+ specDir?: string;
24
+ evidenceManifest?: string;
25
+ evidenceScreenshots?: string;
26
+ artifactsDir?: string;
27
+ storageProvider?: string;
28
+ bucket?: string;
29
+ region?: string;
30
+ endpoint?: string;
31
+ cloudProjectId?: string;
32
+ accountId?: string;
33
+ };
34
+
35
+ export type InitConfig = {
36
+ projectId: string;
37
+ projectName: string;
38
+ appId: string;
39
+ appName: string;
40
+ workspaceId: string;
41
+ workspaceName: string;
42
+ environment: string;
43
+ specDir: string;
44
+ evidenceManifest: string;
45
+ evidenceScreenshots: string;
46
+ artifactsDir: string;
47
+ storageProvider: StorageProvider;
48
+ bucket?: string;
49
+ region?: string;
50
+ endpoint?: string;
51
+ cloudProjectId?: string;
52
+ accountId?: string;
53
+ };
54
+
55
+ const providers = new Set<StorageProvider>(["local", "gcs", "s3", "r2", "bugmole"]);
56
+
57
+ export function validateStorageProvider(value: string): StorageProvider {
58
+ const lowered = value.trim().toLowerCase();
59
+ // "valkyrie" is Bugmole storage's pre-rename name.
60
+ const normalized = (lowered === "valkyrie" ? "bugmole" : lowered) as StorageProvider;
61
+ if (!providers.has(normalized)) {
62
+ throw new Error(`Unsupported storage provider "${value}". Choose local, gcs, s3, r2, or bugmole.`);
63
+ }
64
+ return normalized;
65
+ }
66
+
67
+ function existingDefaults(cwd: string, configPath: string): Partial<InitConfig> {
68
+ const absolute = path.resolve(cwd, configPath);
69
+ if (!fs.existsSync(absolute)) return {};
70
+ const config = YAML.parse(fs.readFileSync(absolute, "utf8")) as Record<string, any>;
71
+ const project = config.project ?? {};
72
+ const runtime = config.runtime ?? {};
73
+ const storage = config.storage ?? {};
74
+ const provider = storage.provider;
75
+ return {
76
+ projectId: project.id,
77
+ projectName: project.name,
78
+ appId: project.app_id ?? project.appId ?? project.applications?.[0]?.id,
79
+ appName: project.app_name ?? project.appName ?? project.applications?.[0]?.name,
80
+ workspaceId: project.workspace_id ?? project.workspaceId,
81
+ workspaceName: project.workspace_name ?? project.workspaceName,
82
+ environment: project.environment ?? runtime.environment,
83
+ specDir: project.spec_dir ?? runtime.spec_dir,
84
+ evidenceManifest: project.evidence_manifest,
85
+ evidenceScreenshots: project.evidence_screenshots,
86
+ artifactsDir: runtime.artifacts_dir ?? storage.local?.directory,
87
+ storageProvider: provider && !String(provider).includes("${") ? provider : undefined,
88
+ bucket: storage[provider]?.bucket,
89
+ region: storage[provider]?.region,
90
+ endpoint: storage[provider]?.endpoint,
91
+ cloudProjectId: storage[provider]?.project_id,
92
+ accountId: storage[provider]?.account_id,
93
+ };
94
+ }
95
+
96
+ const valueOr = (value: string | undefined, fallback: string) => value?.trim() || fallback;
97
+
98
+ export function openBrowser(url: string): void {
99
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
100
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
101
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
102
+ // With no opener installed (xdg-open on a server), spawn emits "error"; unhandled, that crashes the process.
103
+ child.on("error", () => undefined);
104
+ child.unref();
105
+ }
106
+
107
+ function writeEnvValue(envFile: string, key: string, value: string): void {
108
+ const existing = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf8") : "";
109
+ const line = `${key}=${value}`;
110
+ const pattern = new RegExp(`^${key}=.*$`, "m");
111
+ const updated = pattern.test(existing)
112
+ ? existing.replace(pattern, line)
113
+ : `${existing}${existing.endsWith("\n") || !existing ? "" : "\n"}${line}\n`;
114
+ fs.writeFileSync(envFile, updated, "utf8");
115
+ }
116
+
117
+ function renderConfig(config: InitConfig): string {
118
+ const cloud =
119
+ config.storageProvider === "gcs"
120
+ ? ` bucket: ${config.bucket ?? "${GCS_BUCKET}"}\n project_id: ${config.cloudProjectId ?? "${GCS_PROJECT_ID}"}\n credentials_json: \${GCS_CREDENTIALS_JSON}`
121
+ : config.storageProvider === "s3"
122
+ ? ` bucket: ${config.bucket ?? "${S3_BUCKET}"}\n region: ${config.region ?? "${S3_REGION}"}\n endpoint: ${config.endpoint ?? "${S3_ENDPOINT}"}\n access_key_id: \${S3_ACCESS_KEY_ID}\n secret_access_key: \${S3_SECRET_ACCESS_KEY}`
123
+ : config.storageProvider === "r2"
124
+ ? ` bucket: ${config.bucket ?? "${R2_BUCKET}"}\n account_id: ${config.accountId ?? "${R2_ACCOUNT_ID}"}\n endpoint: ${config.endpoint ?? "${R2_ENDPOINT}"}\n access_key_id: \${R2_ACCESS_KEY_ID}\n secret_access_key: \${R2_SECRET_ACCESS_KEY}`
125
+ : config.storageProvider === "bugmole"
126
+ ? ` registry_url: \${BUGMOLE_REGISTRY_URL}`
127
+ : ` directory: \${BUGMOLE_LOCAL_ARTIFACTS_DIR:-${config.artifactsDir}}`;
128
+ return `version: 1
129
+ project:
130
+ id: ${config.projectId}
131
+ name: ${JSON.stringify(config.projectName)}
132
+ app_id: ${config.appId}
133
+ app_name: ${JSON.stringify(config.appName)}
134
+ workspace_id: ${config.workspaceId}
135
+ workspace_name: ${JSON.stringify(config.workspaceName)}
136
+ environment: ${config.environment}
137
+ spec_dir: ${config.specDir}
138
+ evidence_manifest: ${config.evidenceManifest}
139
+ evidence_screenshots: ${config.evidenceScreenshots}
140
+
141
+ runtime:
142
+ artifacts_dir: ${config.artifactsDir}
143
+ spec_dir: ${config.specDir}
144
+ environment: ${config.environment}
145
+
146
+ storage:
147
+ provider: ${config.storageProvider}
148
+ ${config.storageProvider}:
149
+ ${cloud}
150
+
151
+ qa:
152
+ framework_config: ./bugmole.config.yaml
153
+ test_plan: ./QA_TEST_PLAN.md
154
+ evidence: ./QA_EVIDENCE.md
155
+ `;
156
+ }
157
+
158
+ function renderEnv(config: InitConfig): string {
159
+ const lines = [
160
+ "# Generated by bugmole init. Fill credentials privately; never commit this file.",
161
+ `BUGMOLE_PROJECT_ID=${config.projectId}`,
162
+ `BUGMOLE_PROJECT_NAME=${JSON.stringify(config.projectName)}`,
163
+ `BUGMOLE_APP_ID=${config.appId}`,
164
+ `BUGMOLE_APP_NAME=${JSON.stringify(config.appName)}`,
165
+ `BUGMOLE_WORKSPACE_ID=${config.workspaceId}`,
166
+ `BUGMOLE_WORKSPACE_NAME=${JSON.stringify(config.workspaceName)}`,
167
+ `BUGMOLE_ENVIRONMENT=${config.environment}`,
168
+ `BUGMOLE_STORAGE_PROVIDER=${config.storageProvider}`,
169
+ `BUGMOLE_LOCAL_ARTIFACTS_DIR=${config.artifactsDir}`,
170
+ `BUGMOLE_AEGIS_EMAIL=${JSON.stringify(process.env.BUGMOLE_AEGIS_EMAIL?.trim() ?? "")}`,
171
+ "",
172
+ ];
173
+ if (config.storageProvider === "gcs") {
174
+ lines.push(
175
+ "# GCS identifiers",
176
+ `GCS_BUCKET=${config.bucket ?? ""}`,
177
+ `GCS_PROJECT_ID=${config.cloudProjectId ?? ""}`,
178
+ "GCS_CREDENTIALS_JSON=REPLACE_ME",
179
+ );
180
+ } else if (config.storageProvider === "s3") {
181
+ lines.push(
182
+ "# S3-compatible identifiers",
183
+ `S3_BUCKET=${config.bucket ?? ""}`,
184
+ `S3_REGION=${config.region ?? ""}`,
185
+ `S3_ENDPOINT=${config.endpoint ?? ""}`,
186
+ "S3_ACCESS_KEY_ID=REPLACE_ME",
187
+ "S3_SECRET_ACCESS_KEY=REPLACE_ME",
188
+ );
189
+ } else if (config.storageProvider === "r2") {
190
+ lines.push(
191
+ "# Cloudflare R2 identifiers",
192
+ `R2_BUCKET=${config.bucket ?? ""}`,
193
+ `R2_ACCOUNT_ID=${config.accountId ?? ""}`,
194
+ `R2_ENDPOINT=${config.endpoint ?? ""}`,
195
+ "R2_ACCESS_KEY_ID=REPLACE_ME",
196
+ "R2_SECRET_ACCESS_KEY=REPLACE_ME",
197
+ );
198
+ }
199
+ return `${lines.join("\n")}\n`;
200
+ }
201
+
202
+ async function ask(rl: readline.Interface, label: string, fallback: string): Promise<string> {
203
+ const answer = await rl.question(`${label} [${fallback}]: `);
204
+ return valueOr(answer, fallback);
205
+ }
206
+
207
+ export async function runInitWizard(
208
+ options: InitOptions = {},
209
+ ): Promise<{ configPath: string; envPath: string; config: InitConfig }> {
210
+ const cwd = options.cwd ?? process.cwd();
211
+ const configPath = options.configPath ?? ".bugmole/config.yaml";
212
+ const envPath = options.envPath ?? ".bugmole.env";
213
+ const defaults = existingDefaults(cwd, configPath);
214
+ const nonInteractive = options.nonInteractive ?? process.env.BUGMOLE_NON_INTERACTIVE === "true";
215
+ const rl = nonInteractive ? undefined : readline.createInterface({ input, output });
216
+ const choose = async (value: string | undefined, label: string, fallback: string) =>
217
+ value?.trim() || (rl ? await ask(rl, label, fallback) : fallback);
218
+ try {
219
+ const config: InitConfig = {
220
+ workspaceId: await choose(options.workspaceId, "Workspace id", valueOr(defaults.workspaceId, `workspace_${path.basename(cwd)}`)),
221
+ workspaceName: await choose(options.workspaceName, "Workspace name", valueOr(defaults.workspaceName, path.basename(cwd))),
222
+ projectId: await choose(options.projectId, "Project id", valueOr(defaults.projectId, path.basename(cwd))),
223
+ projectName: await choose(options.projectName, "Project name", valueOr(defaults.projectName, path.basename(cwd))),
224
+ appId: await choose(options.appId, "Default app id", valueOr(defaults.appId, "default")),
225
+ appName: await choose(options.appName, "Default app name", valueOr(defaults.appName, path.basename(cwd))),
226
+ environment: await choose(options.environment, "Environment", valueOr(defaults.environment, "local")),
227
+ specDir: await choose(options.specDir, "Spec directory", valueOr(defaults.specDir, "./spec")),
228
+ evidenceManifest: await choose(options.evidenceManifest, "Evidence manifest", valueOr(defaults.evidenceManifest, "./qa/browser-evidence.json")),
229
+ evidenceScreenshots: await choose(options.evidenceScreenshots, "Evidence screenshots directory", valueOr(defaults.evidenceScreenshots, "./qa/screenshots")),
230
+ artifactsDir: await choose(options.artifactsDir, "Artifact directory", valueOr(defaults.artifactsDir, "./qa/bugmole-runs")),
231
+ storageProvider: validateStorageProvider(
232
+ await choose(options.storageProvider, "Storage provider (local|gcs|s3|r2|bugmole)", valueOr(defaults.storageProvider, "local")),
233
+ ),
234
+ };
235
+ if (config.storageProvider !== "local" && config.storageProvider !== "bugmole") {
236
+ config.bucket = await choose(options.bucket, "Bucket", valueOr(defaults.bucket, ""));
237
+ if (config.storageProvider === "gcs")
238
+ config.cloudProjectId = await choose(options.cloudProjectId, "GCS project id", valueOr(defaults.cloudProjectId, ""));
239
+ if (config.storageProvider === "s3")
240
+ config.region = await choose(options.region, "S3 region", valueOr(defaults.region, ""));
241
+ if (config.storageProvider === "r2")
242
+ config.accountId = await choose(options.accountId, "R2 account id", valueOr(defaults.accountId, ""));
243
+ if (config.storageProvider !== "gcs")
244
+ config.endpoint = await choose(options.endpoint, "Endpoint (blank for provider default)", valueOr(defaults.endpoint, ""));
245
+ }
246
+ const configFile = path.resolve(cwd, configPath);
247
+ const envFile = path.resolve(cwd, envPath);
248
+ const exists = fs.existsSync(configFile);
249
+ const shouldWrite =
250
+ !exists || options.force || nonInteractive
251
+ ? !exists || options.force
252
+ : (await rl!.question(`Overwrite ${configPath}? [y/N]: `)).trim().toLowerCase() === "y";
253
+ if (shouldWrite) {
254
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
255
+ fs.writeFileSync(configFile, renderConfig(config), "utf8");
256
+ if (options.force || !fs.existsSync(envFile)) {
257
+ fs.writeFileSync(envFile, renderEnv(config), "utf8");
258
+ console.log(`Generated ${configPath} and ${envPath}.`);
259
+ } else {
260
+ console.log(`Generated ${configPath}; kept existing ${envPath}.`);
261
+ }
262
+ } else {
263
+ console.log(`Kept existing ${configPath}; no files overwritten.`);
264
+ }
265
+ const registryUrl =
266
+ process.env.BUGMOLE_REGISTRY_URL?.trim() || "https://api.bugmole.com";
267
+ const apiKey = process.env.BUGMOLE_API_KEY?.trim();
268
+ if (registryUrl && apiKey && shouldWrite) {
269
+ const headers: Record<string, string> = {
270
+ "content-type": "application/json",
271
+ authorization: `Bearer ${apiKey}`,
272
+ };
273
+ const response = await fetch(`${registryUrl.replace(/\/$/, "")}/api/projects/register`, {
274
+ method: "POST",
275
+ headers,
276
+ body: JSON.stringify({
277
+ id: config.projectId,
278
+ name: config.projectName,
279
+ appId: config.appId,
280
+ appName: config.appName,
281
+ workspaceId: config.workspaceId,
282
+ workspaceName: config.workspaceName,
283
+ environment: config.environment,
284
+ storageProvider: config.storageProvider,
285
+ }),
286
+ });
287
+ if (!response.ok) {
288
+ throw new Error(`Bugmole registry rejected project registration (${response.status})`);
289
+ }
290
+ console.log(`Registered ${config.projectId} with the Bugmole registry.`);
291
+ } else if (registryUrl && !apiKey && shouldWrite && !nonInteractive) {
292
+ const createResponse = await fetch(`${registryUrl.replace(/\/$/, "")}/api/device-authorizations`, {
293
+ method: "POST",
294
+ headers: { "content-type": "application/json" },
295
+ body: JSON.stringify({
296
+ projectId: config.projectId,
297
+ projectName: config.projectName,
298
+ environment: config.environment,
299
+ storageProvider: config.storageProvider,
300
+ }),
301
+ });
302
+ const attempt = await createResponse.json().catch(() => null) as {
303
+ attemptId?: string;
304
+ code?: string;
305
+ pollSecret?: string;
306
+ expiresAt?: string;
307
+ error?: string;
308
+ } | null;
309
+ if (!createResponse.ok || !attempt?.attemptId || !attempt.code || !attempt.pollSecret) {
310
+ throw new Error(attempt?.error ?? "Unable to start Bugmole device registration");
311
+ }
312
+ const dashboardUrl =
313
+ process.env.BUGMOLE_DASHBOARD_URL?.trim() || "https://app.bugmole.com";
314
+ const registrationUrl =
315
+ `${dashboardUrl.replace(/\/$/, "")}/register?attemptId=${encodeURIComponent(attempt.attemptId)}&projectId=${encodeURIComponent(config.projectId)}${process.env.BUGMOLE_AEGIS_EMAIL?.trim() ? `&email=${encodeURIComponent(process.env.BUGMOLE_AEGIS_EMAIL.trim())}` : ""}`;
316
+ console.log(`Open ${registrationUrl} and enter code: ${attempt.code}`);
317
+ openBrowser(registrationUrl);
318
+ console.log("Waiting for Aegis registration...");
319
+ for (let poll = 0; poll < 300; poll++) {
320
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
321
+ const pollResponse = await fetch(`${registryUrl.replace(/\/$/, "")}/api/device-authorizations/poll`, {
322
+ method: "POST",
323
+ headers: { "content-type": "application/json" },
324
+ body: JSON.stringify({ attemptId: attempt.attemptId, pollSecret: attempt.pollSecret }),
325
+ });
326
+ const result = await pollResponse.json().catch(() => null) as {
327
+ status?: string;
328
+ token?: string;
329
+ email?: string;
330
+ alreadyInitialized?: boolean;
331
+ error?: string;
332
+ } | null;
333
+ if (result?.status === "pending") continue;
334
+ if (!pollResponse.ok) {
335
+ const detail = result?.error ?? result?.status ?? `HTTP ${pollResponse.status}`;
336
+ throw new Error(`Bugmole device registration failed: ${detail}`);
337
+ }
338
+ if (result?.alreadyInitialized) {
339
+ throw new Error("This Aegis account already has an API key; revoke it or use that key in .bugmole.env.");
340
+ }
341
+ if (result?.token) {
342
+ writeEnvValue(envFile, "BUGMOLE_REGISTRY_URL", registryUrl);
343
+ writeEnvValue(envFile, "BUGMOLE_API_KEY", result.token);
344
+ if (result.email) writeEnvValue(envFile, "BUGMOLE_AEGIS_EMAIL", JSON.stringify(result.email.trim()));
345
+ console.log(`Registered ${config.projectId}; saved the machine credential to ${envPath}.`);
346
+ break;
347
+ }
348
+ }
349
+ }
350
+ return { configPath, envPath, config };
351
+ } finally {
352
+ rl?.close();
353
+ }
354
+ }
@@ -0,0 +1,68 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+ import {
4
+ chunkPattern,
5
+ chunkPrefix,
6
+ clampIosChunkSeconds,
7
+ DEFAULT_CHUNK_SECONDS,
8
+ DEFAULT_MJPEG_URL,
9
+ ffmpegSegmentArgs,
10
+ mjpegUrl,
11
+ startIosScreenRecording,
12
+ } from "./ios-screen-record.js";
13
+
14
+ test("the names written and the names looked for cannot drift apart", () => {
15
+ // They did drift once: every chunk was recorded correctly and then reported
16
+ // as none, because the prefix was assembled separately from the pattern.
17
+ const base = "/runs/abc/run.mp4";
18
+ assert.equal(chunkPattern(base), "/runs/abc/run-%03d.mp4");
19
+ assert.equal(chunkPrefix(base), "run-");
20
+ assert.ok("run-001.mp4".startsWith(chunkPrefix(base)));
21
+ });
22
+
23
+ test("the chunk number goes before the extension, not after it", () => {
24
+ assert.equal(chunkPattern("/runs/run.mp4"), "/runs/run-%03d.mp4");
25
+ assert.equal(chunkPattern("/runs/run"), "/runs/run-%03d");
26
+ // A dot in a directory name is not an extension.
27
+ assert.equal(chunkPattern("/runs/v1.2/run"), "/runs/v1.2/run-%03d");
28
+ });
29
+
30
+ test("chunks are numbered from one, matching the Android recorder", () => {
31
+ const args = ffmpegSegmentArgs("http://x", "/o/run-%03d.mp4", 10, 10);
32
+ assert.equal(args[args.indexOf("-segment_start_number") + 1], "1");
33
+ });
34
+
35
+ test("MJPEG is stamped with wall-clock time, or every duration is wrong", () => {
36
+ // MJPEG carries no timestamps: ffmpeg otherwise assumes a nominal rate and a
37
+ // 22s recording asks for 550 frames from a source serving nine a second.
38
+ assert.ok(ffmpegSegmentArgs("http://x", "/o/r-%03d.mp4", 10, 10).includes("-use_wallclock_as_timestamps"));
39
+ });
40
+
41
+ test("keyframes are forced at the segment boundary", () => {
42
+ // A segment can only start on a keyframe. Without forcing them a chunk asked
43
+ // to be ten seconds ran fifteen.
44
+ const args = ffmpegSegmentArgs("http://x", "/o/r-%03d.mp4", 10, 10);
45
+ assert.match(args[args.indexOf("-force_key_frames") + 1], /n_forced\*10/);
46
+ assert.equal(args[args.indexOf("-segment_time") + 1], "10");
47
+ });
48
+
49
+ test("an absurd chunk length falls back rather than recording one huge file", () => {
50
+ assert.equal(clampIosChunkSeconds(undefined), DEFAULT_CHUNK_SECONDS);
51
+ assert.equal(clampIosChunkSeconds(0), DEFAULT_CHUNK_SECONDS);
52
+ assert.equal(clampIosChunkSeconds(-5), DEFAULT_CHUNK_SECONDS);
53
+ assert.equal(clampIosChunkSeconds(30), 30);
54
+ assert.equal(clampIosChunkSeconds(99_999), 3_600);
55
+ });
56
+
57
+ test("the MJPEG server is reached over the cable by default", () => {
58
+ assert.equal(mjpegUrl({}), DEFAULT_MJPEG_URL);
59
+ assert.match(DEFAULT_MJPEG_URL, /^http:\/\/127\.0\.0\.1:/);
60
+ assert.equal(mjpegUrl({ BUGMOLE_IOS_MJPEG_URL: "http://127.0.0.1:9200" }), "http://127.0.0.1:9200");
61
+ });
62
+
63
+ test("returns undefined instead of throwing when ffmpeg is unavailable", async () => {
64
+ const recording = await startIosScreenRecording("/tmp/bugmole-ios-rec-test/run.mp4", {
65
+ ffmpegCommand: "definitely-not-a-real-ffmpeg-binary",
66
+ });
67
+ assert.equal(recording, undefined);
68
+ });
@@ -0,0 +1,155 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+ import { dropIdleFrames } from "./screen-recording.js";
5
+
6
+ export type IosScreenRecording = {
7
+ /** Stops recording and returns every chunk, in order. */
8
+ stop(): Promise<string[]>;
9
+ };
10
+
11
+ export interface IosRecordingOptions {
12
+ /** Seconds per chunk. */
13
+ chunkSeconds?: number;
14
+ /** Drop frames where the screen did not change. */
15
+ skipIdle?: boolean;
16
+ /** WebDriverAgent's MJPEG server, reached over the usbmux forward. */
17
+ mjpegUrl?: string;
18
+ ffmpegCommand?: string;
19
+ /** Frames per second WDA is serving; used to place keyframes. */
20
+ fps?: number;
21
+ }
22
+
23
+ export const DEFAULT_MJPEG_URL = "http://127.0.0.1:9100";
24
+ /** WDA's mjpegServerFramerate defaults to 10, and measured ~8.8 in practice. */
25
+ export const DEFAULT_MJPEG_FPS = 10;
26
+ export const DEFAULT_CHUNK_SECONDS = 180;
27
+
28
+ export function clampIosChunkSeconds(value: number | undefined): number {
29
+ if (!Number.isFinite(value) || (value as number) <= 0) return DEFAULT_CHUNK_SECONDS;
30
+ // No screenrecord ceiling here — ffmpeg segments as long as asked — but an
31
+ // hour-long chunk defeats the point of chunking at all.
32
+ return Math.min(Math.floor(value as number), 3_600);
33
+ }
34
+
35
+ export function mjpegUrl(env: Record<string, string | undefined> = process.env): string {
36
+ return env.BUGMOLE_IOS_MJPEG_URL?.trim() || DEFAULT_MJPEG_URL;
37
+ }
38
+
39
+ /**
40
+ * Record WebDriverAgent's MJPEG stream, segmented as it goes.
41
+ *
42
+ * iOS has no screenrecord. pymobiledevice3 offers only stills, and the phone
43
+ * does not appear as an AVFoundation capture device here, so the usable source
44
+ * is WDA's own MJPEG server on port 9100 — the same runner already driving
45
+ * input, reached over the same usbmux forward.
46
+ *
47
+ * ffmpeg does the segmenting rather than a supervisor loop, so unlike the
48
+ * Android path there is no seam between chunks: one continuous input is cut at
49
+ * keyframe boundaries.
50
+ */
51
+ export function ffmpegSegmentArgs(
52
+ source: string,
53
+ outputPattern: string,
54
+ chunkSeconds: number,
55
+ fps: number,
56
+ ): string[] {
57
+ return [
58
+ "-hide_banner", "-loglevel", "error",
59
+ // MJPEG carries no timestamps. Without this ffmpeg assumes a nominal frame
60
+ // rate and every duration is wrong — a 22s recording asked for 550 frames
61
+ // at a source delivering nine a second.
62
+ "-use_wallclock_as_timestamps", "1",
63
+ "-f", "mjpeg", "-i", source,
64
+ "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
65
+ "-fps_mode", "vfr",
66
+ // Segments can only start on a keyframe. Without forcing them the cut
67
+ // lands wherever the encoder last put one, and a chunk asked to be ten
68
+ // seconds ran fifteen.
69
+ "-force_key_frames", `expr:gte(t,n_forced*${chunkSeconds})`,
70
+ "-g", String(Math.max(1, Math.round(fps * chunkSeconds))),
71
+ "-f", "segment",
72
+ "-segment_time", String(chunkSeconds),
73
+ "-reset_timestamps", "1",
74
+ // One-based, to match the Android recorder's run-001.mp4.
75
+ "-segment_start_number", "1",
76
+ outputPattern,
77
+ ];
78
+ }
79
+
80
+ /** `run.mp4` becomes the `run-%03d.mp4` pattern ffmpeg writes chunks through. */
81
+ export function chunkPattern(basePath: string): string {
82
+ const dot = basePath.lastIndexOf(".");
83
+ return dot <= basePath.lastIndexOf("/")
84
+ ? `${basePath}-%03d`
85
+ : `${basePath.slice(0, dot)}-%03d${basePath.slice(dot)}`;
86
+ }
87
+
88
+ /**
89
+ * The literal part of the chunk filenames, up to ffmpeg's number placeholder.
90
+ *
91
+ * Taken from the pattern rather than assembled separately, so the names
92
+ * written and the names looked for cannot drift apart — they already did once,
93
+ * and every chunk was recorded correctly and then reported as none.
94
+ */
95
+ export function chunkPrefix(basePath: string): string {
96
+ const pattern = path.basename(chunkPattern(basePath));
97
+ return pattern.slice(0, pattern.indexOf("%"));
98
+ }
99
+
100
+ export async function startIosScreenRecording(
101
+ localBasePath: string,
102
+ options: IosRecordingOptions = {},
103
+ ): Promise<IosScreenRecording | undefined> {
104
+ const chunkSeconds = clampIosChunkSeconds(options.chunkSeconds);
105
+ const ffmpegCommand = options.ffmpegCommand ?? "ffmpeg";
106
+ const source = options.mjpegUrl ?? mjpegUrl();
107
+ const pattern = chunkPattern(localBasePath);
108
+ await fs.mkdir(path.dirname(localBasePath), { recursive: true }).catch(() => undefined);
109
+
110
+ let spawnFailed = false;
111
+ const child: ChildProcess = spawn(
112
+ ffmpegCommand,
113
+ ffmpegSegmentArgs(source, pattern, chunkSeconds, options.fps ?? DEFAULT_MJPEG_FPS),
114
+ { stdio: "ignore" },
115
+ );
116
+ child.once("error", () => { spawnFailed = true; });
117
+ // Long enough to surface a missing ffmpeg or an unreachable MJPEG server,
118
+ // which both fail immediately rather than midway.
119
+ await new Promise((resolve) => setTimeout(resolve, 800));
120
+ if (spawnFailed || child.exitCode !== null) return undefined;
121
+
122
+ return {
123
+ async stop(): Promise<string[]> {
124
+ // SIGINT, not SIGKILL: ffmpeg writes the trailer of the segment it is
125
+ // holding on interrupt. Killing it outright leaves the last chunk with
126
+ // no moov atom, which is the same way the Android path lost the end of
127
+ // every run.
128
+ try {
129
+ child.kill("SIGINT");
130
+ await new Promise<void>((resolve) => {
131
+ child.once("exit", () => resolve());
132
+ setTimeout(resolve, 15_000);
133
+ });
134
+ } catch {
135
+ // Already gone; whatever it finished writing still counts.
136
+ }
137
+ const dir = path.dirname(localBasePath);
138
+ const entries = await fs.readdir(dir).catch(() => [] as string[]);
139
+ const chunks = entries
140
+ .filter((name) => name.startsWith(chunkPrefix(localBasePath)) && /-\d{3}\.mp4$/.test(name))
141
+ .sort()
142
+ .map((name) => path.join(dir, name));
143
+ const kept: string[] = [];
144
+ for (const chunk of chunks) {
145
+ const stats = await fs.stat(chunk).catch(() => undefined);
146
+ if (!stats || stats.size === 0) {
147
+ await fs.rm(chunk, { force: true }).catch(() => undefined);
148
+ continue;
149
+ }
150
+ kept.push(options.skipIdle ? await dropIdleFrames(chunk, ffmpegCommand) : chunk);
151
+ }
152
+ return kept;
153
+ },
154
+ };
155
+ }