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.
@@ -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
- this.loadFailedRoutes.add(normalizePath(url));
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
- const { elements, forms } = await this.collect();
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
- memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
2801
- for (const f of forms)
2802
- memory.recordForm(fp, f.key, f.guarded);
2803
- memory.recordRoleAccess(this.role, route, "reached");
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
- if (target.startsWith("testid="))
2893
- return page.locator(`[data-testid=${JSON.stringify(target.slice(7))}]`).first();
2894
- if (target.startsWith("text="))
2895
- return page.getByText(target.slice(5), { exact: false }).first();
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();