@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
@@ -91,3 +91,148 @@ test("a worker without Playwright's ffmpeg loses the video, not the run", () =>
91
91
  assert.equal(isMissingVideoEncoder(new Error("Executable doesn't exist at /ms-playwright/chromium-1234/chrome-linux/chrome")), false);
92
92
  assert.equal(isMissingVideoEncoder(new Error("net::ERR_CONNECTION_REFUSED")), false);
93
93
  });
94
+
95
+ // ── Page checks: assertLayout and assertLinks ──
96
+
97
+ import http from "node:http";
98
+ import type { AddressInfo } from "node:net";
99
+ import { chromium } from "playwright";
100
+ import { brokenStatus, checkLayout, checkLinks } from "./playwright-driver.js";
101
+
102
+ async function browserOrSkip(t: { skip: (m: string) => void }) {
103
+ try {
104
+ return await chromium.launch();
105
+ } catch {
106
+ t.skip("Chromium is not installed");
107
+ return null;
108
+ }
109
+ }
110
+
111
+ const CARD = (height: string) => `<!doctype html><meta name="viewport" content="width=device-width">
112
+ <style>body{margin:0;font:16px/1.5 sans-serif}.card{display:flex;gap:30px;${height};padding:0 18px;background:#fff;box-shadow:0 1px 3px #0002;margin:12px}</style>
113
+ <div class="card"><span>icon</span><div><b>UI changes break tests</b><p>Locators and selectors become stale after every redesign of the product.</p></div></div>`;
114
+
115
+ test("assertLayout catches text running out of a fixed-height card on a phone, and passes when it grows", async (t) => {
116
+ const browser = await browserOrSkip(t);
117
+ if (!browser) return;
118
+ try {
119
+ const page = await browser.newPage({ viewport: { width: 360, height: 800 } });
120
+ await page.setContent(CARD("height:60px"));
121
+ const problems = await checkLayout(page);
122
+ assert.equal(problems.length, 1);
123
+ assert.match(problems[0]!, /^Text runs out of its box: “icon UI changes break tests.*” \(div\.card\)/);
124
+ await page.setContent(CARD("min-height:60px"));
125
+ assert.deepEqual(await checkLayout(page), []);
126
+ } finally {
127
+ await browser.close();
128
+ }
129
+ });
130
+
131
+ test("assertLayout catches a page that scrolls sideways and text cut off without an ellipsis", async (t) => {
132
+ const browser = await browserOrSkip(t);
133
+ if (!browser) return;
134
+ try {
135
+ const page = await browser.newPage({ viewport: { width: 360, height: 800 } });
136
+ await page.setContent(`<!doctype html><body style="margin:0"><p style="width:600px">A paragraph wider than the phone</p>
137
+ <div style="width:120px;height:20px;overflow:hidden">A label much too long for its little box here</div>
138
+ <div style="width:120px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">An ellipsis is a deliberate cut</div>
139
+ <div style="overflow-x:auto"><div style="width:900px">A carousel scrolls on purpose</div></div></body>`);
140
+ const problems = await checkLayout(page);
141
+ assert.equal(problems.length, 2, problems.join("\n"));
142
+ assert.match(problems[0]!, /^The page scrolls sideways: it is 600px wide on a 360px screen, because of “A paragraph wider than the phone” \(p\)/);
143
+ assert.match(problems[1]!, /^Text is cut off: “A label much too long/);
144
+ } finally {
145
+ await browser.close();
146
+ }
147
+ });
148
+
149
+ test("assertLinks reports links that don't answer, and doesn't count robots being turned away elsewhere", async (t) => {
150
+ assert.equal(brokenStatus(404, true), true);
151
+ assert.equal(brokenStatus(403, true), true);
152
+ assert.equal(brokenStatus(403, false), false);
153
+ assert.equal(brokenStatus(429, false), false);
154
+ assert.equal(brokenStatus(410, false), true);
155
+ assert.equal(brokenStatus(503, false), true);
156
+ const browser = await browserOrSkip(t);
157
+ if (!browser) return;
158
+ const unsafeHits: string[] = [];
159
+ const server = http.createServer((req, res) => {
160
+ if (req.url === "/") {
161
+ res.setHeader("content-type", "text/html");
162
+ res.end(`<a href="/ok">ok</a><a href="/gone">gone</a><a href="/ok#part">same page</a><a href="mailto:a@b.c">mail</a>
163
+ <a href="/api/auth/logout">Sign out</a><a href="/items/9?action=delete">x</a>`);
164
+ } else if (req.url === "/api/auth/logout" || req.url?.includes("delete")) { unsafeHits.push(req.url); res.end("bye"); }
165
+ else if (req.url === "/ok") res.end("fine");
166
+ else { res.statusCode = 404; res.end("no"); }
167
+ });
168
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
169
+ try {
170
+ const page = await browser.newPage();
171
+ await page.goto(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`);
172
+ const broken = await checkLinks(page, false);
173
+ assert.deepEqual(broken.map((b) => b.replace(/^http:\/\/127\.0\.0\.1:\d+/, "")), ["/gone (404)"]);
174
+ assert.deepEqual(unsafeHits, [], "a link check must never sign out or delete");
175
+ } finally {
176
+ await browser.close();
177
+ server.close();
178
+ }
179
+ });
180
+
181
+ test("flows can ask for the page checks by name", () => {
182
+ const flow = parseMaestroStyleFlow("url: https://example.com\n---\n- launchApp\n- assertLayout\n- assertLinks\n- assertLinks:\n external: true\n");
183
+ assert.deepEqual(flow.steps.slice(1), [{ type: "assertLayout" }, { type: "assertLinks", external: false }, { type: "assertLinks", external: true }]);
184
+ });
185
+
186
+ test("assertLayout also checks with a phone's larger text, and restores the page afterwards", async (t) => {
187
+ const browser = await browserOrSkip(t);
188
+ if (!browser) return;
189
+ try {
190
+ const page = await browser.newPage({ viewport: { width: 412, height: 800 } });
191
+ // Fits at normal size with a little room; not once text is 30% larger.
192
+ await page.setContent(`<!doctype html><body style="margin:0;font:16px/20px sans-serif">
193
+ <div style="height:48px;background:#eee;margin:8px"><b>Important flows get missed</b><br>Journeys are not tested.</div></body>`);
194
+ const problems = await checkLayout(page);
195
+ assert.equal(problems.length, 1, problems.join("\n"));
196
+ assert.match(problems[0]!, /^Text runs out of its box: .* with text 130% \(a phone's larger font setting\)$/);
197
+ assert.equal(await page.evaluate(`getComputedStyle(document.querySelector("b")).fontSize`), "16px");
198
+ assert.deepEqual(await checkLayout(page, 1), []);
199
+ } finally {
200
+ await browser.close();
201
+ }
202
+ });
203
+
204
+ test("a page too tall for one screenshot still gets evidence, and its flow isn't failed for it", async () => {
205
+ const { screenshotWholePage } = await import("./playwright-driver.js");
206
+ const shots: Array<boolean | undefined> = [];
207
+ const page = {
208
+ screenshot: async (options: { fullPage?: boolean }) => {
209
+ shots.push(options.fullPage);
210
+ if (options.fullPage) throw new Error("page.screenshot: Cannot take screenshot larger than 32767 pixels on any dimension");
211
+ },
212
+ };
213
+ const log: string[] = [];
214
+ await screenshotWholePage(page as never, "/tmp/x.png", log);
215
+ assert.deepEqual(shots, [true, false]);
216
+ assert.match(log[0]!, /too tall for one screenshot/);
217
+ const broken = { screenshot: async () => { throw new Error("Target closed"); } };
218
+ await assert.rejects(screenshotWholePage(broken as never, "/tmp/x.png"), /Target closed/);
219
+ });
220
+
221
+ test("assertClickable scrolls to a button below the fold, but still fails one that is covered", async (t) => {
222
+ const browser = await browserOrSkip(t);
223
+ if (!browser) return;
224
+ try {
225
+ const { waitForClickable } = await import("./playwright-driver.js");
226
+ const page = await browser.newPage({ viewport: { width: 393, height: 659 } });
227
+ await page.setContent(`<!doctype html><body style="margin:0">
228
+ <div style="height:1200px">Tall content</div>
229
+ <button id="below">Explore this URL</button>
230
+ <div style="position:relative;height:60px"><button id="covered">Covered</button>
231
+ <div style="position:absolute;inset:0;background:#fff"></div></div></body>`);
232
+ await waitForClickable(page, page.locator("#below"), "Explore this URL", 2_000);
233
+ await assert.rejects(waitForClickable(page, page.locator("#covered"), "Covered", 1_000), /something else is on top/);
234
+ } finally {
235
+ await browser.close();
236
+ }
237
+ });
238
+
@@ -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";
@@ -87,17 +88,30 @@ const visibilityProbe = `(pattern, isRegex) => {
87
88
  return false;
88
89
  }`;
89
90
 
91
+ /** The page moved on while a probe ran in it: try again on the new page. */
92
+ export function isNavigationError(error: unknown): boolean {
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ return /Execution context was destroyed|Cannot find context with specified id|frame was detached|navigat/i.test(message);
95
+ }
96
+
90
97
  async function waitForVisibility(page: Page, text: string, shouldBeVisible: boolean, timeoutMs: number): Promise<void> {
91
98
  const isRegex = isPatternText(text);
92
99
  const deadline = Date.now() + timeoutMs;
93
100
  for (;;) {
94
101
  // Compiled inside the page: Playwright evaluates a bare string as an
95
102
  // expression and drops the argument, so the probe is passed as data.
96
- const visible = await page.evaluate(
97
- (args: [string, string, boolean]) =>
98
- (new Function(`return (${args[0]})`)() as (pattern: string, isRegex: boolean) => boolean)(args[1], args[2]),
99
- [visibilityProbe, text, isRegex] as [string, string, boolean],
100
- );
103
+ // A page that navigates mid-check (a sign-in redirecting to the app)
104
+ // destroys the context the probe ran in: that's "not yet", not a failure.
105
+ const visible = await page
106
+ .evaluate(
107
+ (args: [string, string, boolean]) =>
108
+ (new Function(`return (${args[0]})`)() as (pattern: string, isRegex: boolean) => boolean)(args[1], args[2]),
109
+ [visibilityProbe, text, isRegex] as [string, string, boolean],
110
+ )
111
+ .catch((error: unknown) => {
112
+ if (isNavigationError(error)) return null;
113
+ throw error;
114
+ });
101
115
  if (visible === shouldBeVisible) return;
102
116
  if (Date.now() >= deadline) {
103
117
  throw new Error(`Timed out after ${timeoutMs}ms waiting for "${text}" to be ${shouldBeVisible ? "visible" : "hidden"}.`);
@@ -179,9 +193,12 @@ const failureProbe = `() => {
179
193
  * mascot, a floating action button, a toast) sitting on top of a link or
180
194
  * button and silently swallowing the tap.
181
195
  */
182
- async function waitForClickable(page: Page, locator: Locator, text: string, timeoutMs: number): Promise<void> {
196
+ export async function waitForClickable(page: Page, locator: Locator, text: string, timeoutMs: number): Promise<void> {
183
197
  const deadline = Date.now() + timeoutMs;
184
198
  for (;;) {
199
+ // A person scrolls to a button below the fold; only covered or hidden
200
+ // controls should fail. On a phone's short screen this is most of them.
201
+ await locator.scrollIntoViewIfNeeded({ timeout: 1_000 }).catch(() => undefined);
185
202
  const handle = await locator.elementHandle({ timeout: 1_000 }).catch(() => null);
186
203
  const reachable = handle
187
204
  ? await page
@@ -200,10 +217,206 @@ async function waitForClickable(page: Page, locator: Locator, text: string, time
200
217
  }
201
218
  }
202
219
 
220
+ /**
221
+ * What a person notices first when a page doesn't fit the screen: it scrolls
222
+ * sideways, text runs out of the card it sits in, or text is cut off with no
223
+ * ellipsis. Runs in the page; returns problems in plain words.
224
+ */
225
+ export const layoutProbe = `() => {
226
+ const W = document.documentElement.clientWidth;
227
+ const problems = [];
228
+ const skip = (e) => e.closest("svg, pre, code, [aria-hidden=true], dialog:not([open])");
229
+ const shown = (e, s) => {
230
+ const r = e.getBoundingClientRect();
231
+ return r.width > 2 && r.height > 2 && s.display !== "none" && s.visibility !== "hidden" && Number(s.opacity) !== 0;
232
+ };
233
+ // What a developer searches for: the text, then the element and its first classes.
234
+ const label = (e) => {
235
+ const t = (e.innerText || "").replace(/\\s+/g, " ").trim();
236
+ const cls = typeof e.className === "string" ? e.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
237
+ const where = e.tagName.toLowerCase() + cls.map((c) => "." + c).join("");
238
+ return (t ? "\u201c" + (t.length > 48 ? t.slice(0, 47) + "\u2026" : t) + "\u201d " : "") + "(" + where + ")";
239
+ };
240
+ // Pinned to the screen (a chat button, a banner): it can't make the page wider.
241
+ const pinned = (e) => {
242
+ for (let a = e; a && a !== document.body; a = a.parentElement) {
243
+ const p = getComputedStyle(a).position;
244
+ if (p === "fixed" || p === "sticky") return true;
245
+ }
246
+ return false;
247
+ };
248
+ // Inside something that scrolls or clips on purpose (a carousel, a code block, a marquee).
249
+ const contained = (e) => {
250
+ for (let a = e.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
251
+ const s = getComputedStyle(a);
252
+ if (/(hidden|auto|scroll|clip)/.test(s.overflowX + " " + s.overflowY)) return true;
253
+ }
254
+ return false;
255
+ };
256
+ const boxed = (s) =>
257
+ (s.backgroundColor !== "rgba(0, 0, 0, 0)" && s.backgroundColor !== "transparent") ||
258
+ s.backgroundImage !== "none" || s.boxShadow !== "none" ||
259
+ parseFloat(s.borderTopWidth) + parseFloat(s.borderBottomWidth) + parseFloat(s.borderLeftWidth) + parseFloat(s.borderRightWidth) > 0;
260
+
261
+ if (document.documentElement.scrollWidth > W + 1) {
262
+ let culprit = null;
263
+ for (const e of document.body.querySelectorAll("*")) {
264
+ const s = getComputedStyle(e);
265
+ if (e.closest("svg, [aria-hidden=true]") || !shown(e, s) || contained(e) || pinned(e)) continue;
266
+ const r = e.getBoundingClientRect();
267
+ if (r.right > W + 1 && (e.innerText || "").trim()) culprit = e; // the deepest one wins
268
+ }
269
+ problems.push("The page scrolls sideways: it is " + document.documentElement.scrollWidth + "px wide on a " + W + "px screen" + (culprit ? ", because of " + label(culprit) : ""));
270
+ }
271
+ for (const e of document.body.querySelectorAll("*")) {
272
+ const s = getComputedStyle(e);
273
+ if (s.display === "inline" || skip(e) || !shown(e, s)) continue;
274
+ if (!(e.innerText || "").trim()) continue;
275
+ if (boxed(s) && s.overflowY === "visible" && e.scrollHeight > e.clientHeight + 1 && e.clientHeight > 0) {
276
+ problems.push("Text runs out of its box: " + label(e) + " overflows by " + (e.scrollHeight - e.clientHeight) + "px");
277
+ continue;
278
+ }
279
+ if (boxed(s) && s.overflowX === "visible" && e.scrollWidth > e.clientWidth + 1 && e.clientWidth > 0) {
280
+ problems.push("Text runs out of its box sideways: " + label(e) + " overflows by " + (e.scrollWidth - e.clientWidth) + "px");
281
+ continue;
282
+ }
283
+ const clips = /(hidden|clip)/.test(s.overflowX + " " + s.overflowY);
284
+ const clamped = s.textOverflow === "ellipsis" || (s.webkitLineClamp && s.webkitLineClamp !== "none");
285
+ if (clips && !clamped && e.children.length === 0 && e.clientWidth > 2 && e.clientHeight > 2 &&
286
+ (e.scrollWidth > e.clientWidth + 2 || e.scrollHeight > e.clientHeight + 2)) {
287
+ problems.push("Text is cut off: " + label(e));
288
+ }
289
+ }
290
+ return [...new Set(problems)];
291
+ }`;
292
+
293
+ /**
294
+ * Enlarges (or restores) every element's text by `factor`, the way a phone's
295
+ * font-size setting does: text grows, the boxes the page gave it don't. All
296
+ * sizes are read before any is changed, so nested sizes don't compound.
297
+ */
298
+ const textScaleProbe = `(factor) => {
299
+ const key = "__bugmoleTextScale";
300
+ const elements = Array.from(document.querySelectorAll("body, body *"));
301
+ if (factor === 1) {
302
+ for (const e of elements) {
303
+ const saved = e[key];
304
+ if (!saved) continue;
305
+ e.style.fontSize = saved.fontSize;
306
+ e.style.lineHeight = saved.lineHeight;
307
+ delete e[key];
308
+ }
309
+ return;
310
+ }
311
+ const sizes = elements.map((e) => {
312
+ const s = getComputedStyle(e);
313
+ return [parseFloat(s.fontSize), s.lineHeight.endsWith("px") ? parseFloat(s.lineHeight) : null];
314
+ });
315
+ elements.forEach((e, i) => {
316
+ e[key] = { fontSize: e.style.fontSize, lineHeight: e.style.lineHeight };
317
+ const [font, line] = sizes[i];
318
+ if (font) e.style.fontSize = font * factor + "px";
319
+ if (line) e.style.lineHeight = line * factor + "px";
320
+ });
321
+ }`;
322
+
323
+ async function layoutProblems(page: Page): Promise<string[]> {
324
+ return page.evaluate((source: string) => (new Function(`return (${source})`)() as () => string[])(), layoutProbe);
325
+ }
326
+
327
+ async function scaleText(page: Page, factor: number): Promise<void> {
328
+ await page.evaluate(([source, value]: [string, number]) => (new Function(`return (${source})`)() as (f: number) => void)(value), [textScaleProbe, factor] as [string, number]);
329
+ }
330
+
331
+ /** Larger text, as set on many phones (Android's font size; WCAG asks pages to cope with up to 200%). */
332
+ export const LARGE_TEXT = 1.3;
333
+
334
+ /** Layout problems at normal text size, then those that appear only with larger text. */
335
+ export async function checkLayout(page: Page, largeText = LARGE_TEXT): Promise<string[]> {
336
+ const normal = await layoutProblems(page);
337
+ if (!largeText || largeText === 1) return normal;
338
+ await scaleText(page, largeText);
339
+ try {
340
+ const large = await layoutProblems(page);
341
+ const label = `with text ${Math.round(largeText * 100)}% (a phone's larger font setting)`;
342
+ // The same box overflowing by more pixels isn't a new problem.
343
+ const same = (problem: string) => problem.replace(/\d+px/g, "");
344
+ const seen = new Set(normal.map(same));
345
+ return [...normal, ...large.filter((problem) => !seen.has(same(problem))).map((problem) => `${problem}, ${label}`)];
346
+ } finally {
347
+ await scaleText(page, 1);
348
+ }
349
+ }
350
+
351
+ /** A link a check must not follow: signing out, deleting, unsubscribing (by its text or address). */
352
+ export function isUnsafeLink(href: string, text: string): boolean {
353
+ let path = href;
354
+ try {
355
+ const url = new URL(href);
356
+ path = `${url.pathname} ${url.search}`;
357
+ } catch {
358
+ // keep the raw href
359
+ }
360
+ return UNSAFE_LINK.test(text) || UNSAFE_LINK.test(path.replace(/[/_?=&.-]+/g, " "));
361
+ }
362
+
363
+ /** Answers that mean a link is broken, not that the site turned a robot away. */
364
+ export function brokenStatus(status: number, sameSite: boolean): boolean {
365
+ if (sameSite) return status >= 400;
366
+ return status === 404 || status === 410 || status >= 500;
367
+ }
368
+
369
+ /** Requests every link on the page (same-site only unless `external`). Returns the broken ones. */
370
+ export async function checkLinks(page: Page, external: boolean, limit = 80): Promise<string[]> {
371
+ const origin = new URL(page.url()).origin;
372
+ const links = (await page.evaluate(
373
+ `Array.from(document.querySelectorAll("a[href]")).map((a) => [a.href, (a.innerText || a.getAttribute("aria-label") || "").trim()])`,
374
+ )) as Array<[string, string]>;
375
+ // Requesting a link is visiting it: never sign out, delete or unsubscribe
376
+ // on the way (the same links discovery steers clear of).
377
+ const safe = links.filter(([href, text]) => !isUnsafeLink(href, text)).map(([href]) => href);
378
+ const targets = [...new Set(safe.filter((h) => /^https?:/i.test(h)).map((h) => h.split("#")[0]!))]
379
+ .filter((h) => external || new URL(h).origin === origin)
380
+ .slice(0, limit);
381
+ const request = page.context().request;
382
+ const broken: string[] = [];
383
+ const queue = [...targets];
384
+ const worker = async () => {
385
+ for (let url = queue.shift(); url; url = queue.shift()) {
386
+ const sameSite = new URL(url).origin === origin;
387
+ try {
388
+ let response = await request.head(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
389
+ // Plenty of servers answer HEAD badly; a GET is what a visitor sends.
390
+ if (response.status() >= 400) response = await request.get(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
391
+ if (brokenStatus(response.status(), sameSite)) broken.push(`${url} (${response.status()})`);
392
+ } catch (error) {
393
+ if (sameSite) broken.push(`${url} (no answer: ${error instanceof Error ? error.message.split("\n")[0] : String(error)})`);
394
+ }
395
+ }
396
+ };
397
+ await Promise.all(Array.from({ length: Math.min(6, targets.length) }, worker));
398
+ return broken.sort();
399
+ }
400
+
203
401
  /**
204
402
  * Candidate locators in the order a person resolves a target: the control they
205
403
  * can name, then the field labelled that way, then anything reading that way.
206
404
  */
405
+ /**
406
+ * A screenshot of the whole page, or of the screen when the page is taller
407
+ * than the browser can capture in one image (32,767px in WebKit): evidence
408
+ * must never turn a passing flow into a failure.
409
+ */
410
+ export async function screenshotWholePage(page: Page, file: string, log?: string[]): Promise<void> {
411
+ try {
412
+ await page.screenshot({ path: file, fullPage: true });
413
+ } catch (error) {
414
+ if (!/larger than \d+ pixels|too large|maximum/i.test(error instanceof Error ? error.message : String(error))) throw error;
415
+ log?.push("The page is too tall for one screenshot; captured the screen instead.");
416
+ await page.screenshot({ path: file, fullPage: false });
417
+ }
418
+ }
419
+
207
420
  /** Playwright needs its own ffmpeg build to write a video; nothing else does. */
208
421
  export function isMissingVideoEncoder(error: unknown): boolean {
209
422
  const message = error instanceof Error ? error.message : String(error);
@@ -378,7 +591,7 @@ export class PlaywrightDriver implements AppDriver {
378
591
 
379
592
  const capture = async (name: string) => {
380
593
  const screenshotPath = path.join(artifactsDir, `${name.replace(/[^a-z0-9_-]+/gi, "-")}.png`);
381
- await page.screenshot({ path: screenshotPath, fullPage: true });
594
+ await screenshotWholePage(page, screenshotPath, log);
382
595
  screenshots.push({ path: screenshotPath, name: path.basename(screenshotPath) });
383
596
  return screenshotPath;
384
597
  };
@@ -431,6 +644,22 @@ export class PlaywrightDriver implements AppDriver {
431
644
  await waitForClickable(page, target, step.text, step.timeoutMs ?? stepTimeoutMs);
432
645
  return;
433
646
  }
647
+ case "assertLayout": {
648
+ const problems = await checkLayout(page);
649
+ if (problems.length) {
650
+ await capture(`layout-${String(Date.now()).slice(-6)}`).catch(() => undefined);
651
+ const shown = problems.slice(0, 6).map((problem) => `- ${problem}`).join("\n");
652
+ 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` : ""}`);
653
+ }
654
+ return;
655
+ }
656
+ case "assertLinks": {
657
+ const broken = await checkLinks(page, step.external);
658
+ if (broken.length) {
659
+ throw new Error(`${broken.length} broken link(s) on ${page.url()}:\n${broken.slice(0, 10).map((link) => `- ${link}`).join("\n")}`);
660
+ }
661
+ return;
662
+ }
434
663
  case "scrollUntilVisible": {
435
664
  const target = await resolveTapTarget(page, step.text, 0);
436
665
  if (!target) throw new Error(`No element matching "${step.text}" to scroll to.`);
@@ -582,7 +811,7 @@ export class PlaywrightDriver implements AppDriver {
582
811
  }
583
812
  if (failurePage) {
584
813
  const failurePath = path.join(artifactsDir, "playwright-failure.png");
585
- await failurePage.screenshot({ path: failurePath, fullPage: true });
814
+ await screenshotWholePage(failurePage, failurePath);
586
815
  artifacts.push({ path: failurePath, contentType: "image/png", name: "playwright-failure.png" });
587
816
  }
588
817
  } catch {
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { executeRegistryRun } from "./run-job.js";
4
+
5
+ test("only a passing run promotes the journey it ran, and before it reports done", async () => {
6
+ for (const status of ["success", "partial", "error"] as const) {
7
+ const order: string[] = [];
8
+ const client = { updateRun: async (_id: string, update: Record<string, unknown>) => { if (update.eventType === "completed") order.push("completed"); return {}; } };
9
+ const run = { id: `run_${status}`, planId: "discovered-home", journeyId: "discovered-home", status: "claimed" } as never;
10
+ await executeRegistryRun({}, client as never, run, {
11
+ projectId: "demo",
12
+ workerId: "w1",
13
+ execute: (async () => ({ status, stepsExecuted: 1, stepsPassed: 1, stepsFailed: 0 })) as never,
14
+ stabilize: async (_cfg, promoted) => {
15
+ order.push(`stabilize:${promoted.id}`);
16
+ return promoted.journeyId ?? null;
17
+ },
18
+ });
19
+ assert.deepEqual(order, status === "success" ? ["stabilize:run_success", "completed"] : ["completed"], status);
20
+ }
21
+ });
@@ -8,6 +8,7 @@ import { normalizeTargets } from "./browser-matrix.js";
8
8
  import { execute } from "./executor.js";
9
9
  import type { FailureAnalysis, FailureContext } from "./failure-analysis.js";
10
10
  import { loadDeviceCloudAccess, type DeviceCloudAccess } from "./device-clouds/index.js";
11
+ import { stabilizeAfterPassingRun } from "./journey-editor.js";
11
12
 
12
13
  /** The registry expires a claim 60s after its last update, so a running run renews it well inside that. */
13
14
  export const RUN_HEARTBEAT_MS = 30_000;
@@ -56,6 +57,8 @@ export type ExecuteRunOptions = {
56
57
  heartbeatMs?: number;
57
58
  /** Loads device-cloud credentials and app builds, for runs that target cloud devices. */
58
59
  loadDeviceAccess?: () => Promise<DeviceCloudAccess>;
60
+ /** Promotes the journey a passing run proved; replaceable in tests. */
61
+ stabilize?: typeof stabilizeAfterPassingRun;
59
62
  };
60
63
 
61
64
  /** How a worker fetches a run's device access from the registry. */
@@ -112,6 +115,11 @@ export async function executeRegistryRun(
112
115
  },
113
116
  });
114
117
  clearInterval(heartbeat);
118
+ // Before reporting done, so a dashboard that reloads the graph on
119
+ // completion already sees the flow this run proved.
120
+ if (result.status === "success") {
121
+ await (options.stabilize ?? stabilizeAfterPassingRun)(cfg, run).catch(() => null);
122
+ }
115
123
  await client.updateRun(run.id, {
116
124
  workerId,
117
125
  status: result.status === "success" ? "success" : result.status === "partial" ? "partial" : "error",
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
7
7
  import { chromium } from "playwright";
8
- import { discoverableRoute, discoverSite, flowsFor, slugify } from "./site-discovery.js";
8
+ import { discoverableRoute, discoverSite, flowsFor, slugify, stoppedAtSignIn } from "./site-discovery.js";
9
9
  import { discoveryUrl, runDiscoveryTask } from "./discovery-task.js";
10
10
  import { PlaywrightDriver } from "./playwright-driver.js";
11
11
  import { startLocalRegistryStub } from "./local-registry-stub.js";
@@ -118,6 +118,80 @@ test("explores a site from its URL like a visitor, and the flows it writes pass"
118
118
  });
119
119
  });
120
120
 
121
+ test("explores a signed-in app from a saved session, and stays inside it", async (t) => {
122
+ if (!(await chromiumInstalled())) {
123
+ t.skip("Chromium is not installed");
124
+ return;
125
+ }
126
+ const hits: string[] = [];
127
+ const signedIn: Record<string, string> = {
128
+ // "Test plans" routes on the client, like a single-page app: the address
129
+ // and the page change some time after the click. "Billing" and "Sign out"
130
+ // only exist once the account menu is opened.
131
+ "/": `<h1>Overview</h1><a href="/plans" id="plans">Test plans</a><a href="/account/sign-out">Account</a><a href="/connect">Connect GitHub</a>
132
+ <a href="/manage/domains"><strong>Domains</strong> <span>Verify the sites Bugmole Cloud is allowed to run against.</span></a>
133
+ <button aria-expanded="false" id="account"><span aria-hidden="true">A</span> <strong>Account</strong> <small>menu</small></button><div id="menu"></div>
134
+ <script>
135
+ document.getElementById("plans").addEventListener("click", (event) => {
136
+ event.preventDefault();
137
+ setTimeout(() => {
138
+ history.pushState(null, "", "/plans" + location.search);
139
+ document.querySelector("h1").textContent = "Test plans";
140
+ }, 600);
141
+ });
142
+ const account = document.getElementById("account");
143
+ account.addEventListener("click", () => {
144
+ const open = account.getAttribute("aria-expanded") !== "true";
145
+ account.setAttribute("aria-expanded", String(open));
146
+ document.getElementById("menu").innerHTML = open ? '<a href="/billing">Billing</a><a href="/sign-out">Sign out</a>' : "";
147
+ });
148
+ document.addEventListener("keydown", (event) => { if (event.key === "Escape" && account.getAttribute("aria-expanded") === "true") account.click(); });
149
+ </script>`,
150
+ "/plans": `<h1>Test plans</h1><a href="/">Overview</a>`,
151
+ "/billing": `<h1>Billing</h1><a href="/">Overview</a>`,
152
+ "/manage/domains": `<h1>Domains</h1><a href="/">Overview</a>`,
153
+ };
154
+ const server = http.createServer((request, response) => {
155
+ const route = new URL(request.url ?? "/", "http://x").pathname;
156
+ hits.push(route);
157
+ if (!/session=ok/.test(request.headers.cookie ?? "")) {
158
+ response.writeHead(307, { location: "/login" });
159
+ return response.end();
160
+ }
161
+ // Same-origin link that hands off to another site, like an install redirect.
162
+ if (route === "/connect") {
163
+ response.writeHead(302, { location: `http://localhost:${(server.address() as { port: number }).port}/elsewhere` });
164
+ return response.end();
165
+ }
166
+ const body = signedIn[route];
167
+ response.writeHead(body ? 200 : 404, { "content-type": "text/html" });
168
+ response.end(`<!doctype html><html><body>${body ?? "Not found"}</body></html>`);
169
+ });
170
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
171
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-signed-in-"));
172
+ try {
173
+ const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
174
+ const storageState = path.join(directory, "state.json");
175
+ await fs.writeFile(storageState, JSON.stringify({
176
+ cookies: [{ name: "session", value: "ok", domain: "127.0.0.1", path: "/", expires: -1, httpOnly: true, secure: false, sameSite: "Lax" }],
177
+ origins: [],
178
+ }));
179
+ const result = await discoverSite(`${baseUrl}?project=p1`, { dwellMs: 0, storageState, openEachPage: true });
180
+ assert.deepEqual(result.pages.map((page) => page.route).sort(), ["/", "/billing", "/manage/domains", "/plans"]);
181
+ assert.equal(result.pages.find((page) => page.route === "/plans")?.heading, "Test plans", "waits for a client-side page to render");
182
+ assert.deepEqual(result.pages.find((page) => page.route === "/billing")?.path, ["Account menu", "Billing"]);
183
+ assert.ok(!hits.includes("/account/sign-out"), "a sign-out address is never opened, whatever its label");
184
+ assert.ok(!hits.includes("/sign-out"), "nor a sign-out link inside a menu");
185
+ assert.ok(result.events.some((event) => event.text === "“Connect GitHub” leaves the app"));
186
+ const plans = result.flows.find((flow) => flow.route === "/plans")!;
187
+ assert.match(plans.content, new RegExp(`^url: ${baseUrl}plans\\?project=p1\\n`), "the base URL's app context is kept");
188
+ assert.doesNotMatch(plans.content, /tapOn/, "each flow opens its page directly");
189
+ } finally {
190
+ server.close();
191
+ await fs.rm(directory, { recursive: true, force: true });
192
+ }
193
+ });
194
+
121
195
  test("a discovery task streams progress, then publishes plans and a valid journey graph", async (t) => {
122
196
  if (!(await chromiumInstalled())) {
123
197
  t.skip("Chromium is not installed");
@@ -166,3 +240,63 @@ test("a discovery task streams progress, then publishes plans and a valid journe
166
240
  }
167
241
  });
168
242
  });
243
+
244
+ test("a crawl that only reached sign-in screens is reported as stopped at sign-in", () => {
245
+ const page = (extra: Record<string, unknown>) => ({ route: "/login", url: "https://app.test/login", title: "App", path: [], links: [], forms: [], ...extra });
246
+ assert.equal(stoppedAtSignIn({ pages: [page({ heading: "Welcome back", controls: ["Continue with Aegis"] })] }), true);
247
+ assert.equal(stoppedAtSignIn({ pages: [page({ forms: [{ name: "Log in", fields: [{ label: "Email", type: "email", required: true }, { label: "Password", type: "password", required: true }] }] })] }), true);
248
+ assert.equal(stoppedAtSignIn({ pages: [page({ heading: "Pricing" })] }), false, "a page that isn't a sign-in screen");
249
+ assert.equal(stoppedAtSignIn({ pages: [] }), false, "nothing explored is a different failure");
250
+ assert.equal(
251
+ stoppedAtSignIn({ pages: [page({ heading: "Welcome back" }), page({ route: "/", heading: "Home" }), page({ route: "/a", heading: "A" })] }),
252
+ false,
253
+ "a public site that also has a sign-in page",
254
+ );
255
+ });
256
+
257
+ test("an app that opens on a sign-in button: discovery records the button, says it stopped, and skips AI", async (t) => {
258
+ if (!(await chromiumInstalled())) {
259
+ t.skip("Chromium is not installed");
260
+ return;
261
+ }
262
+ const server = http.createServer((_request, response) => {
263
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
264
+ response.end("<!doctype html><html><head><title>Bugmole</title></head><body><h1>Welcome back</h1><p>Sign in or create your account.</p><button>Continue with Aegis →</button></body></html>");
265
+ });
266
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
267
+ const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
268
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-sign-in-wall-"));
269
+ const stub = await startLocalRegistryStub({ directory: path.join(directory, "registry") });
270
+ try {
271
+ const seen = await discoverSite(baseUrl, { dwellMs: 0 });
272
+ assert.deepEqual(seen.pages[0].controls, ["Continue with Aegis →"]);
273
+ assert.equal(stoppedAtSignIn(seen), true);
274
+
275
+ const client = new ControlPlaneClient({ registryUrl: stub.origin, apiKey: "local", projectId: "demo" });
276
+ const { task } = await client.createTask({ title: "Explore with AI app", description: `Explore ${baseUrl} like a first-time visitor, then have AI write multi-page journeys.`, taskType: "discovery", idempotencyKey: "discover-wall" });
277
+ const cfg = {
278
+ project: { id: "demo" },
279
+ runtime: { spec_dir: path.join(directory, "spec"), artifacts_dir: path.join(directory, "artifacts") },
280
+ storage: { provider: "local" },
281
+ };
282
+ const updates: Array<Record<string, unknown>> = [];
283
+ await runDiscoveryTask(cfg, {
284
+ claimTask: client.claimTask.bind(client),
285
+ publishPlan: client.publishPlan.bind(client),
286
+ updateTask: async (id: string, update: Record<string, unknown>) => {
287
+ updates.push(update);
288
+ return client.updateTask(id, update);
289
+ },
290
+ }, task, "worker-1");
291
+ const final = updates.at(-1)!;
292
+ assert.equal(final.status, "completed", String(final.errorMessage));
293
+ assert.match(String(final.completionSummary), /Stopped at a sign-in page/);
294
+ const texts = (final.continuation as { events: Array<{ text: string }> }).events.map((event) => event.text);
295
+ assert.ok(texts.some((text) => text.startsWith("Stopped at a sign-in page")), texts.join("\n"));
296
+ assert.ok(!texts.some((text) => /AI/.test(text) && !text.startsWith("Stopped")), "the AI step never ran");
297
+ } finally {
298
+ await stub.close();
299
+ server.close();
300
+ await fs.rm(directory, { recursive: true, force: true });
301
+ }
302
+ });