@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,452 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createProjectObjectStore } from "../storage/create-object-store.js";
4
+ import { projectPrefix } from "../storage/keys.js";
5
+ import {
6
+ emptyJourneyGraph,
7
+ repairJourneyGraph,
8
+ validateJourneyGraph,
9
+ type JourneyEdge,
10
+ type JourneyGraph,
11
+ type JourneyNode,
12
+ type UserJourney,
13
+ } from "./journey-graph.js";
14
+ import { blockerFingerprint, readBlockerResolution } from "./blocker-resolution.js";
15
+
16
+ type JourneyConfig = {
17
+ runtime?: { spec_dir?: string; artifacts_dir?: string };
18
+ storage?: any;
19
+ project?: { id?: string; app_id?: string; environment_id?: string };
20
+ };
21
+
22
+ export type JourneyNextTask = {
23
+ status: "actionable" | "exhausted" | "blocked";
24
+ journeyId: string;
25
+ reason: string;
26
+ nodeId?: string;
27
+ route?: string;
28
+ action?: string;
29
+ edgeId?: string;
30
+ controlId?: string;
31
+ nextSteps: string[];
32
+ clarification?: {
33
+ status: "needed" | "remembered";
34
+ fingerprint: string;
35
+ question: string;
36
+ reason: string;
37
+ resolutionId?: string;
38
+ };
39
+ };
40
+
41
+ function graphPath(cfg: JourneyConfig): string {
42
+ return path.join(cfg.runtime?.spec_dir || "./spec", "journeys.graph.json");
43
+ }
44
+
45
+ function projectId(cfg: JourneyConfig): string {
46
+ return cfg.project?.id || "default";
47
+ }
48
+
49
+ function storeFor(cfg: JourneyConfig) {
50
+ return createProjectObjectStore(cfg);
51
+ }
52
+
53
+ export function readJourneyGraph(cfg: JourneyConfig): JourneyGraph {
54
+ const file = graphPath(cfg);
55
+ if (!fs.existsSync(file)) return emptyJourneyGraph(projectId(cfg));
56
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as JourneyGraph;
57
+ const graph = {
58
+ ...emptyJourneyGraph(projectId(cfg)),
59
+ ...parsed,
60
+ journeys: Array.isArray(parsed.journeys) ? parsed.journeys : [],
61
+ nodes: Array.isArray(parsed.nodes) ? parsed.nodes : [],
62
+ edges: Array.isArray(parsed.edges) ? parsed.edges : [],
63
+ };
64
+ return repairJourneyGraph(graph, projectId(cfg));
65
+ }
66
+
67
+ export async function writeJourneyGraph(cfg: JourneyConfig, graph: JourneyGraph): Promise<JourneyGraph> {
68
+ const validation = validateJourneyGraph(graph);
69
+ if (!validation.valid) throw new Error(`Invalid journey graph: ${validation.errors.join("; ")}`);
70
+ const content = JSON.stringify(graph, null, 2) + "\n";
71
+ fs.mkdirSync(path.dirname(graphPath(cfg)), { recursive: true });
72
+ fs.writeFileSync(graphPath(cfg), content, "utf8");
73
+ await storeFor(cfg).put(
74
+ `${projectPrefix(projectId(cfg))}/journeys/graph.json`,
75
+ content,
76
+ "application/json",
77
+ );
78
+ return graph;
79
+ }
80
+
81
+ function idPart(value: string): string {
82
+ return value.trim().replace(/[^a-zA-Z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "");
83
+ }
84
+
85
+ function requireJourney(graph: JourneyGraph, journeyId: string): UserJourney {
86
+ const journey = graph.journeys.find((item) => item.id === journeyId);
87
+ if (!journey) throw new Error(`Journey not found: ${journeyId}`);
88
+ return journey;
89
+ }
90
+
91
+ function nextRevision(journey: UserJourney): UserJourney {
92
+ return {
93
+ ...journey,
94
+ revision: (journey.revision || 0) + 1,
95
+ status: "draft",
96
+ stability: "stabilizing",
97
+ linkedToGraph: false,
98
+ };
99
+ }
100
+
101
+ export async function stabilizeJourney(
102
+ cfg: JourneyConfig,
103
+ input: { journeyId: string; verificationRunId: string; verifiedAt?: string },
104
+ ) {
105
+ if (!input.verificationRunId.trim()) throw new Error("verificationRunId is required");
106
+ const graph = readJourneyGraph(cfg);
107
+ const current = requireJourney(graph, input.journeyId);
108
+ const journey: UserJourney = {
109
+ ...current,
110
+ status: "published",
111
+ stability: "stable",
112
+ linkedToGraph: true,
113
+ verificationRunId: input.verificationRunId.trim(),
114
+ verifiedAt: input.verifiedAt || new Date().toISOString(),
115
+ };
116
+ graph.journeys = graph.journeys.map((item) => item.id === current.id ? journey : item);
117
+ return { graph: await writeJourneyGraph(cfg, graph), journey };
118
+ }
119
+
120
+ export async function createJourney(
121
+ cfg: JourneyConfig,
122
+ input: { id?: string; name: string; description?: string; actor: string; startNodeId?: string },
123
+ ) {
124
+ const graph = readJourneyGraph(cfg);
125
+ const id = idPart(input.id || input.name.toLowerCase().replace(/\s+/g, "-"));
126
+ if (!id || !input.name.trim() || !input.actor.trim()) throw new Error("id/name and actor are required");
127
+ if (graph.journeys.some((journey) => journey.id === id)) throw new Error(`Journey already exists: ${id}`);
128
+ if (input.startNodeId && !graph.nodes.some((node) => node.id === input.startNodeId)) {
129
+ throw new Error(`Start node not found: ${input.startNodeId}`);
130
+ }
131
+ const journey: UserJourney = {
132
+ id,
133
+ name: input.name.trim(),
134
+ description: input.description?.trim(),
135
+ actor: input.actor.trim(),
136
+ startNodeId: input.startNodeId,
137
+ nodeIds: input.startNodeId ? [input.startNodeId] : [],
138
+ edgeIds: [],
139
+ revision: 1,
140
+ status: "draft",
141
+ sourceOfTruth: "canonical_flow",
142
+ stability: "discovered",
143
+ linkedToGraph: false,
144
+ };
145
+ graph.journeys.push(journey);
146
+ return { graph: await writeJourneyGraph(cfg, graph), journey };
147
+ }
148
+
149
+ export async function addJourneyStep(
150
+ cfg: JourneyConfig,
151
+ input: { journeyId: string; node: Omit<JourneyNode, "id"> & { id?: string }; afterNodeId?: string },
152
+ ) {
153
+ const graph = readJourneyGraph(cfg);
154
+ const journey = requireJourney(graph, input.journeyId);
155
+ const nodeId = idPart(input.node.id || `step-${Date.now()}`);
156
+ if (!nodeId || graph.nodes.some((node) => node.id === nodeId)) throw new Error(`Node already exists: ${nodeId}`);
157
+ const node = { ...input.node, id: nodeId } as JourneyNode;
158
+ graph.nodes.push(node);
159
+ if (input.afterNodeId) {
160
+ if (!journey.nodeIds.includes(input.afterNodeId)) throw new Error(`Journey node not found: ${input.afterNodeId}`);
161
+ journey.nodeIds = [...journey.nodeIds, nodeId];
162
+ } else {
163
+ journey.nodeIds = [...journey.nodeIds, nodeId];
164
+ }
165
+ if (!journey.startNodeId) journey.startNodeId = nodeId;
166
+ const updated = nextRevision(journey);
167
+ graph.journeys = graph.journeys.map((item) => item.id === journey.id ? updated : item);
168
+ return { graph: await writeJourneyGraph(cfg, graph), journey: updated, node };
169
+ }
170
+
171
+ export async function reuseJourneyStep(
172
+ cfg: JourneyConfig,
173
+ input: { journeyId: string; sourceJourneyId: string; stepId: string; alias?: string },
174
+ ) {
175
+ const graph = readJourneyGraph(cfg);
176
+ const target = requireJourney(graph, input.journeyId);
177
+ const source = requireJourney(graph, input.sourceJourneyId);
178
+ if (!source.nodeIds.includes(input.stepId)) {
179
+ throw new Error(`Step ${input.stepId} is not part of source journey ${input.sourceJourneyId}`);
180
+ }
181
+ if (!graph.nodes.some((node) => node.id === input.stepId)) {
182
+ throw new Error(`Step node not found: ${input.stepId}`);
183
+ }
184
+ const updated = nextRevision({
185
+ ...target,
186
+ nodeIds: [...new Set([...target.nodeIds, input.stepId])],
187
+ reuses: [
188
+ ...(target.reuses ?? []),
189
+ { flowId: source.id, stepId: input.stepId, alias: input.alias?.trim() || undefined },
190
+ ],
191
+ });
192
+ graph.journeys = graph.journeys.map((item) => item.id === target.id ? updated : item);
193
+ return { graph: await writeJourneyGraph(cfg, graph), journey: updated };
194
+ }
195
+
196
+ export async function addJourneyTransition(
197
+ cfg: JourneyConfig,
198
+ input: { journeyId: string; from: string; to: string; edge?: Partial<JourneyEdge> },
199
+ ) {
200
+ const graph = readJourneyGraph(cfg);
201
+ const journey = requireJourney(graph, input.journeyId);
202
+ if (!graph.nodes.some((node) => node.id === input.from) || !graph.nodes.some((node) => node.id === input.to)) {
203
+ throw new Error("Transition endpoints must reference existing nodes");
204
+ }
205
+ const edgeId = idPart(input.edge?.id || `${input.from}->${input.to}:${Date.now()}`);
206
+ if (graph.edges.some((edge) => edge.id === edgeId)) throw new Error(`Edge already exists: ${edgeId}`);
207
+ const edge: JourneyEdge = { ...input.edge, id: edgeId, from: input.from, to: input.to };
208
+ graph.edges.push(edge);
209
+ journey.nodeIds = [...new Set([...journey.nodeIds, input.from, input.to])];
210
+ journey.edgeIds = [...journey.edgeIds, edgeId];
211
+ const updated = nextRevision(journey);
212
+ graph.journeys = graph.journeys.map((item) => item.id === journey.id ? updated : item);
213
+ return { graph: await writeJourneyGraph(cfg, graph), journey: updated, edge };
214
+ }
215
+
216
+ export async function updateJourney(
217
+ cfg: JourneyConfig,
218
+ input: { journeyId: string; journey?: Partial<UserJourney>; node?: Partial<JourneyNode> & { id: string }; edge?: Partial<JourneyEdge> & { id: string } },
219
+ ) {
220
+ const graph = readJourneyGraph(cfg);
221
+ const current = requireJourney(graph, input.journeyId);
222
+ let updated = { ...current, ...(input.journey || {}) };
223
+ if (input.node) {
224
+ const index = graph.nodes.findIndex((node) => node.id === input.node!.id);
225
+ if (index < 0) throw new Error(`Node not found: ${input.node.id}`);
226
+ graph.nodes[index] = { ...graph.nodes[index], ...input.node };
227
+ }
228
+ if (input.edge) {
229
+ const index = graph.edges.findIndex((edge) => edge.id === input.edge!.id);
230
+ if (index < 0) throw new Error(`Edge not found: ${input.edge.id}`);
231
+ graph.edges[index] = { ...graph.edges[index], ...input.edge };
232
+ }
233
+ updated = nextRevision(updated);
234
+ graph.journeys = graph.journeys.map((journey) => journey.id === current.id ? updated : journey);
235
+ return { graph: await writeJourneyGraph(cfg, graph), journey: updated };
236
+ }
237
+
238
+ export async function removeJourneyItem(
239
+ cfg: JourneyConfig,
240
+ input: { journeyId: string; nodeId?: string; edgeId?: string },
241
+ ) {
242
+ const graph = readJourneyGraph(cfg);
243
+ const current = requireJourney(graph, input.journeyId);
244
+ if (!input.nodeId && !input.edgeId) throw new Error("nodeId or edgeId is required");
245
+ if (input.nodeId) {
246
+ if (!graph.nodes.some((node) => node.id === input.nodeId)) throw new Error(`Node not found: ${input.nodeId}`);
247
+ graph.nodes = graph.nodes.filter((node) => node.id !== input.nodeId);
248
+ graph.edges = graph.edges.filter((edge) => edge.from !== input.nodeId && edge.to !== input.nodeId);
249
+ current.nodeIds = current.nodeIds.filter((id) => id !== input.nodeId);
250
+ current.edgeIds = current.edgeIds.filter((id) => graph.edges.some((edge) => edge.id === id));
251
+ if (current.startNodeId === input.nodeId) current.startNodeId = current.nodeIds[0];
252
+ } else if (input.edgeId) {
253
+ if (!graph.edges.some((edge) => edge.id === input.edgeId)) throw new Error(`Edge not found: ${input.edgeId}`);
254
+ graph.edges = graph.edges.filter((edge) => edge.id !== input.edgeId);
255
+ current.edgeIds = current.edgeIds.filter((id) => id !== input.edgeId);
256
+ }
257
+ const updated = nextRevision(current);
258
+ graph.journeys = graph.journeys.map((journey) => journey.id === current.id ? updated : journey);
259
+ return { graph: await writeJourneyGraph(cfg, graph), journey: updated };
260
+ }
261
+
262
+ export function nextJourneyTask(cfg: JourneyConfig, journeyId: string): JourneyNextTask {
263
+ const graph = readJourneyGraph(cfg);
264
+ const journey = requireJourney(graph, journeyId);
265
+ const included = new Set(journey.nodeIds);
266
+ const includedEdges = new Set(journey.edgeIds);
267
+ const coveredTransitions = new Set(
268
+ graph.edges
269
+ .filter((edge) => includedEdges.has(edge.id))
270
+ .map((edge) => `${edge.from}->${edge.to}`),
271
+ );
272
+ const blocked = graph.nodes.find((node) => {
273
+ if (!included.has(node.id)) return false;
274
+ const probe = (node as any).runtimeProbe;
275
+ return probe?.status === "blocked" || probe?.status === "unreachable";
276
+ });
277
+ if (blocked) {
278
+ const probe = (blocked as any).runtimeProbe;
279
+ return {
280
+ status: "blocked",
281
+ journeyId,
282
+ reason: probe?.reason
283
+ ? `Runtime access is unavailable for ${blocked.route || blocked.id}: ${probe.reason}`
284
+ : `Runtime access is blocked for ${blocked.route || blocked.id}.`,
285
+ nodeId: blocked.id,
286
+ route: blocked.route,
287
+ nextSteps: [
288
+ `Resolve runtime access for ${blocked.route || blocked.id}`,
289
+ "Ask the user for clarification if the required access or startup action is unknown",
290
+ "Retry bugmole_journey_next after recording the resolution",
291
+ ],
292
+ };
293
+ }
294
+ const pathScoped = (journey as any).metadata?.flowScope === "path";
295
+ if (pathScoped) {
296
+ const metadata = (journey as any).metadata ?? {};
297
+ const verifiedEdgeIds = new Set(
298
+ Array.isArray(metadata.verifiedEdgeIds) ? metadata.verifiedEdgeIds : [],
299
+ );
300
+ const unverifiedEdgeId = journey.edgeIds.find((edgeId) => !verifiedEdgeIds.has(edgeId));
301
+ if (unverifiedEdgeId) {
302
+ const edge = graph.edges.find((item) => item.id === unverifiedEdgeId);
303
+ const target = edge ? graph.nodes.find((node) => node.id === edge.to) : undefined;
304
+ return {
305
+ status: "actionable",
306
+ journeyId,
307
+ reason: "This source-discovered transition still needs runtime verification.",
308
+ nodeId: target?.id,
309
+ route: target?.route,
310
+ action: edge?.label || edge?.condition || edge?.id || unverifiedEdgeId,
311
+ edgeId: unverifiedEdgeId,
312
+ nextSteps: [
313
+ `Perform "${edge?.label || edge?.condition || unverifiedEdgeId}" from ${edge?.from || journey.startNodeId}`,
314
+ "Record the observed screen and transition with bugmole_record_discovery",
315
+ "Call bugmole_journey_next again after recording the evidence",
316
+ ],
317
+ };
318
+ }
319
+ const goalNodeId = typeof metadata.goalNodeId === "string"
320
+ ? metadata.goalNodeId
321
+ : typeof metadata.goal?.nodeId === "string" ? metadata.goal.nodeId : undefined;
322
+ const completedGoalIds = new Set(
323
+ Array.isArray(metadata.completedGoalIds) ? metadata.completedGoalIds : [],
324
+ );
325
+ const goal = goalNodeId ? graph.nodes.find((node) => node.id === goalNodeId) : undefined;
326
+ const controls = graph.nodes
327
+ .filter((node) => included.has(node.id))
328
+ .flatMap((node) => Array.isArray((node as any).metadata?.interactiveControls)
329
+ ? (node as any).metadata.interactiveControls.map((control: any) => ({ ...control, nodeId: node.id }))
330
+ : []);
331
+ const verifiedControlIds = new Set(
332
+ Array.isArray(metadata.verifiedControlIds) ? metadata.verifiedControlIds : [],
333
+ );
334
+ const unverifiedControl = controls.find(
335
+ (control: any) => typeof control?.id === "string" && !verifiedControlIds.has(control.id),
336
+ );
337
+ if (unverifiedControl) {
338
+ return {
339
+ status: "actionable",
340
+ journeyId,
341
+ reason: "The flow still has an unchecked button, link, or action.",
342
+ nodeId: unverifiedControl.nodeId,
343
+ route: graph.nodes.find((node) => node.id === unverifiedControl.nodeId)?.route,
344
+ action: `${unverifiedControl.label || unverifiedControl.id}${unverifiedControl.destinationRoute
345
+ ? ` → ${unverifiedControl.destinationRoute}`
346
+ : ""}`,
347
+ controlId: unverifiedControl.id,
348
+ nextSteps: [
349
+ `Inspect and perform "${unverifiedControl.label || unverifiedControl.id}" on ${unverifiedControl.nodeId}`,
350
+ "Confirm its destination or terminal behavior, then record it with verifiedControlIds",
351
+ "Call bugmole_journey_next again before claiming the journey is complete",
352
+ ],
353
+ };
354
+ }
355
+ if (goalNodeId && !completedGoalIds.has(goalNodeId)) {
356
+ return {
357
+ status: "actionable",
358
+ journeyId,
359
+ reason: "The journey reached its goal screen but the terminal action is not verified.",
360
+ nodeId: goal?.id,
361
+ route: goal?.route,
362
+ action: `Complete and verify ${goal?.title || goalNodeId}`,
363
+ nextSteps: [
364
+ `Complete the terminal action on ${goal?.route || goalNodeId}`,
365
+ "Record the result and proof with bugmole_record_discovery using completedNodeIds",
366
+ "Call bugmole_journey_next again after recording the completion",
367
+ ],
368
+ };
369
+ }
370
+ return {
371
+ status: "exhausted",
372
+ journeyId,
373
+ reason: "Every transition was runtime-verified and the terminal goal was completed.",
374
+ nextSteps: ["Read the final journey graph", `Call bugmole_plan for ${journeyId} if planning is required`],
375
+ };
376
+ }
377
+ const candidate = graph.edges.find((edge) => {
378
+ if (!included.has(edge.from)) return false;
379
+ if (coveredTransitions.has(`${edge.from}->${edge.to}`)) return false;
380
+ return !included.has(edge.to) || !includedEdges.has(edge.id);
381
+ });
382
+ if (candidate) {
383
+ const target = graph.nodes.find((node) => node.id === candidate.to);
384
+ return {
385
+ status: "actionable",
386
+ journeyId,
387
+ reason: "The flow has an unrecorded outgoing transition.",
388
+ nodeId: target?.id,
389
+ route: target?.route,
390
+ action: candidate.label || candidate.condition || candidate.id,
391
+ edgeId: candidate.id,
392
+ nextSteps: [
393
+ `Perform or inspect "${candidate.label || candidate.condition || candidate.id}" from ${candidate.from}`,
394
+ `Record the resulting node and transition with bugmole_record_discovery or bugmole_journey_add_step`,
395
+ "Call bugmole_journey_next again after recording the result",
396
+ ],
397
+ };
398
+ }
399
+ return {
400
+ status: "exhausted",
401
+ journeyId,
402
+ reason: "Every node and transition in this selected flow is represented.",
403
+ nextSteps: ["Read the final journey graph", `Call bugmole_plan for ${journeyId} if planning is required`],
404
+ };
405
+ }
406
+
407
+ export async function nextJourneyTaskWithClarification(
408
+ cfg: JourneyConfig,
409
+ journeyId: string,
410
+ ): Promise<JourneyNextTask> {
411
+ const task = nextJourneyTask(cfg, journeyId);
412
+ if (task.status !== "blocked") return task;
413
+ const input = {
414
+ category: "journey-access",
415
+ reason: task.reason,
416
+ route: task.route,
417
+ nodeId: task.nodeId,
418
+ environmentId: cfg.project?.environment_id,
419
+ };
420
+ const fingerprint = blockerFingerprint(input);
421
+ const resolution = await readBlockerResolution(cfg, input);
422
+ if (resolution) {
423
+ return {
424
+ ...task,
425
+ clarification: {
426
+ status: "remembered",
427
+ fingerprint,
428
+ question: resolution.question,
429
+ reason: resolution.reason,
430
+ resolutionId: resolution.resolutionId,
431
+ },
432
+ nextSteps: [
433
+ "Verify the remembered resolution still applies to the current blocker evidence",
434
+ "Call bugmole_resolve_blocker with a corrected resolution if it does not",
435
+ "Retry the journey task after verification",
436
+ ],
437
+ };
438
+ }
439
+ return {
440
+ ...task,
441
+ clarification: {
442
+ status: "needed",
443
+ fingerprint,
444
+ question: `How should the agent resolve this blocker at ${task.route || task.nodeId || "the current step"}?`,
445
+ reason: task.reason,
446
+ },
447
+ nextSteps: [
448
+ "Ask the user the clarification question before guessing",
449
+ "Call bugmole_resolve_blocker with the user's answer",
450
+ ],
451
+ };
452
+ }
@@ -0,0 +1,161 @@
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 { LocalObjectStore } from "../storage/object-store.js";
7
+ import { captureJourneyScreenshots } from "./journey-evidence.js";
8
+ import { emptyJourneyGraph } from "./journey-graph.js";
9
+
10
+ test("captures an active related reference for every journey node", async () => {
11
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-journey-evidence-"));
12
+ const reference = path.join(root, "reference.png");
13
+ await fs.writeFile(reference, Buffer.from("png-bytes"));
14
+ const graph = emptyJourneyGraph("demo");
15
+ graph.nodes = [
16
+ { id: "start", title: "Open app", kind: "screen" },
17
+ { id: "end", title: "Finish", kind: "screen" },
18
+ ];
19
+ graph.journeys = [{
20
+ id: "demo-flow",
21
+ name: "Demo flow",
22
+ actor: "user",
23
+ nodeIds: ["start", "end"],
24
+ edgeIds: [],
25
+ revision: 1,
26
+ status: "draft",
27
+ }];
28
+
29
+ const updated = await captureJourneyScreenshots(graph, {
30
+ projectId: "demo",
31
+ journeyId: "demo-flow",
32
+ store: new LocalObjectStore(root),
33
+ sources: [{ kind: "reference", location: reference, related: true }],
34
+ });
35
+
36
+ assert.equal(updated.nodes.every((node) => node.screenshot?.status === "captured"), true);
37
+ assert.equal(await new LocalObjectStore(root).has("projects/demo/journeys/evidence/demo-flow/start.png"), true);
38
+ });
39
+
40
+ test("falls back to runtime capture when the reference is archived", async () => {
41
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-journey-evidence-"));
42
+ const graph = emptyJourneyGraph("demo");
43
+ graph.nodes = [{ id: "start", title: "Open app", kind: "screen" }];
44
+ graph.journeys = [{
45
+ id: "demo-flow",
46
+ name: "Demo flow",
47
+ actor: "user",
48
+ nodeIds: ["start"],
49
+ edgeIds: [],
50
+ revision: 1,
51
+ status: "draft",
52
+ }];
53
+
54
+ const updated = await captureJourneyScreenshots(graph, {
55
+ projectId: "demo",
56
+ journeyId: "demo-flow",
57
+ store: new LocalObjectStore(root),
58
+ sources: [{ kind: "reference", location: "/archived/mockup.png", active: false }],
59
+ captureRuntime: async () => new Uint8Array([1, 2, 3]),
60
+ });
61
+
62
+ assert.equal(updated.nodes[0].screenshot?.source, "runtime");
63
+ assert.equal(updated.nodes[0].screenshot?.status, "captured");
64
+ });
65
+
66
+ test("uses a browser provider for an active mockup URL", async () => {
67
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-journey-evidence-"));
68
+ const graph = emptyJourneyGraph("demo");
69
+ graph.nodes = [{ id: "start", title: "Open app", kind: "screen" }];
70
+ graph.journeys = [{
71
+ id: "demo-flow",
72
+ name: "Demo flow",
73
+ actor: "user",
74
+ nodeIds: ["start"],
75
+ edgeIds: [],
76
+ revision: 1,
77
+ status: "draft",
78
+ }];
79
+ let capturedUrl = "";
80
+
81
+ const updated = await captureJourneyScreenshots(graph, {
82
+ projectId: "demo",
83
+ journeyId: "demo-flow",
84
+ store: new LocalObjectStore(root),
85
+ sources: [{ kind: "reference", location: "https://mockup.example.com/flow", related: true }],
86
+ captureReferenceUrl: async (url) => {
87
+ capturedUrl = url;
88
+ return new Uint8Array([4, 5, 6]);
89
+ },
90
+ });
91
+
92
+ assert.equal(capturedUrl, "https://mockup.example.com/flow");
93
+ assert.equal(updated.nodes[0].screenshot?.source, "reference");
94
+ assert.equal(updated.nodes[0].screenshot?.status, "captured");
95
+ });
96
+
97
+ test("bounds concurrent captures instead of launching every node's browser at once", async () => {
98
+ const originalLimit = process.env.BUGMOLE_CAPTURE_CONCURRENCY;
99
+ process.env.BUGMOLE_CAPTURE_CONCURRENCY = "2";
100
+ try {
101
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-journey-evidence-"));
102
+ const graph = emptyJourneyGraph("demo");
103
+ graph.nodes = Array.from({ length: 6 }, (_, index) => ({ id: `node-${index}`, title: `Screen ${index}`, kind: "screen" as const }));
104
+ graph.journeys = [{
105
+ id: "demo-flow",
106
+ name: "Demo flow",
107
+ actor: "user",
108
+ nodeIds: graph.nodes.map((node) => node.id),
109
+ edgeIds: [],
110
+ revision: 1,
111
+ status: "draft",
112
+ }];
113
+
114
+ let inFlight = 0;
115
+ let peakInFlight = 0;
116
+
117
+ await captureJourneyScreenshots(graph, {
118
+ projectId: "demo",
119
+ journeyId: "demo-flow",
120
+ store: new LocalObjectStore(root),
121
+ captureRuntime: async () => {
122
+ inFlight += 1;
123
+ peakInFlight = Math.max(peakInFlight, inFlight);
124
+ // Yield so overlapping calls actually get a chance to race, the way
125
+ // real concurrent headless-browser launches would.
126
+ await new Promise((resolve) => setTimeout(resolve, 10));
127
+ inFlight -= 1;
128
+ return new Uint8Array([1]);
129
+ },
130
+ });
131
+
132
+ assert.equal(peakInFlight <= 2, true, `expected at most 2 concurrent captures, saw ${peakInFlight}`);
133
+ } finally {
134
+ if (originalLimit === undefined) delete process.env.BUGMOLE_CAPTURE_CONCURRENCY;
135
+ else process.env.BUGMOLE_CAPTURE_CONCURRENCY = originalLimit;
136
+ }
137
+ });
138
+
139
+ test("preserves nodes with explicit blocked screenshot evidence", async () => {
140
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-journey-evidence-"));
141
+ const graph = emptyJourneyGraph("demo");
142
+ graph.nodes = [{ id: "start", title: "Open app", kind: "screen" }];
143
+ graph.journeys = [{
144
+ id: "demo-flow",
145
+ name: "Demo flow",
146
+ actor: "user",
147
+ nodeIds: ["start"],
148
+ edgeIds: [],
149
+ revision: 1,
150
+ status: "draft",
151
+ }];
152
+
153
+ const updated = await captureJourneyScreenshots(graph, {
154
+ projectId: "demo",
155
+ journeyId: "demo-flow",
156
+ store: new LocalObjectStore(root),
157
+ });
158
+
159
+ assert.equal(updated.nodes[0].screenshot?.status, "blocked");
160
+ assert.match(updated.nodes[0].screenshot?.reason ?? "", /No active related reference/);
161
+ });