scenescout 3.6.0 → 3.6.1

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,13 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 90d1aec: Two findings that share only a quoted string are merged only when their kinds are one family (data, failures, presentation, flow, security, performance, and "other" on its own). A layout defect naming the button it covers ("Save notes") was being folded into the data defect about what that button does, and dropped from the report. The same bug filed twice under neighbouring categories — data-loss and data-inconsistency, page-error and console-error — still merges.
8
+ - 8abf639: The false-success check recognises a refusal the page announces in its own words — "Only an open order can be sent for approval", "You can't delete an approved order", "This order is already approved" — in a status region, alert or dialog, as the page admitting the refusal, instead of reporting the word "sent" or "approved" as a false claim of success. The same wording as ordinary help text elsewhere on the page ("This cannot be undone", "Password must be at least 8 characters") excuses nothing.
9
+ - ca52079: The lane-report fold's check for defects judged but never filed now recognises a filed finding when the lane reworded its evidence, matching on the identifiers that survive rewording — test ids and contrast ratios, two of them in common — or on near-identical wording. Replayed against three archived benchmark runs it raised 6 flags where it had raised 39, and still named every defect that was genuinely left unfiled. One shared test id is never enough, and API paths are not identifiers, so a missed defect on the same button or endpoint as a filed one is still named.
10
+
3
11
  ## 3.6.0
4
12
 
5
13
  ### Minor Changes
@@ -233,13 +233,13 @@ export const MAX_UNFILED_NAMED = 10;
233
233
  */
234
234
  export function unfiledDefects(decisions, findings) {
235
235
  const keys = new Set();
236
- const texts = [];
236
+ const filed = [];
237
237
  for (const f of findings) {
238
- if (!f.evidence)
239
- continue;
240
- for (const key of joinKeys(f.evidence))
241
- keys.add(key);
242
- texts.push(squash(f.evidence));
238
+ const text = `${f.evidence ?? ""} ${f.title}`;
239
+ if (f.evidence)
240
+ for (const key of joinKeys(f.evidence))
241
+ keys.add(key);
242
+ filed.push({ ids: identifiers(text), words: words(text), evidenceWords: words(f.evidence ?? ""), evidence: squash(f.evidence ?? "") });
243
243
  }
244
244
  const out = [];
245
245
  for (const d of decisions) {
@@ -250,25 +250,84 @@ export function unfiledDefects(decisions, findings) {
250
250
  if (joined.some((k) => keys.has(k)))
251
251
  continue;
252
252
  // A failing-endpoint signature is the store's own identity for a bug. When
253
- // the decision has one and no finding shares it, text overlap is not a
254
- // match: "GET /api/items" inside "... GET /api/items returned 500" is.
253
+ // the decision has one and no finding shares it, nothing else is a match.
255
254
  if (joined.length > 0) {
256
255
  out.push(`${d.observation} — ${d.evidence}`);
257
256
  continue;
258
257
  }
258
+ const ids = identifiers(d.evidence);
259
+ const w = words(d.evidence);
259
260
  const text = squash(d.evidence);
260
- if (text && texts.some((t) => t === text || contains(t, text) || contains(text, t)))
261
+ if (filed.some((f) => (text !== "" && f.evidence === text) || covers(ids, w, f)))
261
262
  continue;
262
263
  }
263
264
  out.push(d.evidence ? `${d.observation} — ${d.evidence}` : d.observation);
264
265
  }
265
266
  return out;
266
267
  }
267
- /** One signature inside another, when the inner one is long enough to mean something: "404" is inside half the findings in a run. */
268
- function contains(outer, inner) {
269
- return inner.length >= MIN_CONTAINED && outer.includes(inner);
268
+ function covers(ids, w, f) {
269
+ let shared = 0;
270
+ for (const id of ids)
271
+ if (f.ids.has(id))
272
+ shared += 1;
273
+ // Against the evidence alone as well: a finding's title adds words the
274
+ // lane's report never repeats, and diluted an otherwise identical signature.
275
+ const overlap = Math.max(jaccard(w, f.words), jaccard(w, f.evidenceWords));
276
+ return shared >= 2 || overlap >= 0.6;
277
+ }
278
+ /**
279
+ * Test ids, kebab-case identifiers of three or more parts, and contrast ratios.
280
+ * Not API paths: a path that answered 2xx names a resource, not a defect — two
281
+ * different bugs on POST /api/orders share it — and a failing path already has
282
+ * its own identity in joinKeys.
283
+ */
284
+ function identifiers(text) {
285
+ const out = new Set();
286
+ const t = text.toLowerCase();
287
+ for (const m of t.matchAll(/testid=["']?([a-z0-9_-]+)/g))
288
+ out.add(m[1]);
289
+ for (const m of t.matchAll(/\b[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}\b/g))
290
+ out.add(m[0]);
291
+ for (const m of t.matchAll(/\b\d+(?:\.\d+)?:1\b/g))
292
+ out.add(m[0]);
293
+ return out;
294
+ }
295
+ const STOP = new Set([
296
+ "the",
297
+ "and",
298
+ "with",
299
+ "for",
300
+ "but",
301
+ "not",
302
+ "was",
303
+ "are",
304
+ "has",
305
+ "this",
306
+ "that",
307
+ "from",
308
+ "into",
309
+ "when",
310
+ "then",
311
+ "than",
312
+ "only",
313
+ "also",
314
+ "its",
315
+ ]);
316
+ function words(text) {
317
+ return new Set(text
318
+ .toLowerCase()
319
+ .split(/[^a-z0-9]+/)
320
+ .filter((w) => w.length >= 3 && !STOP.has(w)));
321
+ }
322
+ function jaccard(a, b) {
323
+ if (a.size === 0 || b.size === 0)
324
+ return 0;
325
+ let both = 0;
326
+ for (const x of a)
327
+ if (b.has(x))
328
+ both += 1;
329
+ return both / (a.size + b.size - both);
270
330
  }
271
- const MIN_CONTAINED = 12;
272
331
  function squash(text) {
273
332
  return text.toLowerCase().replace(/\s+/g, " ").trim();
274
333
  }
@@ -64,11 +64,33 @@ const EMPTY_RE = /\b(?:no (?:results?|items?|records?|rows?|data|entries|matches
64
64
  */
65
65
  const SUCCESS_RE = /\b(?:success(?:fully)?|saved|created|updated|deleted|removed|submitted|sent|published|approved|completed|added|changes? saved|done)\b/i;
66
66
  /**
67
- * Phrases that admit something went wrong. Their presence is what makes a page
67
+ * Phrases that admit something went wrong — including a refusal explained in
68
+ * the app's own words ("Only an open order can be sent for approval", "You
69
+ * can't delete an approved order"), which is correct behaviour even though it
70
+ * uses none of the words for an error and may contain one for success. Their presence is what makes a page
68
71
  * INNOCENT: an app that refuses a request and says so has behaved correctly,
69
72
  * whatever else is on the screen, and must not be reported.
70
73
  */
71
- const ERROR_RE = /\b(?:error|failed|failure|could ?n[o']?t|unable to|went wrong|try again|retry|denied|forbidden|unauthori[sz]ed|not allowed|no permission|timed out|unavailable|problem loading)\b/i;
74
+ const ERROR_RE = /\b(?:error|failed|failure|could ?n[o'’]?t|unable to|went wrong|try again|retry|denied|forbidden|unauthori[sz]ed|not allowed|no permission|timed out|unavailable|problem loading)\b/i;
75
+ /**
76
+ * A refusal explained in the app's own words: "Only an open order can be sent
77
+ * for approval", "You can't delete an approved order", "Nothing was saved".
78
+ * Correct behaviour after a refused request, though it uses none of the words
79
+ * for an error and may contain one for success ("sent").
80
+ *
81
+ * Unlike ERROR_RE, this counts only in text the page ANNOUNCES — a live region
82
+ * or an alert (see PageState.announced). The same phrasing is ordinary
83
+ * help text everywhere else ("This cannot be undone", "Password must be at
84
+ * least 8 characters", "Only admins can invite members"), and page-wide it
85
+ * excused real lies: a refused delete reporting "Workspace deleted." went
86
+ * unreported because the Danger zone said "This cannot be undone."
87
+ */
88
+ const REFUSAL_RE = /\b(?:can(?:not|[’'`]?t| not)|must be|(?:is|are) already|only (?:an? |the )?\w+(?: \w+)? (?:can|may|be)|may only|can only(?: be)?|(?:nothing|not) (?:was|has been|have been|been) (?:saved|sent|deleted|updated|created|changed|submitted)|(?:was|were|has|have|is|are)(?: not|n[’']t)(?: been)? (?:saved|sent|deleted|updated|created|changed|submitted))\b/i;
89
+ /** Whether a piece of announced text explains a refusal. */
90
+ export function isRefusalNotice(text) {
91
+ const trimmed = text.trim();
92
+ return trimmed.length > 0 && trimmed.length <= CLAIM_TEXT_MAX && REFUSAL_RE.test(trimmed);
93
+ }
72
94
  /** Longest piece of text judged. An empty state is a sentence; a paragraph that happens to contain one of these words is not a claim. */
73
95
  export const CLAIM_TEXT_MAX = 120;
74
96
  /**
@@ -122,6 +144,10 @@ export function findContradictions(requests, page) {
122
144
  // an error banner and a stale empty state is not reported.
123
145
  if (claims.includes("error"))
124
146
  return [];
147
+ // A refusal explained in the app's own words, where the page announces its
148
+ // responses. Help text with the same wording elsewhere excuses nothing.
149
+ if ((page.announced ?? []).some(isRefusalNotice))
150
+ return [];
125
151
  const out = [];
126
152
  const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
127
153
  const saysEmpty = claims.includes("empty") || page.emptyLists > 0;
@@ -148,6 +174,8 @@ export function findContradictions(requests, page) {
148
174
  }
149
175
  return out;
150
176
  }
177
+ /** Most announced regions read from one page. */
178
+ export const MAX_ANNOUNCED = 40;
151
179
  /** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
152
180
  export const MAX_CLAIM_TEXTS = 120;
153
181
  /**
@@ -167,6 +195,11 @@ export const MAX_CLAIM_TEXTS = 120;
167
195
  export const CLAIM_SCAN_SCRIPT = `(() => {
168
196
  const visible = ${VISIBLE_SRC};
169
197
  const texts = [];
198
+ const announced = [];
199
+ // Where a page SAYS something in response to an action. Not dialogs: a modal
200
+ // is a container of static text (its form's help, its warning), and counting
201
+ // it brought back the help text this set exists to exclude.
202
+ const ANNOUNCES = "[role~='status'], [role~='alert'], [aria-live]:not([aria-live='off']), output";
170
203
  const seen = new Set();
171
204
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
172
205
  let el = document.body;
@@ -197,5 +230,20 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
197
230
  const rows = list.querySelectorAll(":scope > li, :scope > [role='row'], :scope > [role='listitem']");
198
231
  if (rows.length === 0) emptyLists += 1;
199
232
  }
200
- return { texts, emptyLists };
233
+ // Read on its own, past the cap on page texts: a toast rendered at the end of
234
+ // a long page is exactly the text that must not be missed.
235
+ for (const region of document.querySelectorAll(ANNOUNCES)) {
236
+ if (!visible(region)) continue;
237
+ // innerText, not textContent: hidden parts of a region are not said. Split
238
+ // into sentences so a long region is read rather than dropped whole.
239
+ const said = (region.innerText || "").replace(/\\s+/g, " ").trim();
240
+ for (const sentence of said.split(/(?<=[.!?])\\s+/)) {
241
+ if (announced.length >= ${MAX_ANNOUNCED}) break;
242
+ const s = sentence.trim();
243
+ if (s && s.length <= ${CLAIM_TEXT_MAX} && !announced.includes(s)) announced.push(s);
244
+ }
245
+ }
246
+
247
+
248
+ return { texts, announced, emptyLists };
201
249
  })()`;
@@ -412,6 +412,46 @@ function sameEndpointBug(existing, incoming) {
412
412
  return false;
413
413
  return sharesEndpointSignature(existing, incoming);
414
414
  }
415
+ /**
416
+ * Families of finding kinds that one bug is plausibly filed under by two
417
+ * sessions: a crash is a page-error to one and a console-error to another, a
418
+ * refused save is data-loss to one and data-inconsistency to another. Across
419
+ * families — a layout defect and a data defect — a shared quoted string names
420
+ * a place on the page, not a bug.
421
+ */
422
+ const CATEGORY_FAMILY = {
423
+ "data-inconsistency": "data",
424
+ "data-loss": "data",
425
+ "stale-state": "data",
426
+ "http-error": "failure",
427
+ network: "failure",
428
+ "console-error": "failure",
429
+ "page-error": "failure",
430
+ visual: "presentation",
431
+ "ux-polish": "presentation",
432
+ a11y: "presentation",
433
+ "missing-testid": "presentation",
434
+ "ux-confusing": "flow",
435
+ "dead-end": "flow",
436
+ security: "security",
437
+ "permission-leak": "security",
438
+ performance: "performance",
439
+ };
440
+ /**
441
+ * Whether two categories are one family. "other" — the category for nothing
442
+ * that fits — is a family of its own: as a wildcard, one "other" finding
443
+ * quoting a control absorbed both the layout and the data finding about it. A
444
+ * missing or unknown category matches nothing, so a finding read from an older
445
+ * file never merges on a guess.
446
+ */
447
+ export function sameFamily(a, b) {
448
+ if (!a || !b)
449
+ return false;
450
+ if (a === "other" || b === "other")
451
+ return a === b;
452
+ const fa = CATEGORY_FAMILY[a];
453
+ return fa !== undefined && fa === CATEGORY_FAMILY[b];
454
+ }
415
455
  function sameFinding(a, b) {
416
456
  const aEv = a.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
417
457
  const bEv = b.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
@@ -426,17 +466,27 @@ function sameFinding(a, b) {
426
466
  // them, and collapsed into one. Matching a title literal against the other
427
467
  // finding's full text keeps the intended case (one states the string in its
428
468
  // title, the other mentions it in its detail).
429
- const aTitleLits = findingLiterals(a.title);
430
- const bTitleLits = findingLiterals(b.title);
431
- if (aTitleLits.size > 0 || bTitleLits.size > 0) {
432
- const aAll = findingLiterals(a.title, a.detail, a.evidence);
433
- const bAll = findingLiterals(b.title, b.detail, b.evidence);
434
- for (const lit of aTitleLits)
435
- if (bAll.has(lit))
436
- return true;
437
- for (const lit of bTitleLits)
438
- if (aAll.has(lit))
439
- return true;
469
+ //
470
+ // Only between findings of one FAMILY of kinds (see sameFamily). A quoted
471
+ // string is as often the name of a control as a message the app showed, and
472
+ // a layout defect naming the button it covers ("Save notes") shares that
473
+ // literal with the data defect describing what the button does — in its
474
+ // detail or its own title. Merged, the layout defect was filed and then lost
475
+ // from the report on most runs of a benchmark. Within a family, one bug
476
+ // filed twice under neighbouring categories still merges.
477
+ if (sameFamily(a.category, b.category)) {
478
+ const aTitleLits = findingLiterals(a.title);
479
+ const bTitleLits = findingLiterals(b.title);
480
+ if (aTitleLits.size > 0 || bTitleLits.size > 0) {
481
+ const aAll = findingLiterals(a.title, a.detail, a.evidence);
482
+ const bAll = findingLiterals(b.title, b.detail, b.evidence);
483
+ for (const lit of aTitleLits)
484
+ if (bAll.has(lit))
485
+ return true;
486
+ for (const lit of bTitleLits)
487
+ if (aAll.has(lit))
488
+ return true;
489
+ }
440
490
  }
441
491
  // Both carry evidence and neither matched above: distinct bugs, however
442
492
  // similar the titles — never fuzzy-merge across differing evidence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.6.0",
3
+ "version": "3.6.1",
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",