scenescout 3.14.1 → 3.16.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -86,6 +86,41 @@ const ERROR_RE = /\b(?:error|failed|failure|could ?n[o'’]?t|unable to|went wro
86
86
  * unreported because the Danger zone said "This cannot be undone."
87
87
  */
88
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
+ /**
90
+ * Words that name a refusal outright. An admission when the page ANNOUNCES them
91
+ * or puts them up in answer to the action, as when a toast echoes the server's
92
+ * "was refused" message. Not page-wide: "Rejected" and "Blocked" are ordinary
93
+ * status badges and filter tabs on record pages, and counted wherever they
94
+ * stood they would excuse every lie on such a page.
95
+ */
96
+ const REFUSED_RE = /\b(?:refused|rejected|blocked)\b/i;
97
+ /**
98
+ * Addresses of a page's own infrastructure writes: a token refresh, telemetry,
99
+ * an error monitor, a heartbeat. Refusing one says nothing about whether the
100
+ * change the user made was kept, so it is never paired with a success claim.
101
+ */
102
+ export const INFRASTRUCTURE_WRITE_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/envelope\b|client[-_]?errors?\b|\/collect\b|\/logs?\b|\/metrics\b/i;
103
+ /**
104
+ * Whether a request is one the action being judged did not send (see
105
+ * `WatchedRequest.background`).
106
+ *
107
+ * Once a click has started loading a new document, what follows is not the
108
+ * click's own write. Requests the old page sends as it is left (a preference
109
+ * save on pagehide) belong to that page, and requests the new page sends as it
110
+ * loads (an error monitor, a visit beacon) are sent with no input on it yet.
111
+ * Neither is answered by anything on screen, and the new page's text is all
112
+ * new against the old page's, so paired they read every static "Completed" as
113
+ * a false success. The click's own writes come before the navigation starts:
114
+ * a script's save awaited before it moves the page, or the navigation itself
115
+ * when it is a native form post.
116
+ */
117
+ export function isBackgroundRequest(t) {
118
+ if (t.inputSince === null || t.inputSince < t.actionStartedAt || t.started < t.inputSince)
119
+ return true;
120
+ if (t.isDocumentLoad)
121
+ return false;
122
+ return t.documentLoadSince !== null && t.started > t.documentLoadSince;
123
+ }
89
124
  /** Whether a piece of announced text explains a refusal. */
90
125
  export function isRefusalNotice(text) {
91
126
  const trimmed = text.trim();
@@ -110,6 +145,15 @@ export function classify(text) {
110
145
  return "empty";
111
146
  return null;
112
147
  }
148
+ /** What counts as an open dialog: a native one or an ARIA one. */
149
+ const DIALOG_SEL = 'dialog[open], [role="dialog"], [role="alertdialog"]';
150
+ /** Page-side count of the open dialogs alone, for a caller that needs nothing else the claim scan reads. */
151
+ export const OPEN_DIALOGS_SCRIPT = `(() => {
152
+ const visible = ${VISIBLE_SRC};
153
+ let dialogs = 0;
154
+ for (const d of document.querySelectorAll(${JSON.stringify(DIALOG_SEL)})) if (visible(d)) dialogs += 1;
155
+ return dialogs;
156
+ })()`;
113
157
  function shortUrl(url) {
114
158
  try {
115
159
  const u = new URL(url);
@@ -128,25 +172,42 @@ function standIn(req) {
128
172
  ? ` The refusal was the write policy's stand-in (the server never received the request); what failed is the page's handling of a refusal, which a real one would meet the same way.`
129
173
  : "";
130
174
  }
175
+ /** Whether an announced sentence admits the failure: an error, a refusal named outright, or one explained in the app's own words. */
176
+ function admitsInAnnouncement(text) {
177
+ return classify(text) === "error" || REFUSED_RE.test(text) || isRefusalNotice(text);
178
+ }
131
179
  /**
132
180
  * The contradictions this action produced, if any.
133
181
  *
134
182
  * Both rules are silent whenever the page admits the failure, and both require
135
183
  * a genuinely refused request — the page half never fires alone.
184
+ *
185
+ * `before` is what the page said when the action began, when the action was
186
+ * the user's (a click, a keypress, typing). A success claim already on screen
187
+ * then is not the page's answer to this action's write: a status badge reading
188
+ * "Published", a heading, a row from earlier. Only what the action put up can
189
+ * contradict what the action's write met. Without it, every text counts.
136
190
  */
137
- export function findContradictions(requests, page) {
191
+ export function findContradictions(requests, page, before, acted) {
138
192
  const refused = requests.filter(isRefused);
139
193
  if (refused.length === 0)
140
194
  return [];
141
195
  const claims = page.texts.map(classify);
196
+ const earlier = before ? new Set([...before.texts, ...(before.announced ?? [])]) : null;
197
+ const isNew = (text) => earlier === null || !earlier.has(text);
142
198
  // An app that says what went wrong has behaved correctly, and nothing below
143
199
  // applies. This is checked before anything else so that a page carrying both
144
200
  // an error banner and a stale empty state is not reported.
145
201
  if (claims.includes("error"))
146
202
  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))
203
+ // An admission where the page announces its responses: an error, a refusal
204
+ // named outright (an app echoing the server's "was refused"), or one
205
+ // explained in the app's own words. Help text with the same wording
206
+ // elsewhere excuses nothing.
207
+ if ((page.announced ?? []).some(admitsInAnnouncement))
208
+ return [];
209
+ // A refusal named outright in text the action put up, announced or not.
210
+ if (earlier !== null && page.texts.some((t) => isNew(t) && t.length <= CLAIM_TEXT_MAX && REFUSED_RE.test(t)))
150
211
  return [];
151
212
  const out = [];
152
213
  const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
@@ -162,18 +223,171 @@ export function findContradictions(requests, page) {
162
223
  evidence: `refused-empty ${say(worst)}`,
163
224
  });
164
225
  }
165
- const writes = refused.filter((r) => WRITING_METHODS.has(r.method.toUpperCase()));
166
- if (writes.length > 0 && claims.includes("success")) {
226
+ // Only writes this action sent, and not the page's own infrastructure.
227
+ const isActionWrite = (r) => WRITING_METHODS.has(r.method.toUpperCase()) && !r.background && !INFRASTRUCTURE_WRITE_RE.test(r.url);
228
+ const writes = refused.filter(isActionWrite);
229
+ // Writes of the same action that went through. The message may be about
230
+ // one of them, so a success claim beside them is a PARTIAL false success:
231
+ // part of the user's change was refused and the page said nothing about
232
+ // that part. Still reported, at medium, because silent partial loss is a
233
+ // real defect; all refused stays high.
234
+ const kept = requests.filter((r) => isActionWrite(r) && DATA_RESOURCES.has(r.resourceType) && !r.blockedByPolicy && r.status !== null && r.status < 400);
235
+ const successAt = claims.findIndex((c, i) => c === "success" && isNew(page.texts[i]));
236
+ if (writes.length > 0 && successAt >= 0) {
237
+ const worst = writes[0];
238
+ const message = JSON.stringify(page.texts[successAt].trim().slice(0, 80));
239
+ if (kept.length === 0) {
240
+ out.push({
241
+ kind: "false_success",
242
+ detail: `${say(worst)} was refused, and the page says ${message}. The user is told their change was kept when the server rejected it.${standIn(worst)}`,
243
+ evidence: `false-success ${say(worst)}`,
244
+ });
245
+ }
246
+ else {
247
+ const total = writes.length + kept.length;
248
+ out.push({
249
+ kind: "false_success",
250
+ severity: "medium",
251
+ detail: `partial: ${writes.length} of ${total} writes from this action were refused (${say(worst)}), and the page says ${message} with no word about the refused part. ` +
252
+ `The user is told their change was kept when part of it was rejected.${standIn(worst)}`,
253
+ evidence: `false-success-partial ${say(worst)}`,
254
+ });
255
+ }
256
+ }
257
+ // No success word, but the control or a counter beside it shows the change
258
+ // as kept: an optimistic UI telling the same falsehood without words.
259
+ // Medium: the page never said "saved", and a reader may still notice a
260
+ // reload putting the old value back.
261
+ const change = acted && writes.length > 0 && kept.length === 0 && successAt < 0 ? keptChange(acted.before, acted.after) : null;
262
+ if (change) {
167
263
  const worst = writes[0];
168
- const message = page.texts[claims.indexOf("success")];
169
264
  out.push({
170
265
  kind: "false_success",
171
- detail: `${say(worst)} was refused, and the page says ${JSON.stringify(message.trim().slice(0, 80))}. The user is told their change was kept when the server rejected it.${standIn(worst)}`,
172
- evidence: `false-success ${say(worst)}`,
266
+ severity: "medium",
267
+ detail: `${say(worst)} was refused, and the page shows the change as kept (${change}) with no error. The user is told their change was kept when the server rejected it.${standIn(worst)}`,
268
+ evidence: `false-success-kept ${say(worst)}`,
173
269
  });
174
270
  }
175
271
  return out;
176
272
  }
273
+ /** A counter: "1/3", "2 of 5", "40%". Not a date ("10/02/2026") nor a part of a longer number. */
274
+ export const COUNTER_RE = /(?<![\d/.,:])(?:\d{1,4} ?(?:\/|of) ?\d{1,4}|\d{1,3} ?%)(?![\d/.,:])/i;
275
+ /** The containers a counter must share with the control to count as its counter. */
276
+ const CONTROL_CONTAINER_SEL = 'dialog, [role="dialog"], [role="alertdialog"], fieldset, form, [role="group"], [role="radiogroup"], [role="toolbar"], [role="region"], section, aside, [role="complementary"]';
277
+ /** Most counters read for one control. */
278
+ export const MAX_COUNTERS = 20;
279
+ /**
280
+ * Page-side reader of an acted-on control: `(${ACTED_CONTROL_SRC})(node)`
281
+ * returns an ActedControl, or null for no node. A label stands for the box it
282
+ * labels. Shipped as a string for the same reason CLAIM_SCAN_SCRIPT is.
283
+ */
284
+ export const ACTED_CONTROL_SRC = `(node) => {
285
+ if (!node) return null;
286
+ const visible = ${VISIBLE_SRC};
287
+ const box = (n) => n && n.tagName === "INPUT" && (n.type === "checkbox" || n.type === "radio");
288
+ const control = node.control || (box(node) ? node : node.querySelector && node.querySelector("input[type=checkbox], input[type=radio]")) || node;
289
+ const flag = (v) => (v === "true" ? true : v === "false" ? false : null);
290
+ const role = control.getAttribute("role") || (box(control) ? control.type : control.tagName.toLowerCase());
291
+ const counter = ${COUNTER_RE};
292
+ const counters = [];
293
+ const add = (t) => {
294
+ const text = (t || "").replace(/\\s+/g, " ").trim();
295
+ if (text && text.length <= 60 && counter.test(text) && !counters.includes(text) && counters.length < ${MAX_COUNTERS}) counters.push(text);
296
+ };
297
+ const near = control.closest(${JSON.stringify(CONTROL_CONTAINER_SEL)}) || (control.parentElement && control.parentElement.parentElement);
298
+ if (near) {
299
+ const walker = document.createTreeWalker(near, NodeFilter.SHOW_ELEMENT);
300
+ for (let el = near; el && counters.length < ${MAX_COUNTERS}; el = walker.nextNode()) {
301
+ let own = "";
302
+ for (const child of el.childNodes) if (child.nodeType === 3) own += child.nodeValue;
303
+ if (own.trim() && visible(el)) add(own);
304
+ }
305
+ }
306
+ for (const region of document.querySelectorAll("[role~='status'], [role~='alert'], [aria-live]:not([aria-live='off']), output")) {
307
+ if (visible(region)) add(region.innerText);
308
+ }
309
+ return {
310
+ role,
311
+ checked: box(control) ? control.checked : flag(control.getAttribute("aria-checked")),
312
+ pressed: flag(control.getAttribute("aria-pressed")),
313
+ selected: flag(control.getAttribute("aria-selected")),
314
+ counters,
315
+ };
316
+ }`;
317
+ /**
318
+ * How the acted-on control shows its change as kept, or null when it does not.
319
+ *
320
+ * A checked, pressed or selected state that moved is the change itself (a
321
+ * tab's selection is not: choosing a tab is moving around, not a change the
322
+ * user asked to keep). A counter beside it that moved ("0/3" to "1/3
323
+ * completed") counts it as done. A counter that merely appeared does not: a
324
+ * step that opens with "Step 1 of 3" counts nothing yet.
325
+ */
326
+ export function keptChange(before, after) {
327
+ for (const state of ["checked", "pressed", "selected"]) {
328
+ if (state === "selected" && after.role === "tab")
329
+ continue;
330
+ const was = before[state];
331
+ const now = after[state];
332
+ if (was !== null && now !== null && was !== now)
333
+ return `the control is now ${now ? state : `not ${state}`}`;
334
+ }
335
+ const gone = before.counters.filter((c) => !after.counters.includes(c));
336
+ const fresh = after.counters.filter((c) => !before.counters.includes(c));
337
+ if (gone.length > 0 && fresh.length > 0)
338
+ return `${JSON.stringify(gone[0])} became ${JSON.stringify(fresh[0])}`;
339
+ return null;
340
+ }
341
+ /**
342
+ * Whether a submit-shaped click that sent no request was answered on the page
343
+ * anyway, judged from the page as the click began and as it settled.
344
+ *
345
+ * - A dialog opened: a step (a confirmation), not a submit.
346
+ * - A field became invalid, or the page announced something new that is not
347
+ * a success claim: client-side validation said what was wrong, and no
348
+ * request was expected. A new "Saved!" is the opposite, the very case the
349
+ * silent-submit note is for, so it never counts.
350
+ *
351
+ * Null when either reading is missing or nothing visible answered.
352
+ */
353
+ export function quietAnswer(before, after) {
354
+ if (!before || !after)
355
+ return null;
356
+ if ((after.dialogs ?? 0) > (before.dialogs ?? 0))
357
+ return "dialog";
358
+ if ((after.invalid ?? 0) > (before.invalid ?? 0))
359
+ return "validation";
360
+ const said = new Set(before.announced ?? []);
361
+ if ((after.announced ?? []).some((text) => !said.has(text) && classify(text) !== "success"))
362
+ return "validation";
363
+ return null;
364
+ }
365
+ /**
366
+ * Whether a piece of announced text (an alert's or a live region's name, as
367
+ * the snapshot lists it) was not on the page at `baseline`. The name may be a
368
+ * region's sentences run together and cut short, so it is matched by prefix
369
+ * either way against the baseline's texts and announced sentences. False
370
+ * when there is no baseline: nothing says when the text arrived.
371
+ */
372
+ export function saidSince(text, baseline) {
373
+ if (!baseline)
374
+ return false;
375
+ const norm = (t) => t.replace(/\s+/g, " ").trim();
376
+ const said = norm(text);
377
+ if (!said)
378
+ return false;
379
+ for (const earlier of [...baseline.texts, ...(baseline.announced ?? [])]) {
380
+ const e = norm(earlier);
381
+ if (!e)
382
+ continue;
383
+ if (e === said)
384
+ return false;
385
+ const shorter = Math.min(e.length, said.length);
386
+ if (shorter >= 20 && (e.startsWith(said) || said.startsWith(e)))
387
+ return false;
388
+ }
389
+ return true;
390
+ }
177
391
  /** Most announced regions read from one page. */
178
392
  export const MAX_ANNOUNCED = 40;
179
393
  /** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
@@ -200,6 +414,7 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
200
414
  // is a container of static text (its form's help, its warning), and counting
201
415
  // it brought back the help text this set exists to exclude.
202
416
  const ANNOUNCES = "[role~='status'], [role~='alert'], [aria-live]:not([aria-live='off']), output";
417
+ const COLUMN_HEADER = "th, [role~='columnheader'], [aria-sort]";
203
418
  const seen = new Set();
204
419
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
205
420
  let el = document.body;
@@ -207,7 +422,8 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
207
422
  let own = "";
208
423
  for (const node of el.childNodes) if (node.nodeType === 3) own += node.nodeValue;
209
424
  own = own.replace(/\\s+/g, " ").trim();
210
- if (own && own.length <= ${CLAIM_TEXT_MAX} && !seen.has(own) && visible(el)) {
425
+ // A column header names what a column holds ("Updated on", "Completed"); it claims nothing.
426
+ if (own && own.length <= ${CLAIM_TEXT_MAX} && !seen.has(own) && !el.closest(COLUMN_HEADER) && visible(el)) {
211
427
  seen.add(own);
212
428
  texts.push(own);
213
429
  }
@@ -243,7 +459,13 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
243
459
  if (s && s.length <= ${CLAIM_TEXT_MAX} && !announced.includes(s)) announced.push(s);
244
460
  }
245
461
  }
462
+ // Open dialogs, so a page error raised as one opens can be read as the
463
+ // confirmation a router cancelled a route change for (oracles.ts).
464
+ let dialogs = 0;
465
+ for (const d of document.querySelectorAll(${JSON.stringify(DIALOG_SEL)})) if (visible(d)) dialogs += 1;
466
+ // Fields the page marked invalid, so a submit answered by validation is not called silent.
467
+ let invalid = 0;
468
+ for (const f of document.querySelectorAll('[aria-invalid="true"]')) if (visible(f)) invalid += 1;
246
469
 
247
-
248
- return { texts, announced, emptyLists };
470
+ return { texts, announced, emptyLists, dialogs, invalid };
249
471
  })()`;