@bugmole/cli 0.4.0 → 0.6.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 (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. package/src/vendor/byok.ts +371 -0
@@ -6,9 +6,10 @@ import { createProjectObjectStore } from "../storage/create-object-store.js";
6
6
  import { projectPrefix } from "../storage/keys.js";
7
7
  import { readJourneyGraph, writeJourneyGraph } from "./journey-editor.js";
8
8
  import type { JourneyEdge, JourneyNode, UserJourney } from "./journey-graph.js";
9
- import { discoverSite, type DiscoveryResult } from "./site-discovery.js";
10
- import { explorationPrompt, parseExploration, type ExplorationPersona } from "./ai-exploration.js";
9
+ import { discoverSite, stoppedAtSignIn, type DiscoveryLimits, type DiscoveryResult } from "./site-discovery.js";
10
+ import { explorationPrompt, parseCheckedExploration, type ExplorationPersona } from "./ai-exploration.js";
11
11
  import { modelCaller } from "./platform-ai.js";
12
+ import { proposeRoleSuggestions } from "./project-roles.js";
12
13
 
13
14
  /** Discovery tasks queued as "Explore with AI …" also have a model write multi-page journeys. */
14
15
  export function discoveryUsesAi(task: Pick<RegistryTask, "title">): boolean {
@@ -69,6 +70,8 @@ export type DiscoveryProgress = {
69
70
  events: DiscoveryResult["events"];
70
71
  pages: Array<{ route: string; title: string; heading?: string; forms: number }>;
71
72
  flows: Array<{ id: string; name: string; kind: "page" | "form" | "journey"; route: string; steps: string[]; planId?: string }>;
73
+ /** Present once the crawl has finished: the bounds it ran with and what they left out. */
74
+ limits?: Omit<DiscoveryLimits, "notFollowed"> & { notFollowed: number; notFollowedRoutes: string[] };
72
75
  };
73
76
 
74
77
  export function progressOf(url: string, result: DiscoveryResult, planIds: Record<string, string> = {}): DiscoveryProgress {
@@ -79,6 +82,18 @@ export function progressOf(url: string, result: DiscoveryResult, planIds: Record
79
82
  events: result.events.slice(-40),
80
83
  pages: result.pages.map((page) => ({ route: page.route, title: page.title, ...(page.heading ? { heading: page.heading } : {}), forms: page.forms.length })),
81
84
  flows: result.flows.map((flow) => ({ id: flow.id, name: flow.name, kind: flow.kind, route: flow.route, steps: flow.steps, ...(planIds[flow.id] ? { planId: planIds[flow.id] } : {}) })),
85
+ ...(result.limits
86
+ ? {
87
+ limits: {
88
+ maxPages: result.limits.maxPages,
89
+ maxDepth: result.limits.maxDepth,
90
+ stoppedAtPageLimit: result.limits.stoppedAtPageLimit,
91
+ notFollowed: result.limits.notFollowed.length,
92
+ // A sample is enough to show which; every update carries it.
93
+ notFollowedRoutes: result.limits.notFollowed.slice(0, 20),
94
+ },
95
+ }
96
+ : {}),
82
97
  };
83
98
  }
84
99
 
@@ -191,7 +206,7 @@ export async function saveDiscovery(cfg: WorkerConfig, client: Pick<ControlPlane
191
206
  */
192
207
  export async function runDiscoveryTask(
193
208
  cfg: WorkerConfig,
194
- client: Pick<ControlPlaneClient, "claimTask" | "updateTask" | "publishPlan">,
209
+ client: Pick<ControlPlaneClient, "claimTask" | "updateTask" | "publishPlan"> & Partial<Pick<ControlPlaneClient, "proposeRole">>,
195
210
  task: RegistryTask,
196
211
  workerId: string,
197
212
  discover: typeof discoverSite = discoverSite,
@@ -200,6 +215,16 @@ export async function runDiscoveryTask(
200
215
  claimed?: boolean;
201
216
  /** Called once if the registry answers an update with `stop`. */
202
217
  onStop?: (signal: { reason: string; message: string }) => void;
218
+ /**
219
+ * Signs in before exploring (a cloud runner with the project's sign-in
220
+ * flow) and returns the saved browser session to explore in.
221
+ */
222
+ signIn?: (url: string) => Promise<string>;
223
+ /**
224
+ * How far to explore: the workspace plan's bounds on Bugmole Cloud, the
225
+ * machine's own settings on a project worker. Discovery's defaults otherwise.
226
+ */
227
+ limits?: { maxPages?: number; maxDepth?: number };
203
228
  } = {},
204
229
  ): Promise<void> {
205
230
  const url = discoveryUrl(task);
@@ -242,13 +267,60 @@ export async function runDiscoveryTask(
242
267
  heartbeat.unref?.();
243
268
  try {
244
269
  await send("running");
270
+ // Sign-in happens before discovery has a result to report into, so its
271
+ // events are kept here and put in front of discovery's own.
272
+ const before: DiscoveryResult["events"] = [];
273
+ let storageState: string | undefined;
274
+ if (options.signIn) {
275
+ before.push({ at: new Date().toISOString(), kind: "visit", text: "Signing in with the project's sign-in flow…" });
276
+ latest = progressOf(url, { baseUrl: url, pages: [], flows: [], events: before });
277
+ await send("running");
278
+ storageState = await options.signIn(url);
279
+ before.push({ at: new Date().toISOString(), kind: "visit", text: "Signed in; exploring as a signed-in user" });
280
+ }
245
281
  const result = await discover(url, {
282
+ ...(storageState ? { storageState } : {}),
283
+ ...(options.limits?.maxPages ? { maxPages: options.limits.maxPages } : {}),
284
+ ...(options.limits?.maxDepth ? { maxDepth: options.limits.maxDepth } : {}),
246
285
  onEvent: async (_event, snapshot) => {
247
- latest = progressOf(url, snapshot);
286
+ latest = progressOf(url, { ...snapshot, events: [...before, ...snapshot.events] });
248
287
  await send("running");
249
288
  },
250
289
  });
251
- if (discoveryUsesAi(task)) {
290
+ result.events.unshift(...before);
291
+ // Everything reachable was a sign-in screen. Say so plainly, and don't
292
+ // ask AI to imagine the app behind it: that spends credits on journeys
293
+ // through pages nobody has seen.
294
+ const signInWall = stoppedAtSignIn(result);
295
+ if (signInWall) {
296
+ result.events.push({
297
+ at: new Date().toISOString(),
298
+ kind: "skip",
299
+ text: storageState
300
+ ? "Stopped at a sign-in page even after the sign-in flow passed, so everything behind it is unexplored. "
301
+ + "Check that the sign-in flow ends signed in (assert something only a signed-in user sees)."
302
+ : "Stopped at a sign-in page, so everything behind it is unexplored. To explore signed in, add a sign-in flow and its secrets "
303
+ + "under Settings → Sign-in for cloud runs, or run discovery from your machine or CI: bugmole --mode discover --sign-in <flow>.",
304
+ });
305
+ latest = progressOf(url, result);
306
+ await send("running");
307
+ // A sign-in wall says the app has signed-out and signed-in people:
308
+ // suggest both roles for a person to confirm on the Roles page.
309
+ if (client.proposeRole && !storageState) {
310
+ const evidence = `Exploring ${url} reached only a sign-in page`;
311
+ const proposed = await proposeRoleSuggestions([
312
+ { name: "guest", description: "Visitor who is not signed in and only sees public screens.", source: "sign_in_wall", evidence },
313
+ { name: "member", description: "Signed-in user who gets past the sign-in wall.", source: "sign_in_wall", evidence },
314
+ ], { proposeRole: client.proposeRole.bind(client) }).catch(() => []);
315
+ const sent = proposed.filter((role) => role.status === "suggested" && !role.alreadyDecided).map((role) => role.name);
316
+ if (sent.length) {
317
+ result.events.push({ at: new Date().toISOString(), kind: "flows", text: `Suggested the roles ${sent.join(" and ")} for you to confirm under Settings → Roles` });
318
+ latest = progressOf(url, result);
319
+ await send("running");
320
+ }
321
+ }
322
+ }
323
+ if (discoveryUsesAi(task) && !signInWall) {
252
324
  const caller = modelCaller(cfg, { projectId: cfg.project?.id ?? "default", requestId: task.id });
253
325
  const note = (text: string, kind: "flows" | "error") => {
254
326
  result.events.push({ at: new Date().toISOString(), kind, text });
@@ -262,9 +334,13 @@ export async function runDiscoveryTask(
262
334
  await note(persona ? `Asking AI for multi-page journeys as ${persona.name}…` : "Asking AI for multi-page journeys…", "flows");
263
335
  try {
264
336
  const reply = await caller(explorationPrompt(result, persona ?? undefined), "exploration");
265
- const journeys = parseExploration(reply.text, result.baseUrl, result.flows);
337
+ const { flows: journeys, setAside } = parseCheckedExploration(reply.text, result);
266
338
  result.flows.push(...journeys);
267
339
  await note(`AI wrote ${journeys.length} journey${journeys.length === 1 ? "" : "s"}`, "flows");
340
+ for (const journey of setAside.slice(0, 5)) {
341
+ await note(`Set aside “${journey.name}”: it ${journey.reason}`, "error");
342
+ }
343
+ if (setAside.length > 5) await note(`Set aside ${setAside.length - 5} more that used things discovery didn't see`, "error");
268
344
  } catch (error) {
269
345
  await note(`AI exploration failed: ${error instanceof Error ? error.message : String(error)}`, "error");
270
346
  }
@@ -273,7 +349,11 @@ export async function runDiscoveryTask(
273
349
  const planIds = await saveDiscovery(cfg, client, result);
274
350
  latest = progressOf(url, result, planIds);
275
351
  await send("completed", {
276
- completionSummary: `Explored ${result.pages.length} page(s) of ${url} and discovered ${result.flows.length} flow(s).`,
352
+ completionSummary: `Explored ${result.pages.length} page(s) of ${url} and discovered ${result.flows.length} flow(s).`
353
+ + (signInWall ? " Stopped at a sign-in page; the app behind it is unexplored." : "")
354
+ + (result.limits?.notFollowed.length
355
+ ? ` ${result.limits.notFollowed.length} linked page(s) were not opened (limit: ${result.limits.maxPages} pages, ${result.limits.maxDepth} clicks deep).`
356
+ : ""),
277
357
  });
278
358
  } catch (error) {
279
359
  await send("error", { errorMessage: error instanceof Error ? error.message : String(error) });
@@ -11,6 +11,8 @@ import { journeyScreenshotKey } from "./journey-evidence.js";
11
11
  import type { AppDriver, DriverPlatform } from "./driver.js";
12
12
  import { MaestroDriver } from "./maestro-driver.js";
13
13
  import { PlaywrightDriver } from "./playwright-driver.js";
14
+ import { withFlowSecrets } from "./secret-driver.js";
15
+ import { secretMap } from "./secret-sources.js";
14
16
  import { recoverDriverFailure } from "./driver-recovery.js";
15
17
  import { startAndroidScreenRecording } from "./android-screen-record.js";
16
18
  import { startIosScreenRecording } from "./ios-screen-record.js";
@@ -115,16 +117,27 @@ export interface ExecutionResult {
115
117
  }
116
118
 
117
119
  function configuredDriver(cfg: any, driverId: string): AppDriver {
120
+ // Flows type {{secret.NAME}} from wherever the project keeps it (its
121
+ // config's secrets: map, or this machine's keychain).
122
+ const secrets = (driver: AppDriver) => {
123
+ const { map } = secretMap(cfg);
124
+ return withFlowSecrets(driver, {
125
+ map,
126
+ projectId: cfg.project?.id ?? "default",
127
+ // A cloud runner has the project's released secrets already; it has no keychain.
128
+ localFallback: !process.env.BUGMOLE_RUN_TOKEN,
129
+ });
130
+ };
118
131
  if (driverId === "maestro") {
119
- return new MaestroDriver({
132
+ return secrets(new MaestroDriver({
120
133
  command: cfg.execution?.maestro?.command,
121
134
  extraArgs: Array.isArray(cfg.execution?.maestro?.args) ? cfg.execution.maestro.args : undefined,
122
- });
135
+ }));
123
136
  }
124
137
  if (driverId === "playwright") {
125
- return new PlaywrightDriver({
138
+ return secrets(new PlaywrightDriver({
126
139
  headless: cfg.execution?.playwright?.headless,
127
- });
140
+ }));
128
141
  }
129
142
  throw new Error(`No app driver adapter is registered for "${driverId}"`);
130
143
  }
@@ -400,12 +413,7 @@ export async function execute(cfg: any, runOptions: RunOptions): Promise<Executi
400
413
  stepsPassed: 0,
401
414
  stepsFailed: 0,
402
415
  error:
403
- `Plan not found: ${planPath}\n` +
404
- `Plan ID provided: ${planId}\n` +
405
- `Spec dir: ${specDir}\n` +
406
- `Plans dir: ${plansDir}\n` +
407
- `Current working directory: ${process.cwd()}\n` +
408
- `Environment variables: CURSOR_PROJECT_DIRECTORY=${process.env.CURSOR_PROJECT_DIRECTORY || "not set"}, BUGMOLE_PROJECT_DIR=${process.env.BUGMOLE_PROJECT_DIR || "not set"}`,
416
+ `No plan "${planId}" at ${planPath}. Create it with bugmole_plan (or bugmole --mode plan), or check the plan ID and that Bugmole runs in your project folder (now ${process.cwd()}).`,
409
417
  };
410
418
  }
411
419
 
@@ -99,3 +99,35 @@ test("manage hub links resolve against optional catch-all resource routes", asyn
99
99
  assert.ok(targets.has(route), `expected /manage to link to ${route}`);
100
100
  }
101
101
  });
102
+
103
+ test("an app with no route files is explored by crawling it, and recorded transitions survive re-exploring", async () => {
104
+ const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-explorer-plain-"));
105
+ await fs.writeFile(path.join(projectDir, "server.mjs"), "// a plain http server\n");
106
+ const specDir = path.join(projectDir, "spec");
107
+ const cfg = {
108
+ runtime: { spec_dir: specDir, artifacts_dir: path.join(projectDir, "runs") },
109
+ cursor_driver: { project_directory: projectDir },
110
+ project: { id: "plain" },
111
+ };
112
+ const page = (route: string, title: string, links: Array<{ label: string; route: string }>) => ({ route, url: `http://127.0.0.1:1${route}`, title, links });
113
+ const discover = async () => ({
114
+ pages: [
115
+ page("/", "Home", [{ label: "Pricing", route: "/pricing" }, { label: "Sign up", route: "/signup" }]),
116
+ page("/pricing", "Pricing", [{ label: "Home", route: "/" }]),
117
+ page("/signup", "Sign up", []),
118
+ ],
119
+ });
120
+ const first = await explore(cfg, { baseUrl: "http://127.0.0.1:1", captureScreenshots: false, discover });
121
+ assert.equal(first.status, "success");
122
+ let graph = JSON.parse(await fs.readFile(path.join(specDir, "journeys.graph.json"), "utf-8"));
123
+ assert.deepEqual(graph.nodes.map((node: { route: string }) => node.route).sort(), ["/", "/pricing", "/signup"]);
124
+ assert.ok(graph.edges.some((edge: { from: string; to: string; label?: string }) => edge.from === "screen:/" && edge.to === "screen:/signup" && edge.label === "Sign up"));
125
+
126
+ // A transition an agent recorded by hand, for its own journey.
127
+ graph.edges.push({ id: "screen:/signup->screen:/:manual", from: "screen:/signup", to: "screen:/", label: "Back home" });
128
+ graph.journeys.push({ id: "manual", name: "Manual", actor: "visitor", nodeIds: ["screen:/signup", "screen:/"], edgeIds: ["screen:/signup->screen:/:manual"], revision: 1, status: "draft", startNodeId: "screen:/signup" });
129
+ await fs.writeFile(path.join(specDir, "journeys.graph.json"), JSON.stringify(graph));
130
+ await explore(cfg, { baseUrl: "http://127.0.0.1:1", captureScreenshots: false, discover });
131
+ graph = JSON.parse(await fs.readFile(path.join(specDir, "journeys.graph.json"), "utf-8"));
132
+ assert.ok(graph.edges.some((edge: { id: string }) => edge.id === "screen:/signup->screen:/:manual"));
133
+ });
@@ -21,6 +21,10 @@ interface ExploreOptions {
21
21
  accessMethod?: "configured" | "handoff" | "manual" | "unknown";
22
22
  journeyId?: string;
23
23
  actor?: string;
24
+ /** false: never crawl the running app when the source has no route files. */
25
+ crawl?: boolean;
26
+ /** The crawler, replaceable in tests. */
27
+ discover?: (baseUrl: string, options: { maxPages?: number; maxDepth?: number }) => Promise<{ pages: Array<{ route: string; url: string; title: string; heading?: string; links: Array<{ label: string; route: string }> }> }>;
24
28
  }
25
29
 
26
30
  interface ExploreResult {
@@ -572,10 +576,43 @@ export async function explore(cfg: any, options: ExploreOptions): Promise<Explor
572
576
  sourcePaths: entry.source ? [entry.source] : [],
573
577
  });
574
578
  }
579
+ // No route files (Express, Fastify, a plain http server, a framework the
580
+ // walk doesn't know): learn the screens from the running app instead,
581
+ // by clicking through it the way site discovery does.
582
+ const crawledEdges: any[] = [];
583
+ if (discoveredNodes.size === 0 && options.crawl !== false) {
584
+ const { discoverSite } = await import("./site-discovery.js");
585
+ const site = await (options.discover ?? discoverSite)(baseUrl, { maxPages: 30, maxDepth: 3 });
586
+ for (const page of site.pages) {
587
+ const route = normalizeRoute(page.route) || "/";
588
+ const nodeId = `screen:${route}`;
589
+ if (discoveredNodes.has(nodeId)) continue;
590
+ discoveredNodes.set(nodeId, {
591
+ id: nodeId,
592
+ title: page.heading || page.title || semanticScreenTitle(route),
593
+ kind: "screen",
594
+ signature: `${projectRoot}@route:${route}`,
595
+ route,
596
+ source: "running app",
597
+ sourcePaths: [],
598
+ runtimeProbe: { status: "reachable", url: page.url },
599
+ metadata: { discoveredBy: "site-discovery" },
600
+ });
601
+ }
602
+ for (const page of site.pages) {
603
+ const from = `screen:${normalizeRoute(page.route) || "/"}`;
604
+ for (const [index, link] of page.links.entries()) {
605
+ const to = `screen:${normalizeRoute(link.route) || "/"}`;
606
+ if (to === from || !discoveredNodes.has(to)) continue;
607
+ crawledEdges.push({ id: `${from}->${to}:crawl-${index}`, from, to, label: link.label, action: `navigate:${normalizeRoute(link.route) || "/"}`, source: "running app" });
608
+ }
609
+ }
610
+ }
575
611
  graph.nodes = [...discoveredNodes.values()];
576
612
  // Resolve links after all route files are known so source order cannot
577
613
  // make a valid link look unresolved.
578
614
  const routeNodes = graph.nodes as any[];
615
+ const previousEdges: any[] = Array.isArray(graph.edges) ? graph.edges : [];
579
616
  graph.edges = [];
580
617
  for (const filePath of sourceFiles) {
581
618
  const relativePath = path.relative(projectRoot, filePath);
@@ -619,6 +656,17 @@ export async function explore(cfg: any, options: ExploreOptions): Promise<Explor
619
656
  });
620
657
  }
621
658
  }
659
+ for (const edge of crawledEdges) {
660
+ if (!graph.edges.some((existing: any) => existing.from === edge.from && existing.to === edge.to)) graph.edges.push(edge);
661
+ }
662
+ // Transitions a person or an agent recorded for a journey aren't in the
663
+ // source or the crawl: keep them while both of their screens still exist,
664
+ // or rebuilding the sitemap breaks those journeys.
665
+ const recorded = new Set(existingJourneys.flatMap((journey: any) => Array.isArray(journey.edgeIds) ? journey.edgeIds : []));
666
+ for (const edge of previousEdges) {
667
+ if (!recorded.has(edge?.id) || graph.edges.some((existing: any) => existing.id === edge.id)) continue;
668
+ if (discoveredNodes.has(edge.from) && discoveredNodes.has(edge.to)) graph.edges.push(edge);
669
+ }
622
670
  for (const [index, link] of (routesManifest.links ?? []).entries()) {
623
671
  if (!link?.from || !link?.to) continue;
624
672
  const fromNode = routeNodes.find((candidate: any) =>
@@ -1,4 +1,5 @@
1
1
  import YAML from "yaml";
2
+ import { registerSecretValue } from "./secret-redaction.js";
2
3
 
3
4
  /**
4
5
  * The Maestro-style flow subset this project's web plans author.
@@ -25,6 +26,18 @@ export type FlowStep =
25
26
  * sitting on top of a link or button and silently swallowing the tap.
26
27
  */
27
28
  | { type: "assertClickable"; text: string; index: number; timeoutMs?: number }
29
+ /**
30
+ * The page looks right at this screen size: it doesn't scroll sideways,
31
+ * no text spills out of the card or box it sits in, and none is cut off
32
+ * without an ellipsis. What a person notices first on a phone.
33
+ */
34
+ | { type: "assertLayout" }
35
+ /**
36
+ * Every link on the page leads somewhere. Same-site links must answer;
37
+ * with `external`, other sites' links are checked too (a 404, 410 or
38
+ * server error fails; sites that refuse robots are not counted).
39
+ */
40
+ | { type: "assertLinks"; external: boolean }
28
41
  | { type: "scrollUntilVisible"; text: string }
29
42
  | { type: "wait"; ms: number }
30
43
  | { type: "takeScreenshot"; name: string }
@@ -128,6 +141,8 @@ function parseStep(raw: unknown): FlowStep {
128
141
  if (raw === "launchApp") return { type: "launchApp" };
129
142
  if (raw === "stopApp") return { type: "stopApp" };
130
143
  if (raw === "eraseText") return { type: "eraseText" };
144
+ if (raw === "assertLayout") return { type: "assertLayout" };
145
+ if (raw === "assertLinks") return { type: "assertLinks", external: false };
131
146
  if (raw === "requestTempEmail") return { type: "requestTempEmail", as: "tempEmail" };
132
147
  if (raw === "openEmailLink") return { type: "openEmailLink", as: "tempEmail" };
133
148
  // Maestro waits for animations before asserting; the web driver's
@@ -187,6 +202,11 @@ function parseStep(raw: unknown): FlowStep {
187
202
  }
188
203
  return { type: "assertVisible", text: requireText(value.visible, "extendedWaitUntil"), timeoutMs };
189
204
  }
205
+ if ("assertLayout" in record) return { type: "assertLayout" };
206
+ if ("assertLinks" in record) {
207
+ const value = asRecord(record.assertLinks);
208
+ return { type: "assertLinks", external: Boolean(value?.external) };
209
+ }
190
210
  if ("scrollUntilVisible" in record) {
191
211
  const value = asRecord(record.scrollUntilVisible);
192
212
  return { type: "scrollUntilVisible", text: requireText(value ? value.element : record.scrollUntilVisible, "scrollUntilVisible") };
@@ -291,6 +311,13 @@ export type InterpolateOptions = {
291
311
  env?: Readonly<Record<string, string | undefined>>;
292
312
  };
293
313
 
314
+ /** The secret names a flow's text references as `{{secret.NAME}}`, sorted and unique. */
315
+ export function referencedSecretNames(content: string): string[] {
316
+ const names = new Set<string>();
317
+ for (const match of content.matchAll(/\{\{\s*secret\.([a-zA-Z0-9_]+)\s*\}\}/g)) names.add(match[1]);
318
+ return [...names].sort();
319
+ }
320
+
294
321
  /**
295
322
  * Substitutes `{{name}}` tokens with a value bound earlier in the same flow
296
323
  * (currently, only requestTempEmail binds one). Plain text with no template
@@ -316,6 +343,8 @@ export function interpolateTemplate(
316
343
  if (value === undefined || value === "") {
317
344
  throw new Error(`Flow references "{{secret.${name}}}", but ${variable} is not set on this worker.`);
318
345
  }
346
+ // Typed from here on, so it must never show up in a log or result.
347
+ registerSecretValue(value);
319
348
  return value;
320
349
  }
321
350
  if (!(name in context)) {
@@ -351,6 +380,8 @@ export function describeStepLabel(step: FlowStep): string {
351
380
  case "assertVisible": return `See “${step.text}”`;
352
381
  case "assertNotVisible": return `No “${step.text}”`;
353
382
  case "assertClickable": return `“${step.text}” is clickable`;
383
+ case "assertLayout": return "Nothing spills off the screen";
384
+ case "assertLinks": return step.external ? "Every link works" : "Every link on the site works";
354
385
  case "scrollUntilVisible": return `Scroll to “${step.text}”`;
355
386
  case "wait": return `Wait ${step.ms}ms`;
356
387
  case "takeScreenshot": return `Screenshot “${step.name}”`;
@@ -382,22 +413,26 @@ export function describeAddressForLog(address: string): string {
382
413
  * have templated. `retry`'s nested steps are deliberately left alone here --
383
414
  * the driver interpolates each one individually as it actually executes it.
384
415
  */
385
- export function interpolateStep(step: FlowStep, context: Readonly<Record<string, string>>): FlowStep {
416
+ export function interpolateStep(
417
+ step: FlowStep,
418
+ context: Readonly<Record<string, string>>,
419
+ options: Pick<InterpolateOptions, "env"> = {},
420
+ ): FlowStep {
386
421
  switch (step.type) {
387
422
  case "tapOn":
388
- return { ...step, text: interpolateTemplate(step.text, context) };
423
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
389
424
  case "inputText":
390
- return { ...step, text: interpolateTemplate(step.text, context, { allowSecrets: true }) };
425
+ return { ...step, text: interpolateTemplate(step.text, context, { ...options, allowSecrets: true }) };
391
426
  case "assertVisible":
392
- return { ...step, text: interpolateTemplate(step.text, context) };
427
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
393
428
  case "assertNotVisible":
394
- return { ...step, text: interpolateTemplate(step.text, context) };
429
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
395
430
  case "assertClickable":
396
- return { ...step, text: interpolateTemplate(step.text, context) };
431
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
397
432
  case "scrollUntilVisible":
398
- return { ...step, text: interpolateTemplate(step.text, context) };
433
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
399
434
  case "openLink":
400
- return { ...step, url: interpolateTemplate(step.url, context) };
435
+ return { ...step, url: interpolateTemplate(step.url, context, options) };
401
436
  default:
402
437
  return step;
403
438
  }