@bugmole/cli 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { journeyIdFor, listFlowFiles, runWebSuite, summarize } from "./web-suite.js";
|
|
7
|
+
|
|
8
|
+
async function flowDir(names: readonly string[]): Promise<string> {
|
|
9
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-suite-"));
|
|
10
|
+
for (const name of names) await fs.writeFile(path.join(dir, name), "url: http://localhost\n---\n- launchApp\n", "utf8");
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("flows run in file-name order so a numbered suite reads as one journey", async () => {
|
|
15
|
+
const dir = await flowDir(["02-second.yaml", "01-first.yaml", "notes.md", "03-third.yml"]);
|
|
16
|
+
assert.deepEqual((await listFlowFiles(dir)).map(journeyIdFor), ["01-first", "02-second", "03-third"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("grep narrows a suite to one journey", async () => {
|
|
20
|
+
const dir = await flowDir(["01-viewer.yaml", "02-creator-editor.yaml"]);
|
|
21
|
+
assert.deepEqual((await listFlowFiles(dir, "creator")).map(journeyIdFor), ["02-creator-editor"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("grep accepts several journeys so a debugging run can include sign-in", async () => {
|
|
25
|
+
const dir = await flowDir(["01-signin.yaml", "02-editor.yaml", "03-billing.yaml"]);
|
|
26
|
+
assert.deepEqual((await listFlowFiles(dir, "signin, editor")).map(journeyIdFor), ["01-signin", "02-editor"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("an empty flow directory is an error, not a vacuous pass", async () => {
|
|
30
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-suite-empty-"));
|
|
31
|
+
await assert.rejects(
|
|
32
|
+
runWebSuite({ flowsDir: dir, baseUrl: "http://localhost", artifactsDir: dir, projectId: "demo" }),
|
|
33
|
+
/No flow files found/,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("totals count every outcome", () => {
|
|
38
|
+
const totals = summarize([
|
|
39
|
+
{ journeyId: "a", flowPath: "a", status: "passed", message: "", durationMs: 1, artifacts: [] },
|
|
40
|
+
{ journeyId: "b", flowPath: "b", status: "failed", message: "", durationMs: 1, artifacts: [] },
|
|
41
|
+
{ journeyId: "c", flowPath: "c", status: "blocked", message: "", durationMs: 1, artifacts: [] },
|
|
42
|
+
]);
|
|
43
|
+
assert.deepEqual(totals, { total: 3, passed: 1, failed: 1, blocked: 1 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("typed secrets never reach the uploaded log, and the shared session is deleted after the suite", async (t) => {
|
|
47
|
+
const { chromium } = await import("playwright");
|
|
48
|
+
try {
|
|
49
|
+
await fs.access(chromium.executablePath());
|
|
50
|
+
} catch {
|
|
51
|
+
t.skip("Chromium is not installed");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const http = await import("node:http");
|
|
55
|
+
const server = http.createServer((_request, response) => {
|
|
56
|
+
response.writeHead(200, { "content-type": "text/html", "set-cookie": "session=abc; Path=/" });
|
|
57
|
+
response.end('<!doctype html><label>Password <input type="password" name="pw"></label><p>Welcome</p>');
|
|
58
|
+
});
|
|
59
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
60
|
+
const { port } = server.address() as { port: number };
|
|
61
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-suite-secret-"));
|
|
62
|
+
const flows = path.join(dir, "flows");
|
|
63
|
+
await fs.mkdir(flows);
|
|
64
|
+
await fs.writeFile(path.join(flows, "01-signin.yaml"), [
|
|
65
|
+
"name: signin",
|
|
66
|
+
"---",
|
|
67
|
+
"- launchApp",
|
|
68
|
+
'- tapOn: "Password"',
|
|
69
|
+
'- inputText: "{{secret.SUITE_PASSWORD}}"',
|
|
70
|
+
'- inputText: "literal-hunter2"',
|
|
71
|
+
'- assertVisible: "Welcome"',
|
|
72
|
+
"",
|
|
73
|
+
].join("\n"));
|
|
74
|
+
process.env.BUGMOLE_FLOW_SECRET_SUITE_PASSWORD = "env-hunter2";
|
|
75
|
+
try {
|
|
76
|
+
const report = await runWebSuite({
|
|
77
|
+
flowsDir: flows,
|
|
78
|
+
baseUrl: `http://127.0.0.1:${port}/`,
|
|
79
|
+
artifactsDir: path.join(dir, "artifacts"),
|
|
80
|
+
projectId: "demo",
|
|
81
|
+
runId: "run-secret",
|
|
82
|
+
shareSession: true,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(report.totals.passed, 1, report.flows[0]?.message);
|
|
85
|
+
const logPath = report.flows[0].artifacts.find((file) => file.endsWith("playwright.log"));
|
|
86
|
+
assert.ok(logPath);
|
|
87
|
+
const log = await fs.readFile(logPath, "utf8");
|
|
88
|
+
assert.doesNotMatch(log, /hunter2/);
|
|
89
|
+
assert.match(log, /inputText/);
|
|
90
|
+
assert.doesNotMatch(JSON.stringify(report), /hunter2/);
|
|
91
|
+
await assert.rejects(fs.access(path.join(dir, "artifacts", "demo", "run-secret", "session-state.json")));
|
|
92
|
+
} finally {
|
|
93
|
+
delete process.env.BUGMOLE_FLOW_SECRET_SUITE_PASSWORD;
|
|
94
|
+
server.close();
|
|
95
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PlaywrightDriver } from "./playwright-driver.js";
|
|
4
|
+
import type { DriverResult } from "./driver.js";
|
|
5
|
+
import { targetLabel, type RunTarget } from "./browser-matrix.js";
|
|
6
|
+
import { resolveTargetConcurrency, runPool } from "./target-runner.js";
|
|
7
|
+
|
|
8
|
+
export interface WebSuiteOptions {
|
|
9
|
+
flowsDir: string;
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
artifactsDir: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
runId?: string;
|
|
14
|
+
headless?: boolean;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Comma-separated substrings matched against flow file names, for re-running
|
|
18
|
+
* part of a suite. Include the sign-in flow when the suite shares a session.
|
|
19
|
+
*/
|
|
20
|
+
grep?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Carry cookies and local storage from one flow to the next, so a numbered
|
|
23
|
+
* suite reads as one continuous session (sign in once, then keep working).
|
|
24
|
+
* Off by default: independent flows must not prop each other up.
|
|
25
|
+
*/
|
|
26
|
+
shareSession?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Browsers/devices to run the suite on. Each target runs the flows in order
|
|
29
|
+
* in its own session; targets run in parallel. Omit for desktop Chromium.
|
|
30
|
+
*/
|
|
31
|
+
targets?: RunTarget[];
|
|
32
|
+
concurrency?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WebSuiteFlowResult {
|
|
36
|
+
journeyId: string;
|
|
37
|
+
/** Set when the suite ran across a browser/device matrix. */
|
|
38
|
+
targetId?: string;
|
|
39
|
+
flowPath: string;
|
|
40
|
+
status: DriverResult["status"];
|
|
41
|
+
message: string;
|
|
42
|
+
failureCode?: string;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
artifacts: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WebSuiteReport {
|
|
48
|
+
projectId: string;
|
|
49
|
+
runId: string;
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
targets?: RunTarget[];
|
|
52
|
+
startedAt: string;
|
|
53
|
+
finishedAt: string;
|
|
54
|
+
totals: { total: number; passed: number; failed: number; blocked: number };
|
|
55
|
+
flows: WebSuiteFlowResult[];
|
|
56
|
+
reportPath: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Flow files run in file-name order so a suite reads as a numbered journey. */
|
|
60
|
+
export async function listFlowFiles(flowsDir: string, grep?: string): Promise<string[]> {
|
|
61
|
+
const patterns = (grep ?? "").split(",").map((pattern) => pattern.trim()).filter(Boolean);
|
|
62
|
+
const entries = await fs.readdir(flowsDir, { withFileTypes: true });
|
|
63
|
+
return entries
|
|
64
|
+
.filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name))
|
|
65
|
+
.map((entry) => entry.name)
|
|
66
|
+
.filter((name) => patterns.length === 0 || patterns.some((pattern) => name.includes(pattern)))
|
|
67
|
+
.sort((left, right) => left.localeCompare(right, "en"))
|
|
68
|
+
.map((name) => path.join(flowsDir, name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function journeyIdFor(flowPath: string): string {
|
|
72
|
+
return path.basename(flowPath).replace(/\.ya?ml$/i, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function summarize(flows: readonly WebSuiteFlowResult[]) {
|
|
76
|
+
return {
|
|
77
|
+
total: flows.length,
|
|
78
|
+
passed: flows.filter((flow) => flow.status === "passed").length,
|
|
79
|
+
failed: flows.filter((flow) => flow.status === "failed").length,
|
|
80
|
+
blocked: flows.filter((flow) => flow.status === "blocked").length,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Runs every flow in a directory against one base URL and writes a JSON
|
|
86
|
+
* report next to the run's artifacts.
|
|
87
|
+
*
|
|
88
|
+
* Flows run sequentially and each one gets its own browser context, so a
|
|
89
|
+
* journey that leaves state behind cannot silently prop up the next one --
|
|
90
|
+
* unless `shareSession` opts the suite into one continuous signed-in session.
|
|
91
|
+
*/
|
|
92
|
+
export async function runWebSuite(options: WebSuiteOptions): Promise<WebSuiteReport> {
|
|
93
|
+
const runId = options.runId ?? `suite-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
94
|
+
const flowPaths = await listFlowFiles(options.flowsDir, options.grep);
|
|
95
|
+
if (flowPaths.length === 0) {
|
|
96
|
+
throw new Error(`No flow files found in ${options.flowsDir}${options.grep ? ` matching "${options.grep}"` : ""}.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const driver = new PlaywrightDriver({ headless: options.headless ?? true });
|
|
100
|
+
const startedAt = new Date().toISOString();
|
|
101
|
+
const targets = options.targets?.length ? options.targets : undefined;
|
|
102
|
+
|
|
103
|
+
const sessionStatePaths: string[] = [];
|
|
104
|
+
const runLane = async (target: RunTarget | undefined): Promise<WebSuiteFlowResult[]> => {
|
|
105
|
+
const results: WebSuiteFlowResult[] = [];
|
|
106
|
+
// Scoped to the run (and target): a suite must start from a clean browser
|
|
107
|
+
// every time, or yesterday's cookies and saved drafts quietly become this
|
|
108
|
+
// run's fixtures. Use --grep with the sign-in flow included to debug a
|
|
109
|
+
// single journey.
|
|
110
|
+
const sessionStatePath = options.shareSession
|
|
111
|
+
? path.join(options.artifactsDir, options.projectId, runId, ...(target ? [target.id] : []), "session-state.json")
|
|
112
|
+
: undefined;
|
|
113
|
+
if (sessionStatePath) {
|
|
114
|
+
sessionStatePaths.push(sessionStatePath);
|
|
115
|
+
await fs.mkdir(path.dirname(sessionStatePath), { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const flowPath of flowPaths) {
|
|
119
|
+
const journeyId = journeyIdFor(flowPath);
|
|
120
|
+
const startedMs = Date.now();
|
|
121
|
+
const result = await driver.run({
|
|
122
|
+
runId,
|
|
123
|
+
projectId: options.projectId,
|
|
124
|
+
journeyId,
|
|
125
|
+
flowPath,
|
|
126
|
+
platform: "web",
|
|
127
|
+
target: { baseUrl: options.baseUrl },
|
|
128
|
+
timeoutMs: options.timeoutMs ?? 30_000,
|
|
129
|
+
artifactsDir: options.artifactsDir,
|
|
130
|
+
sessionStatePath,
|
|
131
|
+
...(target ? { runTarget: target } : {}),
|
|
132
|
+
});
|
|
133
|
+
results.push({
|
|
134
|
+
journeyId,
|
|
135
|
+
...(target ? { targetId: target.id } : {}),
|
|
136
|
+
flowPath,
|
|
137
|
+
status: result.status,
|
|
138
|
+
message: result.message,
|
|
139
|
+
failureCode: result.failureCode,
|
|
140
|
+
durationMs: result.durationMs ?? Date.now() - startedMs,
|
|
141
|
+
artifacts: result.artifacts.map((artifact) => artifact.path),
|
|
142
|
+
});
|
|
143
|
+
const icon = result.status === "passed" ? "PASS" : result.status === "blocked" ? "BLOCK" : "FAIL";
|
|
144
|
+
console.log(`${icon} ${target ? `[${targetLabel(target)}] ` : ""}${journeyId} — ${result.message}`);
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let flows: WebSuiteFlowResult[];
|
|
150
|
+
try {
|
|
151
|
+
flows = targets
|
|
152
|
+
? (await runPool(targets, resolveTargetConcurrency(options.concurrency, targets.length), (target) => runLane(target))).flat()
|
|
153
|
+
: await runLane(undefined);
|
|
154
|
+
} finally {
|
|
155
|
+
// The shared session holds the app's cookies and tokens; it exists only
|
|
156
|
+
// to carry sign-in between flows and must not outlive the suite.
|
|
157
|
+
await Promise.all(sessionStatePaths.map((file) => fs.rm(file, { force: true })));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const runDir = path.join(options.artifactsDir, options.projectId, runId);
|
|
161
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
162
|
+
const reportPath = path.join(runDir, "web-suite-report.json");
|
|
163
|
+
const report: WebSuiteReport = {
|
|
164
|
+
projectId: options.projectId,
|
|
165
|
+
runId,
|
|
166
|
+
baseUrl: options.baseUrl,
|
|
167
|
+
...(targets ? { targets } : {}),
|
|
168
|
+
startedAt,
|
|
169
|
+
finishedAt: new Date().toISOString(),
|
|
170
|
+
totals: summarize(flows),
|
|
171
|
+
flows,
|
|
172
|
+
reportPath,
|
|
173
|
+
};
|
|
174
|
+
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
175
|
+
return report;
|
|
176
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Storage } from "@google-cloud/storage";
|
|
2
|
+
import { LocalObjectStore, type ObjectStore, type StorageProvider, type StoredObject } from "./object-store.js";
|
|
3
|
+
import { S3ObjectStore } from "./remote-object-store.js";
|
|
4
|
+
import { RegistryObjectStore } from "./registry-object-store.js";
|
|
5
|
+
import { resolveLocalStorageDirectory, type StorageSettings } from "./storage-directory.js";
|
|
6
|
+
|
|
7
|
+
export { resolveLocalStorageDirectory, type StorageSettings };
|
|
8
|
+
|
|
9
|
+
class GcsObjectStore implements ObjectStore {
|
|
10
|
+
readonly provider = "gcs" as const;
|
|
11
|
+
private readonly bucket;
|
|
12
|
+
|
|
13
|
+
constructor(bucketName: string, projectId?: string, credentialsJson?: string) {
|
|
14
|
+
const storage = new Storage({
|
|
15
|
+
projectId,
|
|
16
|
+
credentials: credentialsJson ? JSON.parse(credentialsJson) : undefined,
|
|
17
|
+
});
|
|
18
|
+
this.bucket = storage.bucket(bucketName);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async put(key: string, body: string | Uint8Array, contentType?: string): Promise<StoredObject> {
|
|
22
|
+
const file = this.bucket.file(key);
|
|
23
|
+
await file.save(body, { resumable: false, contentType });
|
|
24
|
+
const [metadata] = await file.getMetadata();
|
|
25
|
+
return { key, size: Number(metadata.size ?? 0), modifiedAt: metadata.updated ?? new Date().toISOString(), contentType };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async get(key: string): Promise<Uint8Array> {
|
|
29
|
+
const [body] = await this.bucket.file(key).download();
|
|
30
|
+
return new Uint8Array(body);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getText(key: string): Promise<string> {
|
|
34
|
+
return new TextDecoder().decode(await this.get(key));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async has(key: string): Promise<boolean> {
|
|
38
|
+
const [exists] = await this.bucket.file(key).exists();
|
|
39
|
+
return exists;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async list(prefix: string): Promise<StoredObject[]> {
|
|
43
|
+
const [files] = await this.bucket.getFiles({ prefix });
|
|
44
|
+
return files.map((file) => ({
|
|
45
|
+
key: file.name,
|
|
46
|
+
size: Number(file.metadata.size ?? 0),
|
|
47
|
+
modifiedAt: file.metadata.updated ?? new Date(0).toISOString(),
|
|
48
|
+
contentType: file.metadata.contentType,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async deletePrefix(prefix: string): Promise<void> {
|
|
53
|
+
const [files] = await this.bucket.getFiles({ prefix });
|
|
54
|
+
await Promise.all(files.map((file) => file.delete()));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async publicUrl(key: string, expiresInSeconds = 300): Promise<string> {
|
|
58
|
+
const [url] = await this.bucket.file(key).getSignedUrl({
|
|
59
|
+
action: "read",
|
|
60
|
+
expires: Date.now() + expiresInSeconds * 1000,
|
|
61
|
+
});
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ObjectStoreConfig = {
|
|
67
|
+
provider: StorageProvider;
|
|
68
|
+
local?: { directory?: string };
|
|
69
|
+
gcs?: { bucket?: string; project_id?: string; credentials_json?: string };
|
|
70
|
+
s3?: { bucket?: string; region?: string; endpoint?: string; access_key_id?: string; secret_access_key?: string };
|
|
71
|
+
r2?: { bucket?: string; account_id?: string; endpoint?: string; access_key_id?: string; secret_access_key?: string };
|
|
72
|
+
bugmole?: { registry_url?: string; project_id?: string };
|
|
73
|
+
localDirectory?: string;
|
|
74
|
+
/** The Bugmole project whose managed storage to use. */
|
|
75
|
+
bugmoleProjectId?: string;
|
|
76
|
+
bucket?: string;
|
|
77
|
+
region?: string;
|
|
78
|
+
endpoint?: string;
|
|
79
|
+
accountId?: string;
|
|
80
|
+
projectId?: string;
|
|
81
|
+
credentialsJson?: string;
|
|
82
|
+
accessKeyId?: string;
|
|
83
|
+
secretAccessKey?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Rewrites a pre-rename `provider: valkyrie` config (and its `valkyrie:` block) to `bugmole`. */
|
|
87
|
+
function withCurrentProviderName(cfg: StorageSettings): StorageSettings {
|
|
88
|
+
const storage = cfg.storage as (Record<string, unknown> & { provider?: string }) | undefined;
|
|
89
|
+
if (storage?.provider !== "valkyrie") return cfg;
|
|
90
|
+
const { valkyrie, ...rest } = storage;
|
|
91
|
+
return { ...cfg, storage: { ...rest, provider: "bugmole", ...(valkyrie ? { bugmole: valkyrie } : {}) } as StorageSettings["storage"] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The project's storage config, with a local store's directory filled in. */
|
|
95
|
+
export function resolveStorageConfig(cfg: StorageSettings, fallback = "./runs"): ObjectStoreConfig {
|
|
96
|
+
cfg = withCurrentProviderName(cfg);
|
|
97
|
+
const provider = cfg.storage?.provider ?? "local";
|
|
98
|
+
if (provider === "bugmole") return { ...(cfg.storage as ObjectStoreConfig), bugmoleProjectId: cfg.project?.id };
|
|
99
|
+
if (provider !== "local") return cfg.storage as ObjectStoreConfig;
|
|
100
|
+
return { ...cfg.storage, provider, localDirectory: resolveLocalStorageDirectory(cfg, fallback) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Opens the project's configured object store. */
|
|
104
|
+
export function createProjectObjectStore(cfg: StorageSettings, fallback = "./runs"): ObjectStore {
|
|
105
|
+
return createObjectStore(resolveStorageConfig(cfg, fallback));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createObjectStore(config: ObjectStoreConfig): ObjectStore {
|
|
109
|
+
const nested = (config as Record<string, unknown>)[config.provider] as Record<string, string | undefined> | undefined;
|
|
110
|
+
const value = (name: string, fallback?: string) => nested?.[name] ?? fallback;
|
|
111
|
+
if (config.provider === "local") {
|
|
112
|
+
return new LocalObjectStore(value("directory", config.localDirectory) ?? "./qa/bugmole-runs");
|
|
113
|
+
}
|
|
114
|
+
if (config.provider === "bugmole") {
|
|
115
|
+
const registryUrl = value("registry_url") || process.env.BUGMOLE_REGISTRY_URL?.trim();
|
|
116
|
+
// A hosted runner carries a run token; everything else uses the project's API key.
|
|
117
|
+
const token = process.env.BUGMOLE_RUN_TOKEN?.trim() || process.env.BUGMOLE_API_KEY?.trim();
|
|
118
|
+
const projectId = value("project_id", config.bugmoleProjectId);
|
|
119
|
+
if (!registryUrl || !token || !projectId) {
|
|
120
|
+
throw new Error("Bugmole storage needs BUGMOLE_REGISTRY_URL, an API key (BUGMOLE_API_KEY), and the project id");
|
|
121
|
+
}
|
|
122
|
+
return new RegistryObjectStore({ registryUrl, token, projectId });
|
|
123
|
+
}
|
|
124
|
+
const bucket = value("bucket", config.bucket);
|
|
125
|
+
if (!bucket) throw new Error(`${config.provider} storage requires a bucket`);
|
|
126
|
+
if (config.provider === "gcs") {
|
|
127
|
+
return new GcsObjectStore(bucket, value("project_id", config.projectId), value("credentials_json", config.credentialsJson));
|
|
128
|
+
}
|
|
129
|
+
const accessKeyId = value("access_key_id", config.accessKeyId);
|
|
130
|
+
const secretAccessKey = value("secret_access_key", config.secretAccessKey);
|
|
131
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
132
|
+
throw new Error(`${config.provider} storage requires access key credentials`);
|
|
133
|
+
}
|
|
134
|
+
return new S3ObjectStore({
|
|
135
|
+
provider: config.provider,
|
|
136
|
+
bucket,
|
|
137
|
+
region: value("region", config.region) ?? (config.provider === "r2" ? "auto" : "us-east-1"),
|
|
138
|
+
endpoint: value("endpoint", config.endpoint) ?? (config.provider === "r2" && (value("account_id", config.accountId))
|
|
139
|
+
? `https://${value("account_id", config.accountId)}.r2.cloudflarestorage.com`
|
|
140
|
+
: undefined),
|
|
141
|
+
accessKeyId,
|
|
142
|
+
secretAccessKey,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function projectPrefix(projectId: string): string {
|
|
2
|
+
return `projects/${encodeURIComponent(projectId)}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function runPrefix(projectId: string, runId: string): string {
|
|
6
|
+
return `${projectPrefix(projectId)}/runs/${encodeURIComponent(runId)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function runKey(projectId: string, runId: string, name: string): string {
|
|
10
|
+
return `${runPrefix(projectId, runId)}/${name.replace(/^\/+/, "")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RunManifest = {
|
|
14
|
+
version: 1;
|
|
15
|
+
projectId: string;
|
|
16
|
+
tenantId?: string;
|
|
17
|
+
environmentId?: string;
|
|
18
|
+
runId: string;
|
|
19
|
+
planId: string;
|
|
20
|
+
journeyId: string;
|
|
21
|
+
driver?: string;
|
|
22
|
+
platform?: string;
|
|
23
|
+
failureCode?: string;
|
|
24
|
+
recoveryAttempts?: number;
|
|
25
|
+
status: "success" | "error" | "partial";
|
|
26
|
+
startedAt: string;
|
|
27
|
+
completedAt: string;
|
|
28
|
+
stepsExecuted: number;
|
|
29
|
+
stepsPassed: number;
|
|
30
|
+
stepsFailed: number;
|
|
31
|
+
artifacts: Array<{ key: string; contentType?: string; size?: number }>;
|
|
32
|
+
/** Per browser/device results when the run fanned out across a matrix. */
|
|
33
|
+
targets?: Array<Record<string, unknown>>;
|
|
34
|
+
};
|