@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
@@ -1,3 +1,4 @@
1
+ import { UNSAFE_LINK } from "./site-discovery.js";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import type { Browser, BrowserContext, Locator, Page } from "playwright";
@@ -19,11 +20,13 @@ import {
19
20
  describeStepLabel,
20
21
  isPatternText,
21
22
  parseMaestroStyleFlow,
23
+ referencedSecretNames,
22
24
  resolveFlowUrl,
23
25
  type FlowStep,
24
26
  type ParsedFlow,
25
27
  } from "./flow-language.js";
26
28
  import { analyzeFailure, rawFlowSteps, type FailureContext } from "./failure-analysis.js";
29
+ import { redactSecrets } from "./secret-redaction.js";
27
30
  import { createTempEmailInbox, deleteTempEmailInbox, extractFirstLink, waitForTempEmailMessage, type TempEmailInbox } from "../integrations/temp-email.js";
28
31
 
29
32
  export { parseMaestroStyleFlow } from "./flow-language.js";
@@ -87,17 +90,30 @@ const visibilityProbe = `(pattern, isRegex) => {
87
90
  return false;
88
91
  }`;
89
92
 
93
+ /** The page moved on while a probe ran in it: try again on the new page. */
94
+ export function isNavigationError(error: unknown): boolean {
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ return /Execution context was destroyed|Cannot find context with specified id|frame was detached|navigat/i.test(message);
97
+ }
98
+
90
99
  async function waitForVisibility(page: Page, text: string, shouldBeVisible: boolean, timeoutMs: number): Promise<void> {
91
100
  const isRegex = isPatternText(text);
92
101
  const deadline = Date.now() + timeoutMs;
93
102
  for (;;) {
94
103
  // Compiled inside the page: Playwright evaluates a bare string as an
95
104
  // expression and drops the argument, so the probe is passed as data.
96
- const visible = await page.evaluate(
97
- (args: [string, string, boolean]) =>
98
- (new Function(`return (${args[0]})`)() as (pattern: string, isRegex: boolean) => boolean)(args[1], args[2]),
99
- [visibilityProbe, text, isRegex] as [string, string, boolean],
100
- );
105
+ // A page that navigates mid-check (a sign-in redirecting to the app)
106
+ // destroys the context the probe ran in: that's "not yet", not a failure.
107
+ const visible = await page
108
+ .evaluate(
109
+ (args: [string, string, boolean]) =>
110
+ (new Function(`return (${args[0]})`)() as (pattern: string, isRegex: boolean) => boolean)(args[1], args[2]),
111
+ [visibilityProbe, text, isRegex] as [string, string, boolean],
112
+ )
113
+ .catch((error: unknown) => {
114
+ if (isNavigationError(error)) return null;
115
+ throw error;
116
+ });
101
117
  if (visible === shouldBeVisible) return;
102
118
  if (Date.now() >= deadline) {
103
119
  throw new Error(`Timed out after ${timeoutMs}ms waiting for "${text}" to be ${shouldBeVisible ? "visible" : "hidden"}.`);
@@ -179,9 +195,12 @@ const failureProbe = `() => {
179
195
  * mascot, a floating action button, a toast) sitting on top of a link or
180
196
  * button and silently swallowing the tap.
181
197
  */
182
- async function waitForClickable(page: Page, locator: Locator, text: string, timeoutMs: number): Promise<void> {
198
+ export async function waitForClickable(page: Page, locator: Locator, text: string, timeoutMs: number): Promise<void> {
183
199
  const deadline = Date.now() + timeoutMs;
184
200
  for (;;) {
201
+ // A person scrolls to a button below the fold; only covered or hidden
202
+ // controls should fail. On a phone's short screen this is most of them.
203
+ await locator.scrollIntoViewIfNeeded({ timeout: 1_000 }).catch(() => undefined);
185
204
  const handle = await locator.elementHandle({ timeout: 1_000 }).catch(() => null);
186
205
  const reachable = handle
187
206
  ? await page
@@ -200,10 +219,232 @@ async function waitForClickable(page: Page, locator: Locator, text: string, time
200
219
  }
201
220
  }
202
221
 
222
+ /**
223
+ * What a person notices first when a page doesn't fit the screen: it scrolls
224
+ * sideways, text runs out of the card it sits in, or text is cut off with no
225
+ * ellipsis. Runs in the page; returns problems in plain words.
226
+ */
227
+ export const layoutProbe = `() => {
228
+ const W = document.documentElement.clientWidth;
229
+ const problems = [];
230
+ const skip = (e) => e.closest("svg, pre, code, [aria-hidden=true], dialog:not([open])");
231
+ const shown = (e, s) => {
232
+ const r = e.getBoundingClientRect();
233
+ return r.width > 2 && r.height > 2 && s.display !== "none" && s.visibility !== "hidden" && Number(s.opacity) !== 0;
234
+ };
235
+ // What a developer searches for: the text, then the element and its first classes.
236
+ const label = (e) => {
237
+ const t = (e.innerText || "").replace(/\\s+/g, " ").trim();
238
+ const cls = typeof e.className === "string" ? e.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
239
+ const where = e.tagName.toLowerCase() + cls.map((c) => "." + c).join("");
240
+ return (t ? "\u201c" + (t.length > 48 ? t.slice(0, 47) + "\u2026" : t) + "\u201d " : "") + "(" + where + ")";
241
+ };
242
+ // Pinned to the screen (a chat button, a banner): it can't make the page wider.
243
+ const pinned = (e) => {
244
+ for (let a = e; a && a !== document.body; a = a.parentElement) {
245
+ const p = getComputedStyle(a).position;
246
+ if (p === "fixed" || p === "sticky") return true;
247
+ }
248
+ return false;
249
+ };
250
+ // Inside something that scrolls or clips on purpose (a carousel, a code block, a marquee).
251
+ const contained = (e) => {
252
+ for (let a = e.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
253
+ const s = getComputedStyle(a);
254
+ if (/(hidden|auto|scroll|clip)/.test(s.overflowX + " " + s.overflowY)) return true;
255
+ }
256
+ return false;
257
+ };
258
+ const boxed = (s) =>
259
+ (s.backgroundColor !== "rgba(0, 0, 0, 0)" && s.backgroundColor !== "transparent") ||
260
+ s.backgroundImage !== "none" || s.boxShadow !== "none" ||
261
+ parseFloat(s.borderTopWidth) + parseFloat(s.borderBottomWidth) + parseFloat(s.borderLeftWidth) + parseFloat(s.borderRightWidth) > 0;
262
+
263
+ if (document.documentElement.scrollWidth > W + 1) {
264
+ let culprit = null;
265
+ for (const e of document.body.querySelectorAll("*")) {
266
+ const s = getComputedStyle(e);
267
+ if (e.closest("svg, [aria-hidden=true]") || !shown(e, s) || contained(e) || pinned(e)) continue;
268
+ const r = e.getBoundingClientRect();
269
+ if (r.right > W + 1 && (e.innerText || "").trim()) culprit = e; // the deepest one wins
270
+ }
271
+ problems.push("The page scrolls sideways: it is " + document.documentElement.scrollWidth + "px wide on a " + W + "px screen" + (culprit ? ", because of " + label(culprit) : ""));
272
+ }
273
+ for (const e of document.body.querySelectorAll("*")) {
274
+ const s = getComputedStyle(e);
275
+ if (s.display === "inline" || skip(e) || !shown(e, s)) continue;
276
+ if (!(e.innerText || "").trim()) continue;
277
+ if (boxed(s) && s.overflowY === "visible" && e.scrollHeight > e.clientHeight + 1 && e.clientHeight > 0) {
278
+ problems.push("Text runs out of its box: " + label(e) + " overflows by " + (e.scrollHeight - e.clientHeight) + "px");
279
+ continue;
280
+ }
281
+ if (boxed(s) && s.overflowX === "visible" && e.scrollWidth > e.clientWidth + 1 && e.clientWidth > 0) {
282
+ problems.push("Text runs out of its box sideways: " + label(e) + " overflows by " + (e.scrollWidth - e.clientWidth) + "px");
283
+ continue;
284
+ }
285
+ const clips = /(hidden|clip)/.test(s.overflowX + " " + s.overflowY);
286
+ const clamped = s.textOverflow === "ellipsis" || (s.webkitLineClamp && s.webkitLineClamp !== "none");
287
+ if (clips && !clamped && e.children.length === 0 && e.clientWidth > 2 && e.clientHeight > 2 &&
288
+ (e.scrollWidth > e.clientWidth + 2 || e.scrollHeight > e.clientHeight + 2)) {
289
+ problems.push("Text is cut off: " + label(e));
290
+ }
291
+ }
292
+ return [...new Set(problems)];
293
+ }`;
294
+
295
+ /**
296
+ * Enlarges (or restores) every element's text by `factor`, the way a phone's
297
+ * font-size setting does: text grows, the boxes the page gave it don't. All
298
+ * sizes are read before any is changed, so nested sizes don't compound.
299
+ */
300
+ const textScaleProbe = `(factor) => {
301
+ const key = "__bugmoleTextScale";
302
+ const elements = Array.from(document.querySelectorAll("body, body *"));
303
+ if (factor === 1) {
304
+ for (const e of elements) {
305
+ const saved = e[key];
306
+ if (!saved) continue;
307
+ e.style.fontSize = saved.fontSize;
308
+ e.style.lineHeight = saved.lineHeight;
309
+ delete e[key];
310
+ }
311
+ return;
312
+ }
313
+ const sizes = elements.map((e) => {
314
+ const s = getComputedStyle(e);
315
+ return [parseFloat(s.fontSize), s.lineHeight.endsWith("px") ? parseFloat(s.lineHeight) : null];
316
+ });
317
+ elements.forEach((e, i) => {
318
+ e[key] = { fontSize: e.style.fontSize, lineHeight: e.style.lineHeight };
319
+ const [font, line] = sizes[i];
320
+ if (font) e.style.fontSize = font * factor + "px";
321
+ if (line) e.style.lineHeight = line * factor + "px";
322
+ });
323
+ }`;
324
+
325
+ async function layoutProblems(page: Page): Promise<string[]> {
326
+ return page.evaluate((source: string) => (new Function(`return (${source})`)() as () => string[])(), layoutProbe);
327
+ }
328
+
329
+ async function scaleText(page: Page, factor: number): Promise<void> {
330
+ await page.evaluate(([source, value]: [string, number]) => (new Function(`return (${source})`)() as (f: number) => void)(value), [textScaleProbe, factor] as [string, number]);
331
+ }
332
+
333
+ /** Larger text, as set on many phones (Android's font size; WCAG asks pages to cope with up to 200%). */
334
+ export const LARGE_TEXT = 1.3;
335
+
336
+ /** Layout problems at normal text size, then those that appear only with larger text. */
337
+ export async function checkLayout(page: Page, largeText = LARGE_TEXT): Promise<string[]> {
338
+ const normal = await layoutProblems(page);
339
+ if (!largeText || largeText === 1) return normal;
340
+ await scaleText(page, largeText);
341
+ try {
342
+ const large = await layoutProblems(page);
343
+ const label = `with text ${Math.round(largeText * 100)}% (a phone's larger font setting)`;
344
+ // The same box overflowing by more pixels isn't a new problem.
345
+ const same = (problem: string) => problem.replace(/\d+px/g, "");
346
+ const seen = new Set(normal.map(same));
347
+ return [...normal, ...large.filter((problem) => !seen.has(same(problem))).map((problem) => `${problem}, ${label}`)];
348
+ } finally {
349
+ await scaleText(page, 1);
350
+ }
351
+ }
352
+
353
+ /** A link a check must not follow: signing out, deleting, unsubscribing (by its text or address). */
354
+ export function isUnsafeLink(href: string, text: string): boolean {
355
+ let path = href;
356
+ try {
357
+ const url = new URL(href);
358
+ path = `${url.pathname} ${url.search}`;
359
+ } catch {
360
+ // keep the raw href
361
+ }
362
+ return UNSAFE_LINK.test(text) || UNSAFE_LINK.test(path.replace(/[/_?=&.-]+/g, " "));
363
+ }
364
+
365
+ /** Answers that mean a link is broken, not that the site turned a robot away. */
366
+ export function brokenStatus(status: number, sameSite: boolean): boolean {
367
+ if (sameSite) return status >= 400;
368
+ // 502-504 from someone else's site is their gateway having a bad moment
369
+ // (GitHub answers 504 to bots now and then), not a link the page got wrong.
370
+ if (status >= 502 && status <= 504) return false;
371
+ return status === 404 || status === 410 || status >= 500;
372
+ }
373
+
374
+ /**
375
+ * Makes the focused field's text invisible on screen (screenshots, video)
376
+ * without changing its value. Password fields are already masked.
377
+ */
378
+ export async function hideFocusedFieldText(page: Page): Promise<void> {
379
+ await page.evaluate(`(() => {
380
+ const field = document.activeElement;
381
+ if (!field || field === document.body) return;
382
+ field.setAttribute("data-bugmole-secret", "");
383
+ if (!document.getElementById("bugmole-secret-style")) {
384
+ const style = document.createElement("style");
385
+ style.id = "bugmole-secret-style";
386
+ style.textContent = "[data-bugmole-secret]{color:transparent!important;-webkit-text-fill-color:transparent!important;text-shadow:none!important;caret-color:currentColor}";
387
+ (document.head || document.documentElement).appendChild(style);
388
+ }
389
+ })()`).catch(() => undefined);
390
+ }
391
+
392
+ /** Requests every link on the page (same-site only unless `external`). Returns the broken ones. */
393
+ export async function checkLinks(page: Page, external: boolean, limit = 80): Promise<string[]> {
394
+ const origin = new URL(page.url()).origin;
395
+ const links = (await page.evaluate(
396
+ `Array.from(document.querySelectorAll("a[href]")).map((a) => [a.href, (a.innerText || a.getAttribute("aria-label") || "").trim()])`,
397
+ )) as Array<[string, string]>;
398
+ // Requesting a link is visiting it: never sign out, delete or unsubscribe
399
+ // on the way (the same links discovery steers clear of).
400
+ const safe = links.filter(([href, text]) => !isUnsafeLink(href, text)).map(([href]) => href);
401
+ const targets = [...new Set(safe.filter((h) => /^https?:/i.test(h)).map((h) => h.split("#")[0]!))]
402
+ .filter((h) => external || new URL(h).origin === origin)
403
+ .slice(0, limit);
404
+ const request = page.context().request;
405
+ const broken: string[] = [];
406
+ const queue = [...targets];
407
+ const worker = async () => {
408
+ for (let url = queue.shift(); url; url = queue.shift()) {
409
+ const sameSite = new URL(url).origin === origin;
410
+ try {
411
+ let response = await request.head(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
412
+ // Plenty of servers answer HEAD badly; a GET is what a visitor sends.
413
+ if (response.status() >= 400) response = await request.get(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
414
+ // A server error can be a blip: ask once more before calling it broken.
415
+ if (response.status() >= 500) {
416
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
417
+ response = await request.get(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
418
+ }
419
+ if (brokenStatus(response.status(), sameSite)) broken.push(`${url} (${response.status()})`);
420
+ } catch (error) {
421
+ if (sameSite) broken.push(`${url} (no answer: ${error instanceof Error ? error.message.split("\n")[0] : String(error)})`);
422
+ }
423
+ }
424
+ };
425
+ await Promise.all(Array.from({ length: Math.min(6, targets.length) }, worker));
426
+ return broken.sort();
427
+ }
428
+
203
429
  /**
204
430
  * Candidate locators in the order a person resolves a target: the control they
205
431
  * can name, then the field labelled that way, then anything reading that way.
206
432
  */
433
+ /**
434
+ * A screenshot of the whole page, or of the screen when the page is taller
435
+ * than the browser can capture in one image (32,767px in WebKit): evidence
436
+ * must never turn a passing flow into a failure.
437
+ */
438
+ export async function screenshotWholePage(page: Page, file: string, log?: string[]): Promise<void> {
439
+ try {
440
+ await page.screenshot({ path: file, fullPage: true });
441
+ } catch (error) {
442
+ if (!/larger than \d+ pixels|too large|maximum/i.test(error instanceof Error ? error.message : String(error))) throw error;
443
+ log?.push("The page is too tall for one screenshot; captured the screen instead.");
444
+ await page.screenshot({ path: file, fullPage: false });
445
+ }
446
+ }
447
+
207
448
  /** Playwright needs its own ffmpeg build to write a video; nothing else does. */
208
449
  export function isMissingVideoEncoder(error: unknown): boolean {
209
450
  const message = error instanceof Error ? error.message : String(error);
@@ -289,6 +530,8 @@ export class PlaywrightDriver implements AppDriver {
289
530
  const videoDir = path.join(artifactsDir, "video");
290
531
  await fs.mkdir(videoDir, { recursive: true });
291
532
  const log: string[] = [];
533
+ const typesSecret = referencedSecretNames(flowContent).length > 0;
534
+ if (typesSecret) log.push("This flow types a secret, so no Playwright trace is recorded.");
292
535
  const steps: DriverStepResult[] = [];
293
536
  const pageErrors: string[] = [];
294
537
  const consoleErrors: string[] = [];
@@ -306,7 +549,7 @@ export class PlaywrightDriver implements AppDriver {
306
549
 
307
550
  const writeLog = async () => {
308
551
  const logPath = path.join(artifactsDir, "playwright.log");
309
- await fs.writeFile(logPath, log.join("\n"), "utf8").catch(() => undefined);
552
+ await fs.writeFile(logPath, redactSecrets(log.join("\n")), "utf8").catch(() => undefined);
310
553
  return logPath;
311
554
  };
312
555
  const stopTrace = async (): Promise<string | undefined> => {
@@ -336,6 +579,7 @@ export class PlaywrightDriver implements AppDriver {
336
579
  };
337
580
  }
338
581
  const storageState = context.sessionStatePath && (await fileExists(context.sessionStatePath)) ? context.sessionStatePath : undefined;
582
+ if (storageState && !typesSecret) log.push("This flow starts signed in, so no Playwright trace is recorded (it would hold the session cookies).");
339
583
  // Record at the page's real size; Playwright's default shrinks to 800px.
340
584
  const videoSize = flow.viewport ?? emulation.options.viewport ?? { width: 1280, height: 720 };
341
585
  const contextOptions = {
@@ -348,7 +592,11 @@ export class PlaywrightDriver implements AppDriver {
348
592
  withVideo ? { ...contextOptions, recordVideo: { dir: videoDir, size: videoSize } } : contextOptions,
349
593
  );
350
594
  // The trace is the step-by-step record a person opens to debug a run.
351
- await opened.tracing.start({ screenshots: true, snapshots: true }).catch(() => undefined);
595
+ // It records every typed character and field value, so a flow that
596
+ // types a secret gets no trace: redaction can't reach inside it.
597
+ // A flow that starts signed in has the session cookies in every
598
+ // request it records, so it gets no trace either.
599
+ if (!typesSecret && !storageState) await opened.tracing.start({ screenshots: true, snapshots: true }).catch(() => undefined);
352
600
  return opened;
353
601
  };
354
602
  browserContext = await openContext(true);
@@ -378,13 +626,13 @@ export class PlaywrightDriver implements AppDriver {
378
626
 
379
627
  const capture = async (name: string) => {
380
628
  const screenshotPath = path.join(artifactsDir, `${name.replace(/[^a-z0-9_-]+/gi, "-")}.png`);
381
- await page.screenshot({ path: screenshotPath, fullPage: true });
629
+ await screenshotWholePage(page, screenshotPath, log);
382
630
  screenshots.push({ path: screenshotPath, name: path.basename(screenshotPath) });
383
631
  return screenshotPath;
384
632
  };
385
633
 
386
634
  const runStep = async (rawStep: FlowStep): Promise<void> => {
387
- const step = interpolateStep(rawStep, flowContext);
635
+ const step = interpolateStep(rawStep, flowContext, { env: { ...process.env, ...context.environment } });
388
636
  switch (step.type) {
389
637
  case "launchApp":
390
638
  await page.goto(flow.url, { waitUntil: "load" });
@@ -409,6 +657,9 @@ export class PlaywrightDriver implements AppDriver {
409
657
  return;
410
658
  }
411
659
  case "inputText":
660
+ // A secret typed into a plain text field would show in every
661
+ // screenshot and the video. Hide that field's text before typing.
662
+ if (rawStep.type === "inputText" && referencedSecretNames(rawStep.text).length) await hideFocusedFieldText(page);
412
663
  await page.keyboard.type(step.text);
413
664
  return;
414
665
  case "eraseText":
@@ -431,6 +682,22 @@ export class PlaywrightDriver implements AppDriver {
431
682
  await waitForClickable(page, target, step.text, step.timeoutMs ?? stepTimeoutMs);
432
683
  return;
433
684
  }
685
+ case "assertLayout": {
686
+ const problems = await checkLayout(page);
687
+ if (problems.length) {
688
+ await capture(`layout-${String(Date.now()).slice(-6)}`).catch(() => undefined);
689
+ const shown = problems.slice(0, 6).map((problem) => `- ${problem}`).join("\n");
690
+ throw new Error(`The page doesn't fit a ${page.viewportSize()?.width ?? "?"}px-wide screen:\n${shown}${problems.length > 6 ? `\n…and ${problems.length - 6} more` : ""}`);
691
+ }
692
+ return;
693
+ }
694
+ case "assertLinks": {
695
+ const broken = await checkLinks(page, step.external);
696
+ if (broken.length) {
697
+ throw new Error(`${broken.length} broken link(s) on ${page.url()}:\n${broken.slice(0, 10).map((link) => `- ${link}`).join("\n")}`);
698
+ }
699
+ return;
700
+ }
434
701
  case "scrollUntilVisible": {
435
702
  const target = await resolveTapTarget(page, step.text, 0);
436
703
  if (!target) throw new Error(`No element matching "${step.text}" to scroll to.`);
@@ -497,7 +764,11 @@ export class PlaywrightDriver implements AppDriver {
497
764
  }
498
765
 
499
766
  const finalScreenshot = await capture("playwright-final");
500
- if (context.sessionStatePath) await browserContext.storageState({ path: context.sessionStatePath });
767
+ if (context.sessionStatePath) {
768
+ // The session file holds the app's cookies: readable by this user only.
769
+ await browserContext.storageState({ path: context.sessionStatePath });
770
+ await fs.chmod(context.sessionStatePath, 0o600).catch(() => undefined);
771
+ }
501
772
  const tracePath = await stopTrace();
502
773
  const logPath = await writeLog();
503
774
  const videoPath = await page.video()?.path();
@@ -531,7 +802,7 @@ export class PlaywrightDriver implements AppDriver {
531
802
  steps,
532
803
  };
533
804
  } catch (error) {
534
- const message = error instanceof Error ? error.message : "Playwright flow failed.";
805
+ const message = redactSecrets(error instanceof Error ? error.message : "Playwright flow failed.");
535
806
  log.push(`ERROR: ${message}`);
536
807
  // The step whose action threw failed; steps after it never ran and must
537
808
  // not be reported as either passed or failed.
@@ -568,12 +839,12 @@ export class PlaywrightDriver implements AppDriver {
568
839
  invalidFields: snapshot?.invalidFields ?? [],
569
840
  };
570
841
  const contextPath = path.join(artifactsDir, "failure-context.json");
571
- await fs.writeFile(contextPath, `${JSON.stringify(failureContext, null, 2)}\n`, "utf8");
842
+ await fs.writeFile(contextPath, `${redactSecrets(JSON.stringify(failureContext, null, 2))}\n`, "utf8");
572
843
  artifacts.push({ path: contextPath, contentType: "application/json", name: "failure-context.json" });
573
844
  try {
574
845
  const analysis = analyzeFailure(flow.steps, rawFlowSteps(flowContent), failureContext);
575
846
  const analysisPath = path.join(artifactsDir, "analysis.json");
576
- await fs.writeFile(analysisPath, `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
847
+ await fs.writeFile(analysisPath, `${redactSecrets(JSON.stringify(analysis, null, 2))}\n`, "utf8");
577
848
  artifacts.push({ path: analysisPath, contentType: "application/json", name: "analysis.json" });
578
849
  log.push(`ANALYSIS: ${analysis.title}`);
579
850
  } catch (analysisError) {
@@ -582,7 +853,7 @@ export class PlaywrightDriver implements AppDriver {
582
853
  }
583
854
  if (failurePage) {
584
855
  const failurePath = path.join(artifactsDir, "playwright-failure.png");
585
- await failurePage.screenshot({ path: failurePath, fullPage: true });
856
+ await screenshotWholePage(failurePage, failurePath);
586
857
  artifacts.push({ path: failurePath, contentType: "image/png", name: "playwright-failure.png" });
587
858
  }
588
859
  } catch {
@@ -0,0 +1,161 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { RegistryRequestError, projectInactiveMessage } from "../registry/control-plane-client.js";
8
+ import { runInitWizard } from "./init-wizard.js";
9
+ import { SCOPE_MESSAGE, describeDetection, detectProjectIdentity, discoverApps } from "./project-identity.js";
10
+ import { announceProjectIdentity, isClaimConflict } from "./serve-worker.js";
11
+
12
+ async function monorepo(remote = "https://ada:ghp_secret@github.com/Acme/Shop.git") {
13
+ const root = await mkdtemp(path.join(os.tmpdir(), "bugmole-identity-"));
14
+ execFileSync("git", ["init", "-q"], { cwd: root });
15
+ execFileSync("git", ["remote", "add", "origin", remote], { cwd: root });
16
+ await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "shop", private: true, workspaces: ["apps/*", "packages/*"], devDependencies: { react: "19" } }));
17
+ await mkdir(path.join(root, "apps/web/src/app"), { recursive: true });
18
+ await writeFile(path.join(root, "apps/web/package.json"), JSON.stringify({ dependencies: { next: "16", react: "19" } }));
19
+ await mkdir(path.join(root, "apps/mobile"), { recursive: true });
20
+ await writeFile(path.join(root, "apps/mobile/package.json"), JSON.stringify({ dependencies: { expo: "54", "react-native": "0.81" } }));
21
+ await mkdir(path.join(root, "packages/ui"), { recursive: true });
22
+ await writeFile(path.join(root, "packages/ui/package.json"), JSON.stringify({ name: "ui", peerDependencies: { react: "19" } }));
23
+ await mkdir(path.join(root, "apps/web/node_modules/next"), { recursive: true });
24
+ await writeFile(path.join(root, "apps/web/node_modules/next/package.json"), JSON.stringify({ dependencies: { react: "19" } }));
25
+ return root;
26
+ }
27
+
28
+ function captureConsole() {
29
+ const lines: string[] = [];
30
+ const original = { log: console.log, warn: console.warn };
31
+ console.log = (...args: unknown[]) => lines.push(args.join(" "));
32
+ console.warn = (...args: unknown[]) => lines.push(args.join(" "));
33
+ return { lines, restore: () => Object.assign(console, original) };
34
+ }
35
+
36
+ test("a monorepo's apps are found separately, and a workspace root isn't one of them", async () => {
37
+ const root = await monorepo();
38
+ try {
39
+ assert.deepEqual(discoverApps(root), [
40
+ { path: "apps/mobile", kind: "mobile", framework: "Expo" },
41
+ { path: "apps/web", kind: "web", framework: "Next.js" },
42
+ ]);
43
+ const atRoot = detectProjectIdentity({ cwd: root });
44
+ assert.equal(atRoot.reason, "ambiguous", "never collapses two apps into one project");
45
+ assert.equal(atRoot.identity, null);
46
+ assert.match(describeDetection(atRoot).join("\n"), /has 2 apps: apps\/mobile \(Expo\), apps\/web \(Next\.js\)[\s\S]*--app-path/);
47
+
48
+ const inWeb = detectProjectIdentity({ cwd: path.join(root, "apps/web/src/app") });
49
+ assert.deepEqual(inWeb.identity, { kind: "repo", remote: "github.com/acme/shop", appPath: "apps/web" });
50
+ assert.equal(inWeb.preview?.key, "repo:github.com/acme/shop#apps/web");
51
+ assert.doesNotMatch(JSON.stringify(inWeb.identity), /ghp_secret|ada/, "the remote's credentials never leave the machine");
52
+
53
+ const chosen = detectProjectIdentity({ cwd: root, appPath: "apps/mobile", appUrl: "http://localhost:8081" });
54
+ assert.deepEqual(chosen.identity, { kind: "repo", remote: "github.com/acme/shop", appPath: "apps/mobile", appUrl: "http://localhost:8081" });
55
+ assert.equal(chosen.app?.kind, "mobile");
56
+ } finally {
57
+ await rm(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test("without a remote, a public URL identifies the app by domain; data projects are out of scope", async () => {
62
+ const app = await mkdtemp(path.join(os.tmpdir(), "bugmole-identity-solo-"));
63
+ const data = await mkdtemp(path.join(os.tmpdir(), "bugmole-identity-data-"));
64
+ try {
65
+ execFileSync("git", ["init", "-q"], { cwd: app });
66
+ await writeFile(path.join(app, "package.json"), JSON.stringify({ dependencies: { vue: "3" } }));
67
+ assert.equal(detectProjectIdentity({ cwd: app }).reason, "unidentified");
68
+ assert.deepEqual(detectProjectIdentity({ cwd: app, appUrl: "https://www.shop.example.com/cart" }).preview?.key, "domain:shop.example.com");
69
+
70
+ await writeFile(path.join(data, "train.ipynb"), "{}");
71
+ await writeFile(path.join(data, "requirements.txt"), "pandas\ntorch\n");
72
+ const detection = detectProjectIdentity({ cwd: data });
73
+ assert.equal(detection.reason, "no_app");
74
+ assert.ok(describeDetection(detection).includes(SCOPE_MESSAGE));
75
+ assert.match(SCOPE_MESSAGE, /web apps and mobile apps \(Android and iOS\).*out of scope/);
76
+ } finally {
77
+ await rm(app, { recursive: true, force: true });
78
+ await rm(data, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test("bugmole init says when there's no web or mobile app to test", async () => {
83
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "bugmole-init-scope-"));
84
+ const output = captureConsole();
85
+ try {
86
+ await runInitWizard({ cwd, nonInteractive: true });
87
+ } finally {
88
+ output.restore();
89
+ await rm(cwd, { recursive: true, force: true });
90
+ }
91
+ assert.ok(output.lines.includes(SCOPE_MESSAGE), output.lines.join("\n"));
92
+ });
93
+
94
+ test("bugmole init sends the app's identity and joins the project that already exists for it", async () => {
95
+ const root = await monorepo();
96
+ const cwd = path.join(root, "apps/web");
97
+ const saved = { key: process.env.BUGMOLE_API_KEY, registry: process.env.BUGMOLE_REGISTRY_URL };
98
+ const realFetch = globalThis.fetch;
99
+ const sent: Array<Record<string, any>> = [];
100
+ process.env.BUGMOLE_API_KEY = "bm_live_test";
101
+ process.env.BUGMOLE_REGISTRY_URL = "https://registry.test";
102
+ globalThis.fetch = (async (_url: string, init: RequestInit) => {
103
+ sent.push(JSON.parse(String(init.body)));
104
+ return Response.json({ project: { id: "shop-web" }, existing: true }, { status: 200 });
105
+ }) as typeof fetch;
106
+ const output = captureConsole();
107
+ try {
108
+ const result = await runInitWizard({ cwd, nonInteractive: true, projectId: "my-copy" });
109
+ assert.equal(result.config.projectId, "shop-web");
110
+ assert.equal(result.config.appPath, "apps/web");
111
+ const config = await readFile(path.join(cwd, result.configPath), "utf8");
112
+ assert.match(config, /^project:\n id: shop-web$/m);
113
+ assert.match(config, /app_path: "apps\/web"/);
114
+ assert.match(await readFile(path.join(cwd, result.envPath), "utf8"), /^BUGMOLE_PROJECT_ID=shop-web$/m);
115
+ } finally {
116
+ output.restore();
117
+ globalThis.fetch = realFetch;
118
+ for (const [name, value] of [["BUGMOLE_API_KEY", saved.key], ["BUGMOLE_REGISTRY_URL", saved.registry]] as const) {
119
+ if (value === undefined) delete process.env[name];
120
+ else process.env[name] = value;
121
+ }
122
+ await rm(root, { recursive: true, force: true });
123
+ }
124
+ assert.deepEqual(sent[0].identity, { kind: "repo", remote: "github.com/acme/shop", appPath: "apps/web" });
125
+ assert.match(output.lines.join("\n"), /Found a web app \(Next\.js\) at apps\/web/);
126
+ assert.match(output.lines.join("\n"), /already project shop-web in the workspace; joined it/);
127
+ });
128
+
129
+ test("a paused project is its own refusal: serve prints the activation link instead of treating it as a lost claim", async () => {
130
+ const paused = new RegistryRequestError(409, "project_inactive", "This project is paused", { activateUrl: "https://dash.test/?project=shop" });
131
+ assert.equal(isClaimConflict(paused), false);
132
+ assert.equal(isClaimConflict(new RegistryRequestError(409)), true);
133
+ assert.match(paused.message, /This project is paused \(409 project_inactive\)/);
134
+ assert.equal(
135
+ projectInactiveMessage("shop", "https://dash.test/?project=shop"),
136
+ "Project shop is paused because nobody used it for a while, so it won't run tests or tasks. A project member can activate it: https://dash.test/?project=shop.",
137
+ );
138
+
139
+ const root = await monorepo();
140
+ const logged: string[] = [];
141
+ const log = { log: (line: string) => logged.push(line), warn: (line: string) => logged.push(line) };
142
+ const reported: unknown[] = [];
143
+ try {
144
+ await announceProjectIdentity({
145
+ reportIdentity: async (identity) => {
146
+ reported.push(identity);
147
+ return { identity: null, inactiveAt: "2026-09-01T00:00:00Z", activateUrl: "https://dash.test/?project=shop" };
148
+ },
149
+ }, "shop", { cwd: root, appPath: "apps/web" }, log);
150
+ await announceProjectIdentity({
151
+ reportIdentity: async () => {
152
+ throw new RegistryRequestError(409, "identity_mismatch", "This project is github.com/acme/shop (apps/web), not github.com/acme/shop (apps/mobile).");
153
+ },
154
+ }, "shop", { cwd: root, appPath: "apps/mobile" }, log);
155
+ } finally {
156
+ await rm(root, { recursive: true, force: true });
157
+ }
158
+ assert.deepEqual(reported, [{ kind: "repo", remote: "github.com/acme/shop", appPath: "apps/web" }]);
159
+ assert.match(logged[0], /Project shop is paused .* activate it: https:\/\/dash\.test\/\?project=shop/);
160
+ assert.match(logged[1], /not github\.com\/acme\/shop \(apps\/mobile\)/);
161
+ });