scenescout 3.2.0 → 3.3.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.
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
5
5
  import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, TASK_SET } from "./memory.js";
6
6
  import { normalizeTask } from "./task.js";
7
+ import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
7
8
  import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
8
9
  import { AuthLossTracker } from "./authloss.js";
9
10
  import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
@@ -11,6 +12,8 @@ import { OracleMonitor, formatViolations } from "./oracles.js";
11
12
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
12
13
  import { formatJourney, measureJourney } from "./journey.js";
13
14
  import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
15
+ import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
16
+ import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
14
17
  import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, } from "../browsers.js";
15
18
  import { revealedLines } from "./hover.js";
16
19
  import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
@@ -220,6 +223,69 @@ export class BrowserEngine {
220
223
  framesKept = 0;
221
224
  /** How many frames could not be written. The first one says so in the log; the rest are counted. */
222
225
  framesFailed = 0;
226
+ /**
227
+ * The Authorization header the app itself last sent, replayed by
228
+ * scout_request so a call with the UI bypassed carries the same credential
229
+ * as a click. Nothing here parses it: whatever scheme the app uses is
230
+ * whatever gets replayed.
231
+ */
232
+ lastAuthHeader = null;
233
+ rememberAuthHeader(headers) {
234
+ const value = headers["authorization"] ?? headers["Authorization"];
235
+ if (value && value.trim())
236
+ this.lastAuthHeader = value;
237
+ }
238
+ /**
239
+ * Call the app's own API as this session, with the UI bypassed.
240
+ *
241
+ * The fetch runs IN the page, so it passes through the same interception the
242
+ * write policy is enforced on: a safe-write session cannot reach past the
243
+ * policy by calling an endpoint instead of clicking it. The refusal comes
244
+ * back as the policy's own error, exactly as it would for a click.
245
+ */
246
+ async apiRequest(input) {
247
+ const page = this.requirePage();
248
+ const method = resolveMethod(input.method);
249
+ if ("problem" in method)
250
+ return `REFUSED: ${method.problem}`;
251
+ const target = resolveRequestUrl(this.baseUrl, input.path);
252
+ if ("problem" in target)
253
+ return `REFUSED: ${target.problem}`;
254
+ const script = buildRequestScript({
255
+ url: target.url,
256
+ method: method.method,
257
+ body: input.body,
258
+ headers: requestHeaders({ given: input.headers, auth: this.lastAuthHeader, body: input.body }),
259
+ });
260
+ let raw;
261
+ try {
262
+ raw = (await page.evaluate(script));
263
+ }
264
+ catch (err) {
265
+ // A blocked request rejects the fetch inside the page. That is the write
266
+ // policy doing its job, not an app fault, and it is reported as such.
267
+ const message = err instanceof Error ? err.message : String(err);
268
+ this.logAction({ action: "request", target: `${method.method} ${input.path}`, url: page.url(), result: `blocked: ${message.split("\n")[0]}` });
269
+ return `${method.method} ${input.path} — the request did not complete: ${message.split("\n")[0]}\nIn a write-limited mode this is usually the policy refusing it, which is the engine's safety net and not a finding about the app.`;
270
+ }
271
+ const result = toReplayResult(raw);
272
+ this.logAction({
273
+ action: "request",
274
+ target: `${method.method} ${input.path}`,
275
+ url: page.url(),
276
+ result: replaySignature(method.method, result.url, result.status),
277
+ });
278
+ return formatReplay(method.method, result);
279
+ }
280
+ /**
281
+ * The frame field for a log entry, or nothing. Spread into logAction so a
282
+ * caller that is not going through afterAction can still record what the
283
+ * page looked like: `...(await this.frameFor("crawl"))`.
284
+ */
285
+ async frameFor(action) {
286
+ const frame = await this.recordFrame(action);
287
+ return frame ? { frame } : {};
288
+ }
223
289
  /**
224
290
  * The frame for the step just taken, as a path relative to the memory
225
291
  * directory, or undefined when this run is not recorded. Failing to write
@@ -266,16 +332,26 @@ export class BrowserEngine {
266
332
  * until it is replaced — a batch costs a few words, not one per click.
267
333
  */
268
334
  currentTask = null;
269
- /** Set what this session is doing now. An empty value clears it. */
270
- setTask(text) {
335
+ /**
336
+ * Set what this session is doing now. An empty value clears it.
337
+ *
338
+ * `stated` is false for the placeholder attach puts up so a fresh card is
339
+ * not blank. It shows on the board but does NOT satisfy the requirement that
340
+ * an agent say what it is doing before it acts — otherwise every session
341
+ * would run for an hour under "Attaching and taking stock", which is the
342
+ * guard's whole purpose defeated by its own convenience.
343
+ */
344
+ setTask(text, stated = true) {
271
345
  const clean = normalizeTask(text);
272
346
  if (!clean) {
273
347
  this.currentTask = null;
274
348
  return;
275
349
  }
276
- if (this.currentTask?.text === clean)
350
+ if (this.currentTask?.text === clean && this.currentTask.stated === stated)
351
+ return;
352
+ this.currentTask = { text: clean, since: Date.now(), stated };
353
+ if (!stated)
277
354
  return;
278
- this.currentTask = { text: clean, since: Date.now() };
279
355
  // Logged so the feed can group the actions that follow under it, the way
280
356
  // it groups a journey's — the trail is where a watcher reads what
281
357
  // happened, and an ungrouped one says nothing about why.
@@ -283,7 +359,7 @@ export class BrowserEngine {
283
359
  }
284
360
  /** Whether anything is standing that the live view could show as the task. */
285
361
  get hasTask() {
286
- return this.journey !== null || this.currentTask !== null;
362
+ return this.journey !== null || (this.currentTask?.stated ?? false);
287
363
  }
288
364
  /**
289
365
  * Begin measuring a user JOURNEY — the interaction cost of completing one
@@ -333,6 +409,15 @@ export class BrowserEngine {
333
409
  engineName = "chromium";
334
410
  /** Non-GET requests fired since the last action — surfaces silent state mutation in read-only runs (timestamped for attribution). */
335
411
  mutationRequests = [];
412
+ /**
413
+ * Every request answered since the last action, with its status. The HTTP
414
+ * oracle sees each 4xx as it happens and reports it on its own; this ledger
415
+ * exists so the contradiction rules can ask the DIFFERENT question of whether
416
+ * the page then agreed with the refusal. Capped, because a page that polls
417
+ * would otherwise grow it without bound.
418
+ */
419
+ watchedResponses = [];
420
+ static MAX_WATCHED_RESPONSES = 60;
336
421
  /**
337
422
  * Requests the write policy aborted, by identity. The request event fires for
338
423
  * every non-GET the page ATTEMPTS, before the route handler decides its fate,
@@ -374,6 +459,52 @@ export class BrowserEngine {
374
459
  * element on the current page? Runs wherever violations are drained, so the
375
460
  * finding reaches the agent in the result of the action that revealed it.
376
461
  */
462
+ /** Record one answered request for the contradiction rules. Policy-aborted ones are marked, never dropped: the rules need to know they were ours. */
463
+ watchResponse(req, status) {
464
+ if (this.watchedResponses.length >= BrowserEngine.MAX_WATCHED_RESPONSES)
465
+ return;
466
+ this.watchedResponses.push({
467
+ method: req.method(),
468
+ url: req.url(),
469
+ status,
470
+ resourceType: req.resourceType(),
471
+ blockedByPolicy: this.abortedByPolicy.has(req),
472
+ });
473
+ }
474
+ /**
475
+ * Did the page agree with what the network just did? Runs in the same slot
476
+ * as the injection scan, so it sees the DOM the action settled on.
477
+ *
478
+ * The ledger is cleared whether or not anything is found: these are facts
479
+ * about ONE action, and carrying a refusal forward would blame the next
480
+ * action's page for the previous action's request.
481
+ */
482
+ async scanForContradictions() {
483
+ const requests = this.watchedResponses;
484
+ this.watchedResponses = [];
485
+ const page = this.page;
486
+ if (!page || page.isClosed() || requests.length === 0)
487
+ return;
488
+ if (!requests.some((r) => r.status === null || r.status >= 400))
489
+ return;
490
+ const url = page.url();
491
+ let state;
492
+ try {
493
+ state = (await page.evaluate(CLAIM_SCAN_SCRIPT));
494
+ }
495
+ catch {
496
+ // A page mid-navigation has no DOM to ask.
497
+ return;
498
+ }
499
+ for (const found of findContradictions(requests, state)) {
500
+ if (this.contradictionsReported.has(found.evidence))
501
+ continue;
502
+ this.contradictionsReported.add(found.evidence);
503
+ this.oracles.noteContradiction(found, url);
504
+ }
505
+ }
506
+ /** Contradiction signatures already reported this session — the same refused endpoint on every page must not flood the run. */
507
+ contradictionsReported = new Set();
377
508
  async scanForInjections() {
378
509
  const page = this.page;
379
510
  if (!page || page.isClosed() || this.probes.length === 0)
@@ -406,6 +537,9 @@ export class BrowserEngine {
406
537
  }
407
538
  this.mode = opts.mode ?? "read-only";
408
539
  this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
540
+ // An agent-supplied task counts as stated; the placeholder does not.
541
+ this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
542
+ this.setPace(opts.paceMs);
409
543
  this.recording = opts.record === true;
410
544
  // A re-attached engine starts a new recording: numbering from where the
411
545
  // last one stopped would run into the cap with frames it never took.
@@ -413,6 +547,8 @@ export class BrowserEngine {
413
547
  this.framesFailed = 0;
414
548
  this.headed = opts.headed ?? false;
415
549
  this.blockedRequests = [];
550
+ this.watchedResponses = [];
551
+ this.contradictionsReported = new Set();
416
552
  this.pendingCreations = new Set();
417
553
  this.baseUrl = opts.url.replace(/\/$/, "");
418
554
  // Ownership (ownedIds/createdResources) deliberately NOT reset here: it
@@ -476,7 +612,19 @@ export class BrowserEngine {
476
612
  if (this.page)
477
613
  watchSockets(this.page);
478
614
  this.context.on("page", watchSockets);
615
+ this.context.on("requestfinished", () => {
616
+ this.inFlight = Math.max(0, this.inFlight - 1);
617
+ });
618
+ this.context.on("requestfailed", (req) => {
619
+ this.inFlight = Math.max(0, this.inFlight - 1);
620
+ this.watchResponse(req, null);
621
+ });
622
+ this.context.on("response", (res) => {
623
+ this.watchResponse(res.request(), res.status());
624
+ });
479
625
  this.context.on("request", (req) => {
626
+ this.inFlight += 1;
627
+ this.lastRequestStart = Date.now();
480
628
  const type = req.resourceType();
481
629
  if (type === "xhr" || type === "fetch")
482
630
  this.xhrCount += 1;
@@ -522,6 +670,7 @@ export class BrowserEngine {
522
670
  return route.continue();
523
671
  const url = req.url();
524
672
  const pathname = pathnameOf(url);
673
+ this.rememberAuthHeader(req.headers());
525
674
  const destructiveWire = isDestructiveWire(pathname, req.postData());
526
675
  // Auth/session flows must work in every mode — but never a destructive
527
676
  // one, and in observe only the requests a login itself needs.
@@ -615,17 +764,30 @@ export class BrowserEngine {
615
764
  // is all this used to do — reads as success and names the very file that
616
765
  // just failed, so a run would proceed for hundreds of calls against a
617
766
  // logged-out browser. The file existing was the only thing ever checked.
618
- const authFailed = Boolean(opts.storageStatePath) && this.authLoss.isLoginRedirect(normalizePath(opts.url), this.page.url(), this.baseUrl);
767
+ // Whether a bounce verdict can matter later: only a session carrying
768
+ // credentials has any to lose, and only it pays for watching the URL on
769
+ // every navigation. An anonymous crawl keeps its full speed.
770
+ this.watchesForBounce = Boolean(opts.storageStatePath);
771
+ const landed = await this.stableUrl();
772
+ const authFailed = Boolean(opts.storageStatePath) && this.authLoss.isLoginRedirect(normalizePath(opts.url), landed, this.baseUrl);
773
+ // An earlier run may have written down how this app's login state is
774
+ // regenerated. "Regenerate it" is advice the reader already had; the
775
+ // command that worked last time is the part worth keeping.
776
+ const recipe = authFailed ? this.memory.setupRecipe() : [];
619
777
  const authWarning = authFailed
620
778
  ? `\n⚠ AUTH FAILED — the storage state at ${opts.storageStatePath} did not produce a signed-in session: ` +
621
- `attaching landed on ${this.page.url()}, a login page. Regenerate it (its token has most likely expired) and re-attach. ` +
622
- `Continuing now tests a logged-out app.`
779
+ `attaching landed on ${landed}, a login page. Regenerate it (its token has most likely expired) and re-attach. ` +
780
+ `Continuing now tests a logged-out app.` +
781
+ (recipe.length > 0
782
+ ? `\n Recorded by an earlier run under setup:\n${recipe.map((line) => ` · ${line}`).join("\n")}`
783
+ : `\n Nothing is recorded about how this app's login state is made. Once the user tells you, write it down with scout_note { section: "setup" } so the next run is told instead of asking.`)
623
784
  : "";
624
785
  return (`Attached to ${this.page.url()} (mode=${this.mode}` +
625
786
  `${this.engineName === "chromium" ? "" : `, browser=${this.engineName}, service workers blocked because their requests cannot be intercepted here`}` +
626
787
  `${focusAdvanceKey(this.engineName, process.platform) === "Tab" ? "" : `, keyboard: Tab stops only at text fields in this browser — press Alt+Tab to reach buttons and links`}` +
627
788
  `${opts.storageStatePath ? `, auth=${opts.storageStatePath}` : ""}). ` +
628
789
  `Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
790
+ (this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
629
791
  `${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
630
792
  `${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""} Call scout_snapshot to see the current state.` +
631
793
  authWarning);
@@ -651,11 +813,80 @@ export class BrowserEngine {
651
813
  async settle() {
652
814
  const page = this.requirePage();
653
815
  await page.waitForLoadState("domcontentloaded").catch(() => { });
654
- // A fixed floor, deliberately: waitForLoadState("networkidle") is latched
655
- // once reached and resolves instantly forever after, so it cannot be used
656
- // to wait out in-flight requests here — and draining oracles before a
657
- // just-fired request lands would misattribute its violations.
658
- await page.waitForTimeout(SETTLE_MS);
816
+ // Wait on what is actually in flight rather than a flat sleep. The rule is
817
+ // in settle.ts so it can be table-tested; the reasoning for not using
818
+ // networkidle is there too.
819
+ const started = Date.now();
820
+ for (;;) {
821
+ const state = {
822
+ inFlight: this.inFlight,
823
+ sinceLastStartMs: Date.now() - this.lastRequestStart,
824
+ elapsedMs: Date.now() - started,
825
+ paceMs: this.paceMs,
826
+ };
827
+ if (!shouldKeepWaiting(state))
828
+ return;
829
+ await page.waitForTimeout(SETTLE_TICK_MS).catch(() => { });
830
+ if (this.page !== page)
831
+ return;
832
+ }
833
+ }
834
+ /**
835
+ * The URL once it has stopped changing.
836
+ *
837
+ * Only called where a bounce verdict is about to be made. A client-side auth
838
+ * guard redirects on a timer and issues no request until it does, so the
839
+ * request-based settle has nothing to wait on and the page is read while it
840
+ * is still, briefly, the page it was asked for.
841
+ */
842
+ async stableUrl() {
843
+ const page = this.page;
844
+ if (!page || page.isClosed())
845
+ return page ? page.url() : "";
846
+ const started = Date.now();
847
+ let url = page.url();
848
+ let changedAt = started;
849
+ for (;;) {
850
+ if (!keepWatchingUrl({ sinceChangeMs: Date.now() - changedAt, elapsedMs: Date.now() - started }))
851
+ return url;
852
+ await page.waitForTimeout(SETTLE_TICK_MS).catch(() => { });
853
+ if (this.page !== page || page.isClosed())
854
+ return url;
855
+ const now = page.url();
856
+ if (now !== url) {
857
+ url = now;
858
+ changedAt = Date.now();
859
+ }
860
+ }
861
+ }
862
+ /**
863
+ * Where a navigation landed. Watched for a late client-side guard only when
864
+ * this session has credentials to lose. The page is passed in rather than
865
+ * read off the engine so a session torn down mid-navigation still reports
866
+ * the last URL it was on, which is what the outcome is recorded against.
867
+ */
868
+ async landedUrl(page) {
869
+ return this.watchesForBounce ? await this.stableUrl() : page.url();
870
+ }
871
+ /** Set at attach: this session was given credentials, so a bounce to a login page is a verdict worth waiting for. */
872
+ watchesForBounce = false;
873
+ /** Requests started and not yet finished or failed, from the context's own events. */
874
+ inFlight = 0;
875
+ /** When the most recent request started, so a page that fires one late is not read too early. */
876
+ lastRequestStart = 0;
877
+ /**
878
+ * A floor between actions this session was asked for, so a person watching
879
+ * can follow along. Zero means as fast as the page allows, which is the
880
+ * default and what every run wants unless somebody is reading it live.
881
+ */
882
+ paceMs = 0;
883
+ /** Set the deliberate pace. Returns what it became, clamped. */
884
+ setPace(paceMs) {
885
+ this.paceMs = normalizePace(paceMs);
886
+ return this.paceMs;
887
+ }
888
+ get pace() {
889
+ return this.paceMs;
659
890
  }
660
891
  /** Collect the current page's interactables into SnapshotElements with stable refs. */
661
892
  async collect() {
@@ -788,8 +1019,13 @@ export class BrowserEngine {
788
1019
  this.currentFingerprint = fp;
789
1020
  const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
790
1021
  memory.recordRoleAccess(this.role, route, "reached");
791
- this.logAction({ action: "snapshot", url, result: fp });
1022
+ // A snapshot is what an agent takes when it wants to LOOK at something, so
1023
+ // it is the frame a reader most wants beside the step. Recording only the
1024
+ // actions that go through afterAction kept 9 frames out of 67 in a real
1025
+ // run, and none of them from the routes a crawl had just swept.
1026
+ this.logAction({ action: "snapshot", url, result: fp, ...(await this.frameFor("snapshot")) });
792
1027
  await this.scanForInjections();
1028
+ await this.scanForContradictions();
793
1029
  const line = (el) => {
794
1030
  const dup = el.key.match(/~(\d+)$/);
795
1031
  const flags = [
@@ -949,6 +1185,7 @@ export class BrowserEngine {
949
1185
  const frame = await this.recordFrame(action);
950
1186
  this.logAction({ action, target, url, ...(frame ? { frame } : {}) });
951
1187
  await this.scanForInjections();
1188
+ await this.scanForContradictions();
952
1189
  const violations = this.oracles.drain();
953
1190
  const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
954
1191
  const navigated = this.snapshotUrl !== "" && url !== this.snapshotUrl;
@@ -1639,14 +1876,14 @@ export class BrowserEngine {
1639
1876
  settled = await this.afterAction("navigate", url);
1640
1877
  }
1641
1878
  catch (err) {
1642
- this.recordNavigationOutcome(url, page.url());
1879
+ this.recordNavigationOutcome(url, await this.landedUrl(page));
1643
1880
  const notice = this.authLoss.take();
1644
1881
  if (!notice)
1645
1882
  throw err;
1646
1883
  const msg = err instanceof Error ? err.message : String(err);
1647
1884
  throw new Error(`${notice}${msg}`);
1648
1885
  }
1649
- this.recordNavigationOutcome(url, page.url());
1886
+ this.recordNavigationOutcome(url, await this.landedUrl(page));
1650
1887
  return this.authLoss.take() + settled;
1651
1888
  }
1652
1889
  /** Where did we ASK to go, where did we END UP, and does that count as coverage? */
@@ -1767,8 +2004,9 @@ export class BrowserEngine {
1767
2004
  // an attempt with the status satisfies the contract.
1768
2005
  if (typeof status === "number" && status >= 400)
1769
2006
  memory.markAttempted(requestedRoute, `status:${status}`, this.role);
1770
- this.logAction({ action: "crawl", target: path, url: finalUrl });
2007
+ this.logAction({ action: "crawl", target: path, url: finalUrl, ...(await this.frameFor("crawl")) });
1771
2008
  await this.scanForInjections();
2009
+ await this.scanForContradictions();
1772
2010
  const violations = this.oracles.drain();
1773
2011
  const deadEnd = elements.length === 0;
1774
2012
  const unnamed = elements.filter((el) => !el.name).length;
@@ -1940,7 +2178,11 @@ export class BrowserEngine {
1940
2178
  }
1941
2179
  }
1942
2180
  await this.settle();
1943
- this.logAction({ action: `plan:${step.action}`, target: step.target ?? step.value, url: page.url() });
2181
+ // A plan is the RECOMMENDED way to run a mechanical sequence, so its
2182
+ // steps are where most of a recorded run actually happens. Leaving
2183
+ // them unframed reproduced, inside run_plan, the same hole that crawl
2184
+ // had: a form filled and submitted with no picture of any of it.
2185
+ this.logAction({ action: `plan:${step.action}`, target: step.target ?? step.value, url: page.url(), ...(await this.frameFor(`plan-${step.action}`)) });
1944
2186
  // Plans must feed coverage like ref-based actions do: record the
1945
2187
  // state and mark the acted-on element class as exercised.
1946
2188
  // Hover is deliberately excluded: a hover is a look, not an
@@ -1985,6 +2227,7 @@ export class BrowserEngine {
1985
2227
  }
1986
2228
  }
1987
2229
  await this.scanForInjections();
2230
+ await this.scanForContradictions();
1988
2231
  const violations = this.oracles.drain();
1989
2232
  const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
1990
2233
  // Abort only on NEW violations: a known-failing endpoint repeating on
@@ -2048,7 +2291,7 @@ export class BrowserEngine {
2048
2291
  this.memory?.setPageScore(route, { ...score, at: new Date().toISOString(), url: page.url() });
2049
2292
  this.memory?.markRouteFact(route, { audited: true });
2050
2293
  }
2051
- this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined });
2294
+ this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined, ...(await this.frameFor("design-audit")) });
2052
2295
  return `URL: ${page.url()}\n` + report;
2053
2296
  }
2054
2297
  /**
@@ -0,0 +1,194 @@
1
+ /**
2
+ * What the page says about itself, checked against what actually happened.
3
+ *
4
+ * Two of the most expensive bugs a web app ships are invisible to every oracle
5
+ * that watches only one side of the wire:
6
+ *
7
+ * - A list request is refused (401, 403, 500) and the page renders its empty
8
+ * state. The user is told they have nothing, when the truth is that nothing
9
+ * could be loaded. Nobody files a bug, because the screen looks fine — it
10
+ * is the single most common way a permission regression reaches production
11
+ * without anyone noticing.
12
+ * - A save is refused and the page says "Saved". The user walks away
13
+ * believing their work is stored.
14
+ *
15
+ * Neither is a crash, so `console_error` and `http_error` miss the harm: the
16
+ * HTTP oracle sees the 403 and reports it as a medium, indistinguishable from
17
+ * the dozens of expected 401s an auth probe produces. The defect is not the
18
+ * refusal. It is the page CONTRADICTING the refusal.
19
+ *
20
+ * The rules pair a precise half with a fuzzy one. The request half is exact —
21
+ * a status code either is an error or is not. The page half only has to be
22
+ * roughly right, because it is never enough on its own: no contradiction is
23
+ * reported unless a request was genuinely refused during the same action. That
24
+ * asymmetry is what keeps the false-positive rate low enough to be worth
25
+ * reporting at high severity.
26
+ *
27
+ * Everything here is pure so it can be table-tested; the DOM read lives in
28
+ * browser.ts.
29
+ */
30
+ import { VISIBLE_SRC } from "./collector.js";
31
+ /**
32
+ * Methods whose refusal a success message would be lying about. A refused GET
33
+ * is the empty-state rule's business; a refused POST is this one's.
34
+ */
35
+ const WRITING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
36
+ /**
37
+ * Resource types worth correlating. A failed image, font or analytics beacon
38
+ * says nothing about whether the list on screen is real, and correlating them
39
+ * would fire on every page with a broken logo.
40
+ */
41
+ const DATA_RESOURCES = new Set(["xhr", "fetch", "document"]);
42
+ /** Whether this request is one whose refusal the page should be admitting to. */
43
+ export function isRefused(req) {
44
+ if (req.blockedByPolicy)
45
+ return false;
46
+ if (!DATA_RESOURCES.has(req.resourceType))
47
+ return false;
48
+ if (req.status === null)
49
+ return true;
50
+ return req.status >= 400;
51
+ }
52
+ /**
53
+ * Phrases that assert there is nothing to show.
54
+ *
55
+ * Deliberately narrow: these are the stock empty-state sentences, not any
56
+ * sentence containing "no". "No results", "Nothing to show", "You have no
57
+ * orders" match; "No changes were saved since yesterday" does not, because the
58
+ * assertion has to be about the absence of the things themselves.
59
+ */
60
+ const EMPTY_RE = /\b(?:no (?:results?|items?|records?|rows?|data|entries|matches)\b|nothing (?:to (?:show|display)|here|found)\b|(?:there are|you have|we found) no\b|(?:0|zero) (?:results?|items?|records?|rows?|matches)\b|no [a-z]{3,20}s (?:found|yet|to show)\b|empty\b)/i;
61
+ /**
62
+ * Phrases that assert something worked. A page that says one of these while
63
+ * the write that produced it was refused is telling the user a falsehood.
64
+ */
65
+ const SUCCESS_RE = /\b(?:success(?:fully)?|saved|created|updated|deleted|removed|submitted|sent|published|approved|completed|added|changes? saved|done)\b/i;
66
+ /**
67
+ * Phrases that admit something went wrong. Their presence is what makes a page
68
+ * INNOCENT: an app that refuses a request and says so has behaved correctly,
69
+ * whatever else is on the screen, and must not be reported.
70
+ */
71
+ const ERROR_RE = /\b(?:error|failed|failure|could ?n[o']?t|unable to|went wrong|try again|retry|denied|forbidden|unauthori[sz]ed|not allowed|no permission|timed out|unavailable|problem loading)\b/i;
72
+ /** Longest piece of text judged. An empty state is a sentence; a paragraph that happens to contain one of these words is not a claim. */
73
+ export const CLAIM_TEXT_MAX = 120;
74
+ /**
75
+ * What one piece of visible page text asserts, or nothing when it asserts none
76
+ * of these. An admission of error outranks the others: a banner reading
77
+ * "Couldn't load orders — no results to show" is the app being honest.
78
+ */
79
+ export function classify(text) {
80
+ const trimmed = text.trim();
81
+ if (!trimmed || trimmed.length > CLAIM_TEXT_MAX)
82
+ return null;
83
+ if (ERROR_RE.test(trimmed))
84
+ return "error";
85
+ if (SUCCESS_RE.test(trimmed))
86
+ return "success";
87
+ if (EMPTY_RE.test(trimmed))
88
+ return "empty";
89
+ return null;
90
+ }
91
+ function shortUrl(url) {
92
+ try {
93
+ const u = new URL(url);
94
+ return u.pathname + u.search;
95
+ }
96
+ catch {
97
+ return url.slice(0, 120);
98
+ }
99
+ }
100
+ function say(req) {
101
+ return `${req.method} ${shortUrl(req.url)} ${req.status === null ? "failed" : req.status}`;
102
+ }
103
+ /**
104
+ * The contradictions this action produced, if any.
105
+ *
106
+ * Both rules are silent whenever the page admits the failure, and both require
107
+ * a genuinely refused request — the page half never fires alone.
108
+ */
109
+ export function findContradictions(requests, page) {
110
+ const refused = requests.filter(isRefused);
111
+ if (refused.length === 0)
112
+ return [];
113
+ const claims = page.texts.map(classify);
114
+ // An app that says what went wrong has behaved correctly, and nothing below
115
+ // applies. This is checked before anything else so that a page carrying both
116
+ // an error banner and a stale empty state is not reported.
117
+ if (claims.includes("error"))
118
+ return [];
119
+ const out = [];
120
+ const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
121
+ const saysEmpty = claims.includes("empty") || page.emptyLists > 0;
122
+ if (reads.length > 0 && saysEmpty) {
123
+ const worst = reads[0];
124
+ const how = claims.includes("empty") ? "an empty state" : `an empty list (${page.emptyLists})`;
125
+ out.push({
126
+ kind: "refused_empty",
127
+ detail: `${say(worst)} was refused, and the page shows ${how} with no error. ` +
128
+ `The user is told there is nothing to see when the truth is that nothing could be loaded.`,
129
+ evidence: `refused-empty ${say(worst)}`,
130
+ });
131
+ }
132
+ const writes = refused.filter((r) => WRITING_METHODS.has(r.method.toUpperCase()));
133
+ if (writes.length > 0 && claims.includes("success")) {
134
+ const worst = writes[0];
135
+ const message = page.texts[claims.indexOf("success")];
136
+ out.push({
137
+ kind: "false_success",
138
+ detail: `${say(worst)} was refused, and the page says ${JSON.stringify(message.trim().slice(0, 80))}. The user is told their change was kept when the server rejected it.`,
139
+ evidence: `false-success ${say(worst)}`,
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+ /** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
145
+ export const MAX_CLAIM_TEXTS = 120;
146
+ /**
147
+ * Page-side reader for the two signals `findContradictions` needs. Shipped as
148
+ * a STRING expression for the same reason the collector is: loader transforms
149
+ * inject a `__name` helper that does not exist in the browser, and a
150
+ * serialized function carrying a call to it fails there.
151
+ *
152
+ * Text is taken as each element's OWN text nodes, not its subtree, so a
153
+ * sentence is read once rather than again for every ancestor that contains it.
154
+ *
155
+ * The empty-list count is structural on purpose: most apps write no
156
+ * empty-state sentence at all, and the ones that do write it in the user's
157
+ * language. A container that renders its header and no rows says the same
158
+ * thing in every language and in every design system.
159
+ */
160
+ export const CLAIM_SCAN_SCRIPT = `(() => {
161
+ const visible = ${VISIBLE_SRC};
162
+ const texts = [];
163
+ const seen = new Set();
164
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
165
+ let el = document.body;
166
+ while (el && texts.length < ${MAX_CLAIM_TEXTS}) {
167
+ let own = "";
168
+ for (const node of el.childNodes) if (node.nodeType === 3) own += node.nodeValue;
169
+ own = own.replace(/\\s+/g, " ").trim();
170
+ if (own && own.length <= ${CLAIM_TEXT_MAX} && !seen.has(own) && visible(el)) {
171
+ seen.add(own);
172
+ texts.push(own);
173
+ }
174
+ el = walker.nextNode();
175
+ }
176
+
177
+ // A list or table that renders its container but no rows. Hidden ones are
178
+ // templates and dropdown shells, not empty states.
179
+ let emptyLists = 0;
180
+ for (const list of document.querySelectorAll("table, ul, ol, [role='table'], [role='grid'], [role='list']")) {
181
+ if (!visible(list)) continue;
182
+ const tag = list.tagName.toLowerCase();
183
+ if (tag === "table") {
184
+ const body = list.tBodies[0];
185
+ // No header means it is a layout table, not a record list.
186
+ if (!list.querySelector("th, thead")) continue;
187
+ if (body && body.rows.length === 0) emptyLists += 1;
188
+ continue;
189
+ }
190
+ const rows = list.querySelectorAll(":scope > li, :scope > [role='row'], :scope > [role='listitem']");
191
+ if (rows.length === 0) emptyLists += 1;
192
+ }
193
+ return { texts, emptyLists };
194
+ })()`;