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 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
@@ -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
- if (this.contradictionsReported.has(found.evidence))
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.add(found.evidence);
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 Set();
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 Set();
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();
@@ -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 });
@@ -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
- const saysEmpty = claims.includes("empty") || page.emptyLists > 0;
215
- if (reads.length > 0 && saysEmpty) {
216
- const worst = reads[0];
217
- const how = claims.includes("empty") ? "an empty state" : `an empty list (${page.emptyLists})`;
218
- out.push({
219
- kind: "refused_empty",
220
- detail: `${say(worst)} was refused, and the page shows ${how} with no error. ` +
221
- `The user is told there is nothing to see when the truth is that nothing could be loaded.` +
222
- standIn(worst),
223
- evidence: `refused-empty ${say(worst)}`,
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);
@@ -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.0",
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",