@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.
- package/LICENSE +18 -4
- package/package.json +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- 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,
|
|
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
|
-
|
|
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 =
|
|
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) });
|
package/src/runtime/executor.ts
CHANGED
|
@@ -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
|
-
`
|
|
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
|
+
});
|
package/src/runtime/explorer.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|