@bugmole/cli 0.4.0 → 0.5.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 +4 -4
- package/scripts/bugmole.ts +60 -0
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry-worker/core.ts +2 -0
- 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/features.ts +2 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/index.ts +25 -7
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +5 -4
- package/src/registry-worker/runner/runner.test.ts +38 -0
- package/src/registry-worker/runner/tokens.ts +4 -1
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +6 -0
- package/src/runtime/discovery-task.ts +24 -5
- package/src/runtime/flow-language.ts +21 -0
- package/src/runtime/journey-editor.ts +39 -0
- package/src/runtime/journey-graph.test.ts +42 -0
- package/src/runtime/playwright-driver.test.ts +145 -0
- package/src/runtime/playwright-driver.ts +237 -8
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/site-discovery.test.ts +135 -1
- package/src/runtime/site-discovery.ts +173 -24
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +3 -0
|
@@ -29,8 +29,15 @@ export type DiscoveredPage = {
|
|
|
29
29
|
heading?: string;
|
|
30
30
|
/** The link labels a visitor clicks, from the home page, to reach this page. */
|
|
31
31
|
path: string[];
|
|
32
|
-
|
|
32
|
+
/** `menu` names the button that must be opened first to see the link. */
|
|
33
|
+
links: Array<{ label: string; route: string; menu?: string }>;
|
|
33
34
|
forms: DiscoveredForm[];
|
|
35
|
+
/**
|
|
36
|
+
* Visible buttons outside forms ("Continue with Aegis", "Add to cart"), by
|
|
37
|
+
* name. Discovery never presses them; they tell AI exploration what is
|
|
38
|
+
* really on the page, so it doesn't invent controls.
|
|
39
|
+
*/
|
|
40
|
+
controls?: string[];
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
export type DiscoveredFlow = {
|
|
@@ -62,10 +69,21 @@ export type DiscoveryOptions = {
|
|
|
62
69
|
* on the app gentle. Defaults to 600ms (BUGMOLE_DISCOVERY_DWELL_MS).
|
|
63
70
|
*/
|
|
64
71
|
dwellMs?: number;
|
|
72
|
+
/**
|
|
73
|
+
* A Playwright storage-state file to start from, for exploring an app that
|
|
74
|
+
* needs sign-in. Links that sign out are already never followed.
|
|
75
|
+
*/
|
|
76
|
+
storageState?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Discovered flows open each page at its address instead of repeating the
|
|
79
|
+
* clicks that reached it. Use it for apps whose navigation hides behind a
|
|
80
|
+
* menu on phones, where the desktop clicks would not exist.
|
|
81
|
+
*/
|
|
82
|
+
openEachPage?: boolean;
|
|
65
83
|
onEvent?: (event: DiscoveryEvent, snapshot: DiscoveryResult) => void | Promise<void>;
|
|
66
84
|
};
|
|
67
85
|
|
|
68
|
-
const UNSAFE_LINK = /\b(log ?out|sign ?out|delete|remove|destroy|unsubscribe|deactivate|cancel (?:my )?(?:account|subscription))\b/i;
|
|
86
|
+
export const UNSAFE_LINK = /\b(log ?out|sign ?out|delete|remove|destroy|unsubscribe|deactivate|cancel (?:my )?(?:account|subscription))\b/i;
|
|
69
87
|
|
|
70
88
|
export function slugify(value: string): string {
|
|
71
89
|
return value.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "home";
|
|
@@ -97,7 +115,7 @@ function sampleValue(field: DiscoveredField): string {
|
|
|
97
115
|
if (hint.includes("email")) return "qa@example.com";
|
|
98
116
|
if (hint.includes("phone") || field.type === "tel") return "+1 555 010 0100";
|
|
99
117
|
if (field.type === "number") return "1";
|
|
100
|
-
if (field.type === "url" ||
|
|
118
|
+
if (field.type === "url" || /\b(url|website|link)\b/.test(hint)) return "https://example.com";
|
|
101
119
|
if (hint.includes("name")) return "Bugmole";
|
|
102
120
|
return "Test";
|
|
103
121
|
}
|
|
@@ -105,7 +123,11 @@ function sampleValue(field: DiscoveredField): string {
|
|
|
105
123
|
const FILLABLE = new Set(["text", "email", "password", "tel", "number", "url", "search", "textarea"]);
|
|
106
124
|
|
|
107
125
|
/** Flows a visitor could run for each discovered page and form. */
|
|
108
|
-
export function flowsFor(
|
|
126
|
+
export function flowsFor(
|
|
127
|
+
baseUrl: string,
|
|
128
|
+
pages: readonly DiscoveredPage[],
|
|
129
|
+
options: { openEachPage?: boolean } = {},
|
|
130
|
+
): DiscoveredFlow[] {
|
|
109
131
|
const flows: DiscoveredFlow[] = [];
|
|
110
132
|
const used = new Set<string>();
|
|
111
133
|
const uniqueId = (base: string) => {
|
|
@@ -114,22 +136,27 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
114
136
|
used.add(id);
|
|
115
137
|
return id;
|
|
116
138
|
};
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
// A query on the base URL is app context (a selected project, say) that
|
|
140
|
+
// every page needs, so each opened page carries it.
|
|
141
|
+
const context = new URL(baseUrl).search;
|
|
142
|
+
const header = (name: string, page: DiscoveredPage) =>
|
|
143
|
+
`url: ${options.openEachPage ? `${new URL(page.route, baseUrl).origin}${page.route}${context}` : baseUrl}\nname: ${JSON.stringify(name)}\n---\n`;
|
|
144
|
+
const pathSteps = (page: DiscoveredPage) => (options.openEachPage ? [] : page.path.map((label) => ({ tapOn: label })));
|
|
119
145
|
|
|
120
146
|
for (const page of pages) {
|
|
121
147
|
const pageName = page.route === "/" ? "Open the home page" : `Visit ${describeRoute(page)}`;
|
|
122
148
|
const landmark = page.heading ?? page.title;
|
|
123
149
|
if (landmark) {
|
|
124
150
|
const id = uniqueId(`discovered-${slugify(page.route === "/" ? "home" : describeRoute(page))}`);
|
|
125
|
-
|
|
151
|
+
// Then the page itself: it fits the screen and its links lead somewhere.
|
|
152
|
+
const steps: unknown[] = ["launchApp", ...pathSteps(page), { assertVisible: landmark }, "assertLayout", "assertLinks"];
|
|
126
153
|
flows.push({
|
|
127
154
|
id,
|
|
128
155
|
name: pageName,
|
|
129
156
|
kind: "page",
|
|
130
157
|
route: page.route,
|
|
131
158
|
file: `flows/discovered/${id}.yaml`,
|
|
132
|
-
content: header(pageName) + YAML.stringify(steps),
|
|
159
|
+
content: header(pageName, page) + YAML.stringify(steps),
|
|
133
160
|
steps: steps.map(stepLabel),
|
|
134
161
|
});
|
|
135
162
|
}
|
|
@@ -141,7 +168,9 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
141
168
|
const steps: unknown[] = [
|
|
142
169
|
"launchApp",
|
|
143
170
|
...pathSteps(page),
|
|
144
|
-
|
|
171
|
+
// Cleared first: a field may already hold a value (an app's own URL,
|
|
172
|
+
// say), and typing into the middle of it makes nonsense.
|
|
173
|
+
...fields.flatMap((field) => [{ tapOn: field.label }, "eraseText", { inputText: sampleValue(field) }]),
|
|
145
174
|
// Discovery never submits; the flow proves the form is ready to.
|
|
146
175
|
...(form.submit ? [{ assertClickable: form.submit }] : []),
|
|
147
176
|
];
|
|
@@ -151,7 +180,7 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
151
180
|
kind: "form",
|
|
152
181
|
route: page.route,
|
|
153
182
|
file: `flows/discovered/${id}.yaml`,
|
|
154
|
-
content: header(name) + YAML.stringify(steps),
|
|
183
|
+
content: header(name, page) + YAML.stringify(steps),
|
|
155
184
|
steps: steps.map(stepLabel),
|
|
156
185
|
});
|
|
157
186
|
}
|
|
@@ -160,7 +189,13 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
160
189
|
}
|
|
161
190
|
|
|
162
191
|
function stepLabel(step: unknown): string {
|
|
163
|
-
if (typeof step === "string")
|
|
192
|
+
if (typeof step === "string") {
|
|
193
|
+
if (step === "launchApp") return "Open the app";
|
|
194
|
+
if (step === "assertLayout") return "Nothing spills off the screen";
|
|
195
|
+
if (step === "assertLinks") return "Every link on the site works";
|
|
196
|
+
if (step === "eraseText") return "Clear the field";
|
|
197
|
+
return step;
|
|
198
|
+
}
|
|
164
199
|
const [[key, value]] = Object.entries(step as Record<string, unknown>);
|
|
165
200
|
const text = String(value);
|
|
166
201
|
switch (key) {
|
|
@@ -186,7 +221,9 @@ const pageProbe = `() => {
|
|
|
186
221
|
for (const link of document.querySelectorAll("a[href]")) {
|
|
187
222
|
if (!visible(link)) continue;
|
|
188
223
|
const label = text(link) || link.getAttribute("aria-label") || "";
|
|
189
|
-
|
|
224
|
+
// Card-style links carry a title and a sentence; only whole paragraphs
|
|
225
|
+
// wrapped in a link are left out.
|
|
226
|
+
if (label && label.length <= 200) links.push({ label, href: link.href });
|
|
190
227
|
}
|
|
191
228
|
const labelOf = (field) => {
|
|
192
229
|
const own = field.labels && field.labels[0] ? text(field.labels[0]) : "";
|
|
@@ -208,15 +245,90 @@ const pageProbe = `() => {
|
|
|
208
245
|
const legend = form.getAttribute("aria-label") || (form.querySelector("legend, h1, h2, h3") ? text(form.querySelector("legend, h1, h2, h3")) : "");
|
|
209
246
|
forms.push({ name: legend || submitText || "form", fields, submit: submitText || undefined });
|
|
210
247
|
}
|
|
211
|
-
|
|
248
|
+
const controls = [];
|
|
249
|
+
for (const control of document.querySelectorAll("button, [role=button], input[type=submit], input[type=button]")) {
|
|
250
|
+
if (!visible(control) || control.closest("form") || control.closest("a[href]")) continue;
|
|
251
|
+
const name = (text(control) || control.getAttribute("aria-label") || control.value || "").trim();
|
|
252
|
+
if (name && name.length <= 60 && !controls.includes(name)) controls.push(name);
|
|
253
|
+
if (controls.length >= 30) break;
|
|
254
|
+
}
|
|
255
|
+
return { title: document.title, heading: heading ? text(heading) : undefined, links, forms, controls };
|
|
212
256
|
}`;
|
|
213
257
|
|
|
214
|
-
type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string }>; forms: DiscoveredForm[] };
|
|
258
|
+
type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string }>; forms: DiscoveredForm[]; controls?: string[] };
|
|
215
259
|
|
|
216
260
|
async function readPage(page: Page): Promise<Probe> {
|
|
217
261
|
return page.evaluate((source: string) => (new Function(`return (${source})`)() as () => Probe)(), pageProbe);
|
|
218
262
|
}
|
|
219
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Menus a person can open — a closed disclosure button (aria-expanded="false")
|
|
266
|
+
* such as an account menu — and the links inside each. Opening one only
|
|
267
|
+
* reveals links; it is closed again before moving on.
|
|
268
|
+
*/
|
|
269
|
+
async function linksInMenus(page: Page, known: ReadonlySet<string>): Promise<Array<{ menu: string; label: string; href: string }>> {
|
|
270
|
+
const found: Array<{ menu: string; label: string; href: string }> = [];
|
|
271
|
+
const startUrl = page.url();
|
|
272
|
+
const buttons = page.locator('button[aria-expanded="false"]');
|
|
273
|
+
const count = Math.min(await buttons.count(), 8);
|
|
274
|
+
const names: string[] = [];
|
|
275
|
+
for (let index = 0; index < count; index += 1) {
|
|
276
|
+
const button = buttons.nth(index);
|
|
277
|
+
if (!(await button.isVisible().catch(() => false))) continue;
|
|
278
|
+
// The accessible name, exactly as getByRole matches it, from the
|
|
279
|
+
// accessibility tree itself.
|
|
280
|
+
const snapshot = await button.ariaSnapshot().catch(() => "");
|
|
281
|
+
const quoted = /^- button "((?:[^"\\]|\\.)*)"/.exec(snapshot.trim());
|
|
282
|
+
const name = quoted ? (JSON.parse(`"${quoted[1]}"`) as string) : "";
|
|
283
|
+
if (name && name.length <= 60 && !UNSAFE_LINK.test(name) && !names.includes(name)) names.push(name);
|
|
284
|
+
}
|
|
285
|
+
for (const name of names) {
|
|
286
|
+
const button = page.getByRole("button", { name, exact: true }).first();
|
|
287
|
+
try {
|
|
288
|
+
await button.click({ timeout: 3_000 });
|
|
289
|
+
await page.waitForTimeout(200);
|
|
290
|
+
if (page.url() !== startUrl) {
|
|
291
|
+
// Not a menu after all: put the page back and stop opening things.
|
|
292
|
+
await page.goto(startUrl, { waitUntil: "load" });
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
for (const link of (await readPage(page)).links) {
|
|
296
|
+
if (!known.has(link.href) && !found.some((item) => item.href === link.href)) found.push({ menu: name, ...link });
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// A button that can't be pressed hides nothing we can reach.
|
|
300
|
+
} finally {
|
|
301
|
+
await page.keyboard.press("Escape").catch(() => undefined);
|
|
302
|
+
if ((await button.getAttribute("aria-expanded").catch(() => null)) === "true") await button.click({ timeout: 3_000 }).catch(() => undefined);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return found;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The first visible h1/h2, to tell when a client-side navigation has rendered. */
|
|
309
|
+
async function currentHeading(page: Page): Promise<string | undefined> {
|
|
310
|
+
return (await readPage(page).catch(() => undefined))?.heading;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const SIGN_IN_WORDS = /\b(sign ?in|log ?in|welcome back)\b/i;
|
|
314
|
+
const SIGN_IN_BUTTON = /^(continue|sign in|log in) with\b/i;
|
|
315
|
+
|
|
316
|
+
function looksLikeSignIn(page: DiscoveredPage): boolean {
|
|
317
|
+
if (page.forms.some((form) => form.fields.some((field) => field.type === "password"))) return true;
|
|
318
|
+
if (SIGN_IN_WORDS.test(page.heading ?? "") || SIGN_IN_WORDS.test(page.title)) return true;
|
|
319
|
+
return Boolean(page.controls?.some((control) => SIGN_IN_BUTTON.test(control)));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The crawl stopped at a sign-in page: everything it could reach is a sign-in
|
|
324
|
+
* screen, so what lies behind it is unexplored. Discovery doesn't sign in on
|
|
325
|
+
* its own; the caller should say so rather than present the one page it saw
|
|
326
|
+
* as the whole app.
|
|
327
|
+
*/
|
|
328
|
+
export function stoppedAtSignIn(result: Pick<DiscoveryResult, "pages">): boolean {
|
|
329
|
+
return result.pages.length > 0 && result.pages.length <= 2 && result.pages.every(looksLikeSignIn);
|
|
330
|
+
}
|
|
331
|
+
|
|
220
332
|
export async function discoverSite(baseUrl: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
|
|
221
333
|
const start = new URL(baseUrl);
|
|
222
334
|
const root = `${start.origin}${start.pathname}`;
|
|
@@ -235,12 +347,15 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
235
347
|
const { type, channel } = launcherFor("chromium");
|
|
236
348
|
const browser = await type.launch({ headless: options.headless ?? true, ...(channel ? { channel } : {}) });
|
|
237
349
|
try {
|
|
238
|
-
const context = await browser.newContext({
|
|
350
|
+
const context = await browser.newContext({
|
|
351
|
+
viewport: { width: 1280, height: 800 },
|
|
352
|
+
...(options.storageState ? { storageState: options.storageState } : {}),
|
|
353
|
+
});
|
|
239
354
|
const page = await context.newPage();
|
|
240
355
|
page.setDefaultTimeout(timeout);
|
|
241
356
|
const seen = new Set<string>();
|
|
242
357
|
// Breadth-first, so pages close to home are found first.
|
|
243
|
-
const queue: Array<{ route: string; path: string[]; parent?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
|
|
358
|
+
const queue: Array<{ route: string; path: string[]; parent?: string; menu?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
|
|
244
359
|
|
|
245
360
|
while (queue.length > 0 && result.pages.length < maxPages) {
|
|
246
361
|
const next = queue.shift()!;
|
|
@@ -249,30 +364,55 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
249
364
|
try {
|
|
250
365
|
if (next.path.length === 0) {
|
|
251
366
|
await emit("visit", "Visiting home page", next.route);
|
|
252
|
-
await page.goto(root
|
|
367
|
+
await page.goto(`${root}${start.search}`, { waitUntil: "load" });
|
|
253
368
|
} else {
|
|
254
369
|
// Reach the page the way a visitor would: from its parent, by clicking.
|
|
255
|
-
const parentUrl = new URL(next.parent ?? "/", root).toString()
|
|
370
|
+
const parentUrl = `${new URL(next.parent ?? "/", root).toString()}${start.search}`;
|
|
256
371
|
if (discoverableRoute(page.url(), root) !== next.parent) await page.goto(parentUrl, { waitUntil: "load" });
|
|
257
372
|
const label = next.path[next.path.length - 1];
|
|
373
|
+
const before = await currentHeading(page);
|
|
374
|
+
if (next.menu) {
|
|
375
|
+
await emit("click", `Opening “${next.menu}”`, next.route);
|
|
376
|
+
await page.getByRole("button", { name: next.menu, exact: true }).first().click();
|
|
377
|
+
}
|
|
258
378
|
await emit("click", `Clicking “${label}”`, next.route);
|
|
259
379
|
await page.getByRole("link", { name: label, exact: true }).first().click();
|
|
380
|
+
// Apps that route on the client change the address and then render,
|
|
381
|
+
// some time after the click returns: wait for both, bounded.
|
|
382
|
+
await page.waitForURL((url) => discoverableRoute(url.toString(), root) !== next.parent, { timeout }).catch(() => undefined);
|
|
260
383
|
await page.waitForLoadState("load").catch(() => undefined);
|
|
384
|
+
await page.waitForFunction(
|
|
385
|
+
([source, previous]) => ((new Function(`return (${source})`)() as () => { heading?: string })().heading ?? "") !== previous,
|
|
386
|
+
[pageProbe, before ?? ""] as const,
|
|
387
|
+
{ timeout: 5_000 },
|
|
388
|
+
).catch(() => undefined);
|
|
261
389
|
await page.waitForTimeout(250);
|
|
262
390
|
}
|
|
263
|
-
const
|
|
391
|
+
const landed = discoverableRoute(page.url(), root);
|
|
392
|
+
if (!landed) {
|
|
393
|
+
// A same-origin link that hands off elsewhere (an OAuth or install
|
|
394
|
+
// redirect, say) is not a page of this app.
|
|
395
|
+
await emit("skip", `“${next.path[next.path.length - 1] ?? next.route}” leaves the app`, next.route);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const route = landed;
|
|
399
|
+
if (next.path.length > 0 && route === next.parent) {
|
|
400
|
+
await emit("skip", `“${next.path[next.path.length - 1]}” did not lead anywhere`, next.route);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
264
403
|
if (route !== next.route && seen.has(route)) continue;
|
|
265
404
|
seen.add(route);
|
|
266
405
|
const probe = await readPage(page);
|
|
406
|
+
const inMenus = await linksInMenus(page, new Set(probe.links.map((link) => link.href)));
|
|
267
407
|
const links: DiscoveredPage["links"] = [];
|
|
268
|
-
for (const link of probe.links) {
|
|
408
|
+
for (const link of [...probe.links, ...inMenus] as Array<{ label: string; href: string; menu?: string }>) {
|
|
269
409
|
const target = discoverableRoute(link.href, root);
|
|
270
410
|
if (!target || target === route || links.some((existing) => existing.route === target)) continue;
|
|
271
|
-
if (UNSAFE_LINK.test(link.label)) {
|
|
411
|
+
if (UNSAFE_LINK.test(link.label) || UNSAFE_LINK.test(target.replace(/[/_-]+/g, " "))) {
|
|
272
412
|
await emit("skip", `Skipping “${link.label}” (it could change data)`, target);
|
|
273
413
|
continue;
|
|
274
414
|
}
|
|
275
|
-
links.push({ label: link.label, route: target });
|
|
415
|
+
links.push({ label: link.label, route: target, ...(link.menu ? { menu: link.menu } : {}) });
|
|
276
416
|
}
|
|
277
417
|
const discovered: DiscoveredPage = {
|
|
278
418
|
route,
|
|
@@ -282,6 +422,7 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
282
422
|
path: next.path,
|
|
283
423
|
links,
|
|
284
424
|
forms: probe.forms,
|
|
425
|
+
...(probe.controls?.length ? { controls: probe.controls } : {}),
|
|
285
426
|
};
|
|
286
427
|
result.pages.push(discovered);
|
|
287
428
|
await emit("page", `Found ${describeRoute(discovered)} (${links.length} link${links.length === 1 ? "" : "s"})`, route);
|
|
@@ -291,14 +432,22 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
291
432
|
if (dwellMs > 0) await page.waitForTimeout(dwellMs);
|
|
292
433
|
if (next.path.length < maxDepth) {
|
|
293
434
|
for (const link of links) {
|
|
294
|
-
if (!seen.has(link.route))
|
|
435
|
+
if (!seen.has(link.route)) {
|
|
436
|
+
queue.push({
|
|
437
|
+
route: link.route,
|
|
438
|
+
// A flow replays the path as taps, so a menu's button is a step too.
|
|
439
|
+
path: [...next.path, ...(link.menu ? [link.menu] : []), link.label],
|
|
440
|
+
parent: route,
|
|
441
|
+
...(link.menu ? { menu: link.menu } : {}),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
295
444
|
}
|
|
296
445
|
}
|
|
297
446
|
} catch (error) {
|
|
298
447
|
await emit("error", `Could not open ${next.route}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`, next.route);
|
|
299
448
|
}
|
|
300
449
|
}
|
|
301
|
-
result.flows = flowsFor(root
|
|
450
|
+
result.flows = flowsFor(`${root}${start.search}`, result.pages, { openEachPage: options.openEachPage });
|
|
302
451
|
await emit("flows", `Discovered ${result.flows.length} flow${result.flows.length === 1 ? "" : "s"} across ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`);
|
|
303
452
|
await emit("done", "Exploration complete");
|
|
304
453
|
return result;
|
|
@@ -95,3 +95,68 @@ test("typed secrets never reach the uploaded log, and the shared session is dele
|
|
|
95
95
|
await fs.rm(dir, { recursive: true, force: true });
|
|
96
96
|
}
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test("a flow written for production runs against --base-url, not production", async (t) => {
|
|
100
|
+
const { chromium } = await import("playwright");
|
|
101
|
+
try {
|
|
102
|
+
await fs.access(chromium.executablePath());
|
|
103
|
+
} catch {
|
|
104
|
+
t.skip("Chromium is not installed");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const http = await import("node:http");
|
|
108
|
+
const seen: string[] = [];
|
|
109
|
+
const server = http.createServer((request, response) => {
|
|
110
|
+
seen.push(request.url ?? "");
|
|
111
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
112
|
+
response.end("<!doctype html><h1>Pricing on staging</h1>");
|
|
113
|
+
});
|
|
114
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
115
|
+
const { port } = server.address() as { port: number };
|
|
116
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-suite-rebase-"));
|
|
117
|
+
await fs.writeFile(path.join(dir, "pricing.yaml"), "url: https://production.invalid/pricing/\n---\n- launchApp\n- assertVisible: \"Pricing on staging\"\n");
|
|
118
|
+
try {
|
|
119
|
+
const report = await runWebSuite({ flowsDir: dir, baseUrl: `http://127.0.0.1:${port}`, artifactsDir: path.join(dir, "artifacts"), projectId: "demo" });
|
|
120
|
+
assert.equal(report.totals.passed, 1, report.flows[0]?.message);
|
|
121
|
+
assert.ok(seen.includes("/pricing/"), seen.join(","));
|
|
122
|
+
} finally {
|
|
123
|
+
server.close();
|
|
124
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("waiting for text survives the page navigating away mid-check, as a sign-in redirect does", async (t) => {
|
|
129
|
+
const { chromium } = await import("playwright");
|
|
130
|
+
try {
|
|
131
|
+
await fs.access(chromium.executablePath());
|
|
132
|
+
} catch {
|
|
133
|
+
t.skip("Chromium is not installed");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const { isNavigationError } = await import("./playwright-driver.js");
|
|
137
|
+
assert.equal(isNavigationError(new Error("page.evaluate: Execution context was destroyed, most likely because of a navigation")), true);
|
|
138
|
+
assert.equal(isNavigationError(new Error("Target closed")), false);
|
|
139
|
+
const http = await import("node:http");
|
|
140
|
+
const server = http.createServer((request, response) => {
|
|
141
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
142
|
+
if (request.url === "/app") {
|
|
143
|
+
// The app is slow to answer, so the check runs while the page is between documents.
|
|
144
|
+
setTimeout(() => response.end("<!doctype html><h1>Quality, as a flow.</h1>"), 700);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
response.end("<!doctype html><p>Signing you in…</p><script>setTimeout(() => { location.href = '/app'; }, 150)</script>");
|
|
148
|
+
});
|
|
149
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
150
|
+
const { port } = server.address() as { port: number };
|
|
151
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-suite-nav-"));
|
|
152
|
+
await fs.writeFile(path.join(dir, "sign-in.yaml"), "url: /\n---\n- launchApp\n- assertVisible:\n text: \"Quality, as a flow.\"\n timeout: 10000\n");
|
|
153
|
+
try {
|
|
154
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
155
|
+
const report = await runWebSuite({ flowsDir: dir, baseUrl: `http://127.0.0.1:${port}/`, artifactsDir: path.join(dir, `artifacts-${attempt}`), projectId: "demo" });
|
|
156
|
+
assert.equal(report.totals.passed, 1, report.flows[0]?.message);
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
server.close();
|
|
160
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
package/src/runtime/web-suite.ts
CHANGED
|
@@ -125,6 +125,9 @@ export async function runWebSuite(options: WebSuiteOptions): Promise<WebSuiteRep
|
|
|
125
125
|
flowPath,
|
|
126
126
|
platform: "web",
|
|
127
127
|
target: { baseUrl: options.baseUrl },
|
|
128
|
+
// The suite runs against --base-url: a flow written for another
|
|
129
|
+
// address (production, say) is moved onto it, as plan runs do.
|
|
130
|
+
rebaseOrigin: true,
|
|
128
131
|
timeoutMs: options.timeoutMs ?? 30_000,
|
|
129
132
|
artifactsDir: options.artifactsDir,
|
|
130
133
|
sessionStatePath,
|