scenescout 3.19.1 → 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,11 @@
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
+
3
9
  ## 3.19.1
4
10
 
5
11
  ### Patch 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(/\/$/, "");
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.19.1",
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",