scenescout 3.6.0 → 3.7.0

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,25 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1b4a688: The live view's close-up has its own Stream button, the same one as the session's card: switch streaming off or on without leaving the close-up. Opening a close-up still streams its session, and closing it without touching the button leaves the card as it was.
8
+ - a11c96e: `scout_coverage` lists the options of each dropdown used in the run that no session has chosen, since a filter counts as exercised after one choice. Disabled and hidden options, and an empty-value placeholder or "all" option, are never listed; dropdowns with more than 20 options are pickers and are not listed either.
9
+
10
+ ### Patch Changes
11
+
12
+ - b726af1: Two findings that quote the same control no longer merge when their evidence names different requests, and a filing merged into an existing finding now names it, with its severity and title.
13
+ - 56382db: The lane-report fold no longer lists a judged defect as unfiled when its evidence appears word for word inside a filed finding's evidence.
14
+
15
+ ## 3.6.1
16
+
17
+ ### Patch Changes
18
+
19
+ - 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.
20
+ - 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.
21
+ - 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.
22
+
3
23
  ## 3.6.0
4
24
 
5
25
  ### Minor Changes
@@ -56,6 +56,31 @@ const BENIGN_MUTATION_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/
56
56
  function xpathLookup(xpath) {
57
57
  return `document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
58
58
  }
59
+ /**
60
+ * Runs in the page against one dropdown, BEFORE a choice: its options' values
61
+ * and labels. Read first because a dropdown may reset itself (a bulk-action or
62
+ * "jump to" menu) or remove itself on change. Skips disabled and hidden
63
+ * options, and a placeholder or "all" option with an empty value — the state
64
+ * the page loads in, which is not an option anyone owes a choice.
65
+ */
66
+ function describeSelect(node) {
67
+ // A plan may target the dropdown by its <label>; selectOption follows a label to its control, so this does too.
68
+ const target = node instanceof HTMLLabelElement ? (node.control ?? node.querySelector("select")) : node;
69
+ const select = target;
70
+ if (!select || !select.options)
71
+ return null;
72
+ return Array.from(select.options)
73
+ .filter((o) => !o.disabled && !o.hidden && o.value !== "")
74
+ .map((o) => ({ value: o.value, label: (o.label || o.textContent || "").trim().slice(0, 80) }))
75
+ .filter((o) => o.label !== "");
76
+ }
77
+ /**
78
+ * A dropdown's options, read without waiting: a select that is not there to
79
+ * read is not worth stalling the action for.
80
+ */
81
+ async function readSelectOptions(loc) {
82
+ return loc.evaluate(describeSelect, undefined, { timeout: 1000 }).catch(() => null);
83
+ }
59
84
  /** Runs in the page against one file input (or the one a chooser belongs to). */
60
85
  function describeFileInput(node) {
61
86
  const input = node;
@@ -1783,6 +1808,15 @@ export class BrowserEngine {
1783
1808
  ? `\nRevealed on hover:\n${notes.map((t) => ` · ${t}`).join("\n")}${caveat}`
1784
1809
  : `\n(no tooltip, overlay, or new page text appeared within ${HOVER_REVEAL_WINDOW_MS / 1000}s — this element reveals nothing on hover${churning ? "; page content was changing on its own, so the text-diff fallback was suppressed" : ""}${this.headed ? ". NOTE: in headed mode the PHYSICAL mouse cursor competes with the synthetic pointer — if it is resting over the browser window, hover warm-ups are cancelled; ask the user to move it off the window and retry" : ""})`));
1785
1810
  }
1811
+ /** Record a dropdown's options and the ones picked, by the values selectOption reported. */
1812
+ recordSelectChoice(fingerprint, key, options, picked) {
1813
+ const labels = options.map((o) => o.label);
1814
+ const chosen = picked.map((v) => options.find((o) => o.value === v)?.label).filter((l) => !!l);
1815
+ if (chosen.length === 0)
1816
+ this.memory.recordSelectChoice(fingerprint, key, labels, "");
1817
+ for (const label of chosen)
1818
+ this.memory.recordSelectChoice(fingerprint, key, labels, label);
1819
+ }
1786
1820
  async select(ref, value) {
1787
1821
  const page = this.requirePage();
1788
1822
  const { el, liveLabel } = await this.resolveForAction(ref);
@@ -1802,8 +1836,12 @@ export class BrowserEngine {
1802
1836
  return destructiveRefusal(optionLabel || value, this.mode);
1803
1837
  }
1804
1838
  }
1805
- await page.locator(`xpath=${el.xpath}`).selectOption(value, { timeout: ACTION_TIMEOUT_MS });
1839
+ const loc = page.locator(`xpath=${el.xpath}`);
1840
+ const options = el.tag === "select" ? await readSelectOptions(loc) : null;
1841
+ const picked = await loc.selectOption(value, { timeout: ACTION_TIMEOUT_MS });
1806
1842
  this.memory.markExercised(this.currentFingerprint, el.key, "select");
1843
+ if (options)
1844
+ this.recordSelectChoice(this.currentFingerprint, el.key, options, picked);
1807
1845
  return this.afterAction("select", `${el.role} "${el.name}" = ${value}`);
1808
1846
  }
1809
1847
  /**
@@ -2131,6 +2169,8 @@ export class BrowserEngine {
2131
2169
  // What a type step has to say about the field it typed into; it goes on
2132
2170
  // the step's own line, so it cannot read as the previous step's.
2133
2171
  let note = "";
2172
+ // A select step's options and choice, recorded against the dropdown the bookkeeping below finds.
2173
+ let chose = null;
2134
2174
  let preState = null;
2135
2175
  try {
2136
2176
  if (step.action === "navigate") {
@@ -2215,8 +2255,12 @@ export class BrowserEngine {
2215
2255
  await loc.press("Enter", { timeout: ACTION_TIMEOUT_MS });
2216
2256
  }
2217
2257
  }
2218
- else if (step.action === "select")
2219
- await loc.selectOption(step.value ?? "", { timeout: ACTION_TIMEOUT_MS });
2258
+ else if (step.action === "select") {
2259
+ const options = await readSelectOptions(loc);
2260
+ const picked = await loc.selectOption(step.value ?? "", { timeout: ACTION_TIMEOUT_MS });
2261
+ if (options)
2262
+ chose = { options, picked };
2263
+ }
2220
2264
  else if (step.action === "upload") {
2221
2265
  const r = await this.performUpload(loc, planUploadOptions(step.value));
2222
2266
  if (r.refused) {
@@ -2263,12 +2307,13 @@ export class BrowserEngine {
2263
2307
  // state so a target the pre-capture missed (or a failed capture)
2264
2308
  // still records something rather than nothing.
2265
2309
  const preHit = preState ? findIn(preState.elements) : undefined;
2266
- if (preHit)
2267
- this.memory.markExercised(preState.fp, preHit.key, `plan:${step.action}`);
2268
- else {
2269
- const postHit = findIn(elements);
2270
- if (postHit)
2271
- this.memory.markExercised(fp, postHit.key, `plan:${step.action}`);
2310
+ const postHit = preHit ? undefined : findIn(elements);
2311
+ const hit = preHit ? { fp: preState.fp, el: preHit } : postHit ? { fp, el: postHit } : undefined;
2312
+ if (hit) {
2313
+ this.memory.markExercised(hit.fp, hit.el.key, `plan:${step.action}`);
2314
+ // The lookup can fall back to a name match; options belong only to a dropdown.
2315
+ if (chose && hit.el.tag === "select")
2316
+ this.recordSelectChoice(hit.fp, hit.el.key, chose.options, chose.picked);
2272
2317
  }
2273
2318
  }
2274
2319
  catch {
@@ -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,117 @@ 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) || restates(text, f) || 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
+ /**
269
+ * Shortest reported evidence that counts as restating a filing it is part of.
270
+ */
271
+ const MIN_RESTATED = 24;
272
+ /** A test id, captured without its attribute; and a kebab-case id of three or more parts. Lowercase text only. */
273
+ const TESTID_RE = /testid=["']?([a-z0-9_-]+)/g;
274
+ const KEBAB_ID_RE = /\b[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}\b/g;
275
+ /**
276
+ * Whether the reported evidence appears word for word inside a finding's
277
+ * evidence: a lane that filed "testid=row-1..6 non-interactive; GET /api/x 200
278
+ * with only three fields" and reported the first half. It must say something
279
+ * beyond naming a control — two words once test ids and kebab-case ids of
280
+ * three or more parts are taken out — or a bare "testid=inventory-sort-qty" would be found inside
281
+ * every finding about that control, which is the one-shared-id match covers()
282
+ * refuses. One direction only: a short FILED evidence found inside a longer
283
+ * report proves nothing about the rest of the report.
284
+ */
285
+ function restates(reported, f) {
286
+ if (reported.length < MIN_RESTATED || !f.evidence.includes(reported))
287
+ return false;
288
+ const rest = reported.replace(TESTID_RE, " ").replace(KEBAB_ID_RE, " ");
289
+ return words(rest).size >= 2;
290
+ }
291
+ /**
292
+ * Whether a finding covers a decision. Lanes reword evidence between filing it
293
+ * and reporting it — an arrow for a hyphen, quoted JSON for bare, "8 links"
294
+ * for "8 link(s)" — but keep the identifiers: test ids and contrast ratios.
295
+ * Two shared identifiers, or a near-identical wording, is the same
296
+ * observation. One shared test id is never enough, with or without some
297
+ * words in common: two different defects on one control share both (sort by
298
+ * quantity sorting as text, and the same sort showing no active state), and a
299
+ * real miss must not hide behind its neighbour.
300
+ */
301
+ function covers(ids, w, f) {
302
+ let shared = 0;
303
+ for (const id of ids)
304
+ if (f.ids.has(id))
305
+ shared += 1;
306
+ // Against the evidence alone as well: a finding's title adds words the
307
+ // lane's report never repeats, and diluted an otherwise identical signature.
308
+ const overlap = Math.max(jaccard(w, f.words), jaccard(w, f.evidenceWords));
309
+ return shared >= 2 || overlap >= 0.6;
310
+ }
311
+ /**
312
+ * Test ids, kebab-case identifiers of three or more parts, and contrast ratios.
313
+ * Not API paths: a path that answered 2xx names a resource, not a defect — two
314
+ * different bugs on POST /api/orders share it — and a failing path already has
315
+ * its own identity in joinKeys.
316
+ */
317
+ function identifiers(text) {
318
+ const out = new Set();
319
+ const t = text.toLowerCase();
320
+ for (const m of t.matchAll(TESTID_RE))
321
+ out.add(m[1]);
322
+ for (const m of t.matchAll(KEBAB_ID_RE))
323
+ out.add(m[0]);
324
+ for (const m of t.matchAll(/\b\d+(?:\.\d+)?:1\b/g))
325
+ out.add(m[0]);
326
+ return out;
327
+ }
328
+ const STOP = new Set([
329
+ "the",
330
+ "and",
331
+ "with",
332
+ "for",
333
+ "but",
334
+ "not",
335
+ "was",
336
+ "are",
337
+ "has",
338
+ "this",
339
+ "that",
340
+ "from",
341
+ "into",
342
+ "when",
343
+ "then",
344
+ "than",
345
+ "only",
346
+ "also",
347
+ "its",
348
+ ]);
349
+ function words(text) {
350
+ return new Set(text
351
+ .toLowerCase()
352
+ .split(/[^a-z0-9]+/)
353
+ .filter((w) => w.length >= 3 && !STOP.has(w)));
354
+ }
355
+ function jaccard(a, b) {
356
+ if (a.size === 0 || b.size === 0)
357
+ return 0;
358
+ let both = 0;
359
+ for (const x of a)
360
+ if (b.has(x))
361
+ both += 1;
362
+ return both / (a.size + b.size - both);
270
363
  }
271
- const MIN_CONTAINED = 12;
272
364
  function squash(text) {
273
365
  return text.toLowerCase().replace(/\s+/g, " ").trim();
274
366
  }
@@ -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
  })()`;
@@ -225,6 +225,7 @@ export const LIVE_PAGE = `<!doctype html>
225
225
  <div class="bar">
226
226
  <strong id="focus-name"></strong>
227
227
  <span class="line" id="focus-line"></span>
228
+ <button type="button" id="focus-stream" aria-pressed="false" data-testid="live-focus-stream-toggle">Stream</button>
228
229
  <button type="button" id="focus-close" data-testid="live-focus-close">Close</button>
229
230
  </div>
230
231
  <div class="stage" id="focus-stage">
@@ -253,6 +254,11 @@ export const LIVE_PAGE = `<!doctype html>
253
254
  /** The header filter, lower-cased. Hides cards; never stops a session running. */
254
255
  var filter = '';
255
256
  var focused = null;
257
+ // Whether opening the close-up is what switched its session's stream on. The
258
+ // close-up streams while it is open; closing it hands the card back as it
259
+ // was, unless the viewer used the close-up's own Stream button, whose choice
260
+ // stands.
261
+ var focusStartedStream = false;
256
262
  var skew = 0;
257
263
  var latest = {};
258
264
  var focusTick = 0;
@@ -309,7 +315,6 @@ export const LIVE_PAGE = `<!doctype html>
309
315
  function syncEvents() {
310
316
  var want = {};
311
317
  Object.keys(cards).forEach(function (name) { if (cards[name].live) want[name] = true; });
312
- if (focused && cards[focused]) want[focused] = true;
313
318
  var key = Object.keys(want).sort().join(',');
314
319
  if (key === eventsKey) return;
315
320
  eventsKey = key;
@@ -353,8 +358,20 @@ export const LIVE_PAGE = `<!doctype html>
353
358
  // Frames for a live card arrive over the shared connection; the thumbnail poll takes over again when it is switched off.
354
359
  if (on && frames[card.name]) card.img.src = frames[card.name];
355
360
  if (!on) card.img.src = shotUrl(card.name);
361
+ if (focused === card.name) paintFocusStream();
356
362
  syncEvents();
357
363
  }
364
+ // The close-up's Stream button is the card's, shown where the viewer is looking.
365
+ function paintFocusStream() {
366
+ var card = focused && cards[focused];
367
+ var button = document.getElementById('focus-stream');
368
+ button.hidden = !card;
369
+ if (!card) return;
370
+ button.setAttribute('aria-pressed', card.live ? 'true' : 'false');
371
+ button.textContent = card.live ? 'Streaming' : 'Stream';
372
+ // Off, the picture is a still that the status poll refreshes.
373
+ if (!card.live && !scrubbed) document.getElementById('focus-img').src = shotUrl(card.name);
374
+ }
358
375
  function refreshThumb(card) {
359
376
  if (card.live || document.hidden) return;
360
377
  var next = new Image();
@@ -697,7 +714,7 @@ export const LIVE_PAGE = `<!doctype html>
697
714
  scrubbed = null;
698
715
  document.getElementById('scrub-where').textContent = 'Live';
699
716
  document.getElementById('scrub-live').hidden = true;
700
- if (focused) document.getElementById('focus-img').src = frames[focused] || shotUrl(focused);
717
+ if (focused) document.getElementById('focus-img').src = (cards[focused] && cards[focused].live && frames[focused]) || shotUrl(focused);
701
718
  // Otherwise the step just left keeps its outline until the next full feed.
702
719
  renderTimeline(null);
703
720
  }
@@ -794,6 +811,10 @@ export const LIVE_PAGE = `<!doctype html>
794
811
  document.getElementById('scrub-where').textContent = 'Live';
795
812
  document.getElementById('scrub-live').hidden = true;
796
813
  document.getElementById('focus').classList.add('open');
814
+ var card = cards[name];
815
+ focusStartedStream = !!card && !card.live;
816
+ if (focusStartedStream) setLive(card, true);
817
+ paintFocusStream();
797
818
  hoverTask = null;
798
819
  renderFeed(document.getElementById('focus-feed'), (latest[name] || {}).feed, showTask);
799
820
  syncEvents();
@@ -806,6 +827,8 @@ export const LIVE_PAGE = `<!doctype html>
806
827
  focused = null;
807
828
  document.getElementById('focus').classList.remove('open');
808
829
  document.getElementById('focus-img').removeAttribute('src');
830
+ if (focusStartedStream && was && cards[was]) setLive(cards[was], false);
831
+ focusStartedStream = false;
809
832
  syncEvents();
810
833
  if (was && cards[was]) cards[was].shot.focus();
811
834
  }
@@ -865,6 +888,7 @@ export const LIVE_PAGE = `<!doctype html>
865
888
  if (!s) { line.textContent = focused ? 'This session has closed.' : ''; return; }
866
889
  var d = describe(s);
867
890
  line.textContent = d.badge + ' · ' + d.tool + ' · ' + (s.url || '');
891
+ paintFocusStream();
868
892
  if (focusTick % 3 === 0) loadFullFeed(focused);
869
893
  focusTick += 1;
870
894
  }
@@ -981,6 +1005,13 @@ export const LIVE_PAGE = `<!doctype html>
981
1005
  });
982
1006
  document.getElementById('focus-img').addEventListener('load', function () { document.getElementById('focus-stage').classList.remove('empty'); });
983
1007
  document.getElementById('focus-close').addEventListener('click', closeFocus);
1008
+ document.getElementById('focus-stream').addEventListener('click', function () {
1009
+ var card = focused && cards[focused];
1010
+ if (!card) return;
1011
+ // The viewer chose: closing the close-up no longer undoes it.
1012
+ focusStartedStream = false;
1013
+ setLive(card, !card.live);
1014
+ });
984
1015
  // A re-rendered feed replaces the group under the pointer without a mouseleave; leaving the feed itself still resets.
985
1016
  document.getElementById('focus-feed').addEventListener('mouseleave', function () { showTask(null); });
986
1017
  document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
@@ -283,6 +283,13 @@ function decisionKey(d) {
283
283
  * them on each save, which is what made an old history slow to open.
284
284
  */
285
285
  export const MAX_LANE_DECISIONS = 1000;
286
+ /**
287
+ * Most options a dropdown may have and still be tracked for unchosen options.
288
+ * A status or sort filter has a handful, each of which can change what the
289
+ * page asks the server for; a country or time-zone picker has hundreds, and
290
+ * nobody owes the page a choice of each. Larger dropdowns are not tracked.
291
+ */
292
+ export const MAX_SELECT_OPTIONS = 20;
286
293
  const MAX_DISCOVERED_ROUTES = 300;
287
294
  /** Shared finding-similarity helpers (used by live dedup and retro-merge). */
288
295
  function findingTokens(s) {
@@ -412,6 +419,63 @@ function sameEndpointBug(existing, incoming) {
412
419
  return false;
413
420
  return sharesEndpointSignature(existing, incoming);
414
421
  }
422
+ /**
423
+ * Whether two pieces of evidence each name a request and share none — compared
424
+ * by method and normalised path, ignoring the status, so `GET /api/x` and
425
+ * `GET /api/x/ 404` are one request. Evidence that names no request (a test id,
426
+ * a toast's text) disagrees with nothing.
427
+ */
428
+ export function requestsDisagree(a, b) {
429
+ const requests = (ev) => new Set([...endpointSignatures(ev)].map((sig) => sig.split(" ").slice(0, 2).join(" ")));
430
+ const aReq = requests(a);
431
+ const bReq = requests(b);
432
+ if (aReq.size === 0 || bReq.size === 0)
433
+ return false;
434
+ for (const r of aReq)
435
+ if (bReq.has(r))
436
+ return false;
437
+ return true;
438
+ }
439
+ /**
440
+ * Families of finding kinds that one bug is plausibly filed under by two
441
+ * sessions: a crash is a page-error to one and a console-error to another, a
442
+ * refused save is data-loss to one and data-inconsistency to another. Across
443
+ * families — a layout defect and a data defect — a shared quoted string names
444
+ * a place on the page, not a bug.
445
+ */
446
+ const CATEGORY_FAMILY = {
447
+ "data-inconsistency": "data",
448
+ "data-loss": "data",
449
+ "stale-state": "data",
450
+ "http-error": "failure",
451
+ network: "failure",
452
+ "console-error": "failure",
453
+ "page-error": "failure",
454
+ visual: "presentation",
455
+ "ux-polish": "presentation",
456
+ a11y: "presentation",
457
+ "missing-testid": "presentation",
458
+ "ux-confusing": "flow",
459
+ "dead-end": "flow",
460
+ security: "security",
461
+ "permission-leak": "security",
462
+ performance: "performance",
463
+ };
464
+ /**
465
+ * Whether two categories are one family. "other" — the category for nothing
466
+ * that fits — is a family of its own: as a wildcard, one "other" finding
467
+ * quoting a control absorbed both the layout and the data finding about it. A
468
+ * missing or unknown category matches nothing, so a finding read from an older
469
+ * file never merges on a guess.
470
+ */
471
+ export function sameFamily(a, b) {
472
+ if (!a || !b)
473
+ return false;
474
+ if (a === "other" || b === "other")
475
+ return a === b;
476
+ const fa = CATEGORY_FAMILY[a];
477
+ return fa !== undefined && fa === CATEGORY_FAMILY[b];
478
+ }
415
479
  function sameFinding(a, b) {
416
480
  const aEv = a.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
417
481
  const bEv = b.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
@@ -426,17 +490,39 @@ function sameFinding(a, b) {
426
490
  // them, and collapsed into one. Matching a title literal against the other
427
491
  // finding's full text keeps the intended case (one states the string in its
428
492
  // 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;
493
+ //
494
+ // Only between findings of one FAMILY of kinds (see sameFamily). A quoted
495
+ // string is as often the name of a control as a message the app showed, and
496
+ // a layout defect naming the button it covers ("Save notes") shares that
497
+ // literal with the data defect describing what the button does — in its
498
+ // detail or its own title. Merged, the layout defect was filed and then lost
499
+ // from the report on most runs of a benchmark. Within a family, one bug
500
+ // filed twice under neighbouring categories still merges.
501
+ //
502
+ // And never when both findings' evidence names requests with no endpoint in
503
+ // common. A quoted control name bridges two findings about that control
504
+ // within one family too: "an unknown order id still offers its actions"
505
+ // (GET /api/orders/9999 404) mentions the "Request manager approval" button
506
+ // that "Request manager approval stays enabled on a pending order" (POST
507
+ // …/request-approval 409) quotes in its title, and was merged into it.
508
+ // Evidence that names a request is the finding's own statement of where it
509
+ // happened; two findings naming different requests are two bugs. A request
510
+ // that answered 2xx counts too — a false success names one — so the same bug
511
+ // described once by its page load and once by its failing call stays as two
512
+ // findings: a visible duplicate, the direction ADR 4 accepts.
513
+ if (sameFamily(a.category, b.category) && !requestsDisagree(a.evidence, b.evidence)) {
514
+ const aTitleLits = findingLiterals(a.title);
515
+ const bTitleLits = findingLiterals(b.title);
516
+ if (aTitleLits.size > 0 || bTitleLits.size > 0) {
517
+ const aAll = findingLiterals(a.title, a.detail, a.evidence);
518
+ const bAll = findingLiterals(b.title, b.detail, b.evidence);
519
+ for (const lit of aTitleLits)
520
+ if (bAll.has(lit))
521
+ return true;
522
+ for (const lit of bTitleLits)
523
+ if (aAll.has(lit))
524
+ return true;
525
+ }
440
526
  }
441
527
  // Both carry evidence and neither matched above: distinct bugs, however
442
528
  // similar the titles — never fuzzy-merge across differing evidence.
@@ -595,6 +681,44 @@ export class MemoryStore {
595
681
  this.probes = [];
596
682
  this.injectionsReported.clear();
597
683
  this.auditsThisRun = 0;
684
+ this.selectChoices.clear();
685
+ }
686
+ /**
687
+ * Each dropdown's options and the ones chosen in THIS run, by any session,
688
+ * keyed by route and element. A select counts as exercised after one choice,
689
+ * so a lane that tried four of a filter's seven options — and reported having
690
+ * tried them all — left the one that failed untried with nothing to say so.
691
+ * Per run, like the probes: whether an earlier run chose an option says
692
+ * nothing about whether this one looked. Keyed without the role: when two
693
+ * roles see different options in one dropdown, the list read last is the
694
+ * one reported.
695
+ */
696
+ selectChoices = new Map();
697
+ recordSelectChoice(fingerprint, key, options, chosen) {
698
+ const route = fingerprint.split("#")[0];
699
+ const id = `${route}\u0000${key}`;
700
+ const distinct = [...new Set(options)];
701
+ if (distinct.length > MAX_SELECT_OPTIONS) {
702
+ this.selectChoices.delete(id);
703
+ return;
704
+ }
705
+ const entry = this.selectChoices.get(id) ?? { route, key, options: [], chosen: new Set() };
706
+ // The latest list wins: options a page added or removed since are not owed.
707
+ if (distinct.length > 0)
708
+ entry.options = distinct;
709
+ if (chosen)
710
+ entry.chosen.add(chosen);
711
+ this.selectChoices.set(id, entry);
712
+ }
713
+ /** Dropdowns with options no session chose this run, in the order they were first used. */
714
+ unchosenOptions() {
715
+ const out = [];
716
+ for (const { route, key, options, chosen } of this.selectChoices.values()) {
717
+ const unchosen = options.filter((o) => !chosen.has(o));
718
+ if (unchosen.length > 0)
719
+ out.push({ route, key, unchosen });
720
+ }
721
+ return out;
598
722
  }
599
723
  constructor(projectDir) {
600
724
  this.dir = path.join(projectDir, MEMORY_DIRNAME);
@@ -318,6 +318,21 @@ export function formatRouteCoverage(allRoutes, unvisited) {
318
318
  ? ` — UNVISITED: ${unvisited.slice(0, 25).join(", ")}${unvisited.length > 25 ? " …" : ""} (scout_crawl covers these in one call)`
319
319
  : " ✓"));
320
320
  }
321
+ /**
322
+ * The dropdowns this run used without trying every option. A select counts as
323
+ * exercised after one choice, so the unexercised list above never shows these:
324
+ * a status filter whose one failing option nobody chose looks fully covered.
325
+ * Empty when there is nothing to say.
326
+ */
327
+ export function formatUnchosenOptions(dropdowns) {
328
+ if (dropdowns.length === 0)
329
+ return [];
330
+ return [
331
+ "Dropdown options never chosen this run (each can change what the page asks the server for):",
332
+ ...dropdowns.slice(0, 15).map((d) => ` ${d.route} ${d.key}: ${d.unchosen.map((o) => JSON.stringify(o)).join(", ")}`),
333
+ ...(dropdowns.length > 15 ? [` … +${dropdowns.length - 15} more`] : []),
334
+ ];
335
+ }
321
336
  /**
322
337
  * The GAP LEDGER — an explicit enumeration of what was NOT tested. This is
323
338
  * what turns "extensive" from a vibe into a verifiable claim: a run is only
@@ -41,7 +41,7 @@ import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
41
41
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
42
42
  import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
43
43
  import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
44
- import { computeGaps, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
44
+ import { computeGaps, formatRouteCoverage, formatUnchosenOptions, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
45
45
  import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
46
46
  import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
47
47
  import { describePace, normalizePace } from "./engine/settle.js";
@@ -1147,14 +1147,14 @@ server.registerTool("scout_finding", {
1147
1147
  ? `Finding recorded: [${finding.severity}] ${finding.title} (id ${finding.id})`
1148
1148
  : finding.regressedAt
1149
1149
  ? `⟳ REOPENED as a REGRESSION: finding ${finding.id} was previously resolved but the evidence reproduces again (seen in ${finding.runs} runs). Worth calling out to the user.`
1150
- : `Duplicate of existing finding ${finding.id} (seen in ${finding.runs} runs) — already known, keep exploring.`, session);
1150
+ : `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, seen in ${finding.runs} runs. If yours is a different bug, file it again with evidence naming the request that failed for you (method and path): two findings are kept apart when both name requests and none is shared.`, session);
1151
1151
  }
1152
1152
  catch (err) {
1153
1153
  return errorText(err);
1154
1154
  }
1155
1155
  }));
1156
1156
  server.registerTool("scout_coverage", {
1157
- description: "Show exploration coverage: states visited across all runs and which elements remain unexercised. Use to decide where to explore next and when the level's budget is satisfied.",
1157
+ description: "Show exploration coverage: states visited across all runs, which elements remain unexercised, and which options of a dropdown used this run no session has chosen yet. Use to decide where to explore next and when the level's budget is satisfied.",
1158
1158
  inputSchema: { session: sessionParam },
1159
1159
  }, serializedPerSession("scout_coverage", async (_args, session) => {
1160
1160
  try {
@@ -1173,6 +1173,7 @@ server.registerTool("scout_coverage", {
1173
1173
  formatRouteCoverage(eng.allKnownRoutes(), unvisited),
1174
1174
  `Unexercised elements by route:`,
1175
1175
  ...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
1176
+ ...formatUnchosenOptions(eng.memory.unchosenOptions()),
1176
1177
  ];
1177
1178
  return text(lines.join("\n"), session);
1178
1179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
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",
@@ -42,7 +42,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
42
42
  8. **Design-connoisseur pass without pixels: `scout_design_audit`.** Run it once per representative page (dashboard, a form, a detail view, a data table). Its output has two tiers: **⚠ measurable defects** (WCAG contrast, tiny targets, clipped text, aspect-distorted images, horizontal overflow, keyboard tab stops with no visible focus indicator — sampled with real Tab presses) and **→ craft suggestions** (line measure and line-height rhythm, spacing-grid adherence, typography entropy, gray census and accent-hue count, pure-#000 body text, elevation/control consistency, heading structure, indistinguishable links, AI-slop tells like gradient text/glassmorphism/side-stripe borders/identical card grids), closing with a SYSTEM SUMMARY of design-system coherence. Judge every line with product context (dense tables legitimately have small targets; a chart page legitimately uses many hues). File ⚠ defects as `visual`/`a11y`, and genuine → opportunities as `ux-polish` findings **quoting the concrete numbers** — "~142 characters per line (65–75 ideal)" beats "text feels wide". Every audit ends with a **PAGE SCORE** (0–100 overall + a11y/craft/consistency/task-clarity subscores) persisted per route — the report ranks pages worst-first, so re-runs show whether pages got better or worse. Separately, every `scout_snapshot` runs an **overlay/modal probe** automatically: an empty dialog over a grayed page, a backdrop with no dialog, a far-off-centre dialog leaving a blank band, or a dialog extending unreachably below the viewport appear as OVERLAY lines in GEOMETRY issues — treat these as high-value findings (the user is visually stuck). This is where "how could this page be better" gets answered, not just "is it broken".
43
43
  9. **Measure task EASE with `scout_journey`, not just correctness.** Wrap each module's primary task (`{action:"start", goal:"Create an order"}` → do it → `{action:"end", completed:…}`). Navigate by CLICKING like a first-time user — typing a known deep URL shortcuts the very thing being measured (a route you can only reach by editing the address bar is itself a finding). The result gives interaction count, distinct screens, the path taken, and BACKTRACKS — returning to a screen already left is the clearest evidence the next step wasn't discoverable. An abandoned journey (`completed:false`) is a high-severity finding: the task is blocked or undiscoverable, which no passing e2e suite would ever reveal.
44
44
  10. **Walk the auth surface too — anonymously.** Attach a second session WITHOUT a storage-state file (a fresh logged-out profile) and exercise signup, login failure states, and forgot/reset-password **as far as they physically go**. The mailbox wall is expected — reaching "check your email" IS the success condition; everything before it is what you're testing: does submit actually fire (a dead signup button is a high finding), are errors specific and actionable, can the user resend or recover from a typo, does the flow dead-end. Use plausible synthetic identities only (invent `qa-<runid>@example.com`-style addresses, never a real person's), submit each form valid AND invalid, and judge the feedback. Two classic findings live here: a forgot-password that answers "no account with that email" is an **account-enumeration leak** (file as security; "if an account exists, we sent a link" is the correct shape), and a signup that accepts the form then lands on a blank or logged-out page with no guidance is a **journey dead-end**. Signup creates a record, so what this pass may do depends on the mode. In `observe`, fill and submit the auth forms for their CLIENT-SIDE behaviour only: the engine blocks signup, password change and reset, and lets only a login itself go out. Disclose the server-side half as a gap. Actually creating an account needs the user's explicit okay and safe-write mode; the engine tracks the created account like any other creation.
45
- 11. **`scout_coverage` decides what's next** — it lists unvisited routes and unexercised elements. Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
45
+ 11. **`scout_coverage` decides what's next** — it lists unvisited routes, unexercised elements, and the options of each dropdown you used that no session has chosen this run (a filter counts as exercised after one choice, and the option you skipped can be the one whose request fails). Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
46
46
 
47
47
  ## Levels (completion contracts — the engine ENFORCES them via `scout_report {level}`)
48
48