scenescout 3.2.0 → 3.4.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 +91 -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/calibration.js +217 -0
- package/dist/engine/claims.js +194 -0
- package/dist/engine/lane.js +20 -6
- package/dist/engine/live-page.js +101 -2
- package/dist/engine/live.js +60 -0
- package/dist/engine/memory.js +188 -5
- package/dist/engine/oracles.js +9 -0
- package/dist/engine/pace.js +112 -0
- package/dist/engine/report.js +74 -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 +173 -15
- package/dist/scan.js +47 -1
- package/package.json +10 -3
- package/skills/scenescout/SKILL.md +10 -2
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,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a lane's confidence means anything.
|
|
3
|
+
*
|
|
4
|
+
* `lane.ts` tells every lane its confidence must be calibrated — "0.5 means a
|
|
5
|
+
* coin flip, 0.95 means you would bet on it" — and then the number was
|
|
6
|
+
* averaged into one line and thrown away. Nothing was stored, so nothing could
|
|
7
|
+
* ever be checked, and a confidence nobody checks is decoration. Decision-only
|
|
8
|
+
* models report an expected calibration error for exactly this reason; asking
|
|
9
|
+
* for calibration without measuring it is the half of the idea that costs
|
|
10
|
+
* nothing and buys nothing.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THIS MEASURES, precisely, because the number is easy to over-read: a
|
|
13
|
+
* lane says "defect" about an observation and attaches a machine signature.
|
|
14
|
+
* The run either went on to file a finding carrying that signature or it did
|
|
15
|
+
* not. That is agreement between the lane and the bar the run actually
|
|
16
|
+
* applied — NOT ground truth about the app. A lane can be perfectly calibrated
|
|
17
|
+
* against a planner that files the wrong things. Where a finding has since
|
|
18
|
+
* been re-tested through `scout_verify`, the verdict is reported beside it,
|
|
19
|
+
* and that IS evidence about the app.
|
|
20
|
+
*
|
|
21
|
+
* Pure, so every rule here is table-tested.
|
|
22
|
+
*/
|
|
23
|
+
import { failingSignatures } from "./memory.js";
|
|
24
|
+
/**
|
|
25
|
+
* Upper edge of each confidence bucket. Five is enough to see a shape and few
|
|
26
|
+
* enough that each holds a usable count on a run of a few dozen decisions;
|
|
27
|
+
* twenty buckets of one decision each measure nothing.
|
|
28
|
+
*/
|
|
29
|
+
export const BUCKET_EDGES = [0.2, 0.4, 0.6, 0.8, 1.0];
|
|
30
|
+
/**
|
|
31
|
+
* Which bucket a confidence falls in. The top bucket is closed so 1.0 has
|
|
32
|
+
* somewhere to go.
|
|
33
|
+
*
|
|
34
|
+
* A value that is not a number lands in the LOWEST bucket, not the highest.
|
|
35
|
+
* The schema refuses those, but stored history is read back without one, and
|
|
36
|
+
* falling through the comparisons put a NaN in the 0.8–1.0 bucket — a
|
|
37
|
+
* confidence nobody stated, reported as near-certainty.
|
|
38
|
+
*/
|
|
39
|
+
export function bucketOf(confidence) {
|
|
40
|
+
if (!Number.isFinite(confidence))
|
|
41
|
+
return 0;
|
|
42
|
+
const c = Math.min(1, Math.max(0, confidence));
|
|
43
|
+
for (let i = 0; i < BUCKET_EDGES.length; i += 1) {
|
|
44
|
+
if (c <= BUCKET_EDGES[i])
|
|
45
|
+
return i;
|
|
46
|
+
}
|
|
47
|
+
return BUCKET_EDGES.length - 1;
|
|
48
|
+
}
|
|
49
|
+
export function bucketLabel(i) {
|
|
50
|
+
const low = i === 0 ? 0 : BUCKET_EDGES[i - 1];
|
|
51
|
+
return `${low.toFixed(1)}–${BUCKET_EDGES[i].toFixed(1)}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The keys a piece of evidence can be joined on, or nothing when it cannot be
|
|
55
|
+
* joined at all.
|
|
56
|
+
*
|
|
57
|
+
* Findings MERGE: the store treats `GET /api/things/3 500` and
|
|
58
|
+
* `GET /api/things/7 500` as one bug, because the path normalises to
|
|
59
|
+
* `/api/things/:id`. Joining on the literal string reported five lane
|
|
60
|
+
* decisions about one endpoint as one filed and four dropped — the lanes
|
|
61
|
+
* understated fivefold, and the number read as a finding about them rather
|
|
62
|
+
* than a bug in this join. So the join uses the store's own rule.
|
|
63
|
+
*
|
|
64
|
+
* Evidence carrying no failure signature returns NOTHING rather than falling
|
|
65
|
+
* back to its literal text. That fallback looked harmless and was the single
|
|
66
|
+
* worst thing here: `500 on GET /api/r0` — ordinary English, and whichever
|
|
67
|
+
* agent wrote the finding chose the word order — produced a key that matched
|
|
68
|
+
* nothing, and a lane that was right about a bug that WAS filed published an
|
|
69
|
+
* expected calibration error of 0.90. An unjoinable decision is now counted
|
|
70
|
+
* and disclosed as unjoinable, not scored as a lane being wrong.
|
|
71
|
+
*/
|
|
72
|
+
export function joinKeys(evidence) {
|
|
73
|
+
return failingSignatures(evidence);
|
|
74
|
+
}
|
|
75
|
+
/** A confidence as stored may be anything; the schema guards the wire, not the file. */
|
|
76
|
+
function usableConfidence(value) {
|
|
77
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Join lane decisions to what the run filed.
|
|
81
|
+
*
|
|
82
|
+
* Only a "defect" carrying a signature has a checkable outcome. An "unsure" is
|
|
83
|
+
* a request to look closer rather than a claim, and a decision with no
|
|
84
|
+
* signature cannot be joined to anything — counting either would measure the
|
|
85
|
+
* lane's willingness to attach evidence, not its judgement.
|
|
86
|
+
*/
|
|
87
|
+
export function calibrate(decisions, findings) {
|
|
88
|
+
const filedKeys = new Map();
|
|
89
|
+
for (const f of findings) {
|
|
90
|
+
if (!f.evidence)
|
|
91
|
+
continue;
|
|
92
|
+
// A finding verified as still present is the most informative match, so it
|
|
93
|
+
// wins a key two findings share; otherwise first write wins and the result
|
|
94
|
+
// does not depend on the order findings came back in.
|
|
95
|
+
for (const key of joinKeys(f.evidence)) {
|
|
96
|
+
const prev = filedKeys.get(key);
|
|
97
|
+
if (!prev || (f.verdict && f.verifiedAt && !(prev.verdict && prev.verifiedAt)))
|
|
98
|
+
filedKeys.set(key, f);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const claims = decisions.filter((d) => d.verdict === "defect" && d.evidence !== null);
|
|
102
|
+
const checkable = claims.filter((d) => usableConfidence(d.confidence) !== null && joinKeys(d.evidence).size > 0);
|
|
103
|
+
const unjoinable = claims.length - checkable.length;
|
|
104
|
+
if (checkable.length === 0)
|
|
105
|
+
return unjoinable > 0 ? { checkable: 0, filed: 0, stated: 0, buckets: [], ece: 0, verified: { present: 0, gone: 0, changed: 0 }, unjoinable } : null;
|
|
106
|
+
const buckets = BUCKET_EDGES.map(() => ({ n: 0, conf: 0, hits: 0 }));
|
|
107
|
+
const verified = { present: 0, gone: 0, changed: 0 };
|
|
108
|
+
const matched = new Map();
|
|
109
|
+
let filed = 0;
|
|
110
|
+
let stated = 0;
|
|
111
|
+
for (const d of checkable) {
|
|
112
|
+
let match;
|
|
113
|
+
for (const key of joinKeys(d.evidence)) {
|
|
114
|
+
match = filedKeys.get(key);
|
|
115
|
+
if (match)
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
const hit = match !== undefined;
|
|
119
|
+
const confidence = usableConfidence(d.confidence);
|
|
120
|
+
const b = buckets[bucketOf(confidence)];
|
|
121
|
+
b.n += 1;
|
|
122
|
+
b.conf += confidence;
|
|
123
|
+
if (hit)
|
|
124
|
+
b.hits += 1;
|
|
125
|
+
if (hit)
|
|
126
|
+
filed += 1;
|
|
127
|
+
stated += confidence;
|
|
128
|
+
// By finding, not by decision: several decisions can match one finding,
|
|
129
|
+
// and this sentence counts findings.
|
|
130
|
+
if (match)
|
|
131
|
+
matched.set(match.id, match);
|
|
132
|
+
}
|
|
133
|
+
for (const f of matched.values()) {
|
|
134
|
+
// A verdict read from disk is not guaranteed to be one of the three.
|
|
135
|
+
if (f.verifiedAt && f.verdict && f.verdict in verified)
|
|
136
|
+
verified[f.verdict] += 1;
|
|
137
|
+
}
|
|
138
|
+
const n = checkable.length;
|
|
139
|
+
let ece = 0;
|
|
140
|
+
const out = [];
|
|
141
|
+
buckets.forEach((b, i) => {
|
|
142
|
+
if (b.n === 0)
|
|
143
|
+
return;
|
|
144
|
+
const meanConf = b.conf / b.n;
|
|
145
|
+
const rate = b.hits / b.n;
|
|
146
|
+
ece += (b.n / n) * Math.abs(meanConf - rate);
|
|
147
|
+
out.push({ label: bucketLabel(i), decisions: b.n, stated: meanConf, filed: rate });
|
|
148
|
+
});
|
|
149
|
+
return { checkable: n, filed, stated: stated / n, buckets: out, ece, verified, unjoinable };
|
|
150
|
+
}
|
|
151
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
152
|
+
/**
|
|
153
|
+
* The report section. It leads with what the number is not, because "ECE 0.24"
|
|
154
|
+
* above a table of percentages reads as a verdict on the app rather than on
|
|
155
|
+
* the lanes that judged it.
|
|
156
|
+
*/
|
|
157
|
+
export function formatCalibration(c) {
|
|
158
|
+
if (!c)
|
|
159
|
+
return [];
|
|
160
|
+
if (c.checkable < MIN_FOR_A_VERDICT) {
|
|
161
|
+
// Suppress the NUMBER, not the fact. A run that recorded seven decisions
|
|
162
|
+
// and a run that recorded none look identical when the section simply
|
|
163
|
+
// vanishes, and the reader concludes the feature is broken — which is this
|
|
164
|
+
// project's own definition of a silent path.
|
|
165
|
+
const had = c.checkable + c.unjoinable;
|
|
166
|
+
if (had === 0)
|
|
167
|
+
return [];
|
|
168
|
+
return [
|
|
169
|
+
`## How well the lanes judged`,
|
|
170
|
+
``,
|
|
171
|
+
`Not enough to say yet: ${c.checkable} lane decision(s) could be checked${c.unjoinable > 0 ? ` (and ${c.unjoinable} could not be looked up at all)` : ""}, ` +
|
|
172
|
+
`and ${MIN_FOR_A_VERDICT} are needed before a calibration figure survives one of them changing.`,
|
|
173
|
+
``,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
const lines = [
|
|
177
|
+
`## How well the lanes judged`,
|
|
178
|
+
``,
|
|
179
|
+
`${c.checkable} lane decision(s) called a defect on an endpoint that failed; ${c.filed} of them match a finding this project holds (${pct(c.filed / c.checkable)}), ` +
|
|
180
|
+
`against a mean stated confidence of ${c.stated.toFixed(2)}.`,
|
|
181
|
+
``,
|
|
182
|
+
`**What this is not.** It covers the project's whole history, not this run, and it measures agreement between the lanes and the bar this project applies — not whether the app is broken. ` +
|
|
183
|
+
`A lane can be perfectly calibrated against a planner that files the wrong things. Two things also count against a lane through no fault of its own: a defect filed with no machine signature, ` +
|
|
184
|
+
`and a finding whose signature was dropped when the store merged it into another. Read the figure as an ordering, not a measurement.`,
|
|
185
|
+
``,
|
|
186
|
+
`| Stated confidence | Decisions | Said | Filed |`,
|
|
187
|
+
`|---|---:|---:|---:|`,
|
|
188
|
+
];
|
|
189
|
+
for (const b of c.buckets) {
|
|
190
|
+
lines.push(`| ${b.label} | ${b.decisions} | ${b.stated.toFixed(2)} | ${pct(b.filed)} |`);
|
|
191
|
+
}
|
|
192
|
+
lines.push(``, `Expected calibration error **${c.ece.toFixed(2)}** — the gap between what the lanes said and what happened, weighted by bucket. ${readEce(c.ece)}`);
|
|
193
|
+
if (c.unjoinable > 0) {
|
|
194
|
+
lines.push(``, `${c.unjoinable} further decision(s) called a defect but named no failing endpoint, so nothing could be looked up for them. They are excluded above rather than counted as wrong.`);
|
|
195
|
+
}
|
|
196
|
+
const seen = c.verified.present + c.verified.gone + c.verified.changed;
|
|
197
|
+
if (seen > 0) {
|
|
198
|
+
lines.push(``, `${seen} of the findings those decisions matched ${seen === 1 ? "has" : "have"} since been re-tested with \`scout_verify\`: ${c.verified.present} still present, ${c.verified.gone} gone, ${c.verified.changed} changed. ` +
|
|
199
|
+
`Those verdicts are evidence about the app, unlike the table above. Findings no lane decision matched are not counted here, so this will not agree with the re-tested column elsewhere in the report.`);
|
|
200
|
+
}
|
|
201
|
+
lines.push(``);
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
/** Below this, the number swings on a single decision and is worse than no number. */
|
|
205
|
+
export const MIN_FOR_A_VERDICT = 8;
|
|
206
|
+
/**
|
|
207
|
+
* What the figure suggests, hedged on purpose. The join loses decisions it
|
|
208
|
+
* cannot key and findings whose signature a merge discarded, and both push the
|
|
209
|
+
* error upward — so a confident "these numbers are wrong" can itself be wrong.
|
|
210
|
+
*/
|
|
211
|
+
function readEce(ece) {
|
|
212
|
+
if (ece <= 0.05)
|
|
213
|
+
return "The lanes' numbers track what happened closely.";
|
|
214
|
+
if (ece <= 0.15)
|
|
215
|
+
return "Close enough to compare lanes by.";
|
|
216
|
+
return "Far enough apart that a lane's confidence is worth reading as an ordering rather than a rate — after checking the exclusions above, which push this upward.";
|
|
217
|
+
}
|