@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.
Files changed (38) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +4 -4
  3. package/scripts/bugmole.ts +60 -0
  4. package/scripts/ensure-playwright.cjs +87 -0
  5. package/scripts/ensure-playwright.test.ts +36 -0
  6. package/scripts/sync-launcher-version.cjs +24 -0
  7. package/scripts/sync-launcher-version.test.ts +28 -0
  8. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  9. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  10. package/src/registry-worker/core.ts +2 -0
  11. package/src/registry-worker/domains/domains.test.ts +64 -1
  12. package/src/registry-worker/domains/routes.ts +32 -15
  13. package/src/registry-worker/domains.ts +40 -2
  14. package/src/registry-worker/features.ts +2 -0
  15. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  16. package/src/registry-worker/feedback/website.ts +77 -0
  17. package/src/registry-worker/index.ts +25 -7
  18. package/src/registry-worker/org/routes.test.ts +24 -1
  19. package/src/registry-worker/org/routes.ts +5 -4
  20. package/src/registry-worker/runner/runner.test.ts +38 -0
  21. package/src/registry-worker/runner/tokens.ts +4 -1
  22. package/src/registry-worker/signup/attribution.test.ts +147 -0
  23. package/src/registry-worker/signup/attribution.ts +194 -0
  24. package/src/runtime/ai-exploration.test.ts +34 -1
  25. package/src/runtime/ai-exploration.ts +97 -3
  26. package/src/runtime/appium-driver.ts +6 -0
  27. package/src/runtime/discovery-task.ts +24 -5
  28. package/src/runtime/flow-language.ts +21 -0
  29. package/src/runtime/journey-editor.ts +39 -0
  30. package/src/runtime/journey-graph.test.ts +42 -0
  31. package/src/runtime/playwright-driver.test.ts +145 -0
  32. package/src/runtime/playwright-driver.ts +237 -8
  33. package/src/runtime/run-job.test.ts +21 -0
  34. package/src/runtime/run-job.ts +8 -0
  35. package/src/runtime/site-discovery.test.ts +135 -1
  36. package/src/runtime/site-discovery.ts +173 -24
  37. package/src/runtime/web-suite.test.ts +65 -0
  38. 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
- links: Array<{ label: string; route: string }>;
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" || hint.includes("website")) return "https://example.com";
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(baseUrl: string, pages: readonly DiscoveredPage[]): DiscoveredFlow[] {
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
- const header = (name: string) => `url: ${baseUrl}\nname: ${JSON.stringify(name)}\n---\n`;
118
- const pathSteps = (page: DiscoveredPage) => page.path.map((label) => ({ tapOn: label }));
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
- const steps: unknown[] = ["launchApp", ...pathSteps(page), { assertVisible: landmark }];
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
- ...fields.flatMap((field) => [{ tapOn: field.label }, { inputText: sampleValue(field) }]),
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") return step === "launchApp" ? "Open the app" : step;
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
- if (label && label.length <= 60) links.push({ label, href: link.href });
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
- return { title: document.title, heading: heading ? text(heading) : undefined, links, forms };
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({ viewport: { width: 1280, height: 800 } });
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, { waitUntil: "load" });
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 route = discoverableRoute(page.url(), root) ?? next.route;
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)) queue.push({ route: link.route, path: [...next.path, link.label], parent: 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, result.pages);
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
+ });
@@ -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,