@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
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/runtime/run-job.ts
CHANGED
|
@@ -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
|
+
});
|