scenescout 3.10.0 → 3.11.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/CHANGELOG.md +10 -0
- package/README.md +12 -3
- package/dist/browsers.js +20 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +252 -19
- package/dist/engine/check.js +251 -33
- package/dist/engine/flow.js +398 -0
- package/dist/engine/memory.js +10 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +2 -2
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/browser.js
CHANGED
|
@@ -2,15 +2,16 @@ import { chromium, firefox, webkit, } from "playwright";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
5
|
-
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, TASK_SET } from "./memory.js";
|
|
5
|
+
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, redactSecrets, TASK_SET } from "./memory.js";
|
|
6
6
|
import { normalizeTask } from "./task.js";
|
|
7
7
|
import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
|
|
8
8
|
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
9
9
|
import { AuthLossTracker } from "./authloss.js";
|
|
10
10
|
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, placeholderOnly, placeholderEvidence, describeControl, labelFlag, } from "./collector.js";
|
|
11
|
-
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
11
|
+
import { OracleMonitor, formatViolations, httpErrorDetail } from "./oracles.js";
|
|
12
12
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
13
13
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
14
|
+
import { describeStep, FLOW_AFTER_LAST_STEP_MS, FLOW_STEP_TIMEOUT_MS, isAction, matchRequest, parseTarget, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
|
|
14
15
|
import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
|
|
15
16
|
import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
|
|
16
17
|
import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
|
|
@@ -849,7 +850,7 @@ export class BrowserEngine {
|
|
|
849
850
|
const why = ESCAPE_REFUSAL;
|
|
850
851
|
// Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
|
|
851
852
|
if (this.blockedRequests.length < 20)
|
|
852
|
-
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why });
|
|
853
|
+
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why, type: req.resourceType() });
|
|
853
854
|
this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
|
|
854
855
|
this.refusedByPolicy.add(req);
|
|
855
856
|
this.oracles.notePolicyBlock();
|
|
@@ -909,7 +910,7 @@ export class BrowserEngine {
|
|
|
909
910
|
const refuse = (why) => {
|
|
910
911
|
const answered = answersWithRefusal(req.resourceType());
|
|
911
912
|
if (this.blockedRequests.length < 20)
|
|
912
|
-
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why });
|
|
913
|
+
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why, type: req.resourceType() });
|
|
913
914
|
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
|
|
914
915
|
this.refusedByPolicy.add(req);
|
|
915
916
|
this.oracles.notePolicyBlock();
|
|
@@ -1389,7 +1390,11 @@ export class BrowserEngine {
|
|
|
1389
1390
|
* route classes (including ?tab= screens) that feed the completion contract,
|
|
1390
1391
|
* so the contract works for any app, not just filesystem-routed Next.
|
|
1391
1392
|
*/
|
|
1393
|
+
/** Set while a measure-only crawl reads a page: its links are not the app's route list. */
|
|
1394
|
+
harvestPaused = false;
|
|
1392
1395
|
harvestRoutes(elements) {
|
|
1396
|
+
if (this.harvestPaused)
|
|
1397
|
+
return;
|
|
1393
1398
|
if (!this.memory)
|
|
1394
1399
|
return;
|
|
1395
1400
|
const found = [];
|
|
@@ -2733,6 +2738,12 @@ export class BrowserEngine {
|
|
|
2733
2738
|
* Output is anomaly-oriented: a summary line per route, details only where
|
|
2734
2739
|
* something is wrong. Navigation-only, so it is safe in read-only mode.
|
|
2735
2740
|
*/
|
|
2741
|
+
/**
|
|
2742
|
+
* `measureOnly`: load and measure the pages and nothing else. No route is
|
|
2743
|
+
* harvested from their links, none is marked visited or attempted, and a
|
|
2744
|
+
* failed load is not remembered, so the pages and what they link to stay on
|
|
2745
|
+
* the unvisited list exactly as before. A check's re-test loads use it.
|
|
2746
|
+
*/
|
|
2736
2747
|
async crawl(paths, opts = {}) {
|
|
2737
2748
|
const page = this.requirePage();
|
|
2738
2749
|
const memory = this.memory;
|
|
@@ -2768,7 +2779,8 @@ export class BrowserEngine {
|
|
|
2768
2779
|
// Not retried by later crawls in this process (a check would otherwise try it
|
|
2769
2780
|
// on every discovery round). Deliberately not written to memory: one outage
|
|
2770
2781
|
// must not count a route as covered in every later run's gap ledger.
|
|
2771
|
-
|
|
2782
|
+
if (!opts.measureOnly)
|
|
2783
|
+
this.loadFailedRoutes.add(normalizePath(url));
|
|
2772
2784
|
// The browser commits its own error page for this failure tens of
|
|
2773
2785
|
// milliseconds after goto has thrown, and that commit interrupts the next
|
|
2774
2786
|
// navigation, charging this route's failure to the next one. Wait for it;
|
|
@@ -2793,21 +2805,24 @@ export class BrowserEngine {
|
|
|
2793
2805
|
continue;
|
|
2794
2806
|
}
|
|
2795
2807
|
await this.settle();
|
|
2796
|
-
|
|
2808
|
+
this.harvestPaused = opts.measureOnly === true;
|
|
2809
|
+
const { elements, forms } = await this.collect().finally(() => (this.harvestPaused = false));
|
|
2797
2810
|
const finalUrl = page.url();
|
|
2798
2811
|
const route = normalizePath(finalUrl);
|
|
2799
2812
|
const fp = fingerprintState(finalUrl, elements);
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2813
|
+
if (!opts.measureOnly) {
|
|
2814
|
+
memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
|
|
2815
|
+
for (const f of forms)
|
|
2816
|
+
memory.recordForm(fp, f.key, f.guarded);
|
|
2817
|
+
memory.recordRoleAccess(this.role, route, "reached");
|
|
2818
|
+
}
|
|
2804
2819
|
// If we landed somewhere else (auth wall, canonical redirect), the
|
|
2805
2820
|
// REQUESTED route still counts as covered for THIS role — a role that
|
|
2806
2821
|
// can't see /admin must not block the completion contract forever. An
|
|
2807
2822
|
// auth-loss bounce is recorded under a prefix that does NOT count.
|
|
2808
2823
|
const requestedRoute = normalizePath(url);
|
|
2809
2824
|
const loginRedirect = this.authLoss.isLoginRedirect(path, finalUrl, this.baseUrl);
|
|
2810
|
-
if (route !== requestedRoute) {
|
|
2825
|
+
if (route !== requestedRoute && !opts.measureOnly) {
|
|
2811
2826
|
const outcome = loginRedirect ? `${AUTH_LOSS_PREFIX}${route}` : `landed:${route}`;
|
|
2812
2827
|
memory.markAttempted(requestedRoute, outcome, this.role);
|
|
2813
2828
|
memory.recordRoleAccess(this.role, requestedRoute, outcome);
|
|
@@ -2819,7 +2834,7 @@ export class BrowserEngine {
|
|
|
2819
2834
|
this.authLoss.clear();
|
|
2820
2835
|
// Error-status routes render but would otherwise be re-crawled forever —
|
|
2821
2836
|
// an attempt with the status satisfies the contract.
|
|
2822
|
-
if (typeof status === "number" && status >= 400)
|
|
2837
|
+
if (typeof status === "number" && status >= 400 && !opts.measureOnly)
|
|
2823
2838
|
memory.markAttempted(requestedRoute, `status:${status}`, this.role);
|
|
2824
2839
|
this.logAction({ action: "crawl", target: path, url: finalUrl, ...(await this.frameFor("crawl")) });
|
|
2825
2840
|
await this.scanForInjections();
|
|
@@ -2889,13 +2904,10 @@ export class BrowserEngine {
|
|
|
2889
2904
|
const page = this.requirePage();
|
|
2890
2905
|
const transcript = [];
|
|
2891
2906
|
const resolveTarget = (target) => {
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
if (target.startsWith("label="))
|
|
2897
|
-
return page.getByLabel(target.slice(6)).first();
|
|
2898
|
-
throw new Error(`Plan targets must be "testid=…", "text=…", or "label=…" (got: ${target})`);
|
|
2907
|
+
const parsed = parseTarget(target);
|
|
2908
|
+
if (!parsed)
|
|
2909
|
+
throw new Error(`Plan targets must be ${TARGET_HELP} (got: ${target})`);
|
|
2910
|
+
return BrowserEngine.locatorFor(page, parsed).first();
|
|
2899
2911
|
};
|
|
2900
2912
|
/** Last state captured this plan — reused as the next step's pre-state while the page has not moved. */
|
|
2901
2913
|
let lastCapture = null;
|
|
@@ -3138,6 +3150,227 @@ export class BrowserEngine {
|
|
|
3138
3150
|
const ran = transcript.filter((l) => /^\d+\. /.test(l)).length;
|
|
3139
3151
|
return `PLAN (${ran}/${Math.min(steps.length, 20)} steps ran):\n${transcript.join("\n")}\nTake scout_snapshot to see the resulting state.`;
|
|
3140
3152
|
}
|
|
3153
|
+
/** A plan's or a flow's target as a Playwright locator (every match; callers pick). */
|
|
3154
|
+
static locatorFor(page, target) {
|
|
3155
|
+
switch (target.by) {
|
|
3156
|
+
case "testid":
|
|
3157
|
+
return page.locator(`[data-testid=${JSON.stringify(target.value)}]`);
|
|
3158
|
+
case "text":
|
|
3159
|
+
return page.getByText(target.value, { exact: false });
|
|
3160
|
+
case "label":
|
|
3161
|
+
return page.getByLabel(target.value);
|
|
3162
|
+
case "role":
|
|
3163
|
+
return page.getByRole(target.role, target.name === undefined ? {} : { name: target.name });
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Replay one saved flow for `scenescout check`. Acts and reports only: the
|
|
3168
|
+
* schema, the matching rules and what an outcome means live in flow.ts and
|
|
3169
|
+
* check.ts. It stops at the first step that breaks, or that the write policy
|
|
3170
|
+
* refuses, and hands back what the oracles caught on the way.
|
|
3171
|
+
*
|
|
3172
|
+
* Stricter than a plan on purpose: a click is never forced through
|
|
3173
|
+
* something covering its target (a user could not click it either), and a
|
|
3174
|
+
* step does not count as done until the page has settled.
|
|
3175
|
+
*/
|
|
3176
|
+
async replayFlow(steps, mode = this.mode === "observe" ? "observe" : "read-only") {
|
|
3177
|
+
const page = this.requirePage();
|
|
3178
|
+
// The write policy reads this.mode on every request, so the flow's rule holds for exactly as long as the flow runs.
|
|
3179
|
+
const crawlMode = this.mode;
|
|
3180
|
+
this.mode = mode;
|
|
3181
|
+
const context = this.context;
|
|
3182
|
+
const violations = [];
|
|
3183
|
+
const here = () => {
|
|
3184
|
+
try {
|
|
3185
|
+
const u = new URL(this.page?.url() ?? "");
|
|
3186
|
+
return `${u.pathname}${u.search}`;
|
|
3187
|
+
}
|
|
3188
|
+
catch {
|
|
3189
|
+
return "/";
|
|
3190
|
+
}
|
|
3191
|
+
};
|
|
3192
|
+
/** Responses since the most recent action step: what an expect-request looks through. */
|
|
3193
|
+
let seen = [];
|
|
3194
|
+
const onResponse = (res) => {
|
|
3195
|
+
seen.push({ method: res.request().method(), url: res.url(), status: res.status() });
|
|
3196
|
+
};
|
|
3197
|
+
context.on("response", onResponse);
|
|
3198
|
+
const appOrigin = new URL(this.baseUrl).origin;
|
|
3199
|
+
const refusedBackground = [];
|
|
3200
|
+
const websockets = new Set();
|
|
3201
|
+
const onSocket = (ws) => void websockets.add(ws.url().slice(0, 120));
|
|
3202
|
+
page.on("websocket", onSocket);
|
|
3203
|
+
/** Refused writes since the last call that a step is charged with; background ones (beacons, pings) are only listed (splitRefusals). */
|
|
3204
|
+
const ownRefusals = () => {
|
|
3205
|
+
const { charged, background } = splitRefusals(this.blockedRequests.splice(0), appOrigin);
|
|
3206
|
+
for (const sig of background)
|
|
3207
|
+
if (!refusedBackground.includes(sig) && refusedBackground.length < 20)
|
|
3208
|
+
refusedBackground.push(sig);
|
|
3209
|
+
return charged;
|
|
3210
|
+
};
|
|
3211
|
+
const done = (outcome) => ({ outcome, violations, refusedBackground, websockets: [...websockets] });
|
|
3212
|
+
// Nothing from the crawl before this flow is charged to it.
|
|
3213
|
+
this.oracles.drain(false);
|
|
3214
|
+
this.blockedRequests = [];
|
|
3215
|
+
const collect = (except) => {
|
|
3216
|
+
const path = here();
|
|
3217
|
+
for (const v of this.oracles.drain())
|
|
3218
|
+
if (v.detail !== except)
|
|
3219
|
+
violations.push({ path, violation: { kind: v.kind, severity: v.severity, detail: v.detail, url: v.url, ...(v.embed ? { embed: v.embed } : {}) } });
|
|
3220
|
+
};
|
|
3221
|
+
const poll = async (done) => {
|
|
3222
|
+
const until = Date.now() + FLOW_STEP_TIMEOUT_MS;
|
|
3223
|
+
for (;;) {
|
|
3224
|
+
if (done())
|
|
3225
|
+
return true;
|
|
3226
|
+
if (Date.now() >= until)
|
|
3227
|
+
return false;
|
|
3228
|
+
await (this.page ?? page).waitForTimeout(100).catch(() => { });
|
|
3229
|
+
}
|
|
3230
|
+
};
|
|
3231
|
+
const firstLine = (err) => (err instanceof Error ? err.message : String(err))
|
|
3232
|
+
.split("\n")[0]
|
|
3233
|
+
.replace(/^[a-z]+\.[a-zA-Z]+: /, "")
|
|
3234
|
+
.trim();
|
|
3235
|
+
try {
|
|
3236
|
+
for (const [i, step] of steps.entries()) {
|
|
3237
|
+
const n = i + 1;
|
|
3238
|
+
const did = describeStep(step);
|
|
3239
|
+
if (isAction(step))
|
|
3240
|
+
seen = [];
|
|
3241
|
+
let failure = null;
|
|
3242
|
+
let refusal = null;
|
|
3243
|
+
this.actionStartedAt = Date.now();
|
|
3244
|
+
try {
|
|
3245
|
+
const current = this.requirePage();
|
|
3246
|
+
if (step.action === "navigate") {
|
|
3247
|
+
const url = `${this.baseUrl}${step.target}`;
|
|
3248
|
+
const resp = await current.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
3249
|
+
await this.settle();
|
|
3250
|
+
const status = resp?.status() ?? null;
|
|
3251
|
+
if (status !== null && status >= 400) {
|
|
3252
|
+
failure = `the page answered HTTP ${status}`;
|
|
3253
|
+
// The page's own status is this step's failure; the oracle's record of the same response is not a second issue.
|
|
3254
|
+
// The oracle stores its details redacted (redactViolation), so the comparison is redacted too.
|
|
3255
|
+
collect(redactSecrets(httpErrorDetail("GET", current.url().replace(/#.*$/, ""), status)));
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
else if (step.action === "press") {
|
|
3259
|
+
refusal = await this.vetFocusedActivation(step.value);
|
|
3260
|
+
if (!refusal) {
|
|
3261
|
+
await current.keyboard.press(step.value);
|
|
3262
|
+
await this.settle();
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
else if (step.action === "click" || step.action === "type" || step.action === "select") {
|
|
3266
|
+
const loc = BrowserEngine.locatorFor(current, parseTarget(step.target)).first();
|
|
3267
|
+
const found = await loc
|
|
3268
|
+
.waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
|
|
3269
|
+
.then(() => true)
|
|
3270
|
+
.catch(() => false);
|
|
3271
|
+
if (!found) {
|
|
3272
|
+
failure = `nothing visible matches ${step.target} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
|
|
3273
|
+
}
|
|
3274
|
+
else {
|
|
3275
|
+
const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
|
|
3276
|
+
(await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
|
|
3277
|
+
"").trim();
|
|
3278
|
+
if (this.readOnly && step.action !== "type" && isDestructive(label, step.action === "select" ? step.value : undefined)) {
|
|
3279
|
+
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
3280
|
+
}
|
|
3281
|
+
else if (step.action === "click") {
|
|
3282
|
+
await loc.click({ timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3283
|
+
}
|
|
3284
|
+
else if (step.action === "select") {
|
|
3285
|
+
await loc.selectOption(step.value, { timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3286
|
+
}
|
|
3287
|
+
else {
|
|
3288
|
+
await this.noteProbe(step.value, step.target);
|
|
3289
|
+
await this.fillOrAppend(loc, step.value, step.replace ?? false);
|
|
3290
|
+
if (step.pressEnter) {
|
|
3291
|
+
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
3292
|
+
const submitLabel = ((await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "").trim();
|
|
3293
|
+
if (this.readOnly && isDestructive(submitLabel))
|
|
3294
|
+
refusal = destructiveRefusal(submitLabel, this.mode);
|
|
3295
|
+
else
|
|
3296
|
+
await loc.press("Enter", { timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
if (!refusal)
|
|
3300
|
+
await this.settle();
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
else if (step.action === "expect-text") {
|
|
3304
|
+
const visible = current.getByText(step.text, { exact: false }).filter({ visible: true }).first();
|
|
3305
|
+
const ok = await visible
|
|
3306
|
+
.waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
|
|
3307
|
+
.then(() => true)
|
|
3308
|
+
.catch(() => false);
|
|
3309
|
+
if (!ok)
|
|
3310
|
+
failure = `no visible text ${JSON.stringify(step.text)} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
|
|
3311
|
+
}
|
|
3312
|
+
else if (step.action === "expect-url") {
|
|
3313
|
+
const ok = await poll(() => urlMatches(step.pattern, this.page?.url() ?? ""));
|
|
3314
|
+
if (!ok)
|
|
3315
|
+
failure = `the page is at ${here()}, which does not match /${step.pattern}/`;
|
|
3316
|
+
}
|
|
3317
|
+
else {
|
|
3318
|
+
let last = { ok: false, reason: "" };
|
|
3319
|
+
await poll(() => (last = matchRequest(step, seen)).ok);
|
|
3320
|
+
if (!last.ok)
|
|
3321
|
+
failure = last.reason;
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
catch (err) {
|
|
3325
|
+
failure = firstLine(err);
|
|
3326
|
+
}
|
|
3327
|
+
await this.scanForInjections().catch(() => { });
|
|
3328
|
+
await this.scanForContradictions().catch(() => { });
|
|
3329
|
+
// What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
|
|
3330
|
+
const blocked = ownRefusals();
|
|
3331
|
+
if (!refusal && blocked.length > 0)
|
|
3332
|
+
refusal = `the ${this.mode} write policy refused ${blocked.join(", ")}`;
|
|
3333
|
+
collect();
|
|
3334
|
+
if (refusal)
|
|
3335
|
+
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
3336
|
+
if (failure)
|
|
3337
|
+
return done({ status: "failed", step: n, did, reason: failure, path: here() });
|
|
3338
|
+
}
|
|
3339
|
+
// A write the last step set off can land after its checks ran (a debounced save): wait a moment, settle, look
|
|
3340
|
+
// again; then leave the page while the flow's rule still holds, so nothing it sends on the way out goes under
|
|
3341
|
+
// the crawl's rule, and look once more.
|
|
3342
|
+
await (this.page ?? page).waitForTimeout(FLOW_AFTER_LAST_STEP_MS).catch(() => { });
|
|
3343
|
+
await this.settle().catch(() => { });
|
|
3344
|
+
const late = ownRefusals();
|
|
3345
|
+
collect();
|
|
3346
|
+
await (this.page ?? page).goto("about:blank").catch(() => { });
|
|
3347
|
+
await this.settle().catch(() => { });
|
|
3348
|
+
late.push(...ownRefusals());
|
|
3349
|
+
const last = steps[steps.length - 1];
|
|
3350
|
+
if (late.length > 0 && last) {
|
|
3351
|
+
return done({
|
|
3352
|
+
status: "refused",
|
|
3353
|
+
step: steps.length,
|
|
3354
|
+
did: describeStep(last),
|
|
3355
|
+
reason: `after the last step, the ${this.mode} write policy refused ${late.join(", ")}`,
|
|
3356
|
+
path: here(),
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
3359
|
+
return done({ status: "passed" });
|
|
3360
|
+
}
|
|
3361
|
+
finally {
|
|
3362
|
+
// Leave the flow's page before the crawl's rule comes back, so nothing it still sends goes out under a looser one.
|
|
3363
|
+
await (this.page ?? page).goto("about:blank").catch(() => { });
|
|
3364
|
+
this.blockedRequests = [];
|
|
3365
|
+
this.oracles.drain(false);
|
|
3366
|
+
this.mode = crawlMode;
|
|
3367
|
+
page.off("websocket", onSocket);
|
|
3368
|
+
context.off("response", onResponse);
|
|
3369
|
+
this.refs.clear();
|
|
3370
|
+
this.lastSnap = null;
|
|
3371
|
+
this.snapshotUrl = "";
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3141
3374
|
/** Computed-style design audit of the current page — visual judgment material without pixels. */
|
|
3142
3375
|
async designAudit() {
|
|
3143
3376
|
const { url, report } = await this.auditPage();
|