@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,194 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://app.bugmole.com/schemas/ui-parity-audit.schema.json",
|
|
4
|
+
"title": "Bugmole UI Parity Audit",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"auditId",
|
|
10
|
+
"reference",
|
|
11
|
+
"current",
|
|
12
|
+
"summary",
|
|
13
|
+
"flowMatrix",
|
|
14
|
+
"comparisons",
|
|
15
|
+
"issues",
|
|
16
|
+
"fixPrompt",
|
|
17
|
+
"blockedVerification"
|
|
18
|
+
],
|
|
19
|
+
"properties": {
|
|
20
|
+
"schemaVersion": { "const": 1 },
|
|
21
|
+
"auditId": { "type": "string", "minLength": 1 },
|
|
22
|
+
"workspaceId": { "type": "string" },
|
|
23
|
+
"projectId": { "type": "string" },
|
|
24
|
+
"appId": { "type": "string" },
|
|
25
|
+
"appName": { "type": "string" },
|
|
26
|
+
"dnaRevision": { "type": "integer", "minimum": 1 },
|
|
27
|
+
"designDnaRefs": { "type": "array", "items": { "type": "string" } },
|
|
28
|
+
"tokenDrift": { "type": "array", "items": { "$ref": "#/$defs/tokenDrift" } },
|
|
29
|
+
"startedAt": { "type": "string", "format": "date-time" },
|
|
30
|
+
"completedAt": { "type": "string", "format": "date-time" },
|
|
31
|
+
"reference": { "$ref": "#/$defs/experience" },
|
|
32
|
+
"current": { "$ref": "#/$defs/experience" },
|
|
33
|
+
"summary": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"required": [
|
|
37
|
+
"screensTotal",
|
|
38
|
+
"actionsTotal",
|
|
39
|
+
"visualDifferencePercent",
|
|
40
|
+
"interactionParityScore",
|
|
41
|
+
"apiParityScore",
|
|
42
|
+
"authenticationParityScore",
|
|
43
|
+
"fixedIssues",
|
|
44
|
+
"unresolvedIssues"
|
|
45
|
+
],
|
|
46
|
+
"properties": {
|
|
47
|
+
"screensTotal": { "type": "integer", "minimum": 0 },
|
|
48
|
+
"actionsTotal": { "type": "integer", "minimum": 0 },
|
|
49
|
+
"visualDifferencePercent": { "type": "number", "minimum": 0, "maximum": 100 },
|
|
50
|
+
"interactionParityScore": { "type": "number", "minimum": 0, "maximum": 100 },
|
|
51
|
+
"apiParityScore": { "type": "number", "minimum": 0, "maximum": 100 },
|
|
52
|
+
"authenticationParityScore": { "type": "number", "minimum": 0, "maximum": 100 },
|
|
53
|
+
"fixedIssues": { "type": "integer", "minimum": 0 },
|
|
54
|
+
"unresolvedIssues": { "type": "integer", "minimum": 0 }
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"flowMatrix": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": { "$ref": "#/$defs/flow" }
|
|
60
|
+
},
|
|
61
|
+
"comparisons": {
|
|
62
|
+
"type": "array",
|
|
63
|
+
"items": { "$ref": "#/$defs/comparison" }
|
|
64
|
+
},
|
|
65
|
+
"issues": {
|
|
66
|
+
"type": "array",
|
|
67
|
+
"items": { "$ref": "#/$defs/issue" }
|
|
68
|
+
},
|
|
69
|
+
"fixPrompt": { "type": "string" },
|
|
70
|
+
"artifacts": {
|
|
71
|
+
"type": "array",
|
|
72
|
+
"items": { "$ref": "#/$defs/artifact" }
|
|
73
|
+
},
|
|
74
|
+
"blockedVerification": {
|
|
75
|
+
"type": "array",
|
|
76
|
+
"items": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"additionalProperties": false,
|
|
79
|
+
"required": ["description", "reason"],
|
|
80
|
+
"properties": {
|
|
81
|
+
"description": { "type": "string" },
|
|
82
|
+
"reason": { "type": "string" }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"$defs": {
|
|
88
|
+
"experience": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"additionalProperties": false,
|
|
91
|
+
"required": ["kind", "location"],
|
|
92
|
+
"properties": {
|
|
93
|
+
"kind": { "enum": ["url", "html", "old_app_link", "image", "figma", "other"] },
|
|
94
|
+
"location": { "type": "string" },
|
|
95
|
+
"viewport": { "type": "string" },
|
|
96
|
+
"notes": { "type": "string" }
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"flow": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"additionalProperties": false,
|
|
102
|
+
"required": ["flowId", "referenceScreen", "currentRoute", "authState", "actions", "status"],
|
|
103
|
+
"properties": {
|
|
104
|
+
"flowId": { "type": "string" },
|
|
105
|
+
"referenceScreen": { "type": "string" },
|
|
106
|
+
"currentRoute": { "type": "string" },
|
|
107
|
+
"authState": { "enum": ["guest", "authenticated", "logged_out", "unknown"] },
|
|
108
|
+
"states": { "type": "array", "items": { "type": "string" } },
|
|
109
|
+
"actions": { "type": "array", "items": { "type": "string" } },
|
|
110
|
+
"status": { "enum": ["matched", "partially_matched", "unmatched", "blocked"] },
|
|
111
|
+
"notes": { "type": "string" }
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"comparison": {
|
|
115
|
+
"type": "object",
|
|
116
|
+
"additionalProperties": false,
|
|
117
|
+
"required": [
|
|
118
|
+
"screenId",
|
|
119
|
+
"differencePercent",
|
|
120
|
+
"changedPixels",
|
|
121
|
+
"totalPixels",
|
|
122
|
+
"heatmap",
|
|
123
|
+
"mismatches"
|
|
124
|
+
],
|
|
125
|
+
"properties": {
|
|
126
|
+
"screenId": { "type": "string" },
|
|
127
|
+
"referenceCapture": { "type": "string" },
|
|
128
|
+
"currentCapture": { "type": "string" },
|
|
129
|
+
"diffCapture": { "type": "string" },
|
|
130
|
+
"heatmapCapture": { "type": "string" },
|
|
131
|
+
"differencePercent": { "type": "number", "minimum": 0, "maximum": 100 },
|
|
132
|
+
"changedPixels": { "type": "integer", "minimum": 0 },
|
|
133
|
+
"totalPixels": { "type": "integer", "minimum": 1 },
|
|
134
|
+
"threshold": { "type": "integer", "minimum": 0, "maximum": 255 },
|
|
135
|
+
"heatmap": { "type": "string" },
|
|
136
|
+
"mismatches": { "type": "array", "items": { "type": "string" } },
|
|
137
|
+
"behavioralMismatches": { "type": "array", "items": { "type": "string" } },
|
|
138
|
+
"backendMismatches": { "type": "array", "items": { "type": "string" } }
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"issue": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"additionalProperties": false,
|
|
144
|
+
"required": ["issueId", "category", "status", "description"],
|
|
145
|
+
"properties": {
|
|
146
|
+
"issueId": { "type": "string" },
|
|
147
|
+
"category": { "enum": ["visual", "behavioral", "backend", "authentication", "platform"] },
|
|
148
|
+
"status": {
|
|
149
|
+
"enum": [
|
|
150
|
+
"confirmed_match",
|
|
151
|
+
"fixed",
|
|
152
|
+
"unresolved",
|
|
153
|
+
"blocked",
|
|
154
|
+
"platform_limitation",
|
|
155
|
+
"missing_backend"
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
"severity": { "enum": ["high", "medium", "low"] },
|
|
159
|
+
"description": { "type": "string" },
|
|
160
|
+
"evidence": { "type": "array", "items": { "type": "string" } },
|
|
161
|
+
"fixPrompt": { "type": "string" },
|
|
162
|
+
"screenId": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"description": "The comparisons[].screenId (and, when the flow is graph-linked, the matching journey node id) this issue was found on. Optional so older audits without it remain valid; the dashboard groups issues without a screenId as ungrouped rather than guessing."
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"artifact": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"additionalProperties": false,
|
|
171
|
+
"required": ["kind", "path"],
|
|
172
|
+
"properties": {
|
|
173
|
+
"kind": {
|
|
174
|
+
"enum": ["reference_screenshot", "current_screenshot", "diff", "heatmap", "log", "other"]
|
|
175
|
+
},
|
|
176
|
+
"path": { "type": "string" },
|
|
177
|
+
"screenId": { "type": "string" }
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"tokenDrift": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"additionalProperties": false,
|
|
183
|
+
"required": ["tokenId", "category", "description", "severity"],
|
|
184
|
+
"properties": {
|
|
185
|
+
"tokenId": { "type": "string" },
|
|
186
|
+
"category": { "type": "string" },
|
|
187
|
+
"description": { "type": "string" },
|
|
188
|
+
"severity": { "enum": ["high", "medium", "low"] },
|
|
189
|
+
"referenceValue": {},
|
|
190
|
+
"currentValue": {}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://app.bugmole.com/schemas/ui-reverse-engineering.schema.json",
|
|
4
|
+
"title": "Bugmole UI Reverse Engineering Job",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "jobId", "projectId", "dnaRevision", "status", "stage", "inputs", "regions", "atomicNodes", "mismatches", "playbooks"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "const": 1 },
|
|
10
|
+
"jobId": { "type": "string", "minLength": 1 },
|
|
11
|
+
"projectId": { "type": "string", "minLength": 1 },
|
|
12
|
+
"dnaRevision": { "type": "integer", "minimum": 1 },
|
|
13
|
+
"status": { "enum": ["queued", "running", "blocked", "completed", "failed"] },
|
|
14
|
+
"stage": { "enum": ["input", "measure", "layout", "dna", "atoms", "compose", "validate", "reconcile", "complete"] },
|
|
15
|
+
"inputs": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"required": ["id", "kind", "location"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"id": { "type": "string", "minLength": 1 },
|
|
23
|
+
"kind": { "enum": ["image", "figma", "pdf", "url", "html", "existing_app"] },
|
|
24
|
+
"location": { "type": "string", "minLength": 1 },
|
|
25
|
+
"viewport": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"width": { "type": "number", "minimum": 1 },
|
|
30
|
+
"height": { "type": "number", "minimum": 1 }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"checksum": { "type": "string" },
|
|
34
|
+
"status": { "enum": ["active", "archived"] },
|
|
35
|
+
"relatedJourneyIds": { "type": "array", "items": { "type": "string" } },
|
|
36
|
+
"appId": { "type": "string" },
|
|
37
|
+
"environmentId": { "type": "string" },
|
|
38
|
+
"registeredAt": { "type": "string", "format": "date-time" },
|
|
39
|
+
"metadata": { "type": "object" }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"regions": { "type": "array" },
|
|
44
|
+
"atomicNodes": { "type": "array" },
|
|
45
|
+
"mismatches": { "type": "array" },
|
|
46
|
+
"playbooks": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"items": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"required": ["id", "name", "purpose", "status", "steps"],
|
|
52
|
+
"properties": {
|
|
53
|
+
"id": { "enum": ["design-dna", "atomic-design"] },
|
|
54
|
+
"name": { "type": "string" },
|
|
55
|
+
"purpose": { "type": "string" },
|
|
56
|
+
"status": { "enum": ["active", "blocked"] },
|
|
57
|
+
"steps": { "type": "array", "items": { "type": "string" } },
|
|
58
|
+
"referenceUrl": { "type": "string", "format": "uri" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"storybook": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"additionalProperties": false,
|
|
65
|
+
"required": ["detected", "components"],
|
|
66
|
+
"properties": {
|
|
67
|
+
"detected": { "type": "boolean" },
|
|
68
|
+
"configPath": { "type": "string" },
|
|
69
|
+
"blockedReason": { "type": "string" },
|
|
70
|
+
"components": {
|
|
71
|
+
"type": "array",
|
|
72
|
+
"items": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"required": ["id", "title", "storyNames", "sourcePath", "level", "confidence"],
|
|
76
|
+
"properties": {
|
|
77
|
+
"id": { "type": "string" },
|
|
78
|
+
"title": { "type": "string" },
|
|
79
|
+
"componentName": { "type": "string" },
|
|
80
|
+
"storyNames": { "type": "array", "items": { "type": "string" } },
|
|
81
|
+
"sourcePath": { "type": "string" },
|
|
82
|
+
"level": { "enum": ["atom", "molecule", "organism", "template", "page"] },
|
|
83
|
+
"confidence": { "enum": ["high", "medium", "low", "blocked"] }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"playbookRefs": { "type": "array", "items": { "type": "string" } },
|
|
90
|
+
"blockedReasons": { "type": "array", "items": { "type": "string" } },
|
|
91
|
+
"createdAt": { "type": "string", "format": "date-time" },
|
|
92
|
+
"updatedAt": { "type": "string", "format": "date-time" }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { COPY_HEADER, catalogCopy, catalogSource } from "../../scripts/sync-plan-catalog.mjs";
|
|
5
|
+
import { AI_CREDIT_COST, PLANS, entitlementsFor, formatUsd, includedQuantity, overageCents } from "./plan-catalog.js";
|
|
6
|
+
|
|
7
|
+
test("the dashboard's copy of the plan catalog matches the source", async () => {
|
|
8
|
+
const [source, copy] = await Promise.all([readFile(catalogSource, "utf8"), readFile(catalogCopy, "utf8")]);
|
|
9
|
+
assert.equal(copy, COPY_HEADER + source, "run `npm run sync:catalog`");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("plans get strictly more capacity as they get more expensive", () => {
|
|
13
|
+
const paid = [PLANS.free, PLANS.team, PLANS.business];
|
|
14
|
+
for (let index = 1; index < paid.length; index++) {
|
|
15
|
+
for (const key of ["cloudMinutes", "aiCredits", "parallel", "historyDays", "projects"] as const) {
|
|
16
|
+
assert.ok((paid[index][key] ?? Infinity) > (paid[index - 1][key] ?? 0), `${paid[index].id}.${key}`);
|
|
17
|
+
}
|
|
18
|
+
assert.ok(paid[index].priceMonthlyCents! > paid[index - 1].priceMonthlyCents!);
|
|
19
|
+
assert.ok(paid[index].priceYearlyMonthlyCents! < paid[index].priceMonthlyCents!, "yearly is a discount");
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("free usage stops at the allowance while paid plans can buy more", () => {
|
|
24
|
+
const free = entitlementsFor("free");
|
|
25
|
+
assert.equal(overageCents(free, "browser_minute"), null);
|
|
26
|
+
assert.equal(overageCents(free, "device_minute"), null);
|
|
27
|
+
const team = entitlementsFor("team");
|
|
28
|
+
assert.equal(overageCents(team, "browser_minute"), 4);
|
|
29
|
+
assert.equal(overageCents(team, "device_minute"), 25);
|
|
30
|
+
assert.equal(includedQuantity(team, "device_minute"), 0);
|
|
31
|
+
assert.equal(includedQuantity(team, "ai_credit"), 1500);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("contract overrides replace only what they name", () => {
|
|
35
|
+
const custom = entitlementsFor("enterprise", { cloudMinutes: 50_000, features: { scim: false } });
|
|
36
|
+
assert.equal(custom.cloudMinutes, 50_000);
|
|
37
|
+
assert.equal(custom.historyDays, 365);
|
|
38
|
+
assert.equal(custom.features.scim, false);
|
|
39
|
+
assert.equal(custom.features.sso, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("AI credit costs match the pricing page", () => {
|
|
43
|
+
assert.deepEqual(AI_CREDIT_COST, { analysis: 1, repair: 20, exploration: 100, discovery: 0 });
|
|
44
|
+
assert.equal(formatUsd(24_900), "$249");
|
|
45
|
+
assert.equal(formatUsd(4), "$0.04");
|
|
46
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// The one definition of Bugmole's plans. The registry enforces it, the
|
|
2
|
+
// dashboard shows it, and the bugmole.com pricing page is built from it, so a
|
|
3
|
+
// price or limit changes in exactly one place.
|
|
4
|
+
//
|
|
5
|
+
// Keep this file dependency-free and limited to type-strippable TypeScript:
|
|
6
|
+
// Node imports it directly in the docs build, and `npm run sync:catalog`
|
|
7
|
+
// copies it verbatim into the dashboard (apps/qa/src/lib/plan-catalog.ts).
|
|
8
|
+
|
|
9
|
+
export type PlanId = "free" | "team" | "business" | "enterprise";
|
|
10
|
+
export type BillingInterval = "month" | "year";
|
|
11
|
+
export type UsageKind = "browser_minute" | "device_minute" | "ai_credit";
|
|
12
|
+
export type AiAction = "analysis" | "repair" | "exploration" | "discovery";
|
|
13
|
+
|
|
14
|
+
export type PlanFeatures = {
|
|
15
|
+
prChecks: boolean;
|
|
16
|
+
slack: boolean;
|
|
17
|
+
teams: boolean;
|
|
18
|
+
aiFixes: boolean;
|
|
19
|
+
deviceClouds: boolean;
|
|
20
|
+
sso: boolean;
|
|
21
|
+
scim: boolean;
|
|
22
|
+
auditLogExport: boolean;
|
|
23
|
+
prioritySupport: boolean;
|
|
24
|
+
selfHostedWorkers: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type PlanDef = {
|
|
28
|
+
id: PlanId;
|
|
29
|
+
name: string;
|
|
30
|
+
audience: string;
|
|
31
|
+
/** Monthly price when billed monthly; null means custom pricing. */
|
|
32
|
+
priceMonthlyCents: number | null;
|
|
33
|
+
/** Per-month price when billed yearly; null when there is no yearly price. */
|
|
34
|
+
priceYearlyMonthlyCents: number | null;
|
|
35
|
+
/** null means no fixed limit (set per contract). */
|
|
36
|
+
projects: number | null;
|
|
37
|
+
cloudMinutes: number | null;
|
|
38
|
+
aiCredits: number | null;
|
|
39
|
+
parallel: number | null;
|
|
40
|
+
historyDays: number | null;
|
|
41
|
+
/** Cents per unit beyond the allowance; null means usage stops at the allowance. */
|
|
42
|
+
overage: { browserMinuteCents: number | null; creditCents: number | null };
|
|
43
|
+
features: PlanFeatures;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const NO_FEATURES: PlanFeatures = {
|
|
47
|
+
prChecks: false,
|
|
48
|
+
slack: false,
|
|
49
|
+
teams: false,
|
|
50
|
+
aiFixes: false,
|
|
51
|
+
deviceClouds: false,
|
|
52
|
+
sso: false,
|
|
53
|
+
scim: false,
|
|
54
|
+
auditLogExport: false,
|
|
55
|
+
prioritySupport: false,
|
|
56
|
+
selfHostedWorkers: false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const PLANS: Record<PlanId, PlanDef> = {
|
|
60
|
+
free: {
|
|
61
|
+
id: "free",
|
|
62
|
+
name: "Free",
|
|
63
|
+
audience: "Individuals and evaluation",
|
|
64
|
+
priceMonthlyCents: 0,
|
|
65
|
+
priceYearlyMonthlyCents: 0,
|
|
66
|
+
projects: 1,
|
|
67
|
+
cloudMinutes: 300,
|
|
68
|
+
aiCredits: 100,
|
|
69
|
+
parallel: 2,
|
|
70
|
+
historyDays: 7,
|
|
71
|
+
overage: { browserMinuteCents: null, creditCents: null },
|
|
72
|
+
features: { ...NO_FEATURES },
|
|
73
|
+
},
|
|
74
|
+
team: {
|
|
75
|
+
id: "team",
|
|
76
|
+
name: "Team",
|
|
77
|
+
audience: "Teams shipping continuously",
|
|
78
|
+
priceMonthlyCents: 24_900,
|
|
79
|
+
priceYearlyMonthlyCents: 19_900,
|
|
80
|
+
projects: 10,
|
|
81
|
+
cloudMinutes: 3_000,
|
|
82
|
+
aiCredits: 1_500,
|
|
83
|
+
parallel: 8,
|
|
84
|
+
historyDays: 30,
|
|
85
|
+
overage: { browserMinuteCents: 4, creditCents: 1 },
|
|
86
|
+
features: { ...NO_FEATURES, prChecks: true, slack: true, teams: true, aiFixes: true },
|
|
87
|
+
},
|
|
88
|
+
business: {
|
|
89
|
+
id: "business",
|
|
90
|
+
name: "Business",
|
|
91
|
+
audience: "Organizations testing across apps, devices and teams",
|
|
92
|
+
priceMonthlyCents: 89_900,
|
|
93
|
+
priceYearlyMonthlyCents: 71_900,
|
|
94
|
+
projects: 50,
|
|
95
|
+
cloudMinutes: 15_000,
|
|
96
|
+
aiCredits: 7_500,
|
|
97
|
+
parallel: 25,
|
|
98
|
+
historyDays: 90,
|
|
99
|
+
overage: { browserMinuteCents: 3, creditCents: 1 },
|
|
100
|
+
features: {
|
|
101
|
+
...NO_FEATURES,
|
|
102
|
+
prChecks: true,
|
|
103
|
+
slack: true,
|
|
104
|
+
teams: true,
|
|
105
|
+
aiFixes: true,
|
|
106
|
+
deviceClouds: true,
|
|
107
|
+
sso: true,
|
|
108
|
+
auditLogExport: true,
|
|
109
|
+
prioritySupport: true,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
enterprise: {
|
|
113
|
+
id: "enterprise",
|
|
114
|
+
name: "Enterprise",
|
|
115
|
+
audience: "Large and regulated organizations",
|
|
116
|
+
priceMonthlyCents: null,
|
|
117
|
+
priceYearlyMonthlyCents: null,
|
|
118
|
+
projects: null,
|
|
119
|
+
cloudMinutes: null,
|
|
120
|
+
aiCredits: null,
|
|
121
|
+
parallel: null,
|
|
122
|
+
historyDays: 365,
|
|
123
|
+
overage: { browserMinuteCents: 3, creditCents: 1 },
|
|
124
|
+
features: {
|
|
125
|
+
prChecks: true,
|
|
126
|
+
slack: true,
|
|
127
|
+
teams: true,
|
|
128
|
+
aiFixes: true,
|
|
129
|
+
deviceClouds: true,
|
|
130
|
+
sso: true,
|
|
131
|
+
scim: true,
|
|
132
|
+
auditLogExport: true,
|
|
133
|
+
prioritySupport: true,
|
|
134
|
+
selfHostedWorkers: true,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const PLAN_IDS = Object.keys(PLANS) as PlanId[];
|
|
140
|
+
|
|
141
|
+
/** Credits each AI action costs. A project's own model key costs no credits. */
|
|
142
|
+
export const AI_CREDIT_COST: Record<AiAction, number> = {
|
|
143
|
+
analysis: 1,
|
|
144
|
+
repair: 20,
|
|
145
|
+
exploration: 100,
|
|
146
|
+
discovery: 0,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Real devices on Bugmole's own AWS Device Farm account: pay as you go, none included. */
|
|
150
|
+
export const DEVICE_FARM_CENTS_PER_MIN = 25;
|
|
151
|
+
|
|
152
|
+
/** Contract-specific adjustments stored with a workspace's plan (Enterprise, trials). */
|
|
153
|
+
export type PlanOverrides = Partial<Pick<PlanDef, "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays">> & {
|
|
154
|
+
features?: Partial<PlanFeatures>;
|
|
155
|
+
overage?: Partial<PlanDef["overage"]>;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export type Entitlements = Omit<PlanDef, "name" | "audience" | "priceMonthlyCents" | "priceYearlyMonthlyCents">;
|
|
159
|
+
|
|
160
|
+
export function isPlanId(value: unknown): value is PlanId {
|
|
161
|
+
return typeof value === "string" && (PLAN_IDS as string[]).includes(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function entitlementsFor(plan: PlanId, overrides: PlanOverrides = {}): Entitlements {
|
|
165
|
+
const base = PLANS[plan];
|
|
166
|
+
const pick = <K extends "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays">(key: K) =>
|
|
167
|
+
overrides[key] !== undefined ? (overrides[key] as PlanDef[K]) : base[key];
|
|
168
|
+
return {
|
|
169
|
+
id: base.id,
|
|
170
|
+
projects: pick("projects"),
|
|
171
|
+
cloudMinutes: pick("cloudMinutes"),
|
|
172
|
+
aiCredits: pick("aiCredits"),
|
|
173
|
+
parallel: pick("parallel"),
|
|
174
|
+
historyDays: pick("historyDays"),
|
|
175
|
+
overage: { ...base.overage, ...overrides.overage },
|
|
176
|
+
features: { ...base.features, ...overrides.features },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** How much of the allowance a usage kind has; device minutes have none on any plan. */
|
|
181
|
+
export function includedQuantity(entitlements: Entitlements, kind: UsageKind): number | null {
|
|
182
|
+
if (kind === "browser_minute") return entitlements.cloudMinutes;
|
|
183
|
+
if (kind === "ai_credit") return entitlements.aiCredits;
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Cents per unit beyond the allowance, or null when usage must stop at the allowance. */
|
|
188
|
+
export function overageCents(entitlements: Entitlements, kind: UsageKind): number | null {
|
|
189
|
+
if (kind === "browser_minute") return entitlements.overage.browserMinuteCents;
|
|
190
|
+
if (kind === "ai_credit") return entitlements.overage.creditCents;
|
|
191
|
+
// Real devices need a paid plan with a subscription; the registry checks that.
|
|
192
|
+
return entitlements.id === "free" ? null : DEVICE_FARM_CENTS_PER_MIN;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function formatUsd(cents: number, { minimumFractionDigits }: { minimumFractionDigits?: number } = {}): string {
|
|
196
|
+
const dollars = cents / 100;
|
|
197
|
+
const fraction = minimumFractionDigits ?? (Number.isInteger(dollars) ? 0 : 2);
|
|
198
|
+
return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: fraction, maximumFractionDigits: Math.max(fraction, 2) })}`;
|
|
199
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { signAwsRequest } from "./aws-sigv4.js";
|
|
4
|
+
|
|
5
|
+
// AWS's published SigV4 example ("get-vanilla"), from the aws-sig-v4 test suite,
|
|
6
|
+
// adjusted for the x-amz-content-sha256 header this signer always includes.
|
|
7
|
+
test("SigV4 matches an independent calculation", async () => {
|
|
8
|
+
const { createHash, createHmac } = await import("node:crypto");
|
|
9
|
+
const credentials = { accessKeyId: "AKIDEXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" };
|
|
10
|
+
const now = new Date("2015-08-30T12:36:00Z");
|
|
11
|
+
const body = '{"arn":"x"}';
|
|
12
|
+
const headers = await signAwsRequest({
|
|
13
|
+
credentials, region: "us-west-2", service: "devicefarm", method: "POST",
|
|
14
|
+
url: "https://devicefarm.us-west-2.amazonaws.com/", headers: { "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": "DeviceFarm_20150623.ListDevices" },
|
|
15
|
+
body, now,
|
|
16
|
+
});
|
|
17
|
+
const payloadHash = createHash("sha256").update(body).digest("hex");
|
|
18
|
+
const canonical = [
|
|
19
|
+
"POST", "/", "",
|
|
20
|
+
`content-type:application/x-amz-json-1.1\nhost:devicefarm.us-west-2.amazonaws.com\nx-amz-content-sha256:${payloadHash}\nx-amz-date:20150830T123600Z\nx-amz-target:DeviceFarm_20150623.ListDevices\n`,
|
|
21
|
+
"content-type;host;x-amz-content-sha256;x-amz-date;x-amz-target",
|
|
22
|
+
payloadHash,
|
|
23
|
+
].join("\n");
|
|
24
|
+
const scope = "20150830/us-west-2/devicefarm/aws4_request";
|
|
25
|
+
const toSign = ["AWS4-HMAC-SHA256", "20150830T123600Z", scope, createHash("sha256").update(canonical).digest("hex")].join("\n");
|
|
26
|
+
let key: Buffer = createHmac("sha256", `AWS4${credentials.secretAccessKey}`).update("20150830").digest();
|
|
27
|
+
for (const part of ["us-west-2", "devicefarm", "aws4_request"]) key = createHmac("sha256", key).update(part).digest();
|
|
28
|
+
const signature = createHmac("sha256", key).update(toSign).digest("hex");
|
|
29
|
+
assert.equal(headers.authorization, `AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/${scope}, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-target, Signature=${signature}`);
|
|
30
|
+
assert.equal(headers["x-amz-date"], "20150830T123600Z");
|
|
31
|
+
assert.equal(headers.host, undefined, "fetch sets the host itself");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("the AWS reference vector for a known key derivation", async () => {
|
|
35
|
+
// From AWS docs "Examples of how to derive a signing key": the canonical get-vanilla request.
|
|
36
|
+
const headers = await signAwsRequest({
|
|
37
|
+
credentials: { accessKeyId: "AKIDEXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" },
|
|
38
|
+
region: "us-east-1", service: "service", method: "GET", url: "https://example.amazonaws.com/?Param2=value2&Param1=value1",
|
|
39
|
+
headers: {}, body: "", now: new Date("2015-08-30T12:36:00Z"),
|
|
40
|
+
});
|
|
41
|
+
assert.match(headers.authorization, /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/20150830\/us-east-1\/service\/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/);
|
|
42
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// AWS Signature Version 4 with WebCrypto, so the same code signs requests in
|
|
2
|
+
// Cloudflare Workers and in Node.
|
|
3
|
+
|
|
4
|
+
export type AwsCredentials = { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
|
|
8
|
+
function hex(bytes: ArrayBuffer | Uint8Array): string {
|
|
9
|
+
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
13
|
+
return hex(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function hmac(key: ArrayBuffer | Uint8Array, value: string): Promise<ArrayBuffer> {
|
|
17
|
+
const imported = await crypto.subtle.importKey("raw", key as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
18
|
+
return crypto.subtle.sign("HMAC", imported, encoder.encode(value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Signs a request with a string body. Returns the headers to send. */
|
|
22
|
+
export async function signAwsRequest(params: {
|
|
23
|
+
credentials: AwsCredentials;
|
|
24
|
+
region: string;
|
|
25
|
+
service: string;
|
|
26
|
+
method: string;
|
|
27
|
+
url: string;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
body: string;
|
|
30
|
+
now?: Date;
|
|
31
|
+
}): Promise<Record<string, string>> {
|
|
32
|
+
const now = params.now ?? new Date();
|
|
33
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
34
|
+
const date = amzDate.slice(0, 8);
|
|
35
|
+
const url = new URL(params.url);
|
|
36
|
+
const payloadHash = await sha256Hex(params.body);
|
|
37
|
+
const headers: Record<string, string> = {
|
|
38
|
+
...Object.fromEntries(Object.entries(params.headers).map(([key, value]) => [key.toLowerCase(), value.trim()])),
|
|
39
|
+
host: url.host,
|
|
40
|
+
"x-amz-date": amzDate,
|
|
41
|
+
"x-amz-content-sha256": payloadHash,
|
|
42
|
+
...(params.credentials.sessionToken ? { "x-amz-security-token": params.credentials.sessionToken } : {}),
|
|
43
|
+
};
|
|
44
|
+
const names = Object.keys(headers).sort();
|
|
45
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
46
|
+
.map(([key, value]) => [encodeRfc3986(key), encodeRfc3986(value)])
|
|
47
|
+
.sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
|
|
48
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
49
|
+
.join("&");
|
|
50
|
+
const canonicalRequest = [
|
|
51
|
+
params.method.toUpperCase(),
|
|
52
|
+
url.pathname.split("/").map((part) => encodeRfc3986(decodeURIComponent(part))).join("/") || "/",
|
|
53
|
+
canonicalQuery,
|
|
54
|
+
names.map((name) => `${name}:${headers[name]}\n`).join(""),
|
|
55
|
+
names.join(";"),
|
|
56
|
+
payloadHash,
|
|
57
|
+
].join("\n");
|
|
58
|
+
const scope = `${date}/${params.region}/${params.service}/aws4_request`;
|
|
59
|
+
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, await sha256Hex(canonicalRequest)].join("\n");
|
|
60
|
+
let key = await hmac(encoder.encode(`AWS4${params.credentials.secretAccessKey}`), date);
|
|
61
|
+
for (const part of [params.region, params.service, "aws4_request"]) key = await hmac(key, part);
|
|
62
|
+
const signature = hex(await hmac(key, stringToSign));
|
|
63
|
+
const { host: _host, ...rest } = headers;
|
|
64
|
+
return {
|
|
65
|
+
...rest,
|
|
66
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${params.credentials.accessKeyId}/${scope}, SignedHeaders=${names.join(";")}, Signature=${signature}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function encodeRfc3986(value: string): string {
|
|
71
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
72
|
+
}
|