scenescout 3.14.1 → 3.15.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 +19 -0
- package/README.md +26 -3
- package/dist/check-run.js +22 -11
- package/dist/ci-run.js +113 -17
- package/dist/cli.js +119 -6
- package/dist/commands.js +39 -2
- package/dist/engine/browser.js +546 -133
- package/dist/engine/check.js +77 -30
- package/dist/engine/ci.js +97 -0
- package/dist/engine/claims.js +81 -15
- package/dist/engine/collector.js +307 -65
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +81 -12
- package/dist/engine/forms.js +17 -0
- package/dist/engine/memory.js +180 -36
- package/dist/engine/oracles.js +126 -1
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +270 -22
- package/dist/engine/report.js +8 -1
- package/dist/engine/scripted-login.js +342 -62
- package/dist/first-run.js +577 -0
- package/dist/login-run.js +136 -35
- package/dist/mcp-server.js +75 -16
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +3 -3
package/dist/engine/memory.js
CHANGED
|
@@ -26,6 +26,26 @@ export const FINDING_CATEGORIES = [
|
|
|
26
26
|
"missing-testid",
|
|
27
27
|
"other",
|
|
28
28
|
];
|
|
29
|
+
/** Most judged merges one finding keeps; the oldest go first. */
|
|
30
|
+
export const MAX_JUDGED_MERGES = 10;
|
|
31
|
+
/**
|
|
32
|
+
* A finding's judged merges that are well formed. The file is read back
|
|
33
|
+
* without a schema, and the report prints each of these, so a hand-edited
|
|
34
|
+
* entry that is not one is dropped, not trusted.
|
|
35
|
+
*/
|
|
36
|
+
export function judgedMergesOf(f) {
|
|
37
|
+
if (!Array.isArray(f.judgedMerges))
|
|
38
|
+
return [];
|
|
39
|
+
return f.judgedMerges.filter((m) => !!m &&
|
|
40
|
+
typeof m === "object" &&
|
|
41
|
+
typeof m.title === "string" &&
|
|
42
|
+
typeof m.category === "string" &&
|
|
43
|
+
typeof m.at === "string" &&
|
|
44
|
+
["high", "medium", "low"].includes(m.severity) &&
|
|
45
|
+
typeof m.pSame === "number" &&
|
|
46
|
+
Number.isFinite(m.pSame) &&
|
|
47
|
+
(m.evidence === undefined || typeof m.evidence === "string"));
|
|
48
|
+
}
|
|
29
49
|
/**
|
|
30
50
|
* Whether a finding is in the "worth a look" tier. Anything else a file holds
|
|
31
51
|
* reads as a defect: a finding is never taken out of the defect count on a
|
|
@@ -217,6 +237,8 @@ export function mergeMemory(mine, theirs) {
|
|
|
217
237
|
// Keep whichever action was actually recorded; ours wins a tie.
|
|
218
238
|
...((el.lastAction ?? prev?.lastAction) ? { lastAction: el.lastAction ?? prev?.lastAction } : {}),
|
|
219
239
|
absentStreak: Math.min(prev?.absentStreak ?? 0, el.absentStreak ?? 0),
|
|
240
|
+
// Ours is the latest reading of whether a user can act on it.
|
|
241
|
+
...(el.inert ? { inert: true } : {}),
|
|
220
242
|
};
|
|
221
243
|
}
|
|
222
244
|
out.states[fp] = {
|
|
@@ -251,6 +273,12 @@ export function mergeMemory(mine, theirs) {
|
|
|
251
273
|
Object.assign(withoutTier(merged), tier);
|
|
252
274
|
if (!isWorthALook(merged) && isWorthALook(newer))
|
|
253
275
|
merged.severity = older.severity;
|
|
276
|
+
// Each side's judged merges are a record of filings, not later knowledge: kept from both.
|
|
277
|
+
const trail = unionJudgedMerges(f, other);
|
|
278
|
+
if (trail)
|
|
279
|
+
merged.judgedMerges = trail;
|
|
280
|
+
else
|
|
281
|
+
delete merged.judgedMerges;
|
|
254
282
|
byId.set(f.id, merged);
|
|
255
283
|
}
|
|
256
284
|
out.findings = [...byId.values()];
|
|
@@ -307,6 +335,16 @@ export function mergeMemory(mine, theirs) {
|
|
|
307
335
|
delete out.laneRoutes;
|
|
308
336
|
return out;
|
|
309
337
|
}
|
|
338
|
+
/** Two findings' judged merges as one list, each filing once, oldest first, at most MAX_JUDGED_MERGES. Idempotent. */
|
|
339
|
+
function unionJudgedMerges(a, b) {
|
|
340
|
+
const all = [...judgedMergesOf(b), ...judgedMergesOf(a)];
|
|
341
|
+
if (all.length === 0)
|
|
342
|
+
return undefined;
|
|
343
|
+
const byKey = new Map();
|
|
344
|
+
for (const m of all)
|
|
345
|
+
byKey.set(`${m.at}\u0000${m.title}`, m);
|
|
346
|
+
return [...byKey.values()].sort((x, y) => x.at.localeCompare(y.at)).slice(-MAX_JUDGED_MERGES);
|
|
347
|
+
}
|
|
310
348
|
/** Most routes kept per lane. A lane report carries at most a few hundred; this keeps a long-lived project's file small. */
|
|
311
349
|
export const MAX_LANE_ROUTES = 255;
|
|
312
350
|
/**
|
|
@@ -374,6 +412,10 @@ function findingJaccard(a, b) {
|
|
|
374
412
|
const union = new Set([...a, ...b]).size;
|
|
375
413
|
return union === 0 ? 0 : inter / union;
|
|
376
414
|
}
|
|
415
|
+
/** How alike two titles are, 0 to 1, by the token overlap the rule's last tier uses. Orders the dedup judge's candidates. */
|
|
416
|
+
export function titleSimilarity(a, b) {
|
|
417
|
+
return findingJaccard(findingTokens(a), findingTokens(b));
|
|
418
|
+
}
|
|
377
419
|
/**
|
|
378
420
|
* Distinctive literals — quoted or parenthesized fragments like
|
|
379
421
|
* `"(role not recorded)"` or `"Document not found"`. Two findings on the same
|
|
@@ -809,7 +851,20 @@ export class MemoryStore {
|
|
|
809
851
|
this.auditsThisRun = 0;
|
|
810
852
|
this.selectChoices.clear();
|
|
811
853
|
this.emptySubmits.clear();
|
|
854
|
+
this.dedupJudge = null;
|
|
855
|
+
this.dedupChoice = undefined;
|
|
856
|
+
this.dedupOff = undefined;
|
|
812
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* The dedup judge filings go through (fileFinding), or null for the rule
|
|
860
|
+
* alone. Per run, like the probes: the server sets it when a session
|
|
861
|
+
* attaches, and a run that never asks for it dedups by the rule only.
|
|
862
|
+
*/
|
|
863
|
+
dedupJudge = null;
|
|
864
|
+
/** What an attach of this run asked for by name; unset means the server's environment decides. */
|
|
865
|
+
dedupChoice;
|
|
866
|
+
/** Why the judge this run asked for could not be set up (no key), for the report; unset when it was not asked for or is on. */
|
|
867
|
+
dedupOff;
|
|
813
868
|
/**
|
|
814
869
|
* Each dropdown's options and the ones chosen in THIS run, by any session,
|
|
815
870
|
* keyed by route and element. A select counts as exercised after one choice,
|
|
@@ -953,6 +1008,10 @@ export class MemoryStore {
|
|
|
953
1008
|
dupOf.runs += f.runs;
|
|
954
1009
|
if (!dupOf.evidence && f.evidence)
|
|
955
1010
|
dupOf.evidence = f.evidence;
|
|
1011
|
+
// Each copy's judged merges are filings: folding the copies keeps them all.
|
|
1012
|
+
const trail = unionJudgedMerges(dupOf, f);
|
|
1013
|
+
if (trail)
|
|
1014
|
+
dupOf.judgedMerges = trail;
|
|
956
1015
|
if (f.status === "resolved")
|
|
957
1016
|
dupOf.status = "resolved";
|
|
958
1017
|
const promoted = isWorthALook(dupOf) && !isWorthALook(f);
|
|
@@ -1435,8 +1494,12 @@ export class MemoryStore {
|
|
|
1435
1494
|
this.actionLog.push(full);
|
|
1436
1495
|
fs.appendFileSync(this.sessionLogPath, JSON.stringify(full) + "\n");
|
|
1437
1496
|
}
|
|
1438
|
-
/**
|
|
1439
|
-
|
|
1497
|
+
/**
|
|
1498
|
+
* Record a visit to a state; returns whether it was new. `inertKeys` are the
|
|
1499
|
+
* listed keys a user cannot act on (collector inertKeys): they stay known,
|
|
1500
|
+
* so a click on one still registers, but coverage does not count them.
|
|
1501
|
+
*/
|
|
1502
|
+
visitState(fingerprint, url, route, elementKeys, inertKeys = []) {
|
|
1440
1503
|
let rec = this.data.states[fingerprint];
|
|
1441
1504
|
const isNew = !rec;
|
|
1442
1505
|
if (!rec) {
|
|
@@ -1446,10 +1509,15 @@ export class MemoryStore {
|
|
|
1446
1509
|
rec.visits += 1;
|
|
1447
1510
|
rec.lastSeen = new Date().toISOString();
|
|
1448
1511
|
const present = new Set(elementKeys);
|
|
1512
|
+
const inert = new Set(inertKeys);
|
|
1449
1513
|
for (const key of elementKeys) {
|
|
1450
1514
|
if (!rec.elements[key])
|
|
1451
1515
|
rec.elements[key] = { exercised: false };
|
|
1452
1516
|
rec.elements[key].absentStreak = 0;
|
|
1517
|
+
if (inert.has(key))
|
|
1518
|
+
rec.elements[key].inert = true;
|
|
1519
|
+
else
|
|
1520
|
+
delete rec.elements[key].inert;
|
|
1453
1521
|
}
|
|
1454
1522
|
// Prune ghosts: dynamic elements (list rows, ordinal-suffixed duplicates)
|
|
1455
1523
|
// that vanish for 3 consecutive visits would otherwise make coverage
|
|
@@ -1492,9 +1560,65 @@ export class MemoryStore {
|
|
|
1492
1560
|
*/
|
|
1493
1561
|
/** Returns the finding, whether it is new, and whether an existing worth-a-look was just promoted to a defect by it. */
|
|
1494
1562
|
addFinding(input) {
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const
|
|
1563
|
+
const f = this.redacted(input);
|
|
1564
|
+
const id = findingId(f);
|
|
1565
|
+
const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
|
|
1566
|
+
if (existing)
|
|
1567
|
+
return [existing, false, this.mergeInto(existing, f, id)];
|
|
1568
|
+
return [this.append(f, id), true, false];
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* File a finding, asking the dedup judge when one is set (dedupJudge) and
|
|
1572
|
+
* the rule keeps the filing apart from everything stored. The rule decides
|
|
1573
|
+
* first, exactly as addFinding: a merge the rule makes is never put to the
|
|
1574
|
+
* model, so the judge can only add merges, never undo one, and the rule's
|
|
1575
|
+
* merge at the next load (retroMerge) cannot split what the judge joined.
|
|
1576
|
+
* Without a judge this is addFinding.
|
|
1577
|
+
*/
|
|
1578
|
+
async fileFinding(input) {
|
|
1579
|
+
const f = this.redacted(input);
|
|
1580
|
+
const id = findingId(f);
|
|
1581
|
+
const byRule = this.data.findings.find((x) => isDuplicateFinding(x, f));
|
|
1582
|
+
if (byRule)
|
|
1583
|
+
return { finding: byRule, isNew: false, promoted: this.mergeInto(byRule, f, id) };
|
|
1584
|
+
const judge = this.dedupJudge;
|
|
1585
|
+
if (!judge)
|
|
1586
|
+
return { finding: this.append(f, id), isNew: true, promoted: false };
|
|
1587
|
+
const before = new Set(this.data.findings.map((x) => x.id));
|
|
1588
|
+
let verdict = null;
|
|
1589
|
+
let judgeError;
|
|
1590
|
+
try {
|
|
1591
|
+
verdict = await judge.judge(f, [...this.data.findings]);
|
|
1592
|
+
}
|
|
1593
|
+
catch (err) {
|
|
1594
|
+
judgeError = err instanceof Error ? err.message : String(err);
|
|
1595
|
+
}
|
|
1596
|
+
const extra = judgeError ? { judgeError } : {};
|
|
1597
|
+
// The list may have changed while the judge was asked. A filing that
|
|
1598
|
+
// landed meanwhile is compared by the rule, as it would have been with no
|
|
1599
|
+
// judge; the finding the judge chose is looked up again by id, since a
|
|
1600
|
+
// merge with another process's memory may have replaced its object.
|
|
1601
|
+
const landed = this.data.findings.find((x) => !before.has(x.id) && isDuplicateFinding(x, f));
|
|
1602
|
+
if (landed)
|
|
1603
|
+
return { finding: landed, isNew: false, promoted: this.mergeInto(landed, f, id), ...extra };
|
|
1604
|
+
// Only what a judge may answer is taken: an open finding on the filing's page, called the same at better than even.
|
|
1605
|
+
const route = f.state.split("#")[0];
|
|
1606
|
+
const chosen = verdict && verdict.pSame >= 0.5
|
|
1607
|
+
? this.data.findings.find((x) => x.id === verdict.sameAs && x.status !== "resolved" && x.state.split("#")[0] === route)
|
|
1608
|
+
: undefined;
|
|
1609
|
+
if (chosen && verdict) {
|
|
1610
|
+
const judged = { pSame: verdict.pSame };
|
|
1611
|
+
return { finding: chosen, isNew: false, promoted: this.mergeInto(chosen, f, id, judged), judged, ...extra };
|
|
1612
|
+
}
|
|
1613
|
+
return { finding: this.append(f, id), isNew: true, promoted: false, ...extra };
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* The filing with secrets redacted. BEFORE the id is derived, so a re-found
|
|
1617
|
+
* finding whose quoted secret differs by a character still hashes to the
|
|
1618
|
+
* same id; and before the dedup judge is shown it.
|
|
1619
|
+
*/
|
|
1620
|
+
redacted(input) {
|
|
1621
|
+
return {
|
|
1498
1622
|
...input,
|
|
1499
1623
|
title: redactSecrets(input.title),
|
|
1500
1624
|
detail: redactSecrets(input.detail),
|
|
@@ -1503,37 +1627,54 @@ export class MemoryStore {
|
|
|
1503
1627
|
url: redactSecrets(input.url),
|
|
1504
1628
|
evidence: input.evidence ? redactSecrets(input.evidence) : input.evidence,
|
|
1505
1629
|
};
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Fold a filing into the finding it duplicates. Returns whether a
|
|
1633
|
+
* worth-a-look was promoted to a defect by it. `judged`: the dedup judge
|
|
1634
|
+
* made this merge, so the filing is kept on the finding (judgedMerges).
|
|
1635
|
+
*/
|
|
1636
|
+
mergeInto(existing, f, id, judged) {
|
|
1637
|
+
existing.runs += 1;
|
|
1638
|
+
existing.foundAt = new Date().toISOString();
|
|
1639
|
+
if (!existing.evidence && f.evidence)
|
|
1640
|
+
existing.evidence = f.evidence;
|
|
1641
|
+
// A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
|
|
1642
|
+
const promoted = isWorthALook(existing) && !isWorthALook(f);
|
|
1643
|
+
const tier = mergeTier(existing, f);
|
|
1644
|
+
Object.assign(withoutTier(existing), tier);
|
|
1645
|
+
if (promoted)
|
|
1646
|
+
existing.severity = f.severity;
|
|
1647
|
+
// Re-finding a RESOLVED finding is a regression — reopen it loudly
|
|
1648
|
+
// rather than letting it hide in the report's completed section. But a
|
|
1649
|
+
// FUZZY match must never resurrect a fixed bug: telling someone a
|
|
1650
|
+
// regression landed when it did not is far more costly than carrying a
|
|
1651
|
+
// visible duplicate, and it corrupts the one signal that says whether a
|
|
1652
|
+
// fix held. Demand an exact identity match (same category+title+route) or
|
|
1653
|
+
// an identical evidence signature before reopening.
|
|
1654
|
+
const exactMatch = existing.id === id ||
|
|
1655
|
+
(!!existing.evidence &&
|
|
1656
|
+
!!f.evidence &&
|
|
1657
|
+
existing.evidence.toLowerCase().replace(/\s+/g, " ").trim() === f.evidence.toLowerCase().replace(/\s+/g, " ").trim());
|
|
1658
|
+
if (existing.status === "resolved" && exactMatch) {
|
|
1659
|
+
existing.status = "open";
|
|
1660
|
+
existing.regressedAt = existing.foundAt;
|
|
1536
1661
|
}
|
|
1662
|
+
if (judged) {
|
|
1663
|
+
const entry = {
|
|
1664
|
+
title: f.title,
|
|
1665
|
+
category: f.category,
|
|
1666
|
+
severity: f.severity,
|
|
1667
|
+
...(f.evidence ? { evidence: f.evidence } : {}),
|
|
1668
|
+
pSame: judged.pSame,
|
|
1669
|
+
at: existing.foundAt,
|
|
1670
|
+
};
|
|
1671
|
+
existing.judgedMerges = [...(existing.judgedMerges ?? []), entry].slice(-MAX_JUDGED_MERGES);
|
|
1672
|
+
}
|
|
1673
|
+
this.flush();
|
|
1674
|
+
return promoted;
|
|
1675
|
+
}
|
|
1676
|
+
/** Store a filing as a new finding, with its repro trace. */
|
|
1677
|
+
append(f, id) {
|
|
1537
1678
|
// Repro trace scoped to the finding's route: everything since the action
|
|
1538
1679
|
// that landed there, not 12 lines of unrelated cross-module noise.
|
|
1539
1680
|
const routeOf = (url) => {
|
|
@@ -1569,7 +1710,7 @@ export class MemoryStore {
|
|
|
1569
1710
|
};
|
|
1570
1711
|
this.data.findings.push(finding);
|
|
1571
1712
|
this.flush();
|
|
1572
|
-
return
|
|
1713
|
+
return finding;
|
|
1573
1714
|
}
|
|
1574
1715
|
get findings() {
|
|
1575
1716
|
return this.data.findings;
|
|
@@ -1684,6 +1825,9 @@ export class MemoryStore {
|
|
|
1684
1825
|
byRoute.set(rec.route, route);
|
|
1685
1826
|
}
|
|
1686
1827
|
for (const [key, v] of Object.entries(rec.elements)) {
|
|
1828
|
+
// Not a control: nothing to exercise, so not counted as a gap or a total.
|
|
1829
|
+
if (v.inert)
|
|
1830
|
+
continue;
|
|
1687
1831
|
route.set(key, (route.get(key) ?? false) || v.exercised);
|
|
1688
1832
|
}
|
|
1689
1833
|
}
|
package/dist/engine/oracles.js
CHANGED
|
@@ -106,6 +106,78 @@ export class EmbedRequestLog {
|
|
|
106
106
|
return key === null ? null : (this.byKey.get(key) ?? null);
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
/** How long after a replay ends the browser's console echo of its failure is still taken for the replay's. */
|
|
110
|
+
export const REPLAY_ECHO_WINDOW_MS = 2000;
|
|
111
|
+
/**
|
|
112
|
+
* Addresses the tester is calling with scout_request, so the browser's console
|
|
113
|
+
* echo of a refused probe ("Failed to load resource: … 403") is known as the
|
|
114
|
+
* tester's and not charged to the page. The request itself is matched by
|
|
115
|
+
* identity in the monitor; the echo carries only an address, so it is matched
|
|
116
|
+
* by address while the replay is in flight and for a short window after, which
|
|
117
|
+
* is when the browser prints it. Bounded, dropping the oldest.
|
|
118
|
+
*/
|
|
119
|
+
export class ReplayLog {
|
|
120
|
+
cap;
|
|
121
|
+
/** Address → until when an echo of it is the replay's (Infinity while in flight). */
|
|
122
|
+
until = new Map();
|
|
123
|
+
constructor(cap = 200) {
|
|
124
|
+
this.cap = cap;
|
|
125
|
+
}
|
|
126
|
+
begin(url) {
|
|
127
|
+
const key = requestKey(url);
|
|
128
|
+
this.until.delete(key);
|
|
129
|
+
this.until.set(key, Infinity);
|
|
130
|
+
if (this.until.size > this.cap)
|
|
131
|
+
this.until.delete(this.until.keys().next().value);
|
|
132
|
+
}
|
|
133
|
+
end(url, now) {
|
|
134
|
+
const key = requestKey(url);
|
|
135
|
+
if (this.until.has(key))
|
|
136
|
+
this.until.set(key, now + REPLAY_ECHO_WINDOW_MS);
|
|
137
|
+
}
|
|
138
|
+
/** Whether a console message is the browser's echo of a replay's failed load. */
|
|
139
|
+
echoes(text, locationUrl, pageUrl, now) {
|
|
140
|
+
const key = failedLoadEchoOf(text, locationUrl, pageUrl);
|
|
141
|
+
if (key === null)
|
|
142
|
+
return false;
|
|
143
|
+
const until = this.until.get(key);
|
|
144
|
+
if (until === undefined)
|
|
145
|
+
return false;
|
|
146
|
+
if (now > until) {
|
|
147
|
+
this.until.delete(key);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Wording of a router cancelling a route change on purpose. Some client-side
|
|
155
|
+
* routers can only stop a navigation (to keep a dirty form, say) by throwing
|
|
156
|
+
* from their route-change event, and the apps that use them filter this
|
|
157
|
+
* sentinel out of their own error monitoring. Narrow on purpose: it must name
|
|
158
|
+
* the route and the cancelling.
|
|
159
|
+
*/
|
|
160
|
+
const ROUTE_CANCEL_RE = /\brout(?:e|ing)\b.{0,40}\b(?:abort|cancel)|\b(?:abort|cancel)\w*\b.{0,40}\brout(?:e|ing)\b/i;
|
|
161
|
+
/**
|
|
162
|
+
* Is this uncaught page error a router deliberately cancelling a route change,
|
|
163
|
+
* rather than a crash? Only for a click that left the URL where it was, and
|
|
164
|
+
* then only when the message says so, or when the click asked for a route
|
|
165
|
+
* change and the page put up a confirmation instead. Still reported, at
|
|
166
|
+
* medium with a note: the error is real and uncaught, but the user never sees
|
|
167
|
+
* it, and ranking it with crashes made every such page argue it away.
|
|
168
|
+
*/
|
|
169
|
+
export function isRouteCancellation(message, c) {
|
|
170
|
+
if (!c.byClick || c.urlChanged)
|
|
171
|
+
return false;
|
|
172
|
+
return ROUTE_CANCEL_RE.test(message) || (c.viaLink && c.dialogOpened);
|
|
173
|
+
}
|
|
174
|
+
/** Appended to a page error read as a route-change cancellation, saying which evidence it was read on. */
|
|
175
|
+
export function routeCancelNote(message) {
|
|
176
|
+
const why = ROUTE_CANCEL_RE.test(message)
|
|
177
|
+
? "the click left the URL unchanged and the error says the route change was cancelled"
|
|
178
|
+
: "the click on a link left the URL unchanged and the page opened a dialog instead";
|
|
179
|
+
return ` (likely a router cancelling the route change on purpose: ${why}; reported at medium, check what the page showed before filing)`;
|
|
180
|
+
}
|
|
109
181
|
/**
|
|
110
182
|
* Invariant oracles: passive listeners that record violations regardless of
|
|
111
183
|
* what the agent is doing. The engine drains the buffer after every action and
|
|
@@ -124,6 +196,11 @@ export class OracleMonitor {
|
|
|
124
196
|
// Benign noise: failed favicon / source map fetches show up as console errors.
|
|
125
197
|
if (/favicon|source map/i.test(text))
|
|
126
198
|
return;
|
|
199
|
+
// The browser's echo of the tester's own scout_request: its answer was in that tool's result.
|
|
200
|
+
if (this.replays.echoes(text, msg.location().url, page.url(), Date.now())) {
|
|
201
|
+
this.replayAttributed += 1;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
127
204
|
this.record({
|
|
128
205
|
kind: "console_error",
|
|
129
206
|
severity: "high",
|
|
@@ -150,6 +227,10 @@ export class OracleMonitor {
|
|
|
150
227
|
return;
|
|
151
228
|
if (BENIGN_URL_RE.test(req.url()))
|
|
152
229
|
return;
|
|
230
|
+
if (this.isReplay(req)) {
|
|
231
|
+
this.replayAttributed += 1;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
153
234
|
if (this.refusedByPolicy(req)) {
|
|
154
235
|
this.policyAttributed += 1;
|
|
155
236
|
return;
|
|
@@ -170,6 +251,12 @@ export class OracleMonitor {
|
|
|
170
251
|
// reported here on every page load teaches the driver to ignore http_error.
|
|
171
252
|
if (BENIGN_URL_RE.test(res.url()))
|
|
172
253
|
return;
|
|
254
|
+
// The tester's own scout_request: a probe of a boundary is meant to be
|
|
255
|
+
// refused, and its answer was in that tool's result, not the page's.
|
|
256
|
+
if (this.isReplay(res.request())) {
|
|
257
|
+
this.replayAttributed += 1;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
173
260
|
// The write policy's own stand-in refusal, matched by request identity
|
|
174
261
|
// like a dropped one: the server never said this.
|
|
175
262
|
if (this.refusedByPolicy(res.request())) {
|
|
@@ -205,6 +292,44 @@ export class OracleMonitor {
|
|
|
205
292
|
* errors look the same.
|
|
206
293
|
*/
|
|
207
294
|
policyAttributed = 0;
|
|
295
|
+
isReplay = () => false;
|
|
296
|
+
replays = new ReplayLog();
|
|
297
|
+
/** Failures of the tester's own scout_request calls, kept out of the page's violations and counted. */
|
|
298
|
+
replayAttributed = 0;
|
|
299
|
+
/**
|
|
300
|
+
* The engine knows which requests are its own scout_request replays, by
|
|
301
|
+
* identity; their failures are the tester's probes, not the page's.
|
|
302
|
+
*/
|
|
303
|
+
setReplayCheck(check) {
|
|
304
|
+
this.isReplay = check;
|
|
305
|
+
}
|
|
306
|
+
/** A scout_request call to this address is starting: the browser's echo of its failure is the tester's. */
|
|
307
|
+
replayStarted(url) {
|
|
308
|
+
this.replays.begin(url);
|
|
309
|
+
}
|
|
310
|
+
/** That call has returned; its echo is still expected for a short window. */
|
|
311
|
+
replayEnded(url) {
|
|
312
|
+
this.replays.end(url, Date.now());
|
|
313
|
+
}
|
|
314
|
+
/** Whether a page error was recorded since `since` (ms) and not yet drained. */
|
|
315
|
+
hasPageErrorSince(since) {
|
|
316
|
+
return this.buffer.some((v) => v.kind === "page_error" && Date.parse(v.at) >= since);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Re-rank the page errors this click raised that read as a router
|
|
320
|
+
* cancelling a route change (isRouteCancellation). Done before the drain, so
|
|
321
|
+
* the action's result, the session log and the report all see one verdict.
|
|
322
|
+
*/
|
|
323
|
+
downgradeRouteCancellations(since, c) {
|
|
324
|
+
for (const v of this.buffer) {
|
|
325
|
+
if (v.kind !== "page_error" || v.severity !== "high" || Date.parse(v.at) < since)
|
|
326
|
+
continue;
|
|
327
|
+
if (!isRouteCancellation(v.detail, c))
|
|
328
|
+
continue;
|
|
329
|
+
v.severity = "medium";
|
|
330
|
+
v.detail += routeCancelNote(v.detail);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
208
333
|
embedOfRequest = () => null;
|
|
209
334
|
embedRequests = new EmbedRequestLog();
|
|
210
335
|
/**
|
|
@@ -241,7 +366,7 @@ export class OracleMonitor {
|
|
|
241
366
|
* it is reported through here rather than by a page event.
|
|
242
367
|
*/
|
|
243
368
|
noteContradiction(c, url) {
|
|
244
|
-
this.record({ kind: c.kind, severity: "high", detail: c.detail, url });
|
|
369
|
+
this.record({ kind: c.kind, severity: c.severity ?? "high", detail: c.detail, url });
|
|
245
370
|
}
|
|
246
371
|
/**
|
|
247
372
|
* The page posted a token-shaped value with targetOrigin "*" (postmessage.ts).
|
package/dist/engine/provider.js
CHANGED
|
@@ -3,7 +3,8 @@ export class MalformedReply extends Error {
|
|
|
3
3
|
}
|
|
4
4
|
/** The most output one model call may produce. Large enough for a plan of steps; small enough that one reply cannot spend the budget. */
|
|
5
5
|
export const MAX_OUTPUT_TOKENS = 16_000;
|
|
6
|
-
|
|
6
|
+
/** A plain object as a record, or null for anything else (an array, null, a string). */
|
|
7
|
+
export const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
|
|
7
8
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
8
9
|
function parseArgs(raw) {
|
|
9
10
|
if (raw === undefined || raw === null || raw === "")
|
|
@@ -31,7 +32,7 @@ export class AnthropicConversation {
|
|
|
31
32
|
headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
|
|
32
33
|
body: {
|
|
33
34
|
model: this.o.model,
|
|
34
|
-
max_tokens: MAX_OUTPUT_TOKENS,
|
|
35
|
+
max_tokens: this.o.maxOutputTokens ?? MAX_OUTPUT_TOKENS,
|
|
35
36
|
// The method and the tools are the same on every call: cached once, read back at a tenth of the price.
|
|
36
37
|
system: [{ type: "text", text: this.o.system, cache_control: { type: "ephemeral" } }],
|
|
37
38
|
tools: this.o.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })),
|
|
@@ -102,7 +103,7 @@ export class OpenAIConversation {
|
|
|
102
103
|
input: this.input,
|
|
103
104
|
tools: this.o.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false })),
|
|
104
105
|
reasoning: { effort: this.o.effort },
|
|
105
|
-
max_output_tokens: MAX_OUTPUT_TOKENS,
|
|
106
|
+
max_output_tokens: this.o.maxOutputTokens ?? MAX_OUTPUT_TOKENS,
|
|
106
107
|
// Stateless: nothing of the tested app is kept on the provider's side
|
|
107
108
|
// between calls, and the reasoning comes back encrypted so it can be
|
|
108
109
|
// handed back with the rest of the turn.
|