@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
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
+
});
|