scenescout 3.15.0 → 3.17.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 +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
package/dist/engine/claims.js
CHANGED
|
@@ -99,7 +99,28 @@ const REFUSED_RE = /\b(?:refused|rejected|blocked)\b/i;
|
|
|
99
99
|
* an error monitor, a heartbeat. Refusing one says nothing about whether the
|
|
100
100
|
* change the user made was kept, so it is never paired with a success claim.
|
|
101
101
|
*/
|
|
102
|
-
export const INFRASTRUCTURE_WRITE_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/collect\b|\/logs?\b|\/metrics\b/i;
|
|
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
|
+
}
|
|
103
124
|
/** Whether a piece of announced text explains a refusal. */
|
|
104
125
|
export function isRefusalNotice(text) {
|
|
105
126
|
const trimmed = text.trim();
|
|
@@ -167,7 +188,7 @@ function admitsInAnnouncement(text) {
|
|
|
167
188
|
* "Published", a heading, a row from earlier. Only what the action put up can
|
|
168
189
|
* contradict what the action's write met. Without it, every text counts.
|
|
169
190
|
*/
|
|
170
|
-
export function findContradictions(requests, page, before) {
|
|
191
|
+
export function findContradictions(requests, page, before, acted) {
|
|
171
192
|
const refused = requests.filter(isRefused);
|
|
172
193
|
if (refused.length === 0)
|
|
173
194
|
return [];
|
|
@@ -233,8 +254,140 @@ export function findContradictions(requests, page, before) {
|
|
|
233
254
|
});
|
|
234
255
|
}
|
|
235
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) {
|
|
263
|
+
const worst = writes[0];
|
|
264
|
+
out.push({
|
|
265
|
+
kind: "false_success",
|
|
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)}`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
236
271
|
return out;
|
|
237
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
|
+
}
|
|
238
391
|
/** Most announced regions read from one page. */
|
|
239
392
|
export const MAX_ANNOUNCED = 40;
|
|
240
393
|
/** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
|
|
@@ -310,6 +463,9 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
|
|
|
310
463
|
// confirmation a router cancelled a route change for (oracles.ts).
|
|
311
464
|
let dialogs = 0;
|
|
312
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;
|
|
313
469
|
|
|
314
|
-
return { texts, announced, emptyLists, dialogs };
|
|
470
|
+
return { texts, announced, emptyLists, dialogs, invalid };
|
|
315
471
|
})()`;
|