@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,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
3
|
+
import { json, type ObjectStore } from "../storage/object-store.js";
|
|
4
|
+
import { projectPrefix } from "../storage/keys.js";
|
|
5
|
+
|
|
6
|
+
export type BlockerFingerprintInput = {
|
|
7
|
+
category: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
route?: string;
|
|
10
|
+
nodeId?: string;
|
|
11
|
+
environmentId?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BlockerResolution = {
|
|
15
|
+
resolutionId: string;
|
|
16
|
+
fingerprint: string;
|
|
17
|
+
category: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
route?: string;
|
|
20
|
+
nodeId?: string;
|
|
21
|
+
workspaceId?: string;
|
|
22
|
+
projectId: string;
|
|
23
|
+
environmentId?: string;
|
|
24
|
+
question: string;
|
|
25
|
+
resolution: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
expiresAt?: string;
|
|
29
|
+
supersededBy?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function storeFor(cfg: any): ObjectStore {
|
|
33
|
+
return createProjectObjectStore(cfg);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectIdFor(cfg: any): string {
|
|
37
|
+
return cfg.project?.id ?? "default";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalize(value: string | undefined): string {
|
|
41
|
+
return (value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function blockerFingerprint(input: BlockerFingerprintInput): string {
|
|
45
|
+
const canonical = [
|
|
46
|
+
normalize(input.category),
|
|
47
|
+
normalize(input.reason),
|
|
48
|
+
normalize(input.route),
|
|
49
|
+
normalize(input.nodeId),
|
|
50
|
+
normalize(input.environmentId),
|
|
51
|
+
].join("|");
|
|
52
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function blockerResolutionKey(cfg: any, fingerprint: string): string {
|
|
56
|
+
return `${projectPrefix(projectIdFor(cfg))}/blocker-resolutions/${fingerprint}.json`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readBlockerResolution(
|
|
60
|
+
cfg: any,
|
|
61
|
+
input: BlockerFingerprintInput,
|
|
62
|
+
): Promise<BlockerResolution | null> {
|
|
63
|
+
const fingerprint = blockerFingerprint(input);
|
|
64
|
+
const store = storeFor(cfg);
|
|
65
|
+
const key = blockerResolutionKey(cfg, fingerprint);
|
|
66
|
+
if (!(await store.has(key))) return null;
|
|
67
|
+
try {
|
|
68
|
+
const value = JSON.parse(await store.getText(key)) as BlockerResolution;
|
|
69
|
+
if (value.supersededBy) return null;
|
|
70
|
+
if (value.expiresAt && Date.parse(value.expiresAt) <= Date.now()) return null;
|
|
71
|
+
return value.fingerprint === fingerprint ? value : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function writeBlockerResolution(
|
|
78
|
+
cfg: any,
|
|
79
|
+
input: BlockerFingerprintInput & {
|
|
80
|
+
workspaceId?: string;
|
|
81
|
+
question: string;
|
|
82
|
+
resolution: string;
|
|
83
|
+
expiresAt?: string;
|
|
84
|
+
},
|
|
85
|
+
): Promise<BlockerResolution> {
|
|
86
|
+
if (!input.question.trim() || !input.resolution.trim()) {
|
|
87
|
+
throw new Error("question and resolution are required");
|
|
88
|
+
}
|
|
89
|
+
const projectId = projectIdFor(cfg);
|
|
90
|
+
const fingerprint = blockerFingerprint(input);
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const existing = await readBlockerResolution(cfg, input);
|
|
93
|
+
const record: BlockerResolution = {
|
|
94
|
+
resolutionId: existing?.resolutionId ?? `resolution_${fingerprint}`,
|
|
95
|
+
fingerprint,
|
|
96
|
+
category: input.category.trim(),
|
|
97
|
+
reason: input.reason.trim(),
|
|
98
|
+
route: input.route?.trim() || undefined,
|
|
99
|
+
nodeId: input.nodeId?.trim() || undefined,
|
|
100
|
+
workspaceId: input.workspaceId ?? cfg.project?.workspace_id ?? cfg.project?.workspaceId,
|
|
101
|
+
projectId,
|
|
102
|
+
environmentId: input.environmentId ?? cfg.project?.environment_id ?? cfg.project?.environmentId,
|
|
103
|
+
question: input.question.trim(),
|
|
104
|
+
resolution: input.resolution.trim(),
|
|
105
|
+
createdAt: existing?.createdAt ?? now,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
expiresAt: input.expiresAt,
|
|
108
|
+
};
|
|
109
|
+
await storeFor(cfg).put(blockerResolutionKey(cfg, fingerprint), json(record), "application/json");
|
|
110
|
+
return record;
|
|
111
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
8
|
+
import { PlaywrightDriver } from "./playwright-driver.js";
|
|
9
|
+
import type { RunTarget } from "./browser-matrix.js";
|
|
10
|
+
|
|
11
|
+
const page = `<!doctype html>
|
|
12
|
+
<html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body>
|
|
13
|
+
<h1>Matrix fixture</h1>
|
|
14
|
+
<button onclick="document.querySelector('p').textContent='Signed in on ' + (window.innerWidth < 600 ? 'mobile' : 'desktop')">Sign in</button>
|
|
15
|
+
<p></p>
|
|
16
|
+
</body></html>`;
|
|
17
|
+
|
|
18
|
+
async function installed(executablePath: () => string): Promise<boolean> {
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(executablePath());
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Engines missing on this machine are skipped rather than failed, so CI with
|
|
28
|
+
// only Chromium still runs the Chromium part.
|
|
29
|
+
const engines: Array<{ target: RunTarget; available: Promise<boolean>; expect: string }> = [
|
|
30
|
+
{ target: { id: "chromium", browser: "chromium" }, available: installed(() => chromium.executablePath()), expect: "desktop" },
|
|
31
|
+
{ target: { id: "firefox", browser: "firefox" }, available: installed(() => firefox.executablePath()), expect: "desktop" },
|
|
32
|
+
{ target: { id: "webkit", browser: "webkit" }, available: installed(() => webkit.executablePath()), expect: "desktop" },
|
|
33
|
+
{
|
|
34
|
+
target: { id: "webkit-iphone-15", browser: "webkit", device: "iPhone 15" },
|
|
35
|
+
available: installed(() => webkit.executablePath()),
|
|
36
|
+
expect: "mobile",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const engine of engines) {
|
|
41
|
+
test(`runs a flow on ${engine.target.id}`, async (t) => {
|
|
42
|
+
if (!(await engine.available)) {
|
|
43
|
+
t.skip(`${engine.target.browser} is not installed`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const server = http.createServer((_request, response) => {
|
|
47
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
48
|
+
response.end(page);
|
|
49
|
+
});
|
|
50
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
51
|
+
const { port } = server.address() as { port: number };
|
|
52
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-matrix-"));
|
|
53
|
+
const flowPath = path.join(directory, "sign-in.yaml");
|
|
54
|
+
// Authored against another origin: the run must follow the environment URL.
|
|
55
|
+
await fs.writeFile(flowPath, `url: http://authored.invalid:9/
|
|
56
|
+
---
|
|
57
|
+
- launchApp
|
|
58
|
+
- tapOn: "Sign in"
|
|
59
|
+
- assertVisible: "Signed in on ${engine.expect}"
|
|
60
|
+
`);
|
|
61
|
+
try {
|
|
62
|
+
const result = await new PlaywrightDriver({ headless: true }).run({
|
|
63
|
+
runId: "run-matrix",
|
|
64
|
+
projectId: "demo",
|
|
65
|
+
journeyId: "sign-in",
|
|
66
|
+
flowPath,
|
|
67
|
+
platform: "web",
|
|
68
|
+
target: { baseUrl: `http://127.0.0.1:${port}` },
|
|
69
|
+
timeoutMs: 30_000,
|
|
70
|
+
artifactsDir: directory,
|
|
71
|
+
runTarget: engine.target,
|
|
72
|
+
rebaseOrigin: true,
|
|
73
|
+
});
|
|
74
|
+
assert.equal(result.status, "passed", result.message);
|
|
75
|
+
assert.equal(typeof result.durationMs, "number");
|
|
76
|
+
const targetDir = path.join(directory, "demo", "run-matrix", "sign-in", engine.target.id);
|
|
77
|
+
assert.ok((await fs.readdir(targetDir)).length > 0, "artifacts are written under the target's own folder");
|
|
78
|
+
} finally {
|
|
79
|
+
server.close();
|
|
80
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("Edge either runs or is reported as not installed with the install command", async () => {
|
|
86
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-matrix-edge-"));
|
|
87
|
+
const server = http.createServer((_request, response) => {
|
|
88
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
89
|
+
response.end(page);
|
|
90
|
+
});
|
|
91
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
92
|
+
const { port } = server.address() as { port: number };
|
|
93
|
+
const flowPath = path.join(directory, "edge.yaml");
|
|
94
|
+
await fs.writeFile(flowPath, `name: edge\n---\n- launchApp\n- assertVisible: "Matrix fixture"\n`);
|
|
95
|
+
try {
|
|
96
|
+
const result = await new PlaywrightDriver({ headless: true }).run({
|
|
97
|
+
runId: "run-edge",
|
|
98
|
+
projectId: "demo",
|
|
99
|
+
journeyId: "edge",
|
|
100
|
+
flowPath,
|
|
101
|
+
platform: "web",
|
|
102
|
+
target: { baseUrl: `http://127.0.0.1:${port}` },
|
|
103
|
+
timeoutMs: 30_000,
|
|
104
|
+
artifactsDir: directory,
|
|
105
|
+
runTarget: { id: "msedge", browser: "msedge" },
|
|
106
|
+
});
|
|
107
|
+
if (result.status === "blocked") {
|
|
108
|
+
assert.equal(result.failureCode, "browser_not_installed", result.message);
|
|
109
|
+
assert.match(result.message, /npx playwright install msedge/);
|
|
110
|
+
} else {
|
|
111
|
+
assert.equal(result.status, "passed", result.message);
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
server.close();
|
|
115
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("reports an unsupported pairing as blocked without launching a browser", async () => {
|
|
120
|
+
const result = await new PlaywrightDriver({ headless: true }).run({
|
|
121
|
+
runId: "run-matrix",
|
|
122
|
+
projectId: "demo",
|
|
123
|
+
journeyId: "sign-in",
|
|
124
|
+
flowPath: "/does/not/matter.yaml",
|
|
125
|
+
platform: "web",
|
|
126
|
+
target: { baseUrl: "http://127.0.0.1:9" },
|
|
127
|
+
timeoutMs: 5_000,
|
|
128
|
+
artifactsDir: os.tmpdir(),
|
|
129
|
+
runTarget: { id: "firefox-iphone-15", browser: "firefox", device: "iPhone 15" },
|
|
130
|
+
});
|
|
131
|
+
assert.equal(result.status, "blocked");
|
|
132
|
+
assert.equal(result.failureCode, "unsupported_target");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a failed checkout captures what was on screen, a trace, and a fix that makes it pass", async (t) => {
|
|
136
|
+
if (!(await installed(() => chromium.executablePath()))) {
|
|
137
|
+
t.skip("Chromium is not installed");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const checkout = `<!doctype html><html><head><title>Checkout</title></head><body>
|
|
141
|
+
<h1>Shipping address</h1>
|
|
142
|
+
<form id="f" novalidate>
|
|
143
|
+
<label>Full name <input name="name" required></label>
|
|
144
|
+
<label>Email <input name="email" type="email" required></label>
|
|
145
|
+
<button type="submit">Continue to payment</button>
|
|
146
|
+
<p id="err" role="alert" hidden>Please enter a valid email</p>
|
|
147
|
+
</form>
|
|
148
|
+
<h2 id="pay" hidden>Payment</h2>
|
|
149
|
+
<script>
|
|
150
|
+
document.getElementById('f').addEventListener('submit', (e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
const email = document.querySelector('[name=email]');
|
|
153
|
+
if (!/.+@.+/.test(email.value)) { email.setAttribute('aria-invalid', 'true'); document.getElementById('err').hidden = false; return; }
|
|
154
|
+
document.getElementById('pay').hidden = false;
|
|
155
|
+
});
|
|
156
|
+
</script></body></html>`;
|
|
157
|
+
const server = http.createServer((_request, response) => {
|
|
158
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
159
|
+
response.end(checkout);
|
|
160
|
+
});
|
|
161
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
162
|
+
const { port } = server.address() as { port: number };
|
|
163
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-analysis-"));
|
|
164
|
+
const flowPath = path.join(directory, "checkout.yaml");
|
|
165
|
+
await fs.writeFile(flowPath, `name: checkout
|
|
166
|
+
---
|
|
167
|
+
- launchApp
|
|
168
|
+
- tapOn: "Full name"
|
|
169
|
+
- inputText: "Ada Lovelace"
|
|
170
|
+
- tapOn: "Continue to payment"
|
|
171
|
+
- assertVisible:
|
|
172
|
+
text: "Payment"
|
|
173
|
+
timeout: 1500
|
|
174
|
+
`);
|
|
175
|
+
const run = (runId: string) => new PlaywrightDriver({ headless: true }).run({
|
|
176
|
+
runId,
|
|
177
|
+
projectId: "demo",
|
|
178
|
+
journeyId: "checkout",
|
|
179
|
+
flowPath,
|
|
180
|
+
platform: "web",
|
|
181
|
+
target: { baseUrl: `http://127.0.0.1:${port}/` },
|
|
182
|
+
timeoutMs: 30_000,
|
|
183
|
+
artifactsDir: directory,
|
|
184
|
+
runTarget: { id: "chromium", browser: "chromium" },
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
const failed = await run("run-fail");
|
|
188
|
+
assert.equal(failed.status, "failed");
|
|
189
|
+
const names = failed.artifacts.map((artifact) => artifact.name);
|
|
190
|
+
for (const name of ["failure-context.json", "analysis.json", "playwright-trace.zip", "playwright-failure.png", "playwright.log"]) {
|
|
191
|
+
assert.ok(names.includes(name), `${name} in ${names.join(", ")}`);
|
|
192
|
+
}
|
|
193
|
+
assert.equal(failed.steps?.[4].status, "failed");
|
|
194
|
+
assert.equal(failed.steps?.[4].label, "See “Payment”");
|
|
195
|
+
assert.equal(typeof failed.steps?.[4].durationMs, "number");
|
|
196
|
+
const read = async (name: string) => JSON.parse(await fs.readFile(failed.artifacts.find((artifact) => artifact.name === name)!.path, "utf8"));
|
|
197
|
+
const context = await read("failure-context.json");
|
|
198
|
+
assert.deepEqual(context.alerts, ["Please enter a valid email"]);
|
|
199
|
+
assert.deepEqual(context.invalidFields.map((field: { label: string }) => field.label), ["Email"]);
|
|
200
|
+
assert.doesNotMatch(JSON.stringify(context), /Ada Lovelace/);
|
|
201
|
+
const analysis = await read("analysis.json");
|
|
202
|
+
assert.equal(analysis.category, "test");
|
|
203
|
+
|
|
204
|
+
const { applyFlowChanges } = await import("./failure-analysis.js");
|
|
205
|
+
await fs.writeFile(flowPath, applyFlowChanges(await fs.readFile(flowPath, "utf8"), analysis.suggestedFix.changes));
|
|
206
|
+
const fixed = await run("run-fixed");
|
|
207
|
+
assert.equal(fixed.status, "passed", fixed.message);
|
|
208
|
+
} finally {
|
|
209
|
+
server.close();
|
|
210
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
contextOptionsFor,
|
|
5
|
+
defaultTargets,
|
|
6
|
+
installHint,
|
|
7
|
+
isMissingBrowserError,
|
|
8
|
+
launcherFor,
|
|
9
|
+
matrixTargets,
|
|
10
|
+
MAX_RUN_TARGETS,
|
|
11
|
+
normalizeTargets,
|
|
12
|
+
RunTargetError,
|
|
13
|
+
targetLabel,
|
|
14
|
+
targetsFromFlags,
|
|
15
|
+
} from "./browser-matrix.js";
|
|
16
|
+
import { aggregateTargetResults, resolveTargetConcurrency, runPool } from "./target-runner.js";
|
|
17
|
+
import type { DriverResult } from "./driver.js";
|
|
18
|
+
|
|
19
|
+
test("normalizes targets into unique, slugged ids", () => {
|
|
20
|
+
const targets = normalizeTargets([
|
|
21
|
+
{ browser: "chromium" },
|
|
22
|
+
{ browser: "webkit", device: "iPhone 15" },
|
|
23
|
+
{ browser: "chromium" },
|
|
24
|
+
{ browser: "msedge", device: "" },
|
|
25
|
+
]);
|
|
26
|
+
assert.deepEqual(targets, [
|
|
27
|
+
{ id: "chromium", browser: "chromium" },
|
|
28
|
+
{ id: "webkit-iphone-15", browser: "webkit", device: "iPhone 15" },
|
|
29
|
+
{ id: "msedge", browser: "msedge" },
|
|
30
|
+
]);
|
|
31
|
+
assert.deepEqual(normalizeTargets(undefined), []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rejects unknown browsers and devices instead of dropping them", () => {
|
|
35
|
+
assert.throws(() => normalizeTargets([{ browser: "opera" }]), RunTargetError);
|
|
36
|
+
assert.throws(() => normalizeTargets([{ browser: "chromium", device: "Nokia 3310" }]), /Unknown device/);
|
|
37
|
+
assert.throws(() => normalizeTargets("chromium"), /must be an array/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("caps the matrix size", () => {
|
|
41
|
+
const devices = ["iPhone 15", "iPhone 14", "iPhone 13", "iPhone 12", "Pixel 7", "Pixel 5", "Galaxy S9+", "iPad Mini", "iPhone SE"];
|
|
42
|
+
assert.throws(() => matrixTargets(["chromium", "firefox", "webkit", "msedge"], devices), new RegExp(`at most ${MAX_RUN_TARGETS}`));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("emulates each device on the engine it ships with", () => {
|
|
46
|
+
const targets = matrixTargets(["firefox"], ["iPhone 15", "Pixel 7"]);
|
|
47
|
+
assert.deepEqual(targets.map((target) => target.id), ["firefox", "webkit-iphone-15", "chromium-pixel-7"]);
|
|
48
|
+
assert.equal(targetLabel(targets[1]), "iPhone 15 · Safari");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reads the matrix from config, defaulting to desktop Chromium", () => {
|
|
52
|
+
assert.deepEqual(defaultTargets(undefined), [{ id: "chromium", browser: "chromium" }]);
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
defaultTargets({ execution: { playwright: { browsers: ["firefox", "webkit"], devices: ["Pixel 7"] } } }).map((target) => target.id),
|
|
55
|
+
["firefox", "webkit", "chromium-pixel-7"],
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("builds targets from CLI flags", () => {
|
|
60
|
+
assert.deepEqual(targetsFromFlags(undefined, undefined), []);
|
|
61
|
+
assert.deepEqual(targetsFromFlags("chromium, msedge", undefined).map((target) => target.id), ["chromium", "msedge"]);
|
|
62
|
+
assert.deepEqual(targetsFromFlags(undefined, "iPhone 15").map((target) => target.id), ["webkit-iphone-15"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("launches Edge through Chromium's msedge channel", () => {
|
|
66
|
+
assert.equal(launcherFor("msedge").channel, "msedge");
|
|
67
|
+
assert.equal(launcherFor("msedge").type.name(), "chromium");
|
|
68
|
+
assert.equal(launcherFor("webkit").type.name(), "webkit");
|
|
69
|
+
assert.equal(launcherFor("firefox").channel, undefined);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("blocks Firefox mobile emulation and applies device descriptors elsewhere", () => {
|
|
73
|
+
const firefoxPhone = contextOptionsFor({ id: "firefox-iphone-15", browser: "firefox", device: "iPhone 15" });
|
|
74
|
+
assert.ok("unsupported" in firefoxPhone);
|
|
75
|
+
const safariPhone = contextOptionsFor({ id: "webkit-iphone-15", browser: "webkit", device: "iPhone 15" });
|
|
76
|
+
assert.ok("options" in safariPhone);
|
|
77
|
+
assert.equal(safariPhone.options.isMobile, true);
|
|
78
|
+
assert.equal("defaultBrowserType" in safariPhone.options, false);
|
|
79
|
+
assert.deepEqual(contextOptionsFor({ id: "chromium", browser: "chromium" }), { options: {} });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("recognizes a missing browser binary and explains how to install it", () => {
|
|
83
|
+
assert.equal(isMissingBrowserError(new Error("browserType.launch: Executable doesn't exist at /ms-playwright/firefox")), true);
|
|
84
|
+
assert.equal(isMissingBrowserError(new Error("Chromium distribution 'msedge' is not found at /opt/microsoft/msedge")), true);
|
|
85
|
+
assert.equal(isMissingBrowserError(new Error("net::ERR_CONNECTION_REFUSED")), false);
|
|
86
|
+
assert.match(installHint("firefox"), /npx playwright install firefox/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("runPool keeps order and never exceeds the limit", async () => {
|
|
90
|
+
let inFlight = 0;
|
|
91
|
+
let peak = 0;
|
|
92
|
+
const results = await runPool([30, 5, 20, 1, 10], 2, async (delay, index) => {
|
|
93
|
+
inFlight += 1;
|
|
94
|
+
peak = Math.max(peak, inFlight);
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
96
|
+
inFlight -= 1;
|
|
97
|
+
return index;
|
|
98
|
+
});
|
|
99
|
+
assert.deepEqual(results, [0, 1, 2, 3, 4]);
|
|
100
|
+
assert.equal(peak, 2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("target concurrency prefers env, then config, then up to four", () => {
|
|
104
|
+
assert.equal(resolveTargetConcurrency(undefined, 6, {}), 4);
|
|
105
|
+
assert.equal(resolveTargetConcurrency(undefined, 2, {}), 2);
|
|
106
|
+
assert.equal(resolveTargetConcurrency(3, 6, {}), 3);
|
|
107
|
+
assert.equal(resolveTargetConcurrency(3, 6, { BUGMOLE_TARGET_CONCURRENCY: "1" }), 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const step = (status: "passed" | "failed" | "blocked") => ({ sequenceNumber: 0, status, durationMs: 1 });
|
|
111
|
+
const outcome = (id: string, result: Partial<DriverResult>) => ({
|
|
112
|
+
target: { id, browser: "chromium" as const },
|
|
113
|
+
result: { status: "passed" as const, message: "ok", artifacts: [], ...result } as DriverResult,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("aggregates target results: any failure fails, blocked-only blocks", () => {
|
|
117
|
+
const failed = aggregateTargetResults([
|
|
118
|
+
outcome("chromium", { steps: [step("passed"), step("passed")] }),
|
|
119
|
+
outcome("firefox", { status: "failed", message: "boom", failureCode: "assertion", steps: [step("passed"), step("failed")] }),
|
|
120
|
+
outcome("msedge", { status: "blocked", message: "not installed", failureCode: "browser_not_installed" }),
|
|
121
|
+
]);
|
|
122
|
+
assert.equal(failed.status, "failed");
|
|
123
|
+
assert.equal(failed.failureCode, "assertion");
|
|
124
|
+
assert.match(failed.message, /1 of 3 target\(s\) failed/);
|
|
125
|
+
assert.deepEqual(failed.steps?.map((entry) => entry.status), ["passed", "failed"]);
|
|
126
|
+
|
|
127
|
+
const passed = aggregateTargetResults([
|
|
128
|
+
outcome("chromium", {}),
|
|
129
|
+
outcome("msedge", { status: "blocked", message: "not installed" }),
|
|
130
|
+
]);
|
|
131
|
+
assert.equal(passed.status, "passed");
|
|
132
|
+
assert.match(passed.message, /1 could not run/);
|
|
133
|
+
|
|
134
|
+
const blocked = aggregateTargetResults([outcome("msedge", { status: "blocked", message: "not installed", failureCode: "browser_not_installed" })]);
|
|
135
|
+
assert.equal(blocked.status, "blocked");
|
|
136
|
+
assert.equal(blocked.failureCode, "browser_not_installed");
|
|
137
|
+
|
|
138
|
+
const mismatched = aggregateTargetResults([
|
|
139
|
+
outcome("chromium", { steps: [step("passed")] }),
|
|
140
|
+
outcome("webkit", { steps: [step("passed"), step("passed")] }),
|
|
141
|
+
]);
|
|
142
|
+
assert.equal(mismatched.steps, undefined);
|
|
143
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { chromium, devices, firefox, webkit, type BrowserContextOptions, type BrowserType } from "playwright";
|
|
2
|
+
import { cloudDeviceLabel, type CloudDevice } from "./device-clouds/types.js";
|
|
3
|
+
import { cloudTargetId, parseCloudDevice as parseCloudDeviceTarget } from "./device-clouds/targets.js";
|
|
4
|
+
|
|
5
|
+
export const BROWSER_NAMES = ["chromium", "firefox", "webkit", "msedge"] as const;
|
|
6
|
+
export type BrowserName = (typeof BROWSER_NAMES)[number];
|
|
7
|
+
|
|
8
|
+
/** One browser (optionally emulating one device) a run executes against. */
|
|
9
|
+
export type RunTarget = {
|
|
10
|
+
id: string;
|
|
11
|
+
/** For a cloud device, the engine closest to its browser (reporting only). */
|
|
12
|
+
browser: BrowserName;
|
|
13
|
+
device?: string;
|
|
14
|
+
/** A real device or emulator on a device cloud, driven over Appium. */
|
|
15
|
+
cloud?: CloudDevice;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type RunTargetInput = { browser?: unknown; device?: unknown; kind?: unknown } & Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export const MAX_RUN_TARGETS = 12;
|
|
23
|
+
|
|
24
|
+
export class RunTargetError extends Error {
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "RunTargetError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const BROWSER_LABELS: Record<BrowserName, string> = {
|
|
32
|
+
chromium: "Chrome",
|
|
33
|
+
firefox: "Firefox",
|
|
34
|
+
webkit: "Safari",
|
|
35
|
+
msedge: "Edge",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const INSTALL_NAMES: Record<BrowserName, string> = {
|
|
39
|
+
chromium: "chromium",
|
|
40
|
+
firefox: "firefox",
|
|
41
|
+
webkit: "webkit",
|
|
42
|
+
msedge: "msedge",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function browserLabel(browser: BrowserName): string {
|
|
46
|
+
return BROWSER_LABELS[browser];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isBrowserName(value: unknown): value is BrowserName {
|
|
50
|
+
return typeof value === "string" && (BROWSER_NAMES as readonly string[]).includes(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isKnownDevice(value: string): boolean {
|
|
54
|
+
return Object.prototype.hasOwnProperty.call(devices, value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function slug(value: string): string {
|
|
58
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function targetId(browser: BrowserName, device?: string): string {
|
|
62
|
+
return device ? `${browser}-${slug(device)}` : browser;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function targetLabel(target: RunTarget): string {
|
|
66
|
+
if (target.cloud) return cloudDeviceLabel(target.cloud);
|
|
67
|
+
return target.device ? `${target.device} · ${browserLabel(target.browser)}` : browserLabel(target.browser);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validates a requested matrix (from a run request, CLI flags, or config) into
|
|
72
|
+
* unique, well-formed targets. Unknown browsers or device names are rejected
|
|
73
|
+
* rather than silently dropped, so a typo never turns into a smaller run than
|
|
74
|
+
* the person asked for.
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeTargets(input: unknown): RunTarget[] {
|
|
77
|
+
if (input === undefined || input === null) return [];
|
|
78
|
+
if (!Array.isArray(input)) throw new RunTargetError("targets must be an array of { browser, device? }");
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const targets: RunTarget[] = [];
|
|
81
|
+
for (const raw of input as RunTargetInput[]) {
|
|
82
|
+
if (raw?.kind === "device") {
|
|
83
|
+
let cloud: CloudDevice;
|
|
84
|
+
try {
|
|
85
|
+
cloud = parseCloudDeviceTarget(raw);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new RunTargetError(error instanceof Error ? error.message : String(error));
|
|
88
|
+
}
|
|
89
|
+
const id = cloudTargetId(cloud);
|
|
90
|
+
if (seen.has(id)) continue;
|
|
91
|
+
seen.add(id);
|
|
92
|
+
targets.push({ id, browser: cloud.platform === "ios" ? "webkit" : "chromium", cloud });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const browser = raw?.browser;
|
|
96
|
+
if (!isBrowserName(browser)) {
|
|
97
|
+
throw new RunTargetError(`Unsupported browser "${String(browser)}". Use one of: ${BROWSER_NAMES.join(", ")}.`);
|
|
98
|
+
}
|
|
99
|
+
let device: string | undefined;
|
|
100
|
+
if (raw.device !== undefined && raw.device !== null && raw.device !== "") {
|
|
101
|
+
if (typeof raw.device !== "string" || !isKnownDevice(raw.device)) {
|
|
102
|
+
throw new RunTargetError(`Unknown device "${String(raw.device)}". Use a Playwright device name such as "iPhone 15" or "Pixel 7".`);
|
|
103
|
+
}
|
|
104
|
+
device = raw.device;
|
|
105
|
+
}
|
|
106
|
+
const id = targetId(browser, device);
|
|
107
|
+
if (seen.has(id)) continue;
|
|
108
|
+
seen.add(id);
|
|
109
|
+
targets.push(device ? { id, browser, device } : { id, browser });
|
|
110
|
+
}
|
|
111
|
+
if (targets.length > MAX_RUN_TARGETS) {
|
|
112
|
+
throw new RunTargetError(`A run can target at most ${MAX_RUN_TARGETS} browser/device combinations.`);
|
|
113
|
+
}
|
|
114
|
+
return targets;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Builds the cross product of browsers and devices. Desktop is always one row
|
|
119
|
+
* per browser; each device adds one row per browser that can emulate it.
|
|
120
|
+
*/
|
|
121
|
+
export function matrixTargets(browsers: string[], deviceNames: string[] = []): RunTarget[] {
|
|
122
|
+
const rows: RunTargetInput[] = browsers.map((browser) => ({ browser }));
|
|
123
|
+
for (const device of deviceNames) {
|
|
124
|
+
// A phone is emulated on the engine that ships on that phone: Safari's
|
|
125
|
+
// WebKit for Apple devices, Chromium for everything else. Firefox cannot
|
|
126
|
+
// emulate mobile at all.
|
|
127
|
+
const descriptor = isKnownDevice(device) ? devices[device] : undefined;
|
|
128
|
+
const preferred: BrowserName = descriptor?.defaultBrowserType === "webkit" ? "webkit" : "chromium";
|
|
129
|
+
rows.push({ browser: preferred, device });
|
|
130
|
+
}
|
|
131
|
+
return normalizeTargets(rows);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function splitList(value: unknown): string[] {
|
|
135
|
+
if (typeof value !== "string") return [];
|
|
136
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Targets from CLI flags (`--browsers chromium,firefox --devices "iPhone 15"`),
|
|
141
|
+
* or an empty list when neither flag was given. Devices alone run on their
|
|
142
|
+
* native engine; browsers alone run desktop.
|
|
143
|
+
*/
|
|
144
|
+
export function targetsFromFlags(browsers: unknown, deviceNames: unknown): RunTarget[] {
|
|
145
|
+
const browserList = splitList(browsers);
|
|
146
|
+
const deviceList = splitList(deviceNames);
|
|
147
|
+
if (browserList.length === 0 && deviceList.length === 0) return [];
|
|
148
|
+
return matrixTargets(browserList, deviceList);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type ExecutionConfig = { execution?: { playwright?: { browsers?: unknown; devices?: unknown } } };
|
|
152
|
+
|
|
153
|
+
export function defaultTargets(cfg: ExecutionConfig | undefined): RunTarget[] {
|
|
154
|
+
const browsers = Array.isArray(cfg?.execution?.playwright?.browsers)
|
|
155
|
+
? (cfg!.execution!.playwright!.browsers as unknown[]).map(String)
|
|
156
|
+
: ["chromium"];
|
|
157
|
+
const deviceNames = Array.isArray(cfg?.execution?.playwright?.devices)
|
|
158
|
+
? (cfg!.execution!.playwright!.devices as unknown[]).map(String)
|
|
159
|
+
: [];
|
|
160
|
+
return matrixTargets(browsers, deviceNames);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function launcherFor(browser: BrowserName): { type: BrowserType; channel?: string } {
|
|
164
|
+
switch (browser) {
|
|
165
|
+
case "firefox":
|
|
166
|
+
return { type: firefox };
|
|
167
|
+
case "webkit":
|
|
168
|
+
return { type: webkit };
|
|
169
|
+
case "msedge":
|
|
170
|
+
return { type: chromium, channel: "msedge" };
|
|
171
|
+
default:
|
|
172
|
+
return { type: chromium };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Context options for a target, or the reason it cannot run. Firefox has no
|
|
178
|
+
* mobile emulation in Playwright (`isMobile` throws), so that pairing is a
|
|
179
|
+
* blocked target rather than a failed run.
|
|
180
|
+
*/
|
|
181
|
+
export function contextOptionsFor(target: RunTarget): { options: BrowserContextOptions } | { unsupported: string } {
|
|
182
|
+
if (!target.device) return { options: {} };
|
|
183
|
+
const descriptor = devices[target.device];
|
|
184
|
+
if (!descriptor) return { unsupported: `Unknown device "${target.device}".` };
|
|
185
|
+
const { defaultBrowserType: _ignored, ...options } = descriptor;
|
|
186
|
+
if (target.browser === "firefox" && options.isMobile) {
|
|
187
|
+
return { unsupported: `Firefox cannot emulate the mobile device "${target.device}".` };
|
|
188
|
+
}
|
|
189
|
+
return { options };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Recognizes Playwright's "browser binary is not installed" launch failure. */
|
|
193
|
+
export function isMissingBrowserError(error: unknown): boolean {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
return /Executable doesn't exist|browserType\.launch: .*(not found|is not installed)|Chromium distribution 'msedge' is not found/i.test(message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function installHint(browser: BrowserName): string {
|
|
199
|
+
return `${browserLabel(browser)} is not installed on this worker. Install it with: npx playwright install ${INSTALL_NAMES[browser]}`;
|
|
200
|
+
}
|