@bugmole/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -4
- package/package.json +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -2,7 +2,20 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import test from "node:test";
|
|
4
4
|
import { COPY_HEADER, catalogCopy, catalogSource } from "../../scripts/sync-plan-catalog.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
AI_CREDIT_COST,
|
|
7
|
+
PLANS,
|
|
8
|
+
PLAN_IDS,
|
|
9
|
+
PRICING_ROWS,
|
|
10
|
+
PRICING_UNLISTED,
|
|
11
|
+
catalogPricingKeys,
|
|
12
|
+
entitlementsFor,
|
|
13
|
+
formatUsd,
|
|
14
|
+
includedQuantity,
|
|
15
|
+
overageCents,
|
|
16
|
+
pricingCell,
|
|
17
|
+
pricingValue,
|
|
18
|
+
} from "./plan-catalog.js";
|
|
6
19
|
|
|
7
20
|
test("the dashboard's copy of the plan catalog matches the source", async () => {
|
|
8
21
|
const [source, copy] = await Promise.all([readFile(catalogSource, "utf8"), readFile(catalogCopy, "utf8")]);
|
|
@@ -26,8 +39,11 @@ test("free usage stops at the allowance while paid plans can buy more", () => {
|
|
|
26
39
|
assert.equal(overageCents(free, "device_minute"), null);
|
|
27
40
|
const team = entitlementsFor("team");
|
|
28
41
|
assert.equal(overageCents(team, "browser_minute"), 4);
|
|
29
|
-
assert.equal(overageCents(team, "device_minute"),
|
|
42
|
+
assert.equal(overageCents(team, "device_minute"), null, "Team has no device clouds, so no real-device minutes");
|
|
30
43
|
assert.equal(includedQuantity(team, "device_minute"), 0);
|
|
44
|
+
assert.equal(overageCents(entitlementsFor("business"), "device_minute"), 25);
|
|
45
|
+
assert.equal(overageCents(entitlementsFor("business"), "browser_minute"), 3);
|
|
46
|
+
assert.equal(overageCents(entitlementsFor("team", { features: { deviceClouds: true } }), "device_minute"), 25, "a contract that adds device clouds adds device minutes");
|
|
31
47
|
assert.equal(includedQuantity(team, "ai_credit"), 1500);
|
|
32
48
|
});
|
|
33
49
|
|
|
@@ -44,3 +60,67 @@ test("AI credit costs match the pricing page", () => {
|
|
|
44
60
|
assert.equal(formatUsd(24_900), "$249");
|
|
45
61
|
assert.equal(formatUsd(4), "$0.04");
|
|
46
62
|
});
|
|
63
|
+
|
|
64
|
+
test("real-device minutes are priced only where device clouds are included", () => {
|
|
65
|
+
for (const plan of Object.values(PLANS)) {
|
|
66
|
+
const priced = overageCents(entitlementsFor(plan.id), "device_minute") !== null;
|
|
67
|
+
assert.equal(priced, plan.features.deviceClouds, plan.id);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("only Enterprise lists contract extras, and they are descriptive only", () => {
|
|
72
|
+
for (const id of ["free", "team", "business"] as const) {
|
|
73
|
+
assert.ok(Object.values(PLANS[id].extras).every((value) => value === false), id);
|
|
74
|
+
}
|
|
75
|
+
assert.ok(Object.values(PLANS.enterprise.extras).every((value) => value === true));
|
|
76
|
+
assert.equal("extras" in entitlementsFor("enterprise"), false, "entitlements never carry contract extras");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("every plan includes the everyone-gets features", () => {
|
|
80
|
+
for (const plan of Object.values(PLANS)) {
|
|
81
|
+
for (const key of ["unlimitedUsers", "unlimitedLocalRuns", "desktopBrowsers", "emulatedMobile", "aiFailureAnalysis"] as const) {
|
|
82
|
+
assert.equal(plan.features[key], true, `${plan.id}.${key}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("every pricing row names a real catalog key", () => {
|
|
88
|
+
for (const row of PRICING_ROWS) {
|
|
89
|
+
for (const plan of PLAN_IDS) assert.notEqual(pricingValue(plan, row.key), undefined, `${row.key} on ${plan}`);
|
|
90
|
+
}
|
|
91
|
+
assert.equal(pricingValue("team", "features.notAThing"), undefined);
|
|
92
|
+
assert.equal(pricingValue("team", "limits.seats"), undefined);
|
|
93
|
+
assert.equal(new Set(PRICING_ROWS.map((row) => row.key)).size, PRICING_ROWS.length, "no duplicate rows");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("every catalog feature, extra and limit is on the pricing page or deliberately unlisted", () => {
|
|
97
|
+
const shown = new Set<string>(PRICING_ROWS.map((row) => row.key));
|
|
98
|
+
for (const key of catalogPricingKeys()) {
|
|
99
|
+
assert.ok(shown.has(key) || PRICING_UNLISTED[key], `${key} is in the catalog but not on the pricing page`);
|
|
100
|
+
assert.ok(!(shown.has(key) && PRICING_UNLISTED[key]), `${key} is both shown and unlisted`);
|
|
101
|
+
}
|
|
102
|
+
for (const key of Object.keys(PRICING_UNLISTED)) assert.ok((catalogPricingKeys() as string[]).includes(key), `unlisted ${key} is not a catalog key`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("pricing cells follow the catalog", () => {
|
|
106
|
+
const row = (key: string) => PRICING_ROWS.find((item) => item.key === key)!;
|
|
107
|
+
assert.equal(pricingCell("team", row("features.teams")), "\u2713");
|
|
108
|
+
assert.equal(pricingCell("free", row("features.teams")), "\u2014");
|
|
109
|
+
assert.equal(pricingCell("team", row("overage.browser_minute")), "$0.04 / min");
|
|
110
|
+
assert.equal(pricingCell("business", row("overage.browser_minute")), "$0.03 / min");
|
|
111
|
+
assert.equal(pricingCell("team", row("overage.device_minute")), "\u2014");
|
|
112
|
+
assert.equal(pricingCell("business", row("overage.device_minute")), "$0.25 / min");
|
|
113
|
+
assert.equal(pricingCell("enterprise", row("overage.browser_minute")), "Volume pricing", "contract usage isn't shown at list rates");
|
|
114
|
+
assert.equal(pricingCell("team", row("limits.cloudMinutes")), "3,000");
|
|
115
|
+
assert.equal(pricingCell("enterprise", row("limits.projects")), "Custom");
|
|
116
|
+
assert.equal(pricingCell("business", row("limits.historyDays")), "90 days");
|
|
117
|
+
assert.equal(pricingCell("free", row("limits.inactiveProjectDays")), "30 days");
|
|
118
|
+
assert.equal(pricingCell("team", row("limits.inactiveProjectDays")), "Never", "no limit reads Never, not Custom");
|
|
119
|
+
assert.equal(pricingCell("enterprise", row("limits.inactiveProjectDays")), "Never");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("only Free pauses inactive projects, and contracts can change it", () => {
|
|
123
|
+
assert.equal(entitlementsFor("free").inactiveProjectDays, 30);
|
|
124
|
+
for (const id of ["team", "business", "enterprise"] as const) assert.equal(entitlementsFor(id).inactiveProjectDays, null, id);
|
|
125
|
+
assert.equal(entitlementsFor("enterprise", { inactiveProjectDays: 90 }).inactiveProjectDays, 90);
|
|
126
|
+
});
|
|
@@ -12,6 +12,16 @@ export type UsageKind = "browser_minute" | "device_minute" | "ai_credit";
|
|
|
12
12
|
export type AiAction = "analysis" | "repair" | "exploration" | "discovery";
|
|
13
13
|
|
|
14
14
|
export type PlanFeatures = {
|
|
15
|
+
/** Every plan: no per-seat pricing. */
|
|
16
|
+
unlimitedUsers: boolean;
|
|
17
|
+
/** Every plan: runs on your own machines and CI are never counted. */
|
|
18
|
+
unlimitedLocalRuns: boolean;
|
|
19
|
+
/** Every plan: Chrome, Firefox, Safari and Edge. */
|
|
20
|
+
desktopBrowsers: boolean;
|
|
21
|
+
/** Every plan: emulated iPhone and Android viewports. */
|
|
22
|
+
emulatedMobile: boolean;
|
|
23
|
+
/** Every plan: AI explains a failed run (uses AI credits). */
|
|
24
|
+
aiFailureAnalysis: boolean;
|
|
15
25
|
prChecks: boolean;
|
|
16
26
|
slack: boolean;
|
|
17
27
|
teams: boolean;
|
|
@@ -38,12 +48,55 @@ export type PlanDef = {
|
|
|
38
48
|
aiCredits: number | null;
|
|
39
49
|
parallel: number | null;
|
|
40
50
|
historyDays: number | null;
|
|
51
|
+
/**
|
|
52
|
+
* Days without any activity (a run, a task, exploration, a CLI/MCP call or a
|
|
53
|
+
* dashboard change) before a project is paused; null means never. A paused
|
|
54
|
+
* project is read-only and doesn't count toward `projects` until someone
|
|
55
|
+
* activates it again.
|
|
56
|
+
*/
|
|
57
|
+
inactiveProjectDays: number | null;
|
|
58
|
+
/**
|
|
59
|
+
* How far one exploration on Bugmole Cloud goes: pages opened, and clicks
|
|
60
|
+
* from home. A project's own machine sets its own (BUGMOLE_DISCOVERY_MAX_*).
|
|
61
|
+
*/
|
|
62
|
+
discovery: { maxPages: number; maxDepth: number };
|
|
41
63
|
/** Cents per unit beyond the allowance; null means usage stops at the allowance. */
|
|
42
64
|
overage: { browserMinuteCents: number | null; creditCents: number | null };
|
|
43
65
|
features: PlanFeatures;
|
|
66
|
+
/**
|
|
67
|
+
* What a contract adds beyond the limits and features above, as shown on the
|
|
68
|
+
* pricing page. Descriptive only: NOT enforced by the registry or read by
|
|
69
|
+
* feature flags; the contract itself sets them up.
|
|
70
|
+
*/
|
|
71
|
+
extras: PlanExtras;
|
|
44
72
|
};
|
|
45
73
|
|
|
46
|
-
|
|
74
|
+
/** Descriptive, contract-only plan extras (never enforced; see PlanDef.extras). */
|
|
75
|
+
export type PlanExtras = {
|
|
76
|
+
dedicatedInfrastructure: boolean;
|
|
77
|
+
privateDevices: boolean;
|
|
78
|
+
ownModelAndStorage: boolean;
|
|
79
|
+
customRetention: boolean;
|
|
80
|
+
sla: boolean;
|
|
81
|
+
volumePricing: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const NO_EXTRAS: PlanExtras = {
|
|
85
|
+
dedicatedInfrastructure: false,
|
|
86
|
+
privateDevices: false,
|
|
87
|
+
ownModelAndStorage: false,
|
|
88
|
+
customRetention: false,
|
|
89
|
+
sla: false,
|
|
90
|
+
volumePricing: false,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** What every plan, Free included, gets; paid plans add to it. */
|
|
94
|
+
const BASE_FEATURES: PlanFeatures = {
|
|
95
|
+
unlimitedUsers: true,
|
|
96
|
+
unlimitedLocalRuns: true,
|
|
97
|
+
desktopBrowsers: true,
|
|
98
|
+
emulatedMobile: true,
|
|
99
|
+
aiFailureAnalysis: true,
|
|
47
100
|
prChecks: false,
|
|
48
101
|
slack: false,
|
|
49
102
|
teams: false,
|
|
@@ -68,8 +121,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|
|
68
121
|
aiCredits: 100,
|
|
69
122
|
parallel: 2,
|
|
70
123
|
historyDays: 7,
|
|
124
|
+
inactiveProjectDays: 30,
|
|
125
|
+
discovery: { maxPages: 12, maxDepth: 2 },
|
|
71
126
|
overage: { browserMinuteCents: null, creditCents: null },
|
|
72
|
-
features: { ...
|
|
127
|
+
features: { ...BASE_FEATURES },
|
|
128
|
+
extras: { ...NO_EXTRAS },
|
|
73
129
|
},
|
|
74
130
|
team: {
|
|
75
131
|
id: "team",
|
|
@@ -82,8 +138,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|
|
82
138
|
aiCredits: 1_500,
|
|
83
139
|
parallel: 8,
|
|
84
140
|
historyDays: 30,
|
|
141
|
+
inactiveProjectDays: null,
|
|
142
|
+
discovery: { maxPages: 40, maxDepth: 3 },
|
|
85
143
|
overage: { browserMinuteCents: 4, creditCents: 1 },
|
|
86
|
-
features: { ...
|
|
144
|
+
features: { ...BASE_FEATURES, prChecks: true, slack: true, teams: true, aiFixes: true },
|
|
145
|
+
extras: { ...NO_EXTRAS },
|
|
87
146
|
},
|
|
88
147
|
business: {
|
|
89
148
|
id: "business",
|
|
@@ -96,9 +155,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|
|
96
155
|
aiCredits: 7_500,
|
|
97
156
|
parallel: 25,
|
|
98
157
|
historyDays: 90,
|
|
158
|
+
inactiveProjectDays: null,
|
|
159
|
+
discovery: { maxPages: 100, maxDepth: 4 },
|
|
99
160
|
overage: { browserMinuteCents: 3, creditCents: 1 },
|
|
100
161
|
features: {
|
|
101
|
-
...
|
|
162
|
+
...BASE_FEATURES,
|
|
102
163
|
prChecks: true,
|
|
103
164
|
slack: true,
|
|
104
165
|
teams: true,
|
|
@@ -108,6 +169,7 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|
|
108
169
|
auditLogExport: true,
|
|
109
170
|
prioritySupport: true,
|
|
110
171
|
},
|
|
172
|
+
extras: { ...NO_EXTRAS },
|
|
111
173
|
},
|
|
112
174
|
enterprise: {
|
|
113
175
|
id: "enterprise",
|
|
@@ -120,8 +182,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|
|
120
182
|
aiCredits: null,
|
|
121
183
|
parallel: null,
|
|
122
184
|
historyDays: 365,
|
|
185
|
+
inactiveProjectDays: null,
|
|
186
|
+
discovery: { maxPages: 250, maxDepth: 5 },
|
|
123
187
|
overage: { browserMinuteCents: 3, creditCents: 1 },
|
|
124
188
|
features: {
|
|
189
|
+
...BASE_FEATURES,
|
|
125
190
|
prChecks: true,
|
|
126
191
|
slack: true,
|
|
127
192
|
teams: true,
|
|
@@ -133,6 +198,16 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|
|
133
198
|
prioritySupport: true,
|
|
134
199
|
selfHostedWorkers: true,
|
|
135
200
|
},
|
|
201
|
+
// Agreed per contract; additional usage is priced there too, not at the list
|
|
202
|
+
// rates above (which only apply until a contract sets its own).
|
|
203
|
+
extras: {
|
|
204
|
+
dedicatedInfrastructure: true,
|
|
205
|
+
privateDevices: true,
|
|
206
|
+
ownModelAndStorage: true,
|
|
207
|
+
customRetention: true,
|
|
208
|
+
sla: true,
|
|
209
|
+
volumePricing: true,
|
|
210
|
+
},
|
|
136
211
|
},
|
|
137
212
|
};
|
|
138
213
|
|
|
@@ -150,12 +225,12 @@ export const AI_CREDIT_COST: Record<AiAction, number> = {
|
|
|
150
225
|
export const DEVICE_FARM_CENTS_PER_MIN = 25;
|
|
151
226
|
|
|
152
227
|
/** Contract-specific adjustments stored with a workspace's plan (Enterprise, trials). */
|
|
153
|
-
export type PlanOverrides = Partial<Pick<PlanDef,
|
|
228
|
+
export type PlanOverrides = Partial<Pick<PlanDef, LimitKey>> & {
|
|
154
229
|
features?: Partial<PlanFeatures>;
|
|
155
230
|
overage?: Partial<PlanDef["overage"]>;
|
|
156
231
|
};
|
|
157
232
|
|
|
158
|
-
export type Entitlements = Omit<PlanDef, "name" | "audience" | "priceMonthlyCents" | "priceYearlyMonthlyCents">;
|
|
233
|
+
export type Entitlements = Omit<PlanDef, "name" | "audience" | "priceMonthlyCents" | "priceYearlyMonthlyCents" | "extras">;
|
|
159
234
|
|
|
160
235
|
export function isPlanId(value: unknown): value is PlanId {
|
|
161
236
|
return typeof value === "string" && (PLAN_IDS as string[]).includes(value);
|
|
@@ -163,7 +238,7 @@ export function isPlanId(value: unknown): value is PlanId {
|
|
|
163
238
|
|
|
164
239
|
export function entitlementsFor(plan: PlanId, overrides: PlanOverrides = {}): Entitlements {
|
|
165
240
|
const base = PLANS[plan];
|
|
166
|
-
const pick = <K extends
|
|
241
|
+
const pick = <K extends LimitKey>(key: K) =>
|
|
167
242
|
overrides[key] !== undefined ? (overrides[key] as PlanDef[K]) : base[key];
|
|
168
243
|
return {
|
|
169
244
|
id: base.id,
|
|
@@ -172,6 +247,8 @@ export function entitlementsFor(plan: PlanId, overrides: PlanOverrides = {}): En
|
|
|
172
247
|
aiCredits: pick("aiCredits"),
|
|
173
248
|
parallel: pick("parallel"),
|
|
174
249
|
historyDays: pick("historyDays"),
|
|
250
|
+
inactiveProjectDays: pick("inactiveProjectDays"),
|
|
251
|
+
discovery: { ...base.discovery },
|
|
175
252
|
overage: { ...base.overage, ...overrides.overage },
|
|
176
253
|
features: { ...base.features, ...overrides.features },
|
|
177
254
|
};
|
|
@@ -188,8 +265,8 @@ export function includedQuantity(entitlements: Entitlements, kind: UsageKind): n
|
|
|
188
265
|
export function overageCents(entitlements: Entitlements, kind: UsageKind): number | null {
|
|
189
266
|
if (kind === "browser_minute") return entitlements.overage.browserMinuteCents;
|
|
190
267
|
if (kind === "ai_credit") return entitlements.overage.creditCents;
|
|
191
|
-
// Real devices
|
|
192
|
-
return entitlements.
|
|
268
|
+
// Real devices come with device clouds (Business and up), billed as you go.
|
|
269
|
+
return entitlements.features.deviceClouds ? DEVICE_FARM_CENTS_PER_MIN : null;
|
|
193
270
|
}
|
|
194
271
|
|
|
195
272
|
export function formatUsd(cents: number, { minimumFractionDigits }: { minimumFractionDigits?: number } = {}): string {
|
|
@@ -197,3 +274,129 @@ export function formatUsd(cents: number, { minimumFractionDigits }: { minimumFra
|
|
|
197
274
|
const fraction = minimumFractionDigits ?? (Number.isInteger(dollars) ? 0 : 2);
|
|
198
275
|
return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: fraction, maximumFractionDigits: Math.max(fraction, 2) })}`;
|
|
199
276
|
}
|
|
277
|
+
|
|
278
|
+
// ---------- pricing page ----------
|
|
279
|
+
// The bugmole.com pricing page renders every plan feature, extra and limit from
|
|
280
|
+
// these rows, so a row can't claim something the catalog doesn't grant. A test
|
|
281
|
+
// fails when a catalog key has no row here and isn't in PRICING_UNLISTED.
|
|
282
|
+
|
|
283
|
+
export type LimitKey = "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays" | "inactiveProjectDays";
|
|
284
|
+
export type PricingKey =
|
|
285
|
+
| `features.${keyof PlanFeatures}`
|
|
286
|
+
| `extras.${keyof PlanExtras}`
|
|
287
|
+
| `limits.${LimitKey}`
|
|
288
|
+
| `overage.${UsageKind}`;
|
|
289
|
+
|
|
290
|
+
export type PricingRow = {
|
|
291
|
+
key: PricingKey;
|
|
292
|
+
/** Compare-table row label. */
|
|
293
|
+
label: string;
|
|
294
|
+
/** Plan-card bullet, when it differs from the label; for an overage, {value} is the rate. */
|
|
295
|
+
card?: string;
|
|
296
|
+
/** Unit after a limit or overage value: " days", " / min", " each". */
|
|
297
|
+
unit?: string;
|
|
298
|
+
/** What a limit of null (no limit) reads as; "Custom" when unset. */
|
|
299
|
+
unlimited?: string;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export const LIMIT_KEYS: LimitKey[] = ["projects", "cloudMinutes", "aiCredits", "parallel", "historyDays", "inactiveProjectDays"];
|
|
303
|
+
|
|
304
|
+
export const PRICING_ROWS: PricingRow[] = [
|
|
305
|
+
{ key: "features.unlimitedUsers", label: "Unlimited users" },
|
|
306
|
+
{ key: "features.unlimitedLocalRuns", label: "Unlimited local & CI runs" },
|
|
307
|
+
{ key: "features.desktopBrowsers", label: "Chrome, Firefox, Safari & Edge" },
|
|
308
|
+
{ key: "features.emulatedMobile", label: "Emulated iPhone & Android" },
|
|
309
|
+
{ key: "features.aiFailureAnalysis", label: "AI failure analysis" },
|
|
310
|
+
{ key: "limits.projects", label: "Projects" },
|
|
311
|
+
{ key: "limits.cloudMinutes", label: "Cloud minutes / month" },
|
|
312
|
+
{ key: "limits.aiCredits", label: "AI credits / month" },
|
|
313
|
+
{ key: "limits.parallel", label: "Cloud runs at once" },
|
|
314
|
+
{ key: "limits.historyDays", label: "Run history", unit: " days" },
|
|
315
|
+
{ key: "limits.inactiveProjectDays", label: "Projects paused after inactivity", unit: " days", unlimited: "Never" },
|
|
316
|
+
{ key: "overage.browser_minute", label: "Additional cloud minutes", unit: " / min" },
|
|
317
|
+
{ key: "overage.ai_credit", label: "Additional AI credits", unit: " each" },
|
|
318
|
+
{
|
|
319
|
+
key: "overage.device_minute",
|
|
320
|
+
label: "Real-device minutes (Bugmole device cloud)",
|
|
321
|
+
card: "Real devices on Bugmole's device cloud at {value}, pay as you go",
|
|
322
|
+
unit: " / min",
|
|
323
|
+
},
|
|
324
|
+
{ key: "features.prChecks", label: "PR checks" },
|
|
325
|
+
{ key: "features.slack", label: "Slack alerts" },
|
|
326
|
+
{ key: "features.teams", label: "Microsoft Teams alerts" },
|
|
327
|
+
{ key: "features.aiFixes", label: "AI test fixes" },
|
|
328
|
+
{
|
|
329
|
+
key: "features.deviceClouds",
|
|
330
|
+
label: "Device clouds (simulators, emulators, real devices)",
|
|
331
|
+
card: "iOS simulators, Android emulators & real devices through BrowserStack, Kobiton or AWS Device Farm",
|
|
332
|
+
},
|
|
333
|
+
{ key: "features.sso", label: "SSO" },
|
|
334
|
+
{ key: "features.auditLogExport", label: "Audit log export" },
|
|
335
|
+
{ key: "features.prioritySupport", label: "Priority support" },
|
|
336
|
+
{ key: "features.scim", label: "SCIM provisioning" },
|
|
337
|
+
{ key: "features.selfHostedWorkers", label: "Self-hosted workers" },
|
|
338
|
+
{ key: "extras.dedicatedInfrastructure", label: "Dedicated infrastructure" },
|
|
339
|
+
{ key: "extras.privateDevices", label: "Private devices" },
|
|
340
|
+
{ key: "extras.ownModelAndStorage", label: "Your own AI model and storage" },
|
|
341
|
+
{ key: "extras.customRetention", label: "Custom retention" },
|
|
342
|
+
{ key: "extras.sla", label: "SLA" },
|
|
343
|
+
{ key: "extras.volumePricing", label: "Volume pricing" },
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Catalog keys deliberately left off the pricing page, each with the reason.
|
|
348
|
+
* Empty today: everything a plan grants is shown.
|
|
349
|
+
*/
|
|
350
|
+
export const PRICING_UNLISTED: Partial<Record<PricingKey, string>> = {};
|
|
351
|
+
|
|
352
|
+
/** Every key the catalog defines for a plan, in pricing-key form. */
|
|
353
|
+
export function catalogPricingKeys(): PricingKey[] {
|
|
354
|
+
const plan = PLANS.free;
|
|
355
|
+
return [
|
|
356
|
+
...(Object.keys(plan.features) as Array<keyof PlanFeatures>).map((key) => `features.${key}` as const),
|
|
357
|
+
...(Object.keys(plan.extras) as Array<keyof PlanExtras>).map((key) => `extras.${key}` as const),
|
|
358
|
+
...LIMIT_KEYS.map((key) => `limits.${key}` as const),
|
|
359
|
+
...(["browser_minute", "ai_credit", "device_minute"] as UsageKind[]).map((key) => `overage.${key}` as const),
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A plan's raw value for a pricing key: a flag, a limit (null: set per
|
|
365
|
+
* contract) or cents per unit (null: not available). undefined means the key
|
|
366
|
+
* isn't in the catalog.
|
|
367
|
+
*/
|
|
368
|
+
export function pricingValue(plan: PlanId, key: string): boolean | number | null | undefined {
|
|
369
|
+
const [group, name] = key.split(".");
|
|
370
|
+
if (!name || !isPlanId(plan)) return undefined;
|
|
371
|
+
const def = PLANS[plan];
|
|
372
|
+
if (group === "features") return Object.hasOwn(def.features, name) ? def.features[name as keyof PlanFeatures] : undefined;
|
|
373
|
+
if (group === "extras") return Object.hasOwn(def.extras, name) ? def.extras[name as keyof PlanExtras] : undefined;
|
|
374
|
+
if (group === "limits") return (LIMIT_KEYS as string[]).includes(name) ? entitlementsFor(plan)[name as LimitKey] : undefined;
|
|
375
|
+
if (group === "overage") {
|
|
376
|
+
return (["browser_minute", "ai_credit", "device_minute"] as string[]).includes(name)
|
|
377
|
+
? overageCents(entitlementsFor(plan), name as UsageKind)
|
|
378
|
+
: undefined;
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Whether a plan has a row's feature, extra or overage at all (limits always apply). */
|
|
384
|
+
export function pricingIncluded(plan: PlanId, row: PricingRow): boolean {
|
|
385
|
+
const value = pricingValue(plan, row.key);
|
|
386
|
+
if (value === undefined) throw new Error(`Unknown pricing key: ${row.key}`);
|
|
387
|
+
return value !== false && value !== null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The compare-table cell for a plan: ✓ or — for flags, the value with its unit otherwise. */
|
|
391
|
+
export function pricingCell(plan: PlanId, row: PricingRow): string {
|
|
392
|
+
const value = pricingValue(plan, row.key);
|
|
393
|
+
if (value === undefined) throw new Error(`Unknown pricing key: ${row.key}`);
|
|
394
|
+
if (typeof value === "boolean") return value ? "\u2713" : "\u2014";
|
|
395
|
+
const custom = PLANS[plan].priceMonthlyCents === null;
|
|
396
|
+
if (row.key.startsWith("overage.")) {
|
|
397
|
+
// Contract plans price additional usage in the contract, not at list rates.
|
|
398
|
+
if (custom) return value === null ? "\u2014" : "Volume pricing";
|
|
399
|
+
return value === null ? "\u2014" : `${formatUsd(value, { minimumFractionDigits: 2 })}${row.unit ?? ""}`;
|
|
400
|
+
}
|
|
401
|
+
return value === null ? (row.unlimited ?? "Custom") : `${value.toLocaleString("en-US")}${row.unit ?? ""}`;
|
|
402
|
+
}
|