@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,192 @@
1
+ import fs from "node:fs/promises";
2
+ import { execFile, spawn, type ChildProcess } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+
5
+ import { dropIdleFrames, mpdecimateArgs } from "./screen-recording.js";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ // Re-exported: both recorders post-process the same way, and this is where
10
+ // callers and tests already look for it.
11
+ export { mpdecimateArgs };
12
+
13
+ export type AndroidScreenRecording = {
14
+ /**
15
+ * Stops recording and pulls every chunk, in order.
16
+ *
17
+ * Returns a list rather than one path: a run longer than a single chunk is
18
+ * several files, and the caller attaches each as its own artifact.
19
+ */
20
+ stop(localBasePath?: string): Promise<string[]>;
21
+ };
22
+
23
+ export interface ScreenRecordingOptions {
24
+ /** Seconds per chunk. screenrecord refuses to run longer than 180. */
25
+ chunkSeconds?: number;
26
+ /**
27
+ * Drop frames where the screen did not change, so a run that spends its
28
+ * time waiting does not produce minutes of identical video. Needs ffmpeg;
29
+ * without it the recording is kept whole rather than lost.
30
+ */
31
+ skipIdle?: boolean;
32
+ ffmpegCommand?: string;
33
+ }
34
+
35
+ /** screenrecord's own ceiling. Asking for more is refused outright. */
36
+ export const MAX_CHUNK_SECONDS = 180;
37
+
38
+ export function clampChunkSeconds(value: number | undefined): number {
39
+ if (!Number.isFinite(value) || (value as number) <= 0) return MAX_CHUNK_SECONDS;
40
+ return Math.min(Math.floor(value as number), MAX_CHUNK_SECONDS);
41
+ }
42
+
43
+ export function deviceChunkPath(index: number): string {
44
+ return `/sdcard/bugmole-run-${String(index).padStart(3, "0")}.mp4`;
45
+ }
46
+
47
+ /** `run.mp4` becomes `run-001.mp4`, `run-002.mp4`, … so the ordering is
48
+ * obvious in a directory listing and survives sorting. */
49
+ export function localChunkPath(basePath: string, index: number): string {
50
+ const suffix = `-${String(index).padStart(3, "0")}`;
51
+ const dot = basePath.lastIndexOf(".");
52
+ return dot <= basePath.lastIndexOf("/")
53
+ ? `${basePath}${suffix}`
54
+ : `${basePath.slice(0, dot)}${suffix}${basePath.slice(dot)}`;
55
+ }
56
+
57
+ /** Size in bytes from `stat -c %s`, or null when the file is not there yet. */
58
+ export function parseDeviceFileSize(output: string): number | null {
59
+ // Digits only. Number("") is 0, so a stat that answered nothing would read
60
+ // as a real zero-byte file and count towards the size having settled.
61
+ const token = output.trim().split(/\s+/)[0] ?? "";
62
+ if (!/^\d+$/.test(token)) return null;
63
+ const size = Number(token);
64
+ return Number.isSafeInteger(size) ? size : null;
65
+ }
66
+
67
+ export function screenrecordArgs(serial: string, devicePath: string, chunkSeconds: number): string[] {
68
+ return ["-s", serial, "shell", "screenrecord", `--time-limit=${chunkSeconds}`, devicePath];
69
+ }
70
+
71
+ /**
72
+ * Wraps `adb shell screenrecord` around a real device run so the actual test
73
+ * execution is captured, not a second, separately-timed replay.
74
+ *
75
+ * screenrecord stops itself at three minutes, which used to mean a long run
76
+ * was silently truncated to its first three minutes. It is now recorded as
77
+ * consecutive chunks instead, started back to back until the run ends. The
78
+ * seam between two chunks loses the moment it takes the device to stop one
79
+ * recording and begin the next, which is a better trade than losing everything
80
+ * after minute three.
81
+ */
82
+ export async function startAndroidScreenRecording(
83
+ serial: string,
84
+ adbCommand = "adb",
85
+ options: ScreenRecordingOptions = {},
86
+ ): Promise<AndroidScreenRecording | undefined> {
87
+ const chunkSeconds = clampChunkSeconds(options.chunkSeconds);
88
+ const ffmpegCommand = options.ffmpegCommand ?? "ffmpeg";
89
+ const devicePaths: string[] = [];
90
+ let stopped = false;
91
+ let current: ChildProcess | null = null;
92
+ let spawnFailed = false;
93
+
94
+ const startChunk = (): ChildProcess => {
95
+ const devicePath = deviceChunkPath(devicePaths.length + 1);
96
+ devicePaths.push(devicePath);
97
+ const child = spawn(adbCommand, screenrecordArgs(serial, devicePath, chunkSeconds), { stdio: "ignore" });
98
+ // spawn() does not throw synchronously for a missing executable; the
99
+ // failure surfaces as an async 'error' event instead.
100
+ child.once("error", () => { spawnFailed = true; });
101
+ return child;
102
+ };
103
+
104
+ current = startChunk();
105
+ // Give screenrecord a moment to actually start (and to surface a spawn
106
+ // error) before the caller proceeds; a successful spawn does not guarantee
107
+ // the device accepted the command yet.
108
+ await new Promise((resolve) => setTimeout(resolve, 500));
109
+ if (spawnFailed) return undefined;
110
+
111
+ // Each chunk ends on its own at the time limit. Start the next immediately
112
+ // so the run keeps being recorded until someone stops it.
113
+ const chain = (child: ChildProcess): void => {
114
+ child.once("exit", () => {
115
+ if (stopped || spawnFailed) return;
116
+ current = startChunk();
117
+ chain(current);
118
+ });
119
+ };
120
+ chain(current);
121
+
122
+ return {
123
+ async stop(localBasePath?: string): Promise<string[]> {
124
+ stopped = true;
125
+ try {
126
+ current?.kill("SIGINT");
127
+ await new Promise<void>((resolve) => {
128
+ current?.once("exit", () => resolve());
129
+ setTimeout(resolve, 5_000);
130
+ });
131
+ } catch {
132
+ // Already gone; the pull below is what actually matters.
133
+ }
134
+ // The local adb client exiting says nothing about the device having
135
+ // finished the file. screenrecord still has to write the mp4 trailer,
136
+ // and pulling before it does yields a file with no moov atom, which no
137
+ // player will open. This only ever bit the chunk that was interrupted —
138
+ // the last one, which is the part of a run worth watching.
139
+ if (devicePaths.length > 0) {
140
+ await waitForStableSize(adbCommand, serial, devicePaths[devicePaths.length - 1]);
141
+ }
142
+ const base = localBasePath ?? "bugmole-run.mp4";
143
+ const pulled: string[] = [];
144
+ for (const [offset, devicePath] of devicePaths.entries()) {
145
+ const localPath = localChunkPath(base, offset + 1);
146
+ try {
147
+ await execFileAsync(adbCommand, ["-s", serial, "pull", devicePath, localPath], { timeout: 60_000 });
148
+ await execFileAsync(adbCommand, ["-s", serial, "shell", "rm", "-f", devicePath]).catch(() => undefined);
149
+ const stats = await fs.stat(localPath).catch(() => undefined);
150
+ // The chunk that was running when stop() arrived can be empty, and a
151
+ // zero-byte video is worse than no video.
152
+ if (!stats || stats.size === 0) {
153
+ await fs.rm(localPath, { force: true }).catch(() => undefined);
154
+ continue;
155
+ }
156
+ pulled.push(options.skipIdle ? await dropIdleFrames(localPath, ffmpegCommand) : localPath);
157
+ } catch {
158
+ // One unreadable chunk should not cost the rest of the recording.
159
+ }
160
+ }
161
+ return pulled;
162
+ },
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Waits until the device stops writing, by watching the size settle.
168
+ *
169
+ * Bounded rather than open-ended: a recording that never settles should delay
170
+ * the run's artifacts, not hold it forever.
171
+ */
172
+ async function waitForStableSize(
173
+ adbCommand: string,
174
+ serial: string,
175
+ devicePath: string,
176
+ timeoutMs = 15_000,
177
+ ): Promise<void> {
178
+ const deadline = Date.now() + timeoutMs;
179
+ let previous: number | null = null;
180
+ while (Date.now() < deadline) {
181
+ let size: number | null = null;
182
+ try {
183
+ const { stdout } = await execFileAsync(adbCommand, ["-s", serial, "shell", "stat", "-c", "%s", devicePath], { timeout: 10_000 });
184
+ size = parseDeviceFileSize(String(stdout));
185
+ } catch {
186
+ // The file may not exist yet, which is itself "not settled".
187
+ }
188
+ if (size !== null && size > 0 && size === previous) return;
189
+ previous = size;
190
+ await new Promise((resolve) => setTimeout(resolve, 400));
191
+ }
192
+ }
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import {
7
+ readApplicationIdentity,
8
+ readDeclaredFlows,
9
+ reconcileDeclaredFlows,
10
+ type DeclaredFlow,
11
+ } from "./app-understanding.js";
12
+
13
+ async function tempProject(files: Record<string, string>): Promise<string> {
14
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-understanding-"));
15
+ for (const [relative, content] of Object.entries(files)) {
16
+ const target = path.join(root, relative);
17
+ await fs.mkdir(path.dirname(target), { recursive: true });
18
+ await fs.writeFile(target, content);
19
+ }
20
+ return root;
21
+ }
22
+
23
+ test("reads the project's own description rather than inferring one", async () => {
24
+ const root = await tempProject({
25
+ "package.json": JSON.stringify({ name: "acme-shop", description: "Storefront for Acme." }),
26
+ });
27
+ try {
28
+ const identity = readApplicationIdentity(root);
29
+ assert.equal(identity.name, "acme-shop");
30
+ assert.equal(identity.description, "Storefront for Acme.");
31
+ assert.deepEqual(identity.sources, ["package.json"]);
32
+ } finally {
33
+ await fs.rm(root, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test("falls back to the README's first prose paragraph, skipping headings and badges", async () => {
38
+ const root = await tempProject({
39
+ "package.json": JSON.stringify({ name: "acme-shop" }),
40
+ "README.md": [
41
+ "# Acme Shop",
42
+ "",
43
+ "[![badge](https://img.shields.io/x)](https://example.com)",
44
+ "",
45
+ "Acme Shop lets customers browse and order parts.",
46
+ "",
47
+ "## Install",
48
+ ].join("\n"),
49
+ });
50
+ try {
51
+ const identity = readApplicationIdentity(root);
52
+ assert.equal(identity.description, "Acme Shop lets customers browse and order parts.");
53
+ assert.ok(identity.sources.includes("README.md"));
54
+ } finally {
55
+ await fs.rm(root, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test("a project that says nothing about itself gets no invented description", async () => {
60
+ const root = await tempProject({ "package.json": JSON.stringify({ name: "quiet" }) });
61
+ try {
62
+ const identity = readApplicationIdentity(root);
63
+ assert.equal(identity.description, undefined);
64
+ } finally {
65
+ await fs.rm(root, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("reads declared journeys, including routes named only by an action target", async () => {
70
+ const root = await tempProject({
71
+ "spec/journey_graph.yaml": [
72
+ "journeys:",
73
+ ' - id: "marketing-entry"',
74
+ ' name: "Marketing Entry"',
75
+ ' description: "Customer lands and starts booking."',
76
+ " screens:",
77
+ ' - path: "/"',
78
+ " actions:",
79
+ ' - type: "navigate"',
80
+ ' target: "/book"',
81
+ ].join("\n"),
82
+ });
83
+ try {
84
+ const flows = readDeclaredFlows(path.join(root, "spec"), root);
85
+ assert.equal(flows.length, 1);
86
+ assert.equal(flows[0].name, "Marketing Entry");
87
+ assert.deepEqual(flows[0].routes, ["/", "/book"]);
88
+ } finally {
89
+ await fs.rm(root, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ const arionFlow: DeclaredFlow = {
94
+ id: "public-booking",
95
+ name: "Public Booking",
96
+ routes: ["/book", "/book/[vehicleId]"],
97
+ source: "spec/journey_graph.yaml",
98
+ };
99
+
100
+ test("a declared flow whose screens exist is adopted", () => {
101
+ const result = reconcileDeclaredFlows([arionFlow], ["/book", "/book/:vehicleId", "/"]);
102
+ assert.equal(result.adopted.length, 1);
103
+ assert.equal(result.stale.length, 0);
104
+ });
105
+
106
+ test("a spec that outlived its product is reported stale, never injected", () => {
107
+ // Exactly this repo's situation: journey_graph.yaml describes a rental
108
+ // app while the project being explored is the dashboard. Injecting it
109
+ // would invent flows over screens that do not exist.
110
+ const result = reconcileDeclaredFlows([arionFlow], ["/", "/plans", "/tasks", "/audits"]);
111
+ assert.equal(result.adopted.length, 0);
112
+ assert.equal(result.stale.length, 1);
113
+ assert.equal(result.stale[0].id, "public-booking");
114
+ assert.deepEqual(result.stale[0].missingRoutes, ["/book", "/book/[vehicleId]"]);
115
+ });
116
+
117
+ test("parameterised routes match whatever syntax each side used", () => {
118
+ const result = reconcileDeclaredFlows(
119
+ [{ id: "f", name: "F", routes: ["/book/[vehicleId]", "/manage/[resource]/[[...action]]"], source: "s" }],
120
+ ["/book/:vehicleId", "/manage/:resource/:[...action]"],
121
+ );
122
+ assert.equal(result.adopted.length, 1, "differing param syntax must not be treated as a missing screen");
123
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * What the project says about itself, read before anything is crawled.
3
+ *
4
+ * Exploration used to begin by pattern-matching link literals in source,
5
+ * which meant it never learned what the application is *for* — only what
6
+ * happened to be wired with a hardcoded href. On this dashboard the sole
7
+ * literals live in the account dropdown, so the whole world model was
8
+ * rebuilt from a secondary menu and every discovered flow started at a
9
+ * settings page.
10
+ *
11
+ * Documentation states intent and priority; a crawl can only ever show
12
+ * structure. So identity and declared flows are read first, and the crawl
13
+ * confirms them afterwards.
14
+ *
15
+ * Declared intent is reconciled against reality rather than trusted: a
16
+ * spec can outlive the product it describes (this repo ships a
17
+ * journey_graph.yaml for a different application entirely), and adopting
18
+ * it blindly would inject flows for screens that do not exist.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import YAML from "yaml";
23
+
24
+ export type ApplicationIdentity = {
25
+ name?: string;
26
+ description?: string;
27
+ /** Where each field came from, so nothing reads as asserted by Bugmole. */
28
+ sources: string[];
29
+ };
30
+
31
+ export type DeclaredFlow = {
32
+ id: string;
33
+ name: string;
34
+ description?: string;
35
+ /** Route paths the flow claims to visit, in order. */
36
+ routes: string[];
37
+ source: string;
38
+ };
39
+
40
+ export type DeclaredFlowReconciliation = {
41
+ adopted: DeclaredFlow[];
42
+ /** Declared flows whose screens do not exist in this app, with why. */
43
+ stale: Array<{ id: string; name: string; source: string; missingRoutes: string[] }>;
44
+ };
45
+
46
+ export type AppUnderstanding = {
47
+ application: ApplicationIdentity;
48
+ declaredFlows: DeclaredFlowReconciliation;
49
+ };
50
+
51
+ function readFileIfPresent(filePath: string): string | null {
52
+ try {
53
+ return fs.readFileSync(filePath, "utf8");
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The project's own words about itself. Only what the repo actually
61
+ * states — an absent description stays absent rather than being inferred
62
+ * from the directory name.
63
+ */
64
+ export function readApplicationIdentity(projectRoot: string): ApplicationIdentity {
65
+ const sources: string[] = [];
66
+ let name: string | undefined;
67
+ let description: string | undefined;
68
+
69
+ const packageJson = readFileIfPresent(path.join(projectRoot, "package.json"));
70
+ if (packageJson) {
71
+ try {
72
+ const parsed = JSON.parse(packageJson) as { name?: unknown; description?: unknown };
73
+ if (typeof parsed.name === "string" && parsed.name.trim()) name = parsed.name.trim();
74
+ if (typeof parsed.description === "string" && parsed.description.trim()) {
75
+ description = parsed.description.trim();
76
+ }
77
+ if (name || description) sources.push("package.json");
78
+ } catch {
79
+ // A malformed package.json must not stop exploration.
80
+ }
81
+ }
82
+
83
+ if (!description) {
84
+ for (const candidate of ["README.md", "readme.md", "README.MD"]) {
85
+ const readme = readFileIfPresent(path.join(projectRoot, candidate));
86
+ if (!readme) continue;
87
+ // First prose paragraph: skip headings, badges, code fences and lists.
88
+ const paragraph = readme
89
+ .split(/\r?\n\s*\r?\n/)
90
+ .map((block) => block.trim())
91
+ .find((block) =>
92
+ block.length > 0
93
+ && !block.startsWith("#")
94
+ && !block.startsWith("```")
95
+ && !block.startsWith("[!")
96
+ && !block.startsWith("-")
97
+ && !block.startsWith("*")
98
+ && !block.startsWith("|"));
99
+ if (paragraph) {
100
+ description = paragraph.replace(/\s+/g, " ").slice(0, 600);
101
+ sources.push(candidate);
102
+ }
103
+ break;
104
+ }
105
+ }
106
+
107
+ return { name, description, sources };
108
+ }
109
+
110
+ function normalizeDeclaredRoute(value: string): string {
111
+ const raw = value.split(/[?#]/, 1)[0]?.trim() ?? "";
112
+ if (!raw) return "";
113
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
114
+ return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : "/";
115
+ }
116
+
117
+ /** Treat `[id]`, `:id` and `[...rest]` as equivalent when matching. */
118
+ function routeShape(route: string): string {
119
+ return normalizeDeclaredRoute(route)
120
+ .replace(/\[\[?\.\.\.[^\]]+\]?\]/g, "*")
121
+ .replace(/\[[^\]]+\]/g, "*")
122
+ .replace(/:[^/]+/g, "*")
123
+ .toLowerCase();
124
+ }
125
+
126
+ /** Declared journeys from spec/journey_graph.yaml, if one is present. */
127
+ export function readDeclaredFlows(specDir: string, projectRoot: string): DeclaredFlow[] {
128
+ const candidates = [
129
+ path.join(specDir, "journey_graph.yaml"),
130
+ path.join(projectRoot, "spec", "journey_graph.yaml"),
131
+ ];
132
+ for (const candidate of candidates) {
133
+ const content = readFileIfPresent(candidate);
134
+ if (!content) continue;
135
+ let doc: any;
136
+ try {
137
+ doc = YAML.parse(content);
138
+ } catch {
139
+ return [];
140
+ }
141
+ if (!Array.isArray(doc?.journeys)) return [];
142
+ return doc.journeys.flatMap((journey: any): DeclaredFlow[] => {
143
+ if (!journey || typeof journey.id !== "string") return [];
144
+ const screenRoutes = Array.isArray(journey.screens)
145
+ ? journey.screens
146
+ .map((screen: any) => (typeof screen?.path === "string" ? normalizeDeclaredRoute(screen.path) : ""))
147
+ .filter(Boolean)
148
+ : [];
149
+ const actionRoutes = Array.isArray(journey.actions)
150
+ ? journey.actions
151
+ .map((action: any) => (typeof action?.target === "string" ? normalizeDeclaredRoute(action.target) : ""))
152
+ .filter(Boolean)
153
+ : [];
154
+ const routes = [...new Set([...screenRoutes, ...actionRoutes])];
155
+ if (routes.length === 0) return [];
156
+ return [{
157
+ id: journey.id,
158
+ name: typeof journey.name === "string" && journey.name.trim() ? journey.name.trim() : journey.id,
159
+ description: typeof journey.description === "string" ? journey.description : undefined,
160
+ routes,
161
+ source: path.relative(projectRoot, candidate) || candidate,
162
+ }];
163
+ });
164
+ }
165
+ return [];
166
+ }
167
+
168
+ /**
169
+ * Keep only declared flows whose screens actually exist in this app.
170
+ *
171
+ * A spec outliving its product is the normal case, not an edge case: this
172
+ * repo's journey_graph.yaml describes a rental application, so adopting it
173
+ * here would invent flows over `/book` and `/owner` — screens that do not
174
+ * exist. Non-matching flows are reported as stale so the mismatch is
175
+ * visible, rather than silently injected or silently dropped.
176
+ */
177
+ export function reconcileDeclaredFlows(
178
+ declared: DeclaredFlow[],
179
+ discoveredRoutes: string[],
180
+ ): DeclaredFlowReconciliation {
181
+ const known = new Set(discoveredRoutes.map(routeShape));
182
+ const adopted: DeclaredFlow[] = [];
183
+ const stale: DeclaredFlowReconciliation["stale"] = [];
184
+ for (const flow of declared) {
185
+ const missingRoutes = flow.routes.filter((route) => !known.has(routeShape(route)));
186
+ if (missingRoutes.length === 0) adopted.push(flow);
187
+ else stale.push({ id: flow.id, name: flow.name, source: flow.source, missingRoutes });
188
+ }
189
+ return { adopted, stale };
190
+ }
191
+
192
+ export function readAppUnderstanding(
193
+ projectRoot: string,
194
+ specDir: string,
195
+ discoveredRoutes: string[],
196
+ ): AppUnderstanding {
197
+ return {
198
+ application: readApplicationIdentity(projectRoot),
199
+ declaredFlows: reconcileDeclaredFlows(readDeclaredFlows(specDir, projectRoot), discoveredRoutes),
200
+ };
201
+ }