scenescout 3.19.0 → 3.19.2
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 +12 -0
- package/dist/engine/browser.js +13 -4
- package/dist/engine/check.js +10 -2
- package/dist/engine/claims.js +182 -11
- package/dist/engine/dedup.js +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.19.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3b953a7: `refused-empty` now pairs an empty-state sentence with the refused read it is about (#413). A sentence that names what is missing ("No comments yet", "There are no orders") is reported at high when a refused read's path names the same thing, and not at all when a successful data read on the page fetched that thing instead: it is a genuinely empty section beside an unrelated refusal. A named sentence that matches neither is still reported, at medium, so `scenescout check` no longer fails a `--fail-on high` gate on it. Sentences that name nothing in particular ("No results", "Nothing to show") and empty rendered lists pair with any refused read at high, as before, and the finding names the refused read the sentence points at. Existing `refused-empty` fingerprints can change once on upgrade, because the evidence may now name a different read.
|
|
8
|
+
|
|
9
|
+
## 3.19.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 3a302b4: A saved flow that fails after typing into a form with an unsaved-changes guard no longer stops the flows after it. During a flow, a page asking to confirm leaving is left, as the flow's `navigate` and its hand-back mean, so the next flow's first `navigate` is not cancelled (`net::ERR_ABORTED`). The session's own answer is restored once the flow hands back.
|
|
14
|
+
|
|
3
15
|
## 3.19.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/dist/engine/browser.js
CHANGED
|
@@ -935,9 +935,13 @@ export class BrowserEngine {
|
|
|
935
935
|
}
|
|
936
936
|
}
|
|
937
937
|
for (const found of findContradictions(requests, state, before, acted && actedNow ? { before: acted.before, after: actedNow } : null)) {
|
|
938
|
-
|
|
938
|
+
// Once per refused request a session, except that a high reading of it
|
|
939
|
+
// replaces an earlier medium one (#413).
|
|
940
|
+
const severity = found.severity ?? "high";
|
|
941
|
+
const earlier = this.contradictionsReported.get(found.evidence);
|
|
942
|
+
if (earlier === "high" || earlier === severity)
|
|
939
943
|
continue;
|
|
940
|
-
this.contradictionsReported.
|
|
944
|
+
this.contradictionsReported.set(found.evidence, severity);
|
|
941
945
|
this.oracles.noteContradiction(found, url);
|
|
942
946
|
}
|
|
943
947
|
}
|
|
@@ -974,7 +978,7 @@ export class BrowserEngine {
|
|
|
974
978
|
}
|
|
975
979
|
}
|
|
976
980
|
/** Contradiction signatures already reported this session — the same refused endpoint on every page must not flood the run. */
|
|
977
|
-
contradictionsReported = new
|
|
981
|
+
contradictionsReported = new Map();
|
|
978
982
|
async scanForInjections() {
|
|
979
983
|
const page = this.page;
|
|
980
984
|
// One copy for the whole scan: the list is shared with every lane, and
|
|
@@ -1109,7 +1113,7 @@ export class BrowserEngine {
|
|
|
1109
1113
|
this.blockNotices.reset();
|
|
1110
1114
|
this.dialogsSeen = [];
|
|
1111
1115
|
this.watchedResponses = [];
|
|
1112
|
-
this.contradictionsReported = new
|
|
1116
|
+
this.contradictionsReported = new Map();
|
|
1113
1117
|
this.tokenPostsReported = new Set();
|
|
1114
1118
|
this.pendingCreations = new Set();
|
|
1115
1119
|
this.baseUrl = opts.url.replace(/\/$/, "");
|
|
@@ -5047,6 +5051,10 @@ export class BrowserEngine {
|
|
|
5047
5051
|
// The write policy judges by writeRule, so the flow's rule holds from here until the flow has handed back (finally).
|
|
5048
5052
|
const crawlMode = this.mode;
|
|
5049
5053
|
this.setMode(mode);
|
|
5054
|
+
// A flow's navigate means leave, and so does handing back: a page that asks to confirm leaving (an unsaved-changes
|
|
5055
|
+
// guard on a form a failed step left half filled) is left, or it would cancel the next flow's first navigate.
|
|
5056
|
+
const leaveBefore = this.leaveChoice;
|
|
5057
|
+
this.leaveChoice = true;
|
|
5050
5058
|
const context = this.context;
|
|
5051
5059
|
const violations = [];
|
|
5052
5060
|
const here = () => {
|
|
@@ -5300,6 +5308,7 @@ export class BrowserEngine {
|
|
|
5300
5308
|
// Pages the session no longer drives are closed only now, at about:blank and under the held rule, and each only
|
|
5301
5309
|
// once what it sent as it was left has been judged (leftSettled).
|
|
5302
5310
|
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map(async (p) => (p === this.page ? undefined : (await this.leftSettled(p), await p.close().catch(() => { }))))), 5000);
|
|
5311
|
+
this.leaveChoice = leaveBefore;
|
|
5303
5312
|
page.off("websocket", onSocket);
|
|
5304
5313
|
context.off("response", onResponse);
|
|
5305
5314
|
this.refs.clear();
|
package/dist/engine/check.js
CHANGED
|
@@ -169,6 +169,14 @@ function stripRefs(line) {
|
|
|
169
169
|
function withoutOrigin(text, origin) {
|
|
170
170
|
return origin ? text.split(origin).join("") : text;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* A violation's own severity where it is lower than its rule's: a refused read
|
|
174
|
+
* beside an empty state that may be about another section is medium (#413).
|
|
175
|
+
* Every other violation takes its rule's severity.
|
|
176
|
+
*/
|
|
177
|
+
function violationSeverity(v) {
|
|
178
|
+
return v.kind === "refused_empty" && v.severity === "medium" ? "medium" : undefined;
|
|
179
|
+
}
|
|
172
180
|
function violationRule(v) {
|
|
173
181
|
switch (v.kind) {
|
|
174
182
|
case "page_error":
|
|
@@ -309,7 +317,7 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
309
317
|
else if (r.elements === 0 && (r.status === null || r.status < 400))
|
|
310
318
|
add("dead-end", `${route}: 0 controls`, route);
|
|
311
319
|
for (const v of r.violations)
|
|
312
|
-
add(violationRule(v), v.detail, route, { embed: v.embed });
|
|
320
|
+
add(violationRule(v), v.detail, route, { embed: v.embed, severity: violationSeverity(v) });
|
|
313
321
|
for (const line of r.geometry) {
|
|
314
322
|
const rule = geometryRule(line);
|
|
315
323
|
if (rule)
|
|
@@ -327,7 +335,7 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
327
335
|
}
|
|
328
336
|
for (const f of flows) {
|
|
329
337
|
for (const { path, violation } of f.violations)
|
|
330
|
-
add(violationRule(violation), violation.detail, path, { embed: violation.embed, flow: f.file });
|
|
338
|
+
add(violationRule(violation), violation.detail, path, { embed: violation.embed, flow: f.file, severity: violationSeverity(violation) });
|
|
331
339
|
// A refused step is not the app's defect: the check reports it as "could not run" (refusedFlowReason).
|
|
332
340
|
if (f.outcome.status === "failed")
|
|
333
341
|
add("flow-step-failed", flowStepEvidence(f), f.outcome.path, { flow: f.file });
|
package/dist/engine/claims.js
CHANGED
|
@@ -145,6 +145,154 @@ export function classify(text) {
|
|
|
145
145
|
return "empty";
|
|
146
146
|
return null;
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Words in an empty-state sentence that name nothing in particular: the
|
|
150
|
+
* generic nouns for "the things", the verbs that follow them ("No results
|
|
151
|
+
* found", "No entries recorded yet") and the adjectives in front of them ("No
|
|
152
|
+
* new items", "No matching records"). Stemmed, as `stem` leaves them.
|
|
153
|
+
*/
|
|
154
|
+
const GENERIC_EMPTY_WORDS = new Set([
|
|
155
|
+
"result",
|
|
156
|
+
"item",
|
|
157
|
+
"record",
|
|
158
|
+
"row",
|
|
159
|
+
"data",
|
|
160
|
+
"entry",
|
|
161
|
+
"match",
|
|
162
|
+
"thing",
|
|
163
|
+
"one",
|
|
164
|
+
"yet",
|
|
165
|
+
"found",
|
|
166
|
+
"show",
|
|
167
|
+
"display",
|
|
168
|
+
"here",
|
|
169
|
+
"recorded",
|
|
170
|
+
"added",
|
|
171
|
+
"created",
|
|
172
|
+
"saved",
|
|
173
|
+
"available",
|
|
174
|
+
"been",
|
|
175
|
+
"matching",
|
|
176
|
+
"more",
|
|
177
|
+
"new",
|
|
178
|
+
"recent",
|
|
179
|
+
"upcoming",
|
|
180
|
+
"open",
|
|
181
|
+
"pending",
|
|
182
|
+
"other",
|
|
183
|
+
"further",
|
|
184
|
+
"api",
|
|
185
|
+
"v1",
|
|
186
|
+
"v2",
|
|
187
|
+
"v3",
|
|
188
|
+
]);
|
|
189
|
+
/** Words that end the subject of an empty-state sentence: "No results | for your search". */
|
|
190
|
+
const SUBJECT_STOP_WORDS = new Set([
|
|
191
|
+
"for",
|
|
192
|
+
"your",
|
|
193
|
+
"you",
|
|
194
|
+
"to",
|
|
195
|
+
"in",
|
|
196
|
+
"on",
|
|
197
|
+
"of",
|
|
198
|
+
"at",
|
|
199
|
+
"by",
|
|
200
|
+
"with",
|
|
201
|
+
"that",
|
|
202
|
+
"this",
|
|
203
|
+
"which",
|
|
204
|
+
"from",
|
|
205
|
+
"match",
|
|
206
|
+
"matches",
|
|
207
|
+
"yet",
|
|
208
|
+
"found",
|
|
209
|
+
"here",
|
|
210
|
+
"available",
|
|
211
|
+
"left",
|
|
212
|
+
"anymore",
|
|
213
|
+
"have",
|
|
214
|
+
"has",
|
|
215
|
+
"had",
|
|
216
|
+
"been",
|
|
217
|
+
"was",
|
|
218
|
+
"were",
|
|
219
|
+
"is",
|
|
220
|
+
"are",
|
|
221
|
+
"such",
|
|
222
|
+
]);
|
|
223
|
+
/** Crude singular form, enough to match "comments" to "/comment/" and "entries" to "/entry". */
|
|
224
|
+
function stem(word) {
|
|
225
|
+
const w = word.toLowerCase();
|
|
226
|
+
if (w.endsWith("ies") && w.length > 4)
|
|
227
|
+
return `${w.slice(0, -3)}y`;
|
|
228
|
+
if (w.endsWith("yses"))
|
|
229
|
+
return `${w.slice(0, -4)}ysis`;
|
|
230
|
+
if (w.endsWith("uses"))
|
|
231
|
+
return w.slice(0, -2);
|
|
232
|
+
if (w.endsWith("es") && /(?:ss|x|ch|sh)es$/.test(w))
|
|
233
|
+
return w.slice(0, -2);
|
|
234
|
+
if (w.endsWith("s") && !/(?:ss|us|is)$/.test(w) && w.length > 3)
|
|
235
|
+
return w.slice(0, -1);
|
|
236
|
+
return w;
|
|
237
|
+
}
|
|
238
|
+
/** A word or a path segment as stems, split on hyphens and underscores the same way on both sides. */
|
|
239
|
+
function stems(text) {
|
|
240
|
+
return text
|
|
241
|
+
.toLowerCase()
|
|
242
|
+
.split(/[^a-z0-9]+/)
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.map(stem);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The words naming what an empty-state sentence says is missing: "comment"
|
|
248
|
+
* for "No comments yet", "corrective action" for "No corrective actions
|
|
249
|
+
* recorded yet", "order" for "There are no orders". Empty for sentences that
|
|
250
|
+
* name nothing in particular ("No results for your search", "Nothing to show",
|
|
251
|
+
* "This list is empty"). Read from where the empty-state phrase starts, so a
|
|
252
|
+
* "no" earlier in the text ("No longer available. No results.") is not taken.
|
|
253
|
+
*/
|
|
254
|
+
export function emptyStateSubject(text) {
|
|
255
|
+
const at = EMPTY_RE.exec(text)?.index ?? 0;
|
|
256
|
+
const m = /\bno\s+((?:[a-z][a-z-]*\s+){0,3}[a-z][a-z-]*)/i.exec(text.slice(at));
|
|
257
|
+
if (!m)
|
|
258
|
+
return [];
|
|
259
|
+
const words = [];
|
|
260
|
+
for (const word of m[1].split(/\s+/)) {
|
|
261
|
+
if (SUBJECT_STOP_WORDS.has(word.toLowerCase()))
|
|
262
|
+
break;
|
|
263
|
+
words.push(...stems(word));
|
|
264
|
+
}
|
|
265
|
+
return words.filter((w) => w.length > 2 && !GENERIC_EMPTY_WORDS.has(w));
|
|
266
|
+
}
|
|
267
|
+
/** Whether a request's path names one of these subject words. */
|
|
268
|
+
function pathNames(req, subject) {
|
|
269
|
+
let path;
|
|
270
|
+
try {
|
|
271
|
+
path = new URL(req.url).pathname;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
path = req.url;
|
|
275
|
+
}
|
|
276
|
+
const segments = new Set(stems(path));
|
|
277
|
+
return subject.some((w) => segments.has(w));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Pairs one empty-state sentence with the refused reads it could be about.
|
|
281
|
+
* `loaded` are the page's reads that succeeded: positive evidence that a
|
|
282
|
+
* sentence naming what they fetched is a genuinely empty section rather than
|
|
283
|
+
* a refusal hidden behind an empty state.
|
|
284
|
+
*/
|
|
285
|
+
export function pairEmptyState(text, refusedReads, loaded) {
|
|
286
|
+
const subject = emptyStateSubject(text);
|
|
287
|
+
if (subject.length === 0)
|
|
288
|
+
return { certain: true, read: refusedReads[0] };
|
|
289
|
+
const named = refusedReads.find((r) => pathNames(r, subject));
|
|
290
|
+
if (named)
|
|
291
|
+
return { certain: true, read: named };
|
|
292
|
+
if (loaded.some((r) => pathNames(r, subject)))
|
|
293
|
+
return { unrelated: true };
|
|
294
|
+
return { certain: false, read: refusedReads[0], subject };
|
|
295
|
+
}
|
|
148
296
|
/** What counts as an open dialog: a native one or an ARIA one. */
|
|
149
297
|
const DIALOG_SEL = 'dialog[open], [role="dialog"], [role="alertdialog"]';
|
|
150
298
|
/** Page-side count of the open dialogs alone, for a caller that needs nothing else the claim scan reads. */
|
|
@@ -211,17 +359,40 @@ export function findContradictions(requests, page, before, acted) {
|
|
|
211
359
|
return [];
|
|
212
360
|
const out = [];
|
|
213
361
|
const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
362
|
+
if (reads.length > 0) {
|
|
363
|
+
// An empty-state sentence that names what is missing is evidence about
|
|
364
|
+
// requests for that thing (#413). "No comments yet" beside a refused
|
|
365
|
+
// history read, with the comments read loaded fine, is a genuinely empty
|
|
366
|
+
// comments section, not a refusal shown as an empty state.
|
|
367
|
+
// Positive evidence only: a data read the page made that succeeded. The
|
|
368
|
+
// page's own document, its images and scripts, and redirects say nothing
|
|
369
|
+
// about which section of it is empty.
|
|
370
|
+
const loaded = requests.filter((r) => (r.resourceType === "xhr" || r.resourceType === "fetch") &&
|
|
371
|
+
r.status !== null &&
|
|
372
|
+
r.status >= 200 &&
|
|
373
|
+
r.status < 300 &&
|
|
374
|
+
!WRITING_METHODS.has(r.method.toUpperCase()));
|
|
375
|
+
const pairings = page.texts.filter((_, i) => claims[i] === "empty").map((t) => ({ text: t, pairing: pairEmptyState(t, reads, loaded) }));
|
|
376
|
+
// A sentence naming what a refused read fetched points at that read; a generic one points at none in particular.
|
|
377
|
+
const certain = pairings.find((p) => "certain" in p.pairing && p.pairing.certain && emptyStateSubject(p.text).length > 0) ??
|
|
378
|
+
pairings.find((p) => "certain" in p.pairing && p.pairing.certain);
|
|
379
|
+
const possible = pairings.find((p) => "certain" in p.pairing && !p.pairing.certain);
|
|
380
|
+
const hit = certain ?? (page.emptyLists > 0 ? undefined : possible);
|
|
381
|
+
if (hit || page.emptyLists > 0) {
|
|
382
|
+
const read = hit && "read" in hit.pairing ? hit.pairing.read : reads[0];
|
|
383
|
+
const how = hit ? "an empty state" : `an empty list (${page.emptyLists})`;
|
|
384
|
+
const unsure = hit !== undefined && hit === possible;
|
|
385
|
+
out.push({
|
|
386
|
+
kind: "refused_empty",
|
|
387
|
+
detail: `${say(read)} was refused, and the page shows ${how} with no error. ` +
|
|
388
|
+
(unsure
|
|
389
|
+
? `The empty state ("${hit.text}") names something no request on the page was for, so it may be about another section; the user may be told there is nothing to see when nothing could be loaded.`
|
|
390
|
+
: `The user is told there is nothing to see when the truth is that nothing could be loaded.`) +
|
|
391
|
+
standIn(read),
|
|
392
|
+
evidence: `refused-empty ${say(read)}`,
|
|
393
|
+
...(unsure ? { severity: "medium" } : {}),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
225
396
|
}
|
|
226
397
|
// Only writes this action sent, and not the page's own infrastructure.
|
|
227
398
|
const isActionWrite = (r) => WRITING_METHODS.has(r.method.toUpperCase()) && !r.background && !INFRASTRUCTURE_WRITE_RE.test(r.url);
|
package/dist/engine/dedup.js
CHANGED
|
@@ -83,6 +83,17 @@ export function buildPairs(archives, keys) {
|
|
|
83
83
|
}
|
|
84
84
|
return out;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The archives dated on or after `since` (YYYY-MM-DD), so a decider can be
|
|
88
|
+
* scored out of sample: on pairs from runs made after it was tuned, none of
|
|
89
|
+
* whose findings it has seen. Filtered before pairing, so a pair never joins
|
|
90
|
+
* a new finding to an old one.
|
|
91
|
+
*/
|
|
92
|
+
export function archivesSince(archives, since) {
|
|
93
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(since))
|
|
94
|
+
throw new Error(`The date must be YYYY-MM-DD, not ${JSON.stringify(since)}.`);
|
|
95
|
+
return archives.filter((a) => a.date >= since);
|
|
96
|
+
}
|
|
86
97
|
/**
|
|
87
98
|
* At most `cap` pairs, chosen by a hash of their texts: the same pairs every
|
|
88
99
|
* time for the same archives, whatever order they were built in, and no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.19.
|
|
3
|
+
"version": "3.19.2",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|