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.
- package/CHANGELOG.md +79 -0
- package/README.md +3 -2
- package/dist/cli.js +39 -23
- package/dist/engine/brief.js +134 -0
- package/dist/engine/browser.js +262 -19
- package/dist/engine/claims.js +194 -0
- package/dist/engine/live-page.js +101 -2
- package/dist/engine/live.js +60 -0
- package/dist/engine/memory.js +100 -1
- package/dist/engine/oracles.js +9 -0
- package/dist/engine/pace.js +112 -0
- package/dist/engine/report.js +70 -6
- package/dist/engine/request.js +144 -0
- package/dist/engine/settle.js +97 -0
- package/dist/engine/verify.js +142 -0
- package/dist/mcp-server.js +144 -11
- package/dist/scan.js +47 -1
- package/package.json +9 -3
- package/skills/scenescout/SKILL.md +9 -1
package/dist/engine/browser.js
CHANGED
|
@@ -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
|
-
/**
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
+
})()`;
|