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 +8 -0
- package/dist/engine/calibration.js +72 -13
- package/dist/engine/claims.js +51 -3
- package/dist/engine/memory.js +61 -11
- package/package.json +1 -1
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
|
|
236
|
+
const filed = [];
|
|
237
237
|
for (const f of findings) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
}
|
package/dist/engine/claims.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
})()`;
|
package/dist/engine/memory.js
CHANGED
|
@@ -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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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.
|
|
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",
|