@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.
Files changed (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. 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 { AI_CREDIT_COST, PLANS, entitlementsFor, formatUsd, includedQuantity, overageCents } from "./plan-catalog.js";
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"), 25);
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
- const NO_FEATURES: PlanFeatures = {
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: { ...NO_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: { ...NO_FEATURES, prChecks: true, slack: true, teams: true, aiFixes: true },
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
- ...NO_FEATURES,
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, "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays">> & {
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 "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays">(key: K) =>
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 need a paid plan with a subscription; the registry checks that.
192
- return entitlements.id === "free" ? null : DEVICE_FARM_CENTS_PER_MIN;
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
+ }