@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,180 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import type { ObjectStore } from "../storage/object-store.js";
6
+ import { headlessBrowserArgs, resolveHeadlessBrowserPath } from "./local-browser.js";
7
+ import type { JourneyGraph, JourneyNode, JourneyScreenshotEvidence } from "./journey-graph.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ // Each captureUrlWithLocalBrowser() call launches a brand-new headless
12
+ // Chrome process, and even a single-page headless launch spawns ~10-15 OS
13
+ // processes (GPU process, network/audio/storage utility services, a
14
+ // renderer, a crashpad handler). Capturing every node in a journey graph
15
+ // concurrently with no limit (as a bare Promise.all did previously) means a
16
+ // graph with N nodes launches N simultaneous Chrome instances — for a
17
+ // realistic ~40-node graph that's ~500+ processes at once, visible on macOS
18
+ // as a burst of windows/Dock icons flashing open and closed. Bounded to a
19
+ // small worker pool instead.
20
+ const DEFAULT_CAPTURE_CONCURRENCY = 3;
21
+
22
+ export function resolveCaptureConcurrency(): number {
23
+ const configured = Number(process.env.BUGMOLE_CAPTURE_CONCURRENCY);
24
+ return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_CAPTURE_CONCURRENCY;
25
+ }
26
+
27
+ export async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
28
+ const results: R[] = new Array(items.length);
29
+ let nextIndex = 0;
30
+ async function worker(): Promise<void> {
31
+ while (nextIndex < items.length) {
32
+ const currentIndex = nextIndex++;
33
+ results[currentIndex] = await fn(items[currentIndex], currentIndex);
34
+ }
35
+ }
36
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
37
+ return results;
38
+ }
39
+
40
+ export async function captureUrlWithLocalBrowser(
41
+ url: string,
42
+ viewport: { width: number; height: number } = { width: 390, height: 844 },
43
+ ): Promise<Uint8Array> {
44
+ const directory = await fs.mkdtemp(path.join("/tmp", "bugmole-journey-capture-"));
45
+ const output = path.join(directory, "capture.png");
46
+ const browser = await resolveHeadlessBrowserPath();
47
+ try {
48
+ await execFileAsync(browser, [
49
+ "--headless=new",
50
+ "--disable-gpu",
51
+ "--hide-scrollbars",
52
+ ...headlessBrowserArgs(),
53
+ `--window-size=${viewport.width},${viewport.height}`,
54
+ `--screenshot=${output}`,
55
+ url,
56
+ ], { timeout: 45_000 });
57
+ return new Uint8Array(await fs.readFile(output));
58
+ } finally {
59
+ await fs.rm(directory, { recursive: true, force: true });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The page's rendered HTML, for reading navigation the source cannot show.
65
+ *
66
+ * A source scan only sees link literals, so navigation built from a route
67
+ * table (`href={hrefFor(value)}`) is invisible to it — on this dashboard
68
+ * that hid the entire primary nav. By the time the page has rendered those
69
+ * hrefs are real strings, so the DOM is the honest place to read them.
70
+ *
71
+ * Same browser and invocation as the screenshot path, with --dump-dom
72
+ * instead of --screenshot, so there is one way to drive a browser here.
73
+ */
74
+ export async function dumpRenderedDom(
75
+ url: string,
76
+ timeoutMs = 45_000,
77
+ ): Promise<string> {
78
+ const browser = await resolveHeadlessBrowserPath();
79
+ const { stdout } = await execFileAsync(browser, [
80
+ "--headless=new",
81
+ "--disable-gpu",
82
+ "--hide-scrollbars",
83
+ ...headlessBrowserArgs(),
84
+ "--virtual-time-budget=4000",
85
+ "--dump-dom",
86
+ url,
87
+ ], { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 });
88
+ return stdout;
89
+ }
90
+
91
+ export type JourneyEvidenceSource = {
92
+ kind: "reference" | "runtime";
93
+ location?: string;
94
+ active?: boolean;
95
+ related?: boolean;
96
+ referenceId?: string;
97
+ viewport?: { width: number; height: number };
98
+ };
99
+
100
+ export type JourneyEvidenceOptions = {
101
+ projectId: string;
102
+ journeyId: string;
103
+ store: ObjectStore;
104
+ sources?: JourneyEvidenceSource[];
105
+ captureReferenceUrl?: (url: string, node: JourneyNode) => Promise<Uint8Array>;
106
+ captureRuntime?: (node: JourneyNode) => Promise<Uint8Array>;
107
+ };
108
+
109
+ export function journeyScreenshotKey(projectId: string, journeyId: string, nodeId: string): string {
110
+ const safe = nodeId.replace(/[^a-z0-9._-]+/gi, "_").replace(/^_+|_+$/g, "") || "node";
111
+ return `projects/${encodeURIComponent(projectId)}/journeys/evidence/${encodeURIComponent(journeyId)}/${safe}.png`;
112
+ }
113
+
114
+ function blocked(source: "reference" | "runtime", reason: string, referenceId?: string): JourneyScreenshotEvidence {
115
+ return { source, status: "blocked", reason, referenceId };
116
+ }
117
+
118
+ export async function captureJourneyScreenshots(
119
+ graph: JourneyGraph,
120
+ options: JourneyEvidenceOptions,
121
+ ): Promise<JourneyGraph> {
122
+ const journey = graph.journeys.find((item) => item.id === options.journeyId);
123
+ if (!journey) return graph;
124
+ const reference = options.sources?.find((source) =>
125
+ source.kind === "reference" && source.active !== false && source.related !== false && source.location,
126
+ );
127
+ const nextNodes = await mapWithConcurrency(graph.nodes, resolveCaptureConcurrency(), async (node) => {
128
+ let bytes: Uint8Array | undefined;
129
+ let source: "reference" | "runtime" = "runtime";
130
+ let referenceId: string | undefined;
131
+ let reason: string | undefined;
132
+ if (reference?.location) {
133
+ source = "reference";
134
+ referenceId = reference.referenceId;
135
+ try {
136
+ if (/^https?:\/\//i.test(reference.location)) {
137
+ if (options.captureReferenceUrl) {
138
+ bytes = await options.captureReferenceUrl(reference.location, node);
139
+ } else {
140
+ bytes = await captureUrlWithLocalBrowser(reference.location, reference.viewport);
141
+ }
142
+ } else {
143
+ bytes = await fs.readFile(path.resolve(reference.location));
144
+ }
145
+ } catch (error) {
146
+ reason = error instanceof Error
147
+ ? error.message
148
+ : `Reference screenshot could not be captured: ${reference.location}`;
149
+ }
150
+ } else if (options.captureRuntime) {
151
+ try {
152
+ bytes = await options.captureRuntime(node);
153
+ } catch (error) {
154
+ reason = error instanceof Error ? error.message : "Runtime screenshot capture failed";
155
+ }
156
+ } else {
157
+ reason = "No active related reference or runtime screenshot capture is available";
158
+ }
159
+ if (!bytes) {
160
+ return { ...node, screenshot: blocked(source, reason ?? "Screenshot capture failed", referenceId) };
161
+ }
162
+ const artifactKey = journeyScreenshotKey(options.projectId, options.journeyId, node.id);
163
+ await options.store.put(artifactKey, bytes, "image/png");
164
+ const screenshot: JourneyScreenshotEvidence = {
165
+ source,
166
+ status: "captured",
167
+ artifactKey,
168
+ contentType: "image/png",
169
+ capturedAt: new Date().toISOString(),
170
+ viewport: reference?.viewport,
171
+ referenceId,
172
+ confidence: source === "reference" ? "high" : "medium",
173
+ };
174
+ return {
175
+ ...node,
176
+ screenshot,
177
+ };
178
+ });
179
+ return { ...graph, nodes: nextNodes };
180
+ }
@@ -0,0 +1,257 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { emptyJourneyGraph, repairJourneyGraph, validateJourneyGraph } from "./journey-graph.js";
7
+ import {
8
+ addJourneyStep,
9
+ addJourneyTransition,
10
+ createJourney,
11
+ reuseJourneyStep,
12
+ stabilizeJourney,
13
+ nextJourneyTask,
14
+ readJourneyGraph,
15
+ removeJourneyItem,
16
+ updateJourney,
17
+ writeJourneyGraph,
18
+ } from "./journey-editor.js";
19
+
20
+ test("validates journey references and dangling edges", () => {
21
+ const graph = emptyJourneyGraph("demo");
22
+ graph.nodes = [{ id: "start", title: "Open app", kind: "screen" }];
23
+ graph.edges = [{ id: "bad", from: "start", to: "missing" }];
24
+ graph.journeys = [{
25
+ id: "signup",
26
+ name: "Sign up",
27
+ actor: "user",
28
+ nodeIds: ["start"],
29
+ edgeIds: ["bad"],
30
+ revision: 1,
31
+ status: "draft",
32
+ }];
33
+ const result = validateJourneyGraph(graph);
34
+ assert.equal(result.valid, false);
35
+ assert.match(result.errors.join("\n"), /missing target/);
36
+ });
37
+
38
+ test("repairs legacy screenshot scope and dangling journey references", () => {
39
+ const graph = emptyJourneyGraph("demo");
40
+ graph.nodes = [
41
+ { id: "home", title: "Home", kind: "screen", screenshot: { source: "reference", status: "captured", artifactKey: "projects/other/journeys/evidence/home.png" } },
42
+ { id: "settings", title: "Settings", kind: "screen", screenshot: { source: "reference", status: "captured", artifactKey: "projects/demo/journeys/evidence/settings.png" } },
43
+ ];
44
+ graph.journeys = [{ id: "settings-flow", name: "Settings", actor: "user", nodeIds: ["home", "missing"], edgeIds: ["missing-edge"], revision: 1, status: "draft" }];
45
+ const repaired = repairJourneyGraph(graph, "demo");
46
+ assert.equal(repaired.nodes[0].screenshot, undefined);
47
+ assert.ok(repaired.nodes[1].screenshot);
48
+ assert.deepEqual(repaired.journeys[0].nodeIds, ["home"]);
49
+ assert.deepEqual(repaired.journeys[0].edgeIds, []);
50
+ assert.equal(validateJourneyGraph(repaired).valid, true);
51
+ });
52
+
53
+ test("accepts a valid branching journey", () => {
54
+ const graph = emptyJourneyGraph("demo");
55
+ graph.nodes = [
56
+ { id: "open", title: "Open app", kind: "screen" },
57
+ { id: "has-user", title: "Has user?", kind: "decision" },
58
+ { id: "login", title: "Login", kind: "action" },
59
+ { id: "buy", title: "Buy plan", kind: "action" },
60
+ ];
61
+ graph.edges = [
62
+ { id: "open-user", from: "open", to: "has-user", label: "open" },
63
+ { id: "yes-login", from: "has-user", to: "login", condition: "yes" },
64
+ { id: "no-buy", from: "has-user", to: "buy", condition: "no" },
65
+ ];
66
+ graph.journeys = [{
67
+ id: "membership",
68
+ name: "Buy membership",
69
+ actor: "user",
70
+ startNodeId: "open",
71
+ nodeIds: graph.nodes.map((node) => node.id),
72
+ edgeIds: graph.edges.map((edge) => edge.id),
73
+ revision: 1,
74
+ status: "draft",
75
+ }];
76
+ assert.deepEqual(validateJourneyGraph(graph), { valid: true, errors: [] });
77
+ });
78
+
79
+ test("supports incremental flow CRUD and mirrors revisions to the object store", async () => {
80
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-flow-"));
81
+ const cfg = {
82
+ project: { id: "demo" },
83
+ runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") },
84
+ storage: { provider: "local", localDirectory: path.join(root, "artifacts") },
85
+ };
86
+ await createJourney(cfg, { id: "checkout", name: "Checkout", actor: "customer" });
87
+ const first = await addJourneyStep(cfg, {
88
+ journeyId: "checkout",
89
+ node: { id: "cart", title: "Open cart", kind: "screen", route: "/cart" },
90
+ });
91
+ const second = await addJourneyStep(cfg, {
92
+ journeyId: "checkout",
93
+ node: { id: "payment", title: "Pay", kind: "action", route: "/payment" },
94
+ });
95
+ await addJourneyTransition(cfg, {
96
+ journeyId: "checkout",
97
+ from: "cart",
98
+ to: "payment",
99
+ edge: { id: "cart-pay", label: "Continue to payment" },
100
+ });
101
+ assert.equal(first.journey.revision, 2);
102
+ assert.equal(second.journey.revision, 3);
103
+ assert.equal(readJourneyGraph(cfg).journeys[0].revision, 4);
104
+ assert.equal(fs.existsSync(path.join(root, "artifacts", "projects", "demo", "journeys", "graph.json")), true);
105
+ assert.equal(nextJourneyTask(cfg, "checkout").status, "exhausted");
106
+
107
+ const edited = await updateJourney(cfg, {
108
+ journeyId: "checkout",
109
+ journey: { description: "Updated flow" },
110
+ });
111
+ assert.equal(edited.journey.description, "Updated flow");
112
+ await removeJourneyItem(cfg, { journeyId: "checkout", edgeId: "cart-pay" });
113
+ assert.deepEqual(readJourneyGraph(cfg).journeys[0].edgeIds, []);
114
+ await fs.promises.rm(root, { recursive: true, force: true });
115
+ });
116
+
117
+ test("stabilizes one canonical flow and reuses its step in another flow", async () => {
118
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-canonical-"));
119
+ const cfg = {
120
+ project: { id: "demo" },
121
+ runtime: { spec_dir: root, artifacts_dir: path.join(root, "runs") },
122
+ storage: { provider: "local", localDirectory: path.join(root, "runs") },
123
+ };
124
+ try {
125
+ await createJourney(cfg, { id: "source", name: "Source", actor: "user" });
126
+ await addJourneyStep(cfg, { journeyId: "source", node: { id: "login", title: "Login", kind: "action" } });
127
+ const stable = await stabilizeJourney(cfg, { journeyId: "source", verificationRunId: "run-1" });
128
+ assert.equal(stable.journey.stability, "stable");
129
+ await createJourney(cfg, { id: "target", name: "Target", actor: "user" });
130
+ const reused = await reuseJourneyStep(cfg, {
131
+ journeyId: "target",
132
+ sourceJourneyId: "source",
133
+ stepId: "login",
134
+ });
135
+ assert.deepEqual(reused.journey.reuses, [{ flowId: "source", stepId: "login", alias: undefined }]);
136
+ assert.equal(reused.journey.linkedToGraph, false);
137
+ } finally {
138
+ fs.rmSync(root, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("returns the next task for a branch outside the selected flow", async () => {
143
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-flow-next-"));
144
+ const cfg = {
145
+ project: { id: "demo" },
146
+ runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") },
147
+ storage: { provider: "local", localDirectory: path.join(root, "artifacts") },
148
+ };
149
+ const graph = emptyJourneyGraph("demo");
150
+ graph.nodes = [
151
+ { id: "home", title: "Home", kind: "screen", route: "/" },
152
+ { id: "settings", title: "Settings", kind: "screen", route: "/settings" },
153
+ ];
154
+ graph.edges = [{ id: "home-settings", from: "home", to: "settings", label: "Open settings" }];
155
+ graph.journeys = [{
156
+ id: "settings-flow", name: "Settings", actor: "user", startNodeId: "home",
157
+ nodeIds: ["home"], edgeIds: [], revision: 1, status: "draft",
158
+ }];
159
+ await writeJourneyGraph(cfg, graph);
160
+ const next = nextJourneyTask(cfg, "settings-flow");
161
+ assert.equal(next.status, "actionable");
162
+ assert.equal(next.route, "/settings");
163
+ assert.equal(next.edgeId, "home-settings");
164
+ await fs.promises.rm(root, { recursive: true, force: true });
165
+ });
166
+
167
+ test("does not make unrelated sitemap screens tasks for a selected flow", async () => {
168
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-flow-scope-"));
169
+ const cfg = {
170
+ project: { id: "demo" },
171
+ runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") },
172
+ storage: { provider: "local", localDirectory: path.join(root, "artifacts") },
173
+ };
174
+ const graph = emptyJourneyGraph("demo");
175
+ graph.nodes = [
176
+ { id: "home", title: "Home", kind: "screen", route: "/" },
177
+ { id: "about", title: "About", kind: "screen", route: "/about" },
178
+ ];
179
+ graph.journeys = [{
180
+ id: "home-flow", name: "Home", actor: "user", startNodeId: "home",
181
+ nodeIds: ["home"], edgeIds: [], revision: 1, status: "draft",
182
+ }];
183
+ await writeJourneyGraph(cfg, graph);
184
+ const next = nextJourneyTask(cfg, "home-flow");
185
+ assert.equal(next.status, "exhausted");
186
+ assert.match(next.reason, /selected flow/);
187
+ await fs.promises.rm(root, { recursive: true, force: true });
188
+ });
189
+
190
+ test("requires runtime transition and terminal-goal completion for source paths", async () => {
191
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-flow-runtime-"));
192
+ const cfg = {
193
+ project: { id: "demo" },
194
+ runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") },
195
+ storage: { provider: "local", localDirectory: path.join(root, "artifacts") },
196
+ };
197
+ const graph = emptyJourneyGraph("demo");
198
+ graph.nodes = [
199
+ { id: "home", title: "Home", kind: "screen", route: "/" },
200
+ {
201
+ id: "top-up",
202
+ title: "Top up credits",
203
+ kind: "screen",
204
+ route: "/top-up-credits",
205
+ metadata: {
206
+ interactiveControls: [{ id: "confirm-top-up", label: "Confirm top up", kind: "action" }],
207
+ },
208
+ },
209
+ ];
210
+ graph.edges = [{ id: "home-top-up", from: "home", to: "top-up", label: "Open top up credits" }];
211
+ graph.journeys = [{
212
+ id: "top-up-flow",
213
+ name: "Top up credits",
214
+ actor: "user",
215
+ startNodeId: "home",
216
+ nodeIds: ["home", "top-up"],
217
+ edgeIds: ["home-top-up"],
218
+ revision: 1,
219
+ status: "draft",
220
+ metadata: {
221
+ flowScope: "path",
222
+ goalNodeId: "top-up",
223
+ verifiedEdgeIds: [],
224
+ completedGoalIds: [],
225
+ },
226
+ }];
227
+ await writeJourneyGraph(cfg, graph);
228
+ const first = nextJourneyTask(cfg, "top-up-flow");
229
+ assert.equal(first.status, "actionable");
230
+ assert.equal(first.edgeId, "home-top-up");
231
+
232
+ graph.journeys[0].metadata = {
233
+ ...graph.journeys[0].metadata,
234
+ verifiedEdgeIds: ["home-top-up"],
235
+ };
236
+ await writeJourneyGraph(cfg, graph);
237
+ const goal = nextJourneyTask(cfg, "top-up-flow");
238
+ assert.equal(goal.status, "actionable");
239
+ assert.equal(goal.controlId, "confirm-top-up");
240
+
241
+ graph.journeys[0].metadata = {
242
+ ...graph.journeys[0].metadata,
243
+ verifiedControlIds: ["confirm-top-up"],
244
+ };
245
+ await writeJourneyGraph(cfg, graph);
246
+ const completion = nextJourneyTask(cfg, "top-up-flow");
247
+ assert.equal(completion.status, "actionable");
248
+ assert.match(completion.action ?? "", /Complete and verify/);
249
+
250
+ graph.journeys[0].metadata = {
251
+ ...graph.journeys[0].metadata,
252
+ completedGoalIds: ["top-up"],
253
+ };
254
+ await writeJourneyGraph(cfg, graph);
255
+ assert.equal(nextJourneyTask(cfg, "top-up-flow").status, "exhausted");
256
+ await fs.promises.rm(root, { recursive: true, force: true });
257
+ });
@@ -0,0 +1,170 @@
1
+ export type JourneyNodeStatus = "success" | "blocked" | "failed";
2
+
3
+ export type JourneyScreenshotEvidence = {
4
+ source: "reference" | "runtime";
5
+ status: "captured" | "blocked";
6
+ artifactKey?: string;
7
+ contentType?: "image/png" | "image/jpeg" | "image/webp" | "video/mp4" | "video/webm";
8
+ capturedAt?: string;
9
+ viewport?: { width: number; height: number };
10
+ referenceId?: string;
11
+ confidence?: "high" | "medium" | "low";
12
+ reason?: string;
13
+ };
14
+
15
+ export type JourneyNode = {
16
+ id: string;
17
+ title: string;
18
+ kind: "screen" | "decision" | "action" | "checkpoint";
19
+ route?: string;
20
+ action?: string;
21
+ actor?: string;
22
+ expectedOutcome?: "pass" | "blocked" | "warn" | "fail" | "unknown";
23
+ proofs?: Array<Record<string, unknown>>;
24
+ position?: { x: number; y: number };
25
+ metadata?: Record<string, unknown>;
26
+ screenshot?: JourneyScreenshotEvidence;
27
+ };
28
+
29
+ export type JourneyEdge = {
30
+ id: string;
31
+ from: string;
32
+ to: string;
33
+ label?: string;
34
+ condition?: string;
35
+ order?: number;
36
+ };
37
+
38
+ export type UserJourney = {
39
+ id: string;
40
+ name: string;
41
+ description?: string;
42
+ actor: string;
43
+ startNodeId?: string;
44
+ nodeIds: string[];
45
+ edgeIds: string[];
46
+ revision: number;
47
+ status: "draft" | "published";
48
+ sourceOfTruth?: "canonical_flow";
49
+ stability?: "discovered" | "stabilizing" | "stable";
50
+ linkedToGraph?: boolean;
51
+ verificationRunId?: string;
52
+ verifiedAt?: string;
53
+ reuses?: Array<{ flowId: string; stepId: string; alias?: string }>;
54
+ metadata?: Record<string, unknown>;
55
+ };
56
+
57
+ export type JourneyGraph = {
58
+ version: 2;
59
+ projectId?: string;
60
+ appId?: string;
61
+ environmentId?: string;
62
+ journeys: UserJourney[];
63
+ nodes: JourneyNode[];
64
+ edges: JourneyEdge[];
65
+ metadata?: Record<string, unknown>;
66
+ };
67
+
68
+ export function canonicalFlows(graph: JourneyGraph): UserJourney[] {
69
+ return graph.journeys.filter((journey) =>
70
+ journey.sourceOfTruth === "canonical_flow" &&
71
+ journey.stability === "stable" &&
72
+ journey.linkedToGraph === true,
73
+ );
74
+ }
75
+
76
+ export type JourneyValidation = {
77
+ valid: boolean;
78
+ errors: string[];
79
+ };
80
+
81
+ export function projectJourneyArtifactPrefix(projectId: string): string {
82
+ return `projects/${encodeURIComponent(projectId)}/`;
83
+ }
84
+
85
+ export function isProjectScopedJourneyArtifact(artifactKey: string, projectId: string): boolean {
86
+ return artifactKey.startsWith(projectJourneyArtifactPrefix(projectId));
87
+ }
88
+
89
+ /**
90
+ * Repair legacy graphs at the read boundary so one bad historical write does
91
+ * not make every later project-scoped CRUD operation impossible. Repair is
92
+ * deliberately conservative: invalid screenshot references are removed, and
93
+ * dangling journey references are pruned, but discovered nodes and edges are
94
+ * retained for inspection.
95
+ */
96
+ export function repairJourneyGraph(graph: JourneyGraph, projectId: string): JourneyGraph {
97
+ const nodeIds = new Set(graph.nodes.map((node) => node.id));
98
+ const nodes = graph.nodes.map((node) => {
99
+ const artifactKey = node.screenshot?.artifactKey;
100
+ if (artifactKey && !isProjectScopedJourneyArtifact(artifactKey, projectId)) {
101
+ const { screenshot: _screenshot, ...withoutScreenshot } = node;
102
+ return withoutScreenshot;
103
+ }
104
+ return node;
105
+ });
106
+ const journeys = graph.journeys.map((journey) => ({
107
+ ...journey,
108
+ nodeIds: journey.nodeIds.filter((nodeId) => nodeIds.has(nodeId)),
109
+ edgeIds: journey.edgeIds.filter((edgeId) => graph.edges.some((edge) => edge.id === edgeId)),
110
+ startNodeId: journey.startNodeId && nodeIds.has(journey.startNodeId)
111
+ ? journey.startNodeId
112
+ : undefined,
113
+ }));
114
+ return {
115
+ ...graph,
116
+ projectId,
117
+ journeys,
118
+ nodes,
119
+ };
120
+ }
121
+
122
+ export function validateJourneyGraph(graph: JourneyGraph): JourneyValidation {
123
+ const errors: string[] = [];
124
+ const nodeIds = new Set(graph.nodes.map((node) => node.id));
125
+ const edgeIds = new Set<string>();
126
+ for (const edge of graph.edges) {
127
+ if (edgeIds.has(edge.id)) errors.push(`Duplicate edge id: ${edge.id}`);
128
+ edgeIds.add(edge.id);
129
+ if (!nodeIds.has(edge.from)) errors.push(`Edge ${edge.id} references missing source ${edge.from}`);
130
+ if (!nodeIds.has(edge.to)) errors.push(`Edge ${edge.id} references missing target ${edge.to}`);
131
+ }
132
+ for (const node of graph.nodes) {
133
+ const screenshot = node.screenshot;
134
+ if (!screenshot) continue;
135
+ if (screenshot.status === "captured" && !screenshot.artifactKey) {
136
+ errors.push(`Node ${node.id} has captured screenshot evidence without an artifact key`);
137
+ }
138
+ if (screenshot.status === "blocked" && !screenshot.reason?.trim()) {
139
+ errors.push(`Node ${node.id} has blocked screenshot evidence without a reason`);
140
+ }
141
+ if (screenshot.artifactKey && graph.projectId
142
+ && screenshot.artifactKey !== `projects/${encodeURIComponent(graph.projectId)}/${screenshot.artifactKey.replace(/^projects\/[^/]+\//, "")}`) {
143
+ errors.push(`Node ${node.id} screenshot artifact is not project-scoped`);
144
+ }
145
+ }
146
+ const journeyIds = new Set<string>();
147
+ for (const journey of graph.journeys) {
148
+ if (journeyIds.has(journey.id)) errors.push(`Duplicate journey id: ${journey.id}`);
149
+ journeyIds.add(journey.id);
150
+ if (!journey.name.trim()) errors.push(`Journey ${journey.id} must have a name`);
151
+ if (!journey.actor.trim()) errors.push(`Journey ${journey.id} must have an actor`);
152
+ if (journey.linkedToGraph && journey.stability !== "stable") {
153
+ errors.push(`Journey ${journey.id} cannot link to the app graph before stabilization`);
154
+ }
155
+ for (const nodeId of journey.nodeIds) {
156
+ if (!nodeIds.has(nodeId)) errors.push(`Journey ${journey.id} references missing node ${nodeId}`);
157
+ }
158
+ for (const edgeId of journey.edgeIds) {
159
+ if (!edgeIds.has(edgeId)) errors.push(`Journey ${journey.id} references missing edge ${edgeId}`);
160
+ }
161
+ if (journey.startNodeId && !journey.nodeIds.includes(journey.startNodeId)) {
162
+ errors.push(`Journey ${journey.id} start node is not part of the journey`);
163
+ }
164
+ }
165
+ return { valid: errors.length === 0, errors };
166
+ }
167
+
168
+ export function emptyJourneyGraph(projectId?: string): JourneyGraph {
169
+ return { version: 2, projectId, journeys: [], nodes: [], edges: [] };
170
+ }
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs";
2
+
3
+ /**
4
+ * Bugmole was called Valkyrie until 0.4.0. Workspaces set up before the
5
+ * rename still have its file names and environment variables; these helpers
6
+ * let them keep working without anyone having to rename anything.
7
+ */
8
+ const LEGACY_ENV_PREFIX = "VALKYRIE_";
9
+ const ENV_PREFIX = "BUGMOLE_";
10
+
11
+ /** Copies each VALKYRIE_* variable to its BUGMOLE_* name, unless that is already set. */
12
+ export function adoptLegacyEnv(env: NodeJS.ProcessEnv = process.env): void {
13
+ for (const [key, value] of Object.entries(env)) {
14
+ if (!key.startsWith(LEGACY_ENV_PREFIX) || value === undefined) continue;
15
+ const current = ENV_PREFIX + key.slice(LEGACY_ENV_PREFIX.length);
16
+ if (env[current] === undefined) env[current] = value;
17
+ }
18
+ }
19
+
20
+ /** The legacy path when only it exists; otherwise the current one. */
21
+ export function preferExisting(current: string, legacy: string): string {
22
+ return !fs.existsSync(current) && fs.existsSync(legacy) ? legacy : current;
23
+ }
24
+
25
+ /** Old file and directory names, keyed by the current name. */
26
+ export const LEGACY_PATHS: Record<string, string> = {
27
+ ".bugmole.env": ".valk.env",
28
+ ".bugmole": ".valkyrie",
29
+ ".bugmole/config.yaml": ".valkyrie/config.yaml",
30
+ "bugmole.config.yaml": "valkyrie.config.yaml",
31
+ "qa/bugmole-runs": "qa/valkyrie-runs",
32
+ };