@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
package/scripts/bugmole.ts
CHANGED
|
@@ -9,6 +9,7 @@ import dotenv from "dotenv";
|
|
|
9
9
|
import YAML from "yaml";
|
|
10
10
|
import { resolveLocalStorageDirectory } from "../src/storage/storage-directory.js";
|
|
11
11
|
import { adoptLegacyEnv, preferExisting } from "../src/runtime/legacy-names.js";
|
|
12
|
+
import { approvalModeOf, workerApprovalMode, type ApprovalMode } from "../src/registry/task-scheduling.js";
|
|
12
13
|
|
|
13
14
|
type Mode =
|
|
14
15
|
| "api-key"
|
|
@@ -18,16 +19,20 @@ type Mode =
|
|
|
18
19
|
| "plan"
|
|
19
20
|
| "run"
|
|
20
21
|
| "propose-diff"
|
|
22
|
+
| "discover"
|
|
21
23
|
| "review"
|
|
22
24
|
| "report"
|
|
23
25
|
| "artifacts"
|
|
24
26
|
| "serve"
|
|
25
27
|
| "gateway"
|
|
26
28
|
| "run-once"
|
|
29
|
+
| "propose"
|
|
30
|
+
| "roles"
|
|
27
31
|
| "reset"
|
|
28
32
|
| "cursor-driver"
|
|
29
33
|
| "suite"
|
|
30
|
-
| "init-env"
|
|
34
|
+
| "init-env"
|
|
35
|
+
| "secrets";
|
|
31
36
|
|
|
32
37
|
function readArg(name: string, argv = process.argv): string | undefined {
|
|
33
38
|
const i = argv.indexOf(name);
|
|
@@ -44,23 +49,42 @@ export function parseArgs(argv = process.argv): Record<string, any> {
|
|
|
44
49
|
if (positionalCommand === "gateway") args.gateway = true;
|
|
45
50
|
if (positionalCommand === "run-once") args.runonce = true;
|
|
46
51
|
if (positionalCommand === "reset") args.reset = true;
|
|
52
|
+
if (positionalCommand === "roles") args.roles = true;
|
|
53
|
+
if (positionalCommand === "secrets") args.secrets = true;
|
|
54
|
+
if (positionalCommand === "propose") {
|
|
55
|
+
args.propose = true;
|
|
56
|
+
const file = argv.slice(argv.indexOf("propose") + 1).find((arg) => !arg.startsWith("--"));
|
|
57
|
+
if (file) args.proposefile = file;
|
|
58
|
+
}
|
|
47
59
|
for (let i = 0; i < argv.length; i++) {
|
|
48
60
|
const arg = argv[i];
|
|
49
61
|
if (arg.startsWith("--")) {
|
|
50
|
-
const
|
|
62
|
+
const name = arg.slice(2);
|
|
63
|
+
const key = name.replace(/-/g, "");
|
|
51
64
|
const next = argv[i + 1];
|
|
52
65
|
const value = next && !next.startsWith("--") ? next : true;
|
|
53
66
|
if (value !== true) i++;
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
else
|
|
67
|
+
// Only counts and durations become numbers: `--grep 404` or
|
|
68
|
+
// `--project 2024` are names, and must stay strings.
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
if (value === true || value === "true") parsed = true;
|
|
71
|
+
else if (value === "false") parsed = false;
|
|
72
|
+
else if (NUMERIC_FLAGS.has(key.toLowerCase()) && value.trim() !== "" && !isNaN(Number(value))) parsed = Number(value);
|
|
73
|
+
else parsed = value;
|
|
74
|
+
args[key] = parsed;
|
|
75
|
+
// Modes read either spelling: --file-path is args.filepath and args.filePath.
|
|
76
|
+
const camel = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
77
|
+
if (camel !== key) args[camel] = parsed;
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
return args;
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
const NUMERIC_FLAGS = new Set([
|
|
84
|
+
"timeout", "maxpages", "maxdepth", "localregistryport", "artifactport", "gatewayport", "pollinterval",
|
|
85
|
+
"nudgeinterval", "nudgemaxnudges", "nudgeinactivitythreshold", "nudgeprogressreporttimeout",
|
|
86
|
+
]);
|
|
87
|
+
|
|
64
88
|
export function printHelp(init = false): void {
|
|
65
89
|
if (init) {
|
|
66
90
|
console.log(`Usage: bugmole init [options]
|
|
@@ -70,40 +94,87 @@ Initialize .bugmole/config.yaml and .bugmole.env without reading credentials.
|
|
|
70
94
|
Options:
|
|
71
95
|
--non-interactive Use defaults and never prompt
|
|
72
96
|
--force Overwrite an existing configuration
|
|
73
|
-
--storage-provider <name> local, gcs, s3, or r2
|
|
97
|
+
--storage-provider <name> local, bugmole, gcs, s3, or r2
|
|
98
|
+
--bucket <name> Bucket for gcs, s3 or r2
|
|
99
|
+
--region <region> S3 region
|
|
100
|
+
--account-id <id> R2 account ID
|
|
101
|
+
--cloud-project-id <id> Google Cloud project for gcs
|
|
102
|
+
--endpoint <url> Custom storage endpoint
|
|
103
|
+
--project-id <id> Project identifier (default: the folder name)
|
|
104
|
+
--project-name <name> Project display name
|
|
105
|
+
--environment <name> Environment name (default: local)
|
|
106
|
+
--spec-dir <folder> Where flows, plans and roles live (default: ./spec)
|
|
74
107
|
--workspace-id <id> Workspace identifier
|
|
75
108
|
--workspace-name <name> Workspace display name
|
|
76
109
|
--app-id <id> Default app identifier
|
|
77
110
|
--app-name <name> Default app display name
|
|
111
|
+
--app-path <folder> The app's folder in a repository with several apps
|
|
112
|
+
--app-url <url> The app's URL (a public one identifies the project by domain)
|
|
78
113
|
--config <path> Configuration output path
|
|
79
|
-
--help Show this help
|
|
114
|
+
--help Show this help
|
|
115
|
+
|
|
116
|
+
Bugmole tests web apps and mobile apps (Android and iOS). One app is one
|
|
117
|
+
project: a repository's git remote plus the app's folder identify it, so
|
|
118
|
+
teammates who clone the same repository join the same project.`);
|
|
80
119
|
return;
|
|
81
120
|
}
|
|
82
121
|
console.log(`Usage: bugmole [command] [options]
|
|
83
122
|
|
|
84
123
|
Commands:
|
|
85
124
|
init Initialize a project configuration
|
|
86
|
-
api-key create Generate a one-time machine API key
|
|
125
|
+
api-key create Generate a one-time machine API key (needs a key with keys:write;
|
|
126
|
+
the dashboard's API keys page works without one)
|
|
127
|
+
--name <name> What the key is for
|
|
128
|
+
--project-ids <ids> Comma-separated projects it may use
|
|
87
129
|
--mcp Start the MCP server
|
|
88
130
|
artifacts serve Serve local artifacts to the dashboard over loopback
|
|
89
131
|
--artifact-port <port> Loopback port (default: 3190)
|
|
90
132
|
--dashboard-origin <url> Allowed dashboard origin
|
|
133
|
+
propose <file.json> Propose test-case changes (add, change, retire) for review;
|
|
134
|
+
once accepted each becomes a new revision and the old one obsolete
|
|
135
|
+
secrets List the {{secret.NAME}}s your flows use and where each comes from
|
|
136
|
+
secrets set <NAME> Keep a value in this machine's secure store (typed hidden, or piped on stdin)
|
|
137
|
+
secrets delete <NAME> Remove it. Or map names to your vault (gcpsm://, awssm://, keychain://,
|
|
138
|
+
env://) under secrets: in the config
|
|
139
|
+
roles List the project's roles: confirmed ones are used for test cases and runs
|
|
140
|
+
roles suggest <name> Suggest a role for a person to confirm on the dashboard's Roles page
|
|
141
|
+
--evidence <text> What points at it (a sign-in wall, a gated route or menu)
|
|
142
|
+
--description <text> Who this is and what they can do
|
|
143
|
+
--from-evidence Suggest every role the journey graph, blockers and roles.yaml imply
|
|
91
144
|
serve Start the local artifact bridge and HTTP MCP server
|
|
92
145
|
--no-open Print the dashboard handoff without opening a browser
|
|
146
|
+
--approval <mode> manual | auto | extreme: only take tasks this mode would allow.
|
|
147
|
+
Stricter than the project's mode, never looser (sets BUGMOLE_APPROVAL).
|
|
93
148
|
--dashboard-url <url> Dashboard URL for the handoff (overrides BUGMOLE_DASHBOARD_URL)
|
|
94
149
|
--local-registry Stand in for the real registry locally (overrides BUGMOLE_LOCAL_REGISTRY)
|
|
95
150
|
so queueing tasks/runs and this worker executing them works without
|
|
96
|
-
|
|
151
|
+
signing in or a BUGMOLE_API_KEY scoped to this project.
|
|
97
152
|
--local-registry-port Local registry stub port (default: 4600, or BUGMOLE_LOCAL_REGISTRY_PORT)
|
|
98
153
|
gateway Multiplex project serve workers over loopback
|
|
99
154
|
--gateway-port <port> Loopback port (default: 3191)
|
|
100
155
|
--gateway-secret <value> Shared secret for worker registration
|
|
101
156
|
reset Delete current-project execution data after confirmation
|
|
157
|
+
--yes Skip the typed confirmation (scripts and CI)
|
|
102
158
|
run-once Execute one Bugmole Cloud run and exit (used inside cloud runners;
|
|
103
159
|
reads BUGMOLE_REGISTRY_URL, BUGMOLE_RUN_ID, BUGMOLE_RUN_TOKEN)
|
|
104
|
-
--mode <mode> explore, plan, run, report, review, propose-diff, or
|
|
160
|
+
--mode <mode> explore, plan, run, report, review, propose-diff, suite, or discover
|
|
161
|
+
--app-path <folder> The app's folder in a repository with several apps
|
|
162
|
+
--app-url <url> The app's URL
|
|
163
|
+
--timeout <ms> How long a flow may take
|
|
164
|
+
--mode explore Learn the app's screens from its source, or by crawling it
|
|
165
|
+
--base-url <url> The running app
|
|
166
|
+
--mode plan Write a runnable plan for a journey
|
|
167
|
+
--journey-id <id> The journey to plan
|
|
168
|
+
--actor <role> A confirmed role to plan it for
|
|
169
|
+
--mode discover Click through a running app and write a flow for each page and form
|
|
170
|
+
--base-url <url> The running app
|
|
171
|
+
--out <dir> Where to write the flows (required)
|
|
172
|
+
--sign-in <flow> Sign in with this flow first, to explore the signed-in app
|
|
173
|
+
--max-pages <n> Stop after this many pages
|
|
174
|
+
--max-depth <n> Follow links at most this many clicks from the start
|
|
175
|
+
--open-each-page Open every page directly as well as by clicking
|
|
105
176
|
--mode suite Run a directory of web flows against a running app
|
|
106
|
-
--flows <dir> Flow directory (default: .maestro)
|
|
177
|
+
--flows <dir> Flow directory (default: spec/flows, then flows, then .maestro)
|
|
107
178
|
--base-url <url> Application base URL (or BUGMOLE_BASE_URL)
|
|
108
179
|
--artifacts-dir <dir> Evidence directory (default: qa/bugmole-runs)
|
|
109
180
|
--project <id> Project id used in the evidence path
|
|
@@ -128,6 +199,9 @@ export function resolveMode(args: Record<string, any>): Mode {
|
|
|
128
199
|
if (args.gateway === true) return "gateway";
|
|
129
200
|
if (args.runonce === true) return "run-once";
|
|
130
201
|
if (args.reset === true) return "reset";
|
|
202
|
+
if (args.propose === true) return "propose";
|
|
203
|
+
if (args.roles === true) return "roles";
|
|
204
|
+
if (args.secrets === true) return "secrets";
|
|
131
205
|
if (args.artifacts === true) return "artifacts";
|
|
132
206
|
return (args.mode ?? "mcp") as Mode;
|
|
133
207
|
}
|
|
@@ -137,9 +211,10 @@ export function resolveMode(args: Record<string, any>): Mode {
|
|
|
137
211
|
// step needs it to fail the build, so `--mode run` is the one mode whose exit code
|
|
138
212
|
// reflects the result rather than just whether the process threw.
|
|
139
213
|
export function runModeExitCode(mode: string, result: unknown): number {
|
|
140
|
-
if (mode !== "run") return 0;
|
|
141
214
|
const status = result && typeof result === "object" ? (result as { status?: unknown }).status : undefined;
|
|
142
|
-
|
|
215
|
+
// A run passes only when it all passed; the other modes fail on an error.
|
|
216
|
+
if (mode === "run") return status === "success" ? 0 : 1;
|
|
217
|
+
return status === "error" ? 1 : 0;
|
|
143
218
|
}
|
|
144
219
|
|
|
145
220
|
export function shouldOpenServeBrowser(
|
|
@@ -209,6 +284,19 @@ export function resolveLocalRegistryEnabled(args: Record<string, any>): boolean
|
|
|
209
284
|
// every fetch with "bad port" — 4190 is the reserved ManageSieve port).
|
|
210
285
|
const DEFAULT_LOCAL_REGISTRY_PORT = 4600;
|
|
211
286
|
|
|
287
|
+
/**
|
|
288
|
+
* `--approval manual|auto|extreme` for `bugmole serve`. It becomes
|
|
289
|
+
* BUGMOLE_APPROVAL so every part of this process (worker, MCP, the coding
|
|
290
|
+
* agent driver) holds back the same tasks. Returns null for a bad value.
|
|
291
|
+
*/
|
|
292
|
+
export function applyApprovalFlag(args: Record<string, any>, env: Record<string, string | undefined> = process.env): ApprovalMode | undefined | null {
|
|
293
|
+
if (args.approval === undefined) return workerApprovalMode(env);
|
|
294
|
+
const mode = approvalModeOf(typeof args.approval === "string" ? args.approval.trim() : "");
|
|
295
|
+
if (!mode) return null;
|
|
296
|
+
env.BUGMOLE_APPROVAL = mode;
|
|
297
|
+
return mode;
|
|
298
|
+
}
|
|
299
|
+
|
|
212
300
|
export function resolveLocalRegistryPort(args: Record<string, any>): number {
|
|
213
301
|
if (typeof args.localregistryport === "number") return args.localregistryport;
|
|
214
302
|
const fromEnv = process.env.BUGMOLE_LOCAL_REGISTRY_PORT?.trim();
|
|
@@ -351,6 +439,13 @@ async function main() {
|
|
|
351
439
|
if (positionalCommand === "artifacts") args.artifacts = true;
|
|
352
440
|
if (positionalCommand === "serve") args.serve = true;
|
|
353
441
|
if (positionalCommand === "reset") args.reset = true;
|
|
442
|
+
if (positionalCommand === "roles") args.roles = true;
|
|
443
|
+
if (positionalCommand === "secrets") args.secrets = true;
|
|
444
|
+
if (positionalCommand === "propose") {
|
|
445
|
+
args.propose = true;
|
|
446
|
+
const file = process.argv.slice(process.argv.indexOf("propose") + 1).find((arg) => !arg.startsWith("--"));
|
|
447
|
+
if (file) args.proposefile = file;
|
|
448
|
+
}
|
|
354
449
|
// quiet: dotenv 17 prints an "injected env" banner on every load, which
|
|
355
450
|
// would land in the middle of this CLI's own output.
|
|
356
451
|
dotenv.config({ path: preferExisting(".bugmole.env", ".valk.env"), quiet: true });
|
|
@@ -384,29 +479,38 @@ async function main() {
|
|
|
384
479
|
|
|
385
480
|
// Init must run from the caller's workspace, not the package directory.
|
|
386
481
|
if (resolveMode(args) === "init") {
|
|
387
|
-
const { runInitWizard } = await import("../src/runtime/init-wizard.js");
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
482
|
+
const { runInitWizard, PlanLimitError, describePlanLimit } = await import("../src/runtime/init-wizard.js");
|
|
483
|
+
try {
|
|
484
|
+
await runInitWizard({
|
|
485
|
+
nonInteractive: args.noninteractive === true,
|
|
486
|
+
force: args.force === true,
|
|
487
|
+
configPath: args.config,
|
|
488
|
+
workspaceId: args.workspaceid,
|
|
489
|
+
workspaceName: args.workspacename,
|
|
490
|
+
projectId: args.projectid,
|
|
491
|
+
projectName: args.projectname,
|
|
492
|
+
appId: args.appid,
|
|
493
|
+
appName: args.appname,
|
|
494
|
+
environment: args.environment,
|
|
495
|
+
specDir: args.specdir,
|
|
496
|
+
evidenceManifest: args.evidencemanifest,
|
|
497
|
+
evidenceScreenshots: args.evidencescreenshots,
|
|
498
|
+
artifactsDir: args.artifactsdir,
|
|
499
|
+
storageProvider: args.storageprovider,
|
|
500
|
+
bucket: args.bucket,
|
|
501
|
+
region: args.region,
|
|
502
|
+
endpoint: args.endpoint,
|
|
503
|
+
cloudProjectId: args.cloudprojectid,
|
|
504
|
+
accountId: args.accountid,
|
|
505
|
+
appPath: typeof args.apppath === "string" ? args.apppath : undefined,
|
|
506
|
+
appUrl: typeof args.appurl === "string" ? args.appurl : undefined,
|
|
507
|
+
});
|
|
508
|
+
} catch (error) {
|
|
509
|
+
// A plan limit is an answer, not a crash: say what the plan allows and where to upgrade.
|
|
510
|
+
if (!(error instanceof PlanLimitError)) throw error;
|
|
511
|
+
console.error(describePlanLimit(error));
|
|
512
|
+
process.exitCode = 1;
|
|
513
|
+
}
|
|
410
514
|
return;
|
|
411
515
|
}
|
|
412
516
|
|
|
@@ -448,23 +552,56 @@ async function main() {
|
|
|
448
552
|
return;
|
|
449
553
|
}
|
|
450
554
|
|
|
555
|
+
// Suite and discover run without a project configuration, but use its
|
|
556
|
+
// secrets: map when there is one, so flows can type values from a vault.
|
|
557
|
+
const flowSecrets = async (projectId: string) => {
|
|
558
|
+
const { secretMap } = await import("../src/runtime/secret-sources.js");
|
|
559
|
+
const { withFlowSecrets } = await import("../src/runtime/secret-driver.js");
|
|
560
|
+
let cfg: { secrets?: unknown; project?: { id?: string } } | null = null;
|
|
561
|
+
try {
|
|
562
|
+
if (fs.existsSync(path.resolve(workspaceRoot, configPath))) cfg = loadConfig(path.resolve(workspaceRoot, configPath));
|
|
563
|
+
} catch {
|
|
564
|
+
cfg = null;
|
|
565
|
+
}
|
|
566
|
+
const { map, problems } = secretMap(cfg);
|
|
567
|
+
for (const problem of problems) console.warn(`[Bugmole] ${problem}`);
|
|
568
|
+
const id = cfg?.project?.id ?? projectId;
|
|
569
|
+
return (driver: import("../src/runtime/driver.js").AppDriver) => withFlowSecrets(driver, { map, projectId: id });
|
|
570
|
+
};
|
|
571
|
+
|
|
451
572
|
// A web suite is deterministic: it needs a running app and flow files, not
|
|
452
573
|
// an LLM or a project configuration, so it runs in any workspace as-is.
|
|
453
574
|
if (mode === "suite") {
|
|
454
575
|
const { runWebSuite } = await import("../src/runtime/web-suite.js");
|
|
576
|
+
let configured: { project?: { id?: string }; runtime?: { spec_dir?: string } } | null = null;
|
|
577
|
+
try {
|
|
578
|
+
if (fs.existsSync(path.resolve(workspaceRoot, configPath))) configured = loadConfig(path.resolve(workspaceRoot, configPath));
|
|
579
|
+
} catch {
|
|
580
|
+
configured = null;
|
|
581
|
+
}
|
|
582
|
+
// Evidence goes under the project's ID, so reset finds it.
|
|
583
|
+
const suiteProject = typeof args.project === "string" ? args.project : configured?.project?.id ?? path.basename(workspaceRoot);
|
|
584
|
+
// The first flows folder that exists: spec/flows (where the docs put
|
|
585
|
+
// them), flows/, then .maestro.
|
|
586
|
+
const specFlows = path.join(configured?.runtime?.spec_dir ?? "spec", "flows");
|
|
587
|
+
const defaultFlows = [specFlows, "flows", ".maestro"].find((dir) => fs.existsSync(path.resolve(workspaceRoot, dir)));
|
|
588
|
+
if (typeof args.flows !== "string" && !defaultFlows) {
|
|
589
|
+
throw new Error(`No flows folder found (looked for ${specFlows}, flows and .maestro). Pass --flows <dir>.`);
|
|
590
|
+
}
|
|
455
591
|
const { targetsFromFlags } = await import("../src/runtime/browser-matrix.js");
|
|
456
592
|
const baseUrl = typeof args.baseurl === "string" ? args.baseurl : process.env.BUGMOLE_BASE_URL;
|
|
457
593
|
if (!baseUrl) throw new Error("--base-url (or BUGMOLE_BASE_URL) is required for --mode suite");
|
|
458
594
|
const report = await runWebSuite({
|
|
459
|
-
flowsDir: path.resolve(workspaceRoot, typeof args.flows === "string" ? args.flows :
|
|
595
|
+
flowsDir: path.resolve(workspaceRoot, typeof args.flows === "string" ? args.flows : defaultFlows!),
|
|
460
596
|
baseUrl,
|
|
461
597
|
artifactsDir: path.resolve(workspaceRoot, typeof args.artifactsdir === "string" ? args.artifactsdir : preferExisting(path.join(workspaceRoot, "qa/bugmole-runs"), path.join(workspaceRoot, "qa/valkyrie-runs"))),
|
|
462
|
-
projectId:
|
|
598
|
+
projectId: suiteProject,
|
|
463
599
|
headless: args.headed !== true,
|
|
464
600
|
timeoutMs: typeof args.timeout === "number" ? args.timeout : undefined,
|
|
465
601
|
grep: typeof args.grep === "string" ? args.grep : undefined,
|
|
466
602
|
shareSession: args.sharesession === true,
|
|
467
603
|
targets: targetsFromFlags(args.browsers, args.devices),
|
|
604
|
+
wrapDriver: await flowSecrets(suiteProject),
|
|
468
605
|
});
|
|
469
606
|
const { total, passed, failed, blocked } = report.totals;
|
|
470
607
|
console.log(`[Bugmole] Suite ${passed}/${total} passed (${failed} failed, ${blocked} blocked). Report: ${report.reportPath}`);
|
|
@@ -472,6 +609,81 @@ async function main() {
|
|
|
472
609
|
return;
|
|
473
610
|
}
|
|
474
611
|
|
|
612
|
+
// Explores a running app like a first-time visitor and writes a flow for
|
|
613
|
+
// every page and form it finds, ready for --mode suite. With --sign-in, that
|
|
614
|
+
// flow runs first and exploration continues in its session, which is
|
|
615
|
+
// deleted afterwards.
|
|
616
|
+
if (mode === "discover") {
|
|
617
|
+
const { discoverSite } = await import("../src/runtime/site-discovery.js");
|
|
618
|
+
const baseUrl = typeof args.baseurl === "string" ? args.baseurl : process.env.BUGMOLE_BASE_URL;
|
|
619
|
+
if (!baseUrl) throw new Error("--base-url (or BUGMOLE_BASE_URL) is required for --mode discover");
|
|
620
|
+
if (typeof args.out !== "string") throw new Error("--out <directory> is required for --mode discover");
|
|
621
|
+
const outDir = path.resolve(workspaceRoot, args.out);
|
|
622
|
+
const sessionDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-discover-"));
|
|
623
|
+
try {
|
|
624
|
+
let storageState: string | undefined;
|
|
625
|
+
if (typeof args.signin === "string") {
|
|
626
|
+
const { PlaywrightDriver } = await import("../src/runtime/playwright-driver.js");
|
|
627
|
+
storageState = path.join(sessionDir, "session-state.json");
|
|
628
|
+
const projectId = typeof args.project === "string" ? args.project : path.basename(workspaceRoot);
|
|
629
|
+
const signIn = await (await flowSecrets(projectId))(new PlaywrightDriver({ headless: args.headed !== true })).run({
|
|
630
|
+
runId: `discover-${Date.now()}`,
|
|
631
|
+
projectId: typeof args.project === "string" ? args.project : path.basename(workspaceRoot),
|
|
632
|
+
journeyId: "sign-in",
|
|
633
|
+
flowPath: path.resolve(workspaceRoot, args.signin),
|
|
634
|
+
platform: "web",
|
|
635
|
+
target: { baseUrl },
|
|
636
|
+
rebaseOrigin: true,
|
|
637
|
+
timeoutMs: typeof args.timeout === "number" ? args.timeout : 30_000,
|
|
638
|
+
artifactsDir: path.join(sessionDir, "artifacts"),
|
|
639
|
+
sessionStatePath: storageState,
|
|
640
|
+
});
|
|
641
|
+
if (signIn.status !== "passed") throw new Error(`Sign-in flow did not pass: ${signIn.message}`);
|
|
642
|
+
console.log(`[Bugmole] Signed in with ${args.signin}`);
|
|
643
|
+
}
|
|
644
|
+
const result = await discoverSite(baseUrl, {
|
|
645
|
+
storageState,
|
|
646
|
+
openEachPage: args.openeachpage === true,
|
|
647
|
+
maxPages: typeof args.maxpages === "number" ? args.maxpages : undefined,
|
|
648
|
+
maxDepth: typeof args.maxdepth === "number" ? args.maxdepth : undefined,
|
|
649
|
+
headless: args.headed !== true,
|
|
650
|
+
onEvent: (event) => {
|
|
651
|
+
if (event.kind !== "click") console.log(`[discover] ${event.text}`);
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
await fs.promises.mkdir(outDir, { recursive: true });
|
|
655
|
+
for (const flow of result.flows) {
|
|
656
|
+
await fs.promises.writeFile(path.join(outDir, path.basename(flow.file)), flow.content, "utf8");
|
|
657
|
+
}
|
|
658
|
+
await fs.promises.writeFile(
|
|
659
|
+
path.join(outDir, "discovery.json"),
|
|
660
|
+
`${JSON.stringify({
|
|
661
|
+
baseUrl: result.baseUrl,
|
|
662
|
+
pages: result.pages.map(({ route, heading, title, path: via }) => ({ route, heading, title, via })),
|
|
663
|
+
limits: result.limits,
|
|
664
|
+
events: result.events,
|
|
665
|
+
}, null, 2)}\n`,
|
|
666
|
+
"utf8",
|
|
667
|
+
);
|
|
668
|
+
// Flows found signed in only pass signed in: put the sign-in flow first
|
|
669
|
+
// in the folder, so a suite with --share-session signs in, then runs them.
|
|
670
|
+
if (typeof args.signin === "string") {
|
|
671
|
+
await fs.promises.copyFile(path.resolve(workspaceRoot, args.signin), path.join(outDir, "00-sign-in.yaml"));
|
|
672
|
+
}
|
|
673
|
+
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
674
|
+
const outName = path.relative(workspaceRoot, outDir) || ".";
|
|
675
|
+
console.log(`[Bugmole] Discovered ${plural(result.pages.length, "page")} and wrote ${plural(result.flows.length, "flow")} to ${outName}`);
|
|
676
|
+
if (result.flows.length) {
|
|
677
|
+
console.log(`[Bugmole] Run them: bugmole --mode suite --flows ${outName} --base-url ${baseUrl}${typeof args.signin === "string" ? " --share-session" : ""}`);
|
|
678
|
+
}
|
|
679
|
+
if (result.pages.length === 0) process.exitCode = 1;
|
|
680
|
+
} finally {
|
|
681
|
+
// Holds the app's cookies and tokens: never outlives the exploration.
|
|
682
|
+
await fs.promises.rm(sessionDir, { recursive: true, force: true });
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
475
687
|
const cfg = loadConfig(configPath);
|
|
476
688
|
|
|
477
689
|
if (mode === "reset") {
|
|
@@ -489,7 +701,13 @@ async function main() {
|
|
|
489
701
|
// Serve only needs the local runtime/storage and registry settings. Full
|
|
490
702
|
// execution validation belongs to plan/run modes, which require LLM and
|
|
491
703
|
// browser configuration that an idle worker does not use.
|
|
492
|
-
if (mode
|
|
704
|
+
if (mode === "secrets") {
|
|
705
|
+
const { runSecretsCommand } = await import("../src/runtime/secrets-cli.js");
|
|
706
|
+
process.exitCode = await runSecretsCommand(cfg, process.argv, { workspaceRoot });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (mode !== "serve" && mode !== "artifacts" && mode !== "explore" && mode !== "roles") {
|
|
493
711
|
const { validateConfigOrThrow } = await import("../src/runtime/config-validate.js");
|
|
494
712
|
validateConfigOrThrow(cfg);
|
|
495
713
|
}
|
|
@@ -503,7 +721,36 @@ async function main() {
|
|
|
503
721
|
|
|
504
722
|
// Dispatch modes
|
|
505
723
|
switch (mode) {
|
|
724
|
+
case "roles": {
|
|
725
|
+
const { parseRolesCommand, runRolesCommand } = await import("../src/runtime/roles-cli.js");
|
|
726
|
+
const command = parseRolesCommand(process.argv, args);
|
|
727
|
+
if ("error" in command) {
|
|
728
|
+
console.error(command.error);
|
|
729
|
+
process.exit(2);
|
|
730
|
+
}
|
|
731
|
+
for (const line of await runRolesCommand(cfg, command)) console.log(`[Bugmole] ${line}`);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
case "propose": {
|
|
735
|
+
if (typeof args.proposefile !== "string") {
|
|
736
|
+
console.error("Usage: bugmole propose <changes.json>");
|
|
737
|
+
process.exit(2);
|
|
738
|
+
}
|
|
739
|
+
const { proposeFromFile } = await import("../src/runtime/propose-cli.js");
|
|
740
|
+
const proposals = await proposeFromFile(cfg, args.proposefile);
|
|
741
|
+
for (const proposal of proposals) {
|
|
742
|
+
const state = proposal.status === "pending" ? "waiting for a manager's review" : proposal.status;
|
|
743
|
+
console.log(`[Bugmole] ${proposal.action} ${proposal.caseSetId}/${proposal.caseId}: ${state} (${proposal.id})`);
|
|
744
|
+
}
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
506
747
|
case "serve": {
|
|
748
|
+
const approval = applyApprovalFlag(args);
|
|
749
|
+
if (approval === null) {
|
|
750
|
+
console.error("[Bugmole] --approval must be manual, auto or extreme");
|
|
751
|
+
process.exit(2);
|
|
752
|
+
}
|
|
753
|
+
if (approval) console.log(`[Bugmole] Approval: ${approval}. This worker waits for a person's approval on ${approval === "manual" ? "every task" : approval === "auto" ? "tasks that change code" : "nothing"}, whatever the project allows.`);
|
|
507
754
|
const remoteStorage = Boolean(cfg.storage?.provider && cfg.storage.provider !== "local");
|
|
508
755
|
let closeBridge: () => void = () => {};
|
|
509
756
|
if (remoteStorage) {
|
|
@@ -542,7 +789,7 @@ async function main() {
|
|
|
542
789
|
console.log(`[Bugmole] MCP will start on the configured HTTP port`);
|
|
543
790
|
console.log(`[Bugmole] Capability expires at ${bridge.expiresAt}`);
|
|
544
791
|
if (!process.env.BUGMOLE_AEGIS_EMAIL?.trim()) {
|
|
545
|
-
console.warn("[Bugmole]
|
|
792
|
+
console.warn("[Bugmole] Sign in with your Bugmole account email on the page that opens.");
|
|
546
793
|
}
|
|
547
794
|
console.log(
|
|
548
795
|
`[Bugmole] Dashboard handoff: ${buildDashboardHandoffUrl(dashboardHandoff.toString(), cfg.project?.id ?? "default", fragment)}`,
|
|
@@ -651,7 +898,7 @@ async function startMcpServerForServe(cfg: any): Promise<void> {
|
|
|
651
898
|
|
|
652
899
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
653
900
|
main().catch((err) => {
|
|
654
|
-
console.error(`[Bugmole
|
|
901
|
+
console.error(`[Bugmole] ${err?.message ?? err}`);
|
|
655
902
|
process.exit(1);
|
|
656
903
|
});
|
|
657
904
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Downloads the browsers Bugmole runs tests in, since the `playwright` package
|
|
3
|
+
// installs only the library: without this, the first `bugmole run` or
|
|
4
|
+
// `bugmole serve` on a new machine stops with "Chromium is not installed".
|
|
5
|
+
// Runs as a postinstall step next to ensure-maestro.cjs, and like it never
|
|
6
|
+
// fails the parent `npm install` — this is best-effort.
|
|
7
|
+
//
|
|
8
|
+
// It uses the package's own Playwright, so the browsers always match the
|
|
9
|
+
// version Bugmole drives. Edge is left out: installing it needs admin rights
|
|
10
|
+
// (sudo on Linux, a system package on macOS), so it stays a one-line opt-in.
|
|
11
|
+
const { spawnSync } = require("node:child_process");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
|
|
14
|
+
// Chromium, Firefox and WebKit (Safari) are the browsers a run targets by
|
|
15
|
+
// default; ffmpeg is what records a run's video.
|
|
16
|
+
const BROWSERS = ["chromium", "firefox", "webkit", "ffmpeg"];
|
|
17
|
+
|
|
18
|
+
function log(message) {
|
|
19
|
+
console.log(`[bugmole postinstall] ${message}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function skipReason(env = process.env) {
|
|
23
|
+
if (env.BUGMOLE_SKIP_PLAYWRIGHT_INSTALL === "true") return "BUGMOLE_SKIP_PLAYWRIGHT_INSTALL=true";
|
|
24
|
+
// A CI job that runs Bugmole's tests asks for the browsers explicitly.
|
|
25
|
+
if (env.BUGMOLE_PLAYWRIGHT_INSTALL === "true") return null;
|
|
26
|
+
// Playwright's own switch, which CI images and Docker builds already set.
|
|
27
|
+
if (env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD && env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD !== "0") return "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is set";
|
|
28
|
+
// CI installs dependencies on every job; several hundred MB of browsers
|
|
29
|
+
// each time is wasted unless the job runs tests, and then it says so.
|
|
30
|
+
if (env.CI && env.CI !== "false" && env.CI !== "0") return "running in CI";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Playwright doesn't export its CLI, so find it the way npm does: through
|
|
35
|
+
// the `bin` its package.json declares.
|
|
36
|
+
function playwrightCli() {
|
|
37
|
+
try {
|
|
38
|
+
const manifest = require.resolve("playwright/package.json");
|
|
39
|
+
const bin = require(manifest).bin;
|
|
40
|
+
const entry = typeof bin === "string" ? bin : bin && bin.playwright;
|
|
41
|
+
return entry ? path.join(path.dirname(manifest), entry) : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function main() {
|
|
48
|
+
const reason = skipReason();
|
|
49
|
+
if (reason) {
|
|
50
|
+
log(`Skipping Playwright browser install (${reason}). Set BUGMOLE_PLAYWRIGHT_INSTALL=true to install them anyway, or later: npx playwright install ${BROWSERS.join(" ")}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const cli = playwrightCli();
|
|
54
|
+
if (!cli) {
|
|
55
|
+
log("Playwright is not installed next to Bugmole, so there are no browsers to fetch yet.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
log(`Installing Playwright browsers (${BROWSERS.join(", ")}). Already-installed ones are skipped.`);
|
|
59
|
+
const install = spawnSync(process.execPath, [cli, "install", ...BROWSERS], {
|
|
60
|
+
stdio: "inherit",
|
|
61
|
+
timeout: 10 * 60 * 1000,
|
|
62
|
+
});
|
|
63
|
+
if (install.error || install.status !== 0) {
|
|
64
|
+
log(
|
|
65
|
+
"Automatic browser install failed. Runs report the missing browser until it is installed: " +
|
|
66
|
+
`npx playwright install ${BROWSERS.join(" ")}`,
|
|
67
|
+
);
|
|
68
|
+
if (process.platform === "linux") {
|
|
69
|
+
log("On Linux the browsers also need system libraries: sudo npx playwright install-deps");
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
log("Playwright browsers are ready. For Edge as well: npx playwright install msedge (needs admin rights).");
|
|
74
|
+
if (process.platform === "linux") {
|
|
75
|
+
log("If a browser fails to start on Linux, install its system libraries: sudo npx playwright install-deps");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { BROWSERS, playwrightCli, skipReason };
|
|
80
|
+
|
|
81
|
+
if (require.main === module) {
|
|
82
|
+
try {
|
|
83
|
+
main();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
log(`Skipping Playwright browser install after unexpected error: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const { BROWSERS, playwrightCli, skipReason } = require("./ensure-playwright.cjs") as {
|
|
10
|
+
BROWSERS: string[];
|
|
11
|
+
playwrightCli: () => string | null;
|
|
12
|
+
skipReason: (env: Record<string, string | undefined>) => string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
test("installing the CLI fetches the browsers a run targets by default, plus ffmpeg for video", () => {
|
|
16
|
+
assert.deepEqual(BROWSERS, ["chromium", "firefox", "webkit", "ffmpeg"]);
|
|
17
|
+
assert.ok(!BROWSERS.includes("msedge"), "Edge needs admin rights, so it stays opt-in");
|
|
18
|
+
const pkg = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")) as { scripts: Record<string, string> };
|
|
19
|
+
assert.match(pkg.scripts.postinstall, /ensure-playwright\.cjs/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the browser download is skipped in CI and when either skip switch is set", () => {
|
|
23
|
+
assert.equal(skipReason({}), null);
|
|
24
|
+
assert.equal(skipReason({ CI: "false" }), null);
|
|
25
|
+
assert.match(skipReason({ CI: "true" }) ?? "", /CI/);
|
|
26
|
+
assert.match(skipReason({ BUGMOLE_SKIP_PLAYWRIGHT_INSTALL: "true" }) ?? "", /BUGMOLE_SKIP_PLAYWRIGHT_INSTALL/);
|
|
27
|
+
assert.match(skipReason({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" }) ?? "", /PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD/);
|
|
28
|
+
assert.equal(skipReason({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "0" }), null);
|
|
29
|
+
assert.equal(skipReason({ CI: "true", BUGMOLE_PLAYWRIGHT_INSTALL: "true" }), null, "a CI job that runs tests can ask for the browsers");
|
|
30
|
+
assert.match(skipReason({ BUGMOLE_PLAYWRIGHT_INSTALL: "true", BUGMOLE_SKIP_PLAYWRIGHT_INSTALL: "true" }) ?? "", /SKIP/, "an explicit skip still wins");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("finds Playwright's CLI even though the package doesn't export it", () => {
|
|
34
|
+
const cli = playwrightCli();
|
|
35
|
+
assert.ok(cli && cli.endsWith("cli.js"), `expected Playwright's cli.js, got ${cli}`);
|
|
36
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Vendors Arcana's bring-your-own-vault resolver (packages/byok/src/byok.ts
|
|
2
|
+
// in theaiinc/arcana) into src/vendor/byok.ts, verbatim under a header. It is
|
|
3
|
+
// one dependency-free file, so the CLI ships it without the Arcana app.
|
|
4
|
+
// ARCANA_DIR=~/arcana node scripts/sync-byok.mjs
|
|
5
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
10
|
+
export const byokCopy = path.join(root, "src/vendor/byok.ts");
|
|
11
|
+
export const BYOK_HEADER = "// VENDORED from theaiinc/arcana packages/byok/src/byok.ts by `node scripts/sync-byok.mjs` — change it there, then sync.\n";
|
|
12
|
+
|
|
13
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
14
|
+
const arcana = process.env.ARCANA_DIR ?? path.join(os.homedir(), "arcana");
|
|
15
|
+
const source = await readFile(path.join(arcana, "packages/byok/src/byok.ts"), "utf8");
|
|
16
|
+
await writeFile(byokCopy, BYOK_HEADER + source);
|
|
17
|
+
console.log(`Wrote ${path.relative(root, byokCopy)} from ${arcana}`);
|
|
18
|
+
}
|