@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,405 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ derivePipelineState,
5
+ pipelineIdempotencyKey,
6
+ plannedJourneyIds,
7
+ type PipelineInputs,
8
+ } from "./pipeline.js";
9
+
10
+ const canonicalJourney = {
11
+ id: "flow:login",
12
+ actor: "user",
13
+ revision: 2,
14
+ nodeIds: ["screen:/", "screen:/login"],
15
+ stability: "stable",
16
+ linkedToGraph: true,
17
+ };
18
+
19
+ function inputs(overrides: Partial<PipelineInputs> = {}): PipelineInputs {
20
+ return {
21
+ projectId: "bugmole",
22
+ journeys: [canonicalJourney],
23
+ knownActors: ["user"],
24
+ caseSetJourneyIds: [],
25
+ plannedJourneyIds: [],
26
+ tasks: [],
27
+ hardStopCategories: [],
28
+ recordedBlockerCategories: [],
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ test("with nothing discovered yet, the first thing to do is explore", () => {
34
+ const state = derivePipelineState(inputs({ journeys: [] }));
35
+ assert.equal(state.status, "running");
36
+ assert.equal(state.next?.stage, "explore");
37
+ assert.equal(state.next?.idempotencyKey, pipelineIdempotencyKey("bugmole", "explore"));
38
+ });
39
+
40
+ test("after exploration it moves on by itself — this is the stall being fixed", () => {
41
+ const state = derivePipelineState(inputs());
42
+ assert.equal(state.next?.stage, "audit");
43
+ assert.equal(state.next?.journeyId, "flow:login");
44
+ });
45
+
46
+ test("the audit task is what wires up the Storybook scan that populates the component library", () => {
47
+ const state = derivePipelineState(inputs());
48
+ assert.equal(state.next?.stage, "audit");
49
+ assert.match(state.next!.description, /bugmole_ui_re_start/);
50
+ assert.match(state.next!.description, /bugmole_ui_re_stage/);
51
+ assert.match(state.next!.description, /atoms/);
52
+ // It must not invent a catalogue when the project has no Storybook.
53
+ assert.match(state.next!.description, /blocked reason/i);
54
+ });
55
+
56
+ test("test cases only come after the audit, so a case is written against known components", () => {
57
+ const state = derivePipelineState(inputs({ auditedJourneyIds: ["flow:login"] }));
58
+ assert.equal(state.next?.stage, "testcases");
59
+ });
60
+
61
+ test("an actor missing from roles.yaml is reconciled instead of dead-ending in the planner", () => {
62
+ const state = derivePipelineState(inputs({ knownActors: ["owner"] }));
63
+ assert.equal(state.next?.stage, "roles");
64
+ assert.match(state.next!.description, /user/);
65
+ });
66
+
67
+ test("once cases exist the flow gets planned", () => {
68
+ const state = derivePipelineState(inputs({
69
+ auditedJourneyIds: ["flow:login"],
70
+ caseSetJourneyIds: ["flow:login"],
71
+ }));
72
+ assert.equal(state.next?.stage, "plan");
73
+ });
74
+
75
+ test("a fully satisfied project is complete and queues nothing further", () => {
76
+ const state = derivePipelineState(inputs({
77
+ auditedJourneyIds: ["flow:login"],
78
+ caseSetJourneyIds: ["flow:login"],
79
+ plannedJourneyIds: ["flow:login"],
80
+ publishedPlans: [{ planId: "flow_login", journeyId: "flow:login" }],
81
+ ranJourneyIds: ["flow:login"],
82
+ verdictJourneyIds: ["flow:login"],
83
+ }));
84
+ assert.equal(state.status, "complete");
85
+ assert.equal(state.next, undefined);
86
+ assert.equal(state.nextRun, undefined);
87
+ });
88
+
89
+ test("only one stage is queued at a time, so stages cannot race", () => {
90
+ const state = derivePipelineState(inputs({
91
+ tasks: [{ status: "running", idempotencyKey: pipelineIdempotencyKey("bugmole", "explore") }],
92
+ }));
93
+ assert.equal(state.status, "running");
94
+ assert.equal(state.next, undefined);
95
+ });
96
+
97
+ test("cancelling stops the whole pipeline, not just the one task", () => {
98
+ const state = derivePipelineState(inputs({
99
+ tasks: [{
100
+ status: "error",
101
+ idempotencyKey: pipelineIdempotencyKey("bugmole", "explore"),
102
+ completionSummary: "Cancelled",
103
+ }],
104
+ }));
105
+ assert.equal(state.status, "stopped");
106
+ assert.equal(state.next, undefined);
107
+ assert.match(state.reason, /cancelled/i);
108
+ });
109
+
110
+ test("an unrelated cancelled task does not stop the pipeline", () => {
111
+ const state = derivePipelineState(inputs({
112
+ tasks: [{ status: "error", idempotencyKey: "manual-thing", completionSummary: "Cancelled" }],
113
+ }));
114
+ assert.equal(state.status, "running");
115
+ assert.equal(state.next?.stage, "audit");
116
+ });
117
+
118
+ test("a recorded hard-stop blocker terminates the run with a visible reason", () => {
119
+ const state = derivePipelineState(inputs({
120
+ hardStopCategories: ["secret_value_requested"],
121
+ recordedBlockerCategories: ["secret_value_requested"],
122
+ }));
123
+ assert.equal(state.status, "stopped");
124
+ assert.match(state.reason, /secret_value_requested/);
125
+ });
126
+
127
+ test("a soft blocker does not stop the pipeline", () => {
128
+ const state = derivePipelineState(inputs({
129
+ hardStopCategories: ["secret_value_requested"],
130
+ recordedBlockerCategories: ["supabase_schema_missing"],
131
+ }));
132
+ assert.equal(state.status, "running");
133
+ });
134
+
135
+ test("an unanswered clarification pauses rather than guessing", () => {
136
+ const state = derivePipelineState(inputs({
137
+ pendingClarification: "Which account should the owner flow use?",
138
+ }));
139
+ assert.equal(state.status, "paused");
140
+ assert.equal(state.next, undefined);
141
+ assert.match(state.reason, /Which account/);
142
+ });
143
+
144
+ test("a discovered-but-unstabilised flow is not treated as ready to plan", () => {
145
+ const state = derivePipelineState(inputs({
146
+ journeys: [{ ...canonicalJourney, stability: "discovered", linkedToGraph: false }],
147
+ }));
148
+ // Explore is satisfied (the flow has nodes) but nothing downstream acts
149
+ // on a flow the graph does not consider canonical yet.
150
+ assert.notEqual(state.next?.stage, "plan");
151
+ });
152
+
153
+ test("stage keys are stable, so the agent's own enqueue and the reconciler converge", () => {
154
+ const a = derivePipelineState(inputs()).next!.idempotencyKey;
155
+ const b = derivePipelineState(inputs()).next!.idempotencyKey;
156
+ assert.equal(a, b);
157
+ assert.equal(a, pipelineIdempotencyKey("bugmole", "audit", "flow:login"));
158
+ });
159
+
160
+ test("a planned flow gets run — without this every case stays an unproven assumption", () => {
161
+ const state = derivePipelineState(inputs({
162
+ auditedJourneyIds: ["flow:login"],
163
+ caseSetJourneyIds: ["flow:login"],
164
+ plannedJourneyIds: ["flow:login"],
165
+ publishedPlans: [{ planId: "flow_login", journeyId: "flow:login" }],
166
+ environmentId: "local",
167
+ }));
168
+ assert.equal(state.status, "running");
169
+ assert.equal(state.nextRun?.stage, "run");
170
+ assert.equal(state.nextRun?.planId, "flow_login");
171
+ assert.equal(state.nextRun?.environmentId, "local");
172
+ });
173
+
174
+ test("a project is only complete once its plans have run AND judged their cases", () => {
175
+ const base = {
176
+ auditedJourneyIds: ["flow:login"],
177
+ caseSetJourneyIds: ["flow:login"],
178
+ plannedJourneyIds: ["flow:login"],
179
+ publishedPlans: [{ planId: "flow_login", journeyId: "flow:login" }],
180
+ };
181
+ assert.notEqual(derivePipelineState(inputs(base)).status, "complete");
182
+ // A run artifact on its own is not enough. This is the exact shape the
183
+ // real project was in: 20/20 flows "executed" while all 118 cases still
184
+ // had no status, and the pipeline called itself finished.
185
+ const ranOnly = derivePipelineState(inputs({ ...base, ranJourneyIds: ["flow:login"] }));
186
+ assert.notEqual(ranOnly.status, "complete", "running without recording verdicts is not coverage");
187
+ const done = derivePipelineState(inputs({
188
+ ...base,
189
+ ranJourneyIds: ["flow:login"],
190
+ verdictJourneyIds: ["flow:login"],
191
+ }));
192
+ assert.equal(done.status, "complete");
193
+ });
194
+
195
+ test("a flow that ran but judged nothing is queued to run again, not left as covered", () => {
196
+ const state = derivePipelineState(inputs({
197
+ auditedJourneyIds: ["flow:login"],
198
+ caseSetJourneyIds: ["flow:login"],
199
+ plannedJourneyIds: ["flow:login"],
200
+ publishedPlans: [{ planId: "flow_login", journeyId: "flow:login" }],
201
+ ranJourneyIds: ["flow:login"],
202
+ }));
203
+ assert.equal(state.status, "running");
204
+ assert.equal(state.nextRun?.stage, "run");
205
+ assert.equal(state.nextRun?.journeyId, "flow:login");
206
+ });
207
+
208
+ test("the run stage says how many flows ran but recorded nothing", () => {
209
+ const state = derivePipelineState(inputs({
210
+ auditedJourneyIds: ["flow:login"],
211
+ caseSetJourneyIds: ["flow:login"],
212
+ plannedJourneyIds: ["flow:login"],
213
+ publishedPlans: [{ planId: "flow_login", journeyId: "flow:login" }],
214
+ ranJourneyIds: ["flow:login"],
215
+ }));
216
+ const run = state.stages.find((stage) => stage.stage === "run")!;
217
+ assert.equal(run.satisfied, false);
218
+ assert.match(run.detail, /ran but recorded no verdicts/);
219
+ });
220
+
221
+ test("a judged flow with no registry run record does not report a negative count", () => {
222
+ // verdicts can be recorded by something other than a registry run, so
223
+ // ran minus judged went negative and printed "-1 recorded no verdicts".
224
+ const state = derivePipelineState(inputs({
225
+ auditedJourneyIds: ["flow:login"],
226
+ caseSetJourneyIds: ["flow:login"],
227
+ plannedJourneyIds: ["flow:login"],
228
+ publishedPlans: [{ planId: "flow_login", journeyId: "flow:login" }],
229
+ ranJourneyIds: [],
230
+ verdictJourneyIds: ["flow:login"],
231
+ }));
232
+ const run = state.stages.find((stage) => stage.stage === "run")!;
233
+ assert.equal(run.satisfied, true);
234
+ assert.doesNotMatch(run.detail, /-\d/, run.detail);
235
+ });
236
+
237
+ test("a plan that was never published pauses with a reason instead of idling silently", () => {
238
+ const state = derivePipelineState(inputs({
239
+ auditedJourneyIds: ["flow:login"],
240
+ caseSetJourneyIds: ["flow:login"],
241
+ plannedJourneyIds: ["flow:login"],
242
+ publishedPlans: [],
243
+ }));
244
+ assert.equal(state.status, "paused");
245
+ assert.match(state.reason, /no published plan/);
246
+ });
247
+
248
+ test("artifacts belonging to deleted flows do not count as progress", () => {
249
+ // Regenerating the graph renames journeys, orphaning everything written
250
+ // against the old ids. Counting those raw reported "4/7 flows have test
251
+ // cases" when no live flow had any, and marked planning satisfied while
252
+ // nothing was planned — so the stage was skipped entirely.
253
+ const state = derivePipelineState(inputs({
254
+ caseSetJourneyIds: ["flow:deleted-a", "flow:deleted-b"],
255
+ auditedJourneyIds: ["flow:deleted-a"],
256
+ plannedJourneyIds: ["flow:deleted-a"],
257
+ }));
258
+ const byStage = new Map(state.stages.map((stage) => [stage.stage, stage]));
259
+ assert.equal(byStage.get("testcases")!.satisfied, false);
260
+ assert.equal(byStage.get("testcases")!.detail, "0/1 flows have test case assumptions");
261
+ assert.equal(byStage.get("plan")!.satisfied, false, "planning must not look done when nothing live is planned");
262
+ assert.equal(byStage.get("audit")!.satisfied, false);
263
+ });
264
+
265
+ test("orphaned artifacts are reported, so a skewed-looking stage is explainable", () => {
266
+ const state = derivePipelineState(inputs({
267
+ caseSetJourneyIds: ["flow:login", "flow:deleted"],
268
+ plannedJourneyIds: ["flow:gone"],
269
+ }));
270
+ const kinds = state.orphanedArtifacts.map((entry) => `${entry.kind}:${entry.journeyId}`).sort();
271
+ assert.deepEqual(kinds, ["plan:flow:gone", "test cases:flow:deleted"]);
272
+ // The live flow's own case set still counts.
273
+ const testcases = state.stages.find((stage) => stage.stage === "testcases")!;
274
+ assert.equal(testcases.detail, "1/1 flows have test case assumptions");
275
+ });
276
+
277
+ test("a project with nothing orphaned reports an empty list", () => {
278
+ const state = derivePipelineState(inputs({ caseSetJourneyIds: ["flow:login"] }));
279
+ assert.deepEqual(state.orphanedArtifacts, []);
280
+ });
281
+
282
+ test("a stage that gave up asks for your input instead of being re-queued forever", () => {
283
+ // A blocked task with its attempts spent is neither in flight nor
284
+ // terminal, so the reconciler used to keep enqueueing it — getting the
285
+ // same task back every few seconds, making no progress and saying
286
+ // nothing. The one thing that can unblock it is an answer.
287
+ const state = derivePipelineState(inputs({
288
+ tasks: [{
289
+ status: "blocked",
290
+ idempotencyKey: pipelineIdempotencyKey("bugmole", "audit", "flow:login"),
291
+ title: "Audit the controls and components of flow:login",
292
+ attempts: 3,
293
+ errorMessage: "Needs a login for the owner account",
294
+ }],
295
+ }));
296
+ assert.equal(state.status, "paused");
297
+ assert.equal(state.next, undefined, "must not queue more work while stuck");
298
+ assert.match(state.reason, /need your input/);
299
+ assert.match(state.reason, /Needs a login for the owner account/);
300
+ assert.equal(state.blockedTasks?.length, 1);
301
+ });
302
+
303
+ test("a stage still inside its retry budget keeps going on its own", () => {
304
+ const state = derivePipelineState(inputs({
305
+ tasks: [{
306
+ status: "blocked",
307
+ idempotencyKey: pipelineIdempotencyKey("bugmole", "audit", "flow:login"),
308
+ attempts: 1,
309
+ }],
310
+ }));
311
+ // Retrying is the pipeline's job; only a spent budget is the user's.
312
+ assert.notEqual(state.status, "paused");
313
+ assert.equal(state.next?.stage, "audit");
314
+ });
315
+
316
+ test("work continues on other flows only once the stuck one is resolved", () => {
317
+ const stuck = {
318
+ status: "blocked",
319
+ idempotencyKey: pipelineIdempotencyKey("bugmole", "audit", "flow:login"),
320
+ title: "Audit flow:login",
321
+ attempts: 3,
322
+ };
323
+ assert.equal(derivePipelineState(inputs({ tasks: [stuck] })).status, "paused");
324
+ // Cleared (completed by a human or retried successfully) → back to work.
325
+ const resumed = derivePipelineState(inputs({
326
+ tasks: [{ ...stuck, status: "completed" }],
327
+ auditedJourneyIds: ["flow:login"],
328
+ }));
329
+ assert.equal(resumed.status, "running");
330
+ assert.equal(resumed.next?.stage, "testcases");
331
+ });
332
+
333
+ const runnable = {
334
+ auditedJourneyIds: ["flow:login"],
335
+ caseSetJourneyIds: ["flow:login"],
336
+ plannedJourneyIds: ["flow:login"],
337
+ publishedPlans: [{ planId: "flow_login", journeyId: "flow:login" }],
338
+ };
339
+
340
+ test("a finished run does not bind the flow to the same run forever", () => {
341
+ // The run key used to be stable per flow, so once any run existed the
342
+ // registry handed that one back on every reconcile. A finished run from
343
+ // hours earlier was returned each poll: the pipeline logged "started run"
344
+ // indefinitely while nothing executed and no verdict was ever recorded.
345
+ const first = derivePipelineState(inputs(runnable));
346
+ const afterOneRun = derivePipelineState(inputs({
347
+ ...runnable,
348
+ runs: [{ journeyId: "flow:login", status: "partial" }],
349
+ }));
350
+ assert.ok(first.nextRun);
351
+ assert.ok(afterOneRun.nextRun);
352
+ assert.notEqual(
353
+ afterOneRun.nextRun!.idempotencyKey,
354
+ first.nextRun!.idempotencyKey,
355
+ "a fresh attempt needs a key the finished run does not already own",
356
+ );
357
+ });
358
+
359
+ test("a run already in flight is not started again", () => {
360
+ const state = derivePipelineState(inputs({
361
+ ...runnable,
362
+ runs: [{ journeyId: "flow:login", status: "running" }],
363
+ }));
364
+ assert.equal(state.status, "running");
365
+ assert.equal(state.nextRun, undefined, "starting a second run would duplicate the work");
366
+ assert.match(state.reason, /already in flight/);
367
+ });
368
+
369
+ test("a flow that keeps running without recording verdicts stops instead of looping", () => {
370
+ const state = derivePipelineState(inputs({
371
+ ...runnable,
372
+ runs: Array.from({ length: 3 }, () => ({ journeyId: "flow:login", status: "success" })),
373
+ }));
374
+ assert.equal(state.status, "paused");
375
+ assert.equal(state.nextRun, undefined);
376
+ assert.match(state.reason, /without recording any verdict/);
377
+ });
378
+
379
+ test("the in-flight key is stable, so repeated reconciles do not duplicate a queued run", () => {
380
+ const a = derivePipelineState(inputs(runnable)).nextRun!.idempotencyKey;
381
+ const b = derivePipelineState(inputs(runnable)).nextRun!.idempotencyKey;
382
+ assert.equal(a, b);
383
+ });
384
+
385
+ test("a web plan pinned to the mobile driver does not count as planned", async () => {
386
+ // 18 plans were written with driver "maestro" against platform "web".
387
+ // Every run of them failed identically without checking anything, yet the
388
+ // plan stage read 20/20 satisfied — so the pipeline never re-planned them.
389
+ const fs = await import("node:fs/promises");
390
+ const os = await import("node:os");
391
+ const path = await import("node:path");
392
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-plans-"));
393
+ try {
394
+ const plansDir = path.join(root, "plans");
395
+ await fs.mkdir(plansDir, { recursive: true });
396
+ await fs.writeFile(path.join(plansDir, "flow_web.flow.yaml"), "driver: maestro\nplatform: web\n");
397
+ await fs.writeFile(path.join(plansDir, "flow_ok.flow.yaml"), "driver: playwright\nplatform: web\n");
398
+ await fs.writeFile(path.join(plansDir, "flow_mobile.flow.yaml"), "driver: maestro\nplatform: ios\n");
399
+
400
+ const planned = plannedJourneyIds(root, ["flow:web", "flow:ok", "flow:mobile"]);
401
+ assert.deepEqual(planned.sort(), ["flow:mobile", "flow:ok"]);
402
+ } finally {
403
+ await fs.rm(root, { recursive: true, force: true });
404
+ }
405
+ });