scenescout 1.0.0 → 1.1.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 +16 -0
- package/README.md +104 -51
- package/dist/cli.js +2 -2
- package/dist/code-routes.js +462 -0
- package/dist/engine/browser.js +67 -21
- package/dist/engine/collector.js +148 -7
- package/dist/engine/policy.js +55 -3
- package/dist/engine/report.js +6 -1
- package/dist/installer.js +19 -7
- package/dist/mcp-server.js +8 -3
- package/dist/scan.js +26 -2
- package/package.json +5 -4
- package/skills/scenescout/SKILL.md +8 -5
package/dist/engine/browser.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
5
5
|
import { AUTH_LOSS_PREFIX, MemoryStore } from "./memory.js";
|
|
6
6
|
import { AuthLossTracker } from "./authloss.js";
|
|
7
|
-
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues } from "./collector.js";
|
|
7
|
+
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
|
|
8
8
|
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
9
9
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
10
10
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
@@ -12,7 +12,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
|
12
12
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
13
13
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
14
14
|
import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
|
|
15
|
-
import {
|
|
15
|
+
import { destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, isAuthExempt } from "./policy.js";
|
|
16
16
|
import { scanProject } from "../scan.js";
|
|
17
17
|
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
18
18
|
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
@@ -125,7 +125,8 @@ export class BrowserEngine {
|
|
|
125
125
|
authLoss = new AuthLossTracker();
|
|
126
126
|
/** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
|
|
127
127
|
get readOnly() {
|
|
128
|
-
|
|
128
|
+
// observe is read-only and then some: every UI-level refusal applies to it too.
|
|
129
|
+
return this.mode === "read-only" || this.mode === "observe";
|
|
129
130
|
}
|
|
130
131
|
/** Append to the shared action log, stamped with THIS session so per-session
|
|
131
132
|
* reads (journey paths) can separate concurrent roles' interleaved actions. */
|
|
@@ -134,6 +135,14 @@ export class BrowserEngine {
|
|
|
134
135
|
}
|
|
135
136
|
/** Requests blocked by the write policy since the last action (timestamped for attribution). */
|
|
136
137
|
blockedRequests = [];
|
|
138
|
+
/**
|
|
139
|
+
* WebSockets this session's pages opened. The write policy works on HTTP
|
|
140
|
+
* requests; frames sent over a socket are not inspected. In observe mode that
|
|
141
|
+
* is a hole in "nothing leaves the page", so it is said out loud rather than
|
|
142
|
+
* left for the reader to discover.
|
|
143
|
+
*/
|
|
144
|
+
openSockets = new Set();
|
|
145
|
+
socketsWarned = false;
|
|
137
146
|
/** When the current action began — requests recorded before this are late arrivals from a previous action. */
|
|
138
147
|
actionStartedAt = 0;
|
|
139
148
|
/**
|
|
@@ -299,6 +308,13 @@ export class BrowserEngine {
|
|
|
299
308
|
// Label-based read-only blocking can't catch every mutation (an innocuous
|
|
300
309
|
// "Add to Cart" fires a POST). Track non-GET traffic so actions that
|
|
301
310
|
// changed server state are at least REPORTED in read-only runs.
|
|
311
|
+
this.openSockets.clear();
|
|
312
|
+
this.socketsWarned = false;
|
|
313
|
+
const watchSockets = (p) => void p.on("websocket", (ws) => this.openSockets.add(ws.url().slice(0, 120)));
|
|
314
|
+
// The first page already exists by now; later ones (popups) arrive as events.
|
|
315
|
+
if (this.page)
|
|
316
|
+
watchSockets(this.page);
|
|
317
|
+
this.context.on("page", watchSockets);
|
|
302
318
|
this.context.on("request", (req) => {
|
|
303
319
|
const type = req.resourceType();
|
|
304
320
|
if (type === "xhr" || type === "fetch")
|
|
@@ -321,6 +337,9 @@ export class BrowserEngine {
|
|
|
321
337
|
// here let a REFUSED destructive POST mark the route as mutated — a form
|
|
322
338
|
// that was never submitted reading as tested, in read-only mode where by
|
|
323
339
|
// definition nothing is.
|
|
340
|
+
// Same test the route handler uses: in observe only an exempt auth request goes out.
|
|
341
|
+
if (this.mode === "observe" && !isAuthExempt(this.mode, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
|
|
342
|
+
return;
|
|
324
343
|
if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
|
|
325
344
|
return;
|
|
326
345
|
const pageUrl = this.page?.url();
|
|
@@ -342,10 +361,11 @@ export class BrowserEngine {
|
|
|
342
361
|
return route.continue();
|
|
343
362
|
const url = req.url();
|
|
344
363
|
const pathname = pathnameOf(url);
|
|
345
|
-
// Auth/session flows (login, refresh, logout) must work in every mode.
|
|
346
|
-
if (AUTH_FLOW_RE.test(pathname) && method === "POST")
|
|
347
|
-
return route.continue();
|
|
348
364
|
const destructiveWire = isDestructiveWire(pathname, req.postData());
|
|
365
|
+
// Auth/session flows must work in every mode — but never a destructive
|
|
366
|
+
// one, and in observe only the requests a login itself needs.
|
|
367
|
+
if (isAuthExempt(this.mode, method, pathname, destructiveWire))
|
|
368
|
+
return route.continue();
|
|
349
369
|
let owned = this.isOwnedResource(pathname);
|
|
350
370
|
// A single UI action commonly fires create-then-immediately-save
|
|
351
371
|
// (POST gets an id, PUT saves content under it) faster than the
|
|
@@ -362,7 +382,7 @@ export class BrowserEngine {
|
|
|
362
382
|
}
|
|
363
383
|
// POST: creation/RPC passes unless it smells destructive and isn't ours.
|
|
364
384
|
// PUT/PATCH/DELETE: only in safe-write, only on our own resources.
|
|
365
|
-
const allow =
|
|
385
|
+
const allow = allowsWrite(this.mode, method, destructiveWire, owned);
|
|
366
386
|
if (allow) {
|
|
367
387
|
// Ownership tracking (safe-write): register the creation-tracking
|
|
368
388
|
// task BEFORE the POST goes out. Registering from a context
|
|
@@ -668,6 +688,7 @@ export class BrowserEngine {
|
|
|
668
688
|
const geometry = geometryIssues(elements, page.viewportSize() ?? { width: 1280, height: 900 });
|
|
669
689
|
geometry.push(...(await probeOverlays(page)));
|
|
670
690
|
const hiddenFileInputs = await this.hiddenFileInputs(page);
|
|
691
|
+
const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
|
|
671
692
|
const cov = memory.coverage();
|
|
672
693
|
const unvisited = this.unvisitedKnownRoutes();
|
|
673
694
|
const title = await page.title();
|
|
@@ -677,10 +698,12 @@ export class BrowserEngine {
|
|
|
677
698
|
`\n` +
|
|
678
699
|
body +
|
|
679
700
|
(geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
|
|
701
|
+
(brokenImages.length > 0 ? `\nBROKEN IMAGES:\n` + brokenImages.map((b) => ` ⚠ ${b}`).join("\n") : "") +
|
|
680
702
|
(hiddenFileInputs.length > 0
|
|
681
703
|
? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
|
|
682
704
|
`scout_upload {ref} on the control that opens one, or scout_upload {} when it is the page's only file input.`
|
|
683
705
|
: "") +
|
|
706
|
+
this.socketNotice() +
|
|
684
707
|
formatViolations(this.oracles.drain()) +
|
|
685
708
|
(elements.length === 0 ? "\n⚠ DEAD END: no interactable elements found on this page." : ""));
|
|
686
709
|
}
|
|
@@ -732,7 +755,7 @@ export class BrowserEngine {
|
|
|
732
755
|
if (el.role === "textbox" || el.role === "file")
|
|
733
756
|
return null;
|
|
734
757
|
if (el.destructive || isDestructive(liveLabel)) {
|
|
735
|
-
return destructiveRefusal(liveLabel || el.name || el.testid || el.ref);
|
|
758
|
+
return destructiveRefusal(liveLabel || el.name || el.testid || el.ref, this.mode);
|
|
736
759
|
}
|
|
737
760
|
return null;
|
|
738
761
|
}
|
|
@@ -741,13 +764,22 @@ export class BrowserEngine {
|
|
|
741
764
|
await this.settle();
|
|
742
765
|
let url = page.url();
|
|
743
766
|
if (url !== "about:blank" && !this.isSameOrigin(url)) {
|
|
744
|
-
//
|
|
767
|
+
// Either a click carried us off the app's origin, or the write policy
|
|
768
|
+
// aborted a NAVIGATION (a native form post) and the browser is showing
|
|
769
|
+
// its error page. The second is the tester's own doing and must say so:
|
|
770
|
+
// reported as an off-origin bounce, it hid the block, and the caller then
|
|
771
|
+
// read the unchanged URL as "the app silently discarded the data".
|
|
772
|
+
const policyAbortedNavigation = url.startsWith("chrome-error://") && this.blockedRequests.length > 0;
|
|
745
773
|
this.logAction({ action, target, url });
|
|
746
|
-
this.logAction({ action: "origin-fence:bounced", target: url.slice(0, 200), url });
|
|
774
|
+
this.logAction({ action: policyAbortedNavigation ? "write-policy:navigation-blocked" : "origin-fence:bounced", target: url.slice(0, 200), url });
|
|
747
775
|
await page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
|
|
748
776
|
url = page.url();
|
|
749
777
|
this.refs.clear();
|
|
750
|
-
|
|
778
|
+
const blocked = this.drainBlocked();
|
|
779
|
+
return ((policyAbortedNavigation
|
|
780
|
+
? `OK: ${action} ${target}\nThe page tried to navigate with a request the write policy blocked, so the browser showed an error page; returned to ${url}.`
|
|
781
|
+
: `OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.`) +
|
|
782
|
+
blocked +
|
|
751
783
|
formatViolations(this.oracles.drain()));
|
|
752
784
|
}
|
|
753
785
|
this.logAction({ action, target, url });
|
|
@@ -821,9 +853,20 @@ export class BrowserEngine {
|
|
|
821
853
|
this.blockedRequests = [];
|
|
822
854
|
return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
|
|
823
855
|
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
824
|
-
(this.mode === "
|
|
825
|
-
? `Re-attach with mode="
|
|
826
|
-
:
|
|
856
|
+
(this.mode === "observe"
|
|
857
|
+
? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target.`
|
|
858
|
+
: this.mode === "read-only"
|
|
859
|
+
? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
|
|
860
|
+
: `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
|
|
861
|
+
}
|
|
862
|
+
/** Once per session, in observe mode only: say that socket frames are outside the policy. */
|
|
863
|
+
socketNotice() {
|
|
864
|
+
if (this.mode !== "observe" || this.socketsWarned || this.openSockets.size === 0)
|
|
865
|
+
return "";
|
|
866
|
+
this.socketsWarned = true;
|
|
867
|
+
return (`\n⚠ OBSERVE LIMIT: this app holds an open WebSocket (${[...this.openSockets].slice(0, 2).join(", ")}). ` +
|
|
868
|
+
`The write policy blocks HTTP requests; frames sent over a socket are NOT inspected. ` +
|
|
869
|
+
`Do not perform actions that send data over it (chat messages, live edits, presence) — look, do not type, in socket-driven widgets — and say so in your summary.`);
|
|
827
870
|
}
|
|
828
871
|
/** xhr/fetch requests seen this session — lets clicks detect silent no-op submits. */
|
|
829
872
|
xhrCount = 0;
|
|
@@ -851,7 +894,7 @@ export class BrowserEngine {
|
|
|
851
894
|
.join("; ");
|
|
852
895
|
const extra = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : "";
|
|
853
896
|
return this.readOnly
|
|
854
|
-
? `\n⚠ READ-ONLY notice: this action fired state-changing requests — server state may have mutated despite
|
|
897
|
+
? `\n⚠ READ-ONLY notice: this action fired state-changing requests — server state may have mutated despite ${this.mode} mode: ${list}${extra}. Consider whether this flow should be avoided or the environment confirmed disposable.`
|
|
855
898
|
: `\n(state-changing requests: ${list}${extra})`;
|
|
856
899
|
}
|
|
857
900
|
/**
|
|
@@ -934,7 +977,10 @@ export class BrowserEngine {
|
|
|
934
977
|
const forcedNote = forced
|
|
935
978
|
? `\nℹ NOTE: the strict click timed out waiting for this element to be the stable, unobstructed top hit at its coordinates, so a forced click was used instead (which still landed — this succeeded). Something is likely rendered on top of it (an icon, a decorative layer, an animating wrapper) or it delegates via a label; cross-check against any GEOMETRY overlap on this element before treating that as a real bug.`
|
|
936
979
|
: "";
|
|
937
|
-
|
|
980
|
+
// Not when the write policy blocked the submission: a native form POST or a
|
|
981
|
+
// beacon is not counted as xhr/fetch, so an aborted one looks exactly like
|
|
982
|
+
// "fired nothing" — and the note would blame the app for the tool's block.
|
|
983
|
+
if (submitLike && this.xhrCount === xhrBefore && this.lastActionBlocked === 0 && page.url() === this.snapshotUrl) {
|
|
938
984
|
return (result +
|
|
939
985
|
`\nℹ NOTE: this submit-style click fired ZERO network requests and no navigation — if the UI showed success, the data may have been silently discarded (worth verifying; category: other/silent-failure).` +
|
|
940
986
|
forcedNote);
|
|
@@ -1033,7 +1079,7 @@ export class BrowserEngine {
|
|
|
1033
1079
|
.catch(() => ""));
|
|
1034
1080
|
if (isDestructive(submitLabel)) {
|
|
1035
1081
|
this.logAction({ action: "type:enter-refused", target: submitLabel, url: page.url() });
|
|
1036
|
-
return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel);
|
|
1082
|
+
return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel, this.mode);
|
|
1037
1083
|
}
|
|
1038
1084
|
}
|
|
1039
1085
|
await locator.press("Enter", { timeout: ACTION_TIMEOUT_MS });
|
|
@@ -1307,7 +1353,7 @@ export class BrowserEngine {
|
|
|
1307
1353
|
.catch(() => ""));
|
|
1308
1354
|
if (isDestructive(value) || isDestructive(optionLabel)) {
|
|
1309
1355
|
this.logAction({ action: "select:refused", target: optionLabel || value, url: page.url() });
|
|
1310
|
-
return destructiveRefusal(optionLabel || value);
|
|
1356
|
+
return destructiveRefusal(optionLabel || value, this.mode);
|
|
1311
1357
|
}
|
|
1312
1358
|
}
|
|
1313
1359
|
await page.locator(`xpath=${el.xpath}`).selectOption(value, { timeout: ACTION_TIMEOUT_MS });
|
|
@@ -1329,7 +1375,7 @@ export class BrowserEngine {
|
|
|
1329
1375
|
.catch(() => "");
|
|
1330
1376
|
if (typeof focusedLabel === "string" && isDestructive(focusedLabel)) {
|
|
1331
1377
|
this.logAction({ action: "press:refused", target: focusedLabel, url: page.url() });
|
|
1332
|
-
return destructiveRefusal(focusedLabel);
|
|
1378
|
+
return destructiveRefusal(focusedLabel, this.mode);
|
|
1333
1379
|
}
|
|
1334
1380
|
return null;
|
|
1335
1381
|
}
|
|
@@ -1689,7 +1735,7 @@ export class BrowserEngine {
|
|
|
1689
1735
|
const label = await liveLabel(loc);
|
|
1690
1736
|
preLabel = label;
|
|
1691
1737
|
if (this.readOnly && (step.action === "click" || step.action === "select" || step.action === "upload") && isDestructive(label, step.value)) {
|
|
1692
|
-
transcript.push(`${desc} → ${destructiveRefusal(label || step.target)}`);
|
|
1738
|
+
transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
|
|
1693
1739
|
break;
|
|
1694
1740
|
}
|
|
1695
1741
|
if (step.action === "click")
|
|
@@ -1711,7 +1757,7 @@ export class BrowserEngine {
|
|
|
1711
1757
|
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
1712
1758
|
const submitLabel = (await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "";
|
|
1713
1759
|
if (isDestructive(submitLabel)) {
|
|
1714
|
-
transcript.push(`${desc} → filled, Enter withheld: ${destructiveRefusal(submitLabel.trim())}`);
|
|
1760
|
+
transcript.push(`${desc} → filled, Enter withheld: ${destructiveRefusal(submitLabel.trim(), this.mode)}`);
|
|
1715
1761
|
break;
|
|
1716
1762
|
}
|
|
1717
1763
|
}
|
package/dist/engine/collector.js
CHANGED
|
@@ -18,6 +18,14 @@ export const VISIBLE_SRC = `(el) => {
|
|
|
18
18
|
const style = window.getComputedStyle(el);
|
|
19
19
|
return style.visibility !== "hidden" && style.display !== "none";
|
|
20
20
|
}`;
|
|
21
|
+
/**
|
|
22
|
+
* Anything that plausibly presents as a modal/dialog panel. Deliberately wider
|
|
23
|
+
* than the ARIA set: a hand-rolled role-less modal must still count as "an
|
|
24
|
+
* overlay is up", or the scroll-lock oracle files a false leaked-lock finding
|
|
25
|
+
* against every healthy modal that locks the page behind it.
|
|
26
|
+
*/
|
|
27
|
+
export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
|
|
28
|
+
// Declared above the collector script because that script interpolates it.
|
|
21
29
|
/**
|
|
22
30
|
* Page-side interactable collector. Shipped as a STRING, not a function:
|
|
23
31
|
* loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
|
|
@@ -61,6 +69,10 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
61
69
|
if (named) return named.replace(/\\s+/g, " ").slice(0, 80);
|
|
62
70
|
}
|
|
63
71
|
const tag = el.tagName.toLowerCase();
|
|
72
|
+
// An image's name is its alt text. Without this an <img> read as
|
|
73
|
+
// "(unnamed)" even when it was labelled, and a missing alt looked the same
|
|
74
|
+
// as a present one.
|
|
75
|
+
if (tag === "img") return (el.getAttribute("alt") || "").trim().slice(0, 80);
|
|
64
76
|
if (tag === "input" || tag === "textarea") {
|
|
65
77
|
const id = el.getAttribute("id");
|
|
66
78
|
if (id) {
|
|
@@ -119,6 +131,75 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
119
131
|
}
|
|
120
132
|
return { layer, chrome };
|
|
121
133
|
};
|
|
134
|
+
// A pinned control (inside position:fixed/sticky chrome) whose centre is
|
|
135
|
+
// owned by ANOTHER piece of pinned chrome. Two pieces of chrome overlapping
|
|
136
|
+
// is usually intended layering, which is why the box-overlap oracle skips
|
|
137
|
+
// that pair — but boxes cannot tell which one is on top. A hit test can: if
|
|
138
|
+
// the point at the control's centre belongs to a different pinned element,
|
|
139
|
+
// a click aimed at the control lands on that element instead.
|
|
140
|
+
// Deliberately narrow, to stay quiet on intended layering:
|
|
141
|
+
// - only interactive controls, and only while their centre is in the viewport;
|
|
142
|
+
// - the control must be pinned with NO scrollable pane anywhere above it, or
|
|
143
|
+
// scrolling that pane would simply bring it out from under;
|
|
144
|
+
// - dialogs, menus, consent banners, toasts and anything covering half the
|
|
145
|
+
// viewport are overlays, not chrome.
|
|
146
|
+
const INTERACTIVE_ROLES = ["button", "link", "textbox", "combobox", "checkbox", "radio", "switch", "tab", "menuitem", "file"];
|
|
147
|
+
const isScroller = (n) => {
|
|
148
|
+
const cs = window.getComputedStyle(n);
|
|
149
|
+
return /(auto|scroll)/.test(cs.overflowY + cs.overflowX) && (n.scrollHeight > n.clientHeight + 1 || n.scrollWidth > n.clientWidth + 1);
|
|
150
|
+
};
|
|
151
|
+
/** Nearest fixed/sticky ancestor-or-self. */
|
|
152
|
+
const pinnedRootOf = (node) => {
|
|
153
|
+
for (let n = node; n && n !== document.documentElement; n = n.parentElement) {
|
|
154
|
+
const pos = window.getComputedStyle(n).position;
|
|
155
|
+
if (pos === "fixed" || pos === "sticky") return n;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Is there a scrollable pane anywhere between this node and the document?
|
|
161
|
+
* Checked over the WHOLE chain, above the pinned root as well as below it: a
|
|
162
|
+
* sticky first-column cell inside a scrolling grid is pinned within that
|
|
163
|
+
* grid, and scrolling the grid brings it out from under the sticky header.
|
|
164
|
+
* position:fixed escapes every ancestor's scrolling, so the walk stops there.
|
|
165
|
+
*/
|
|
166
|
+
const insideScrollablePane = (node) => {
|
|
167
|
+
for (let n = node; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
|
168
|
+
if (n !== node && isScroller(n)) return true;
|
|
169
|
+
if (window.getComputedStyle(n).position === "fixed") return false;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
};
|
|
173
|
+
// Pinned things that are overlays by nature, not layout: consent banners sit
|
|
174
|
+
// over everything until dismissed, toasts are gone in seconds. A click they
|
|
175
|
+
// intercept is real but it is not an app defect, and the consent case would
|
|
176
|
+
// otherwise fire on the first snapshot of nearly every site.
|
|
177
|
+
const TRANSIENT_SEL = '[role="alert"], [role="status"], [aria-live], [class*="toast" i], [class*="snackbar" i], [class*="cookie" i], [class*="consent" i], [id*="cookie" i], [id*="consent" i]';
|
|
178
|
+
const describe = (node) => {
|
|
179
|
+
const tid = node.getAttribute("data-testid");
|
|
180
|
+
if (tid) return "[" + tid + "]";
|
|
181
|
+
const text = (node.innerText || node.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 40);
|
|
182
|
+
return "<" + node.tagName.toLowerCase() + ">" + (text ? ' "' + text + '"' : "");
|
|
183
|
+
};
|
|
184
|
+
const coveredByPinnedChrome = (el, rect, role) => {
|
|
185
|
+
if (INTERACTIVE_ROLES.indexOf(role) === -1) return null;
|
|
186
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
187
|
+
if (cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight) return null;
|
|
188
|
+
const ownRoot = pinnedRootOf(el);
|
|
189
|
+
if (!ownRoot || insideScrollablePane(el)) return null;
|
|
190
|
+
const top = document.elementFromPoint(cx, cy);
|
|
191
|
+
if (!top || top === el || el.contains(top) || top.contains(el)) return null;
|
|
192
|
+
const coverRoot = pinnedRootOf(top);
|
|
193
|
+
if (!coverRoot || coverRoot === ownRoot || coverRoot.contains(ownRoot) || ownRoot.contains(coverRoot)) return null;
|
|
194
|
+
// A dialog's fixed wrapper often carries no role or class itself; the
|
|
195
|
+
// role="dialog" is on a child. Look both ways.
|
|
196
|
+
const OVERLAY_SEL = '${DIALOG_LIKE_SEL}, ' + TRANSIENT_SEL + ', [role="menu"], [role="listbox"], [role="tooltip"]';
|
|
197
|
+
if (coverRoot.closest(OVERLAY_SEL) || coverRoot.querySelector(OVERLAY_SEL) || top.closest(OVERLAY_SEL)) return null;
|
|
198
|
+
const cr = coverRoot.getBoundingClientRect();
|
|
199
|
+
if (cr.width * cr.height > window.innerWidth * window.innerHeight * 0.5) return null;
|
|
200
|
+
return describe(coverRoot);
|
|
201
|
+
};
|
|
202
|
+
|
|
122
203
|
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
123
204
|
if (seen.has(el) || !visible(el)) continue;
|
|
124
205
|
seen.add(el);
|
|
@@ -136,6 +217,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
136
217
|
// Its own role routes it to scout_upload instead.
|
|
137
218
|
: inputType === "file" ? "file"
|
|
138
219
|
: "textbox")
|
|
220
|
+
: tag === "img" ? "image"
|
|
139
221
|
: "generic");
|
|
140
222
|
const rect = el.getBoundingClientRect();
|
|
141
223
|
// Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
|
|
@@ -171,6 +253,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
171
253
|
}
|
|
172
254
|
}
|
|
173
255
|
out.push({
|
|
256
|
+
coveredBy: coveredByPinnedChrome(el, rect, role),
|
|
174
257
|
tag,
|
|
175
258
|
role,
|
|
176
259
|
name: accessibleName(el),
|
|
@@ -193,13 +276,6 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
193
276
|
}
|
|
194
277
|
return out;
|
|
195
278
|
})()`;
|
|
196
|
-
/**
|
|
197
|
-
* Anything that plausibly presents as a modal/dialog panel. Deliberately wider
|
|
198
|
-
* than the ARIA set: a hand-rolled role-less modal must still count as "an
|
|
199
|
-
* overlay is up", or the scroll-lock oracle files a false leaked-lock finding
|
|
200
|
-
* against every healthy modal that locks the page behind it.
|
|
201
|
-
*/
|
|
202
|
-
export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
|
|
203
279
|
/**
|
|
204
280
|
* Deterministic geometry oracles — the checks people reach for screenshots to
|
|
205
281
|
* do, computed from layout boxes instead: interactables rendered fully outside
|
|
@@ -231,6 +307,20 @@ export function geometryIssues(elements, viewport) {
|
|
|
231
307
|
if (clippedTotal > 3) {
|
|
232
308
|
issues.push(`…and ${clippedTotal - 3} more controls clipped inside overflow-hidden ancestors`);
|
|
233
309
|
}
|
|
310
|
+
// Pinned controls sitting underneath other pinned chrome (hit-tested in the
|
|
311
|
+
// page; see coveredByPinnedChrome). Reported before the box overlaps because
|
|
312
|
+
// an unclickable Save button outranks two badges touching.
|
|
313
|
+
let coveredTotal = 0;
|
|
314
|
+
for (const el of elements) {
|
|
315
|
+
if (!el.coveredBy)
|
|
316
|
+
continue;
|
|
317
|
+
coveredTotal += 1;
|
|
318
|
+
if (coveredTotal <= 3) {
|
|
319
|
+
issues.push(`${el.ref} ${el.role} "${el.name}" is COVERED by pinned chrome ${el.coveredBy} at this scroll position — a click aimed at it lands on that element instead`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (coveredTotal > 3)
|
|
323
|
+
issues.push(`…and ${coveredTotal - 3} more pinned controls covered by other pinned chrome`);
|
|
234
324
|
const overlapArea = (a, b) => {
|
|
235
325
|
const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
236
326
|
const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
@@ -264,3 +354,54 @@ export function geometryIssues(elements, viewport) {
|
|
|
264
354
|
}
|
|
265
355
|
return issues;
|
|
266
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Images that failed to load, read from the DOM rather than from the network.
|
|
359
|
+
*
|
|
360
|
+
* A 404 on an image already shows up as an HTTP violation, but a broken image
|
|
361
|
+
* is not always a failed request: a 200 that returns an HTML error page, a
|
|
362
|
+
* truncated upload, a wrong content type or a blocked cross-origin file all
|
|
363
|
+
* respond successfully and still render as the browser's broken-image icon.
|
|
364
|
+
* `complete` with no intrinsic size is what "the browser gave up" looks like.
|
|
365
|
+
* SVGs are skipped: one without intrinsic dimensions reports 0×0 while
|
|
366
|
+
* rendering correctly.
|
|
367
|
+
*/
|
|
368
|
+
export const BROKEN_IMAGES_SCRIPT = `(() => {
|
|
369
|
+
const images = [];
|
|
370
|
+
let total = 0;
|
|
371
|
+
for (const img of Array.from(document.images)) {
|
|
372
|
+
const src = img.currentSrc || img.getAttribute("src") || "";
|
|
373
|
+
if (!src || src.indexOf("data:") === 0 || /\\.svg(\\?|#|$)/i.test(src)) continue;
|
|
374
|
+
if (!img.complete || img.naturalWidth > 0 || img.naturalHeight > 0) continue;
|
|
375
|
+
// Visible means it occupies space. The box test is what catches an image
|
|
376
|
+
// inside a display:none ANCESTOR (a closed modal, an inactive tab): display
|
|
377
|
+
// is not inherited, so the image's own computed style still says "inline".
|
|
378
|
+
// It also drops tracking pixels, whose endpoint answers 204 by design.
|
|
379
|
+
const r = img.getBoundingClientRect();
|
|
380
|
+
if (r.width <= 2 || r.height <= 2) continue;
|
|
381
|
+
const cs = window.getComputedStyle(img);
|
|
382
|
+
if (cs.display === "none" || cs.visibility === "hidden") continue;
|
|
383
|
+
total += 1;
|
|
384
|
+
if (images.length < 20) images.push({ alt: (img.getAttribute("alt") || "").trim().slice(0, 80), src: src.slice(0, 160), testid: img.getAttribute("data-testid") });
|
|
385
|
+
}
|
|
386
|
+
return { images, total };
|
|
387
|
+
})()`;
|
|
388
|
+
/** Snapshot lines for images that failed to load. The origin is dropped when it is the page's own, to keep the line short. */
|
|
389
|
+
export function brokenImageIssues(scan, pageUrl) {
|
|
390
|
+
let origin = "";
|
|
391
|
+
try {
|
|
392
|
+
origin = new URL(pageUrl).origin;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
/* an unparseable page URL just means the full src is shown */
|
|
396
|
+
}
|
|
397
|
+
// Only a real origin match: "http://x" is also a prefix of "http://x.other.test/a.png".
|
|
398
|
+
const short = (src) => (origin && (src === origin || src.startsWith(`${origin}/`)) ? src.slice(origin.length) || "/" : src);
|
|
399
|
+
const lines = scan.images.slice(0, 5).map((img) => {
|
|
400
|
+
const name = img.alt ? `"${img.alt}"` : "(no alt text)";
|
|
401
|
+
return `image ${name}${img.testid ? ` [testid=${img.testid}]` : ""} FAILED TO LOAD — ${short(img.src)}`;
|
|
402
|
+
});
|
|
403
|
+
const total = Math.max(scan.total, scan.images.length);
|
|
404
|
+
if (total > 5)
|
|
405
|
+
lines.push(`…and ${total - 5} more images that failed to load`);
|
|
406
|
+
return lines;
|
|
407
|
+
}
|
package/dist/engine/policy.js
CHANGED
|
@@ -77,8 +77,60 @@ export const AUTH_FLOW_RE = /\/(auth|login|logout|signin|sign-in|signup|sign-up|
|
|
|
77
77
|
export function isDestructive(...labels) {
|
|
78
78
|
return labels.some((label) => typeof label === "string" && label.length > 0 && DESTRUCTIVE_PATTERNS.some((re) => re.test(label)));
|
|
79
79
|
}
|
|
80
|
-
export function destructiveRefusal(label) {
|
|
81
|
-
return (`REFUSED by
|
|
82
|
-
`This run is
|
|
80
|
+
export function destructiveRefusal(label, mode = "read-only") {
|
|
81
|
+
return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
|
|
82
|
+
`This run is ${mode}; do not attempt this element again. If destructive flows must be tested, ` +
|
|
83
83
|
`the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
|
|
84
84
|
}
|
|
85
|
+
export const WRITE_MODES = ["observe", "read-only", "safe-write", "destructive"];
|
|
86
|
+
/**
|
|
87
|
+
* May this non-GET request leave the page? Auth-flow requests are let through
|
|
88
|
+
* before this is asked. `owned` means the request addresses a record this run
|
|
89
|
+
* created (always false outside safe-write, where nothing is tracked).
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* In observe mode, the only auth requests let through are the ones a session
|
|
93
|
+
* needs in order to exist: logging in, logging out, refreshing a token. Whole
|
|
94
|
+
* path SEGMENTS, never substrings — `/users/login-history/clear` and
|
|
95
|
+
* `/api/tokens` (mint an API token) are not logins — and `session(s)` only as
|
|
96
|
+
* the last segment, where a POST means "log in", not "act on session 123".
|
|
97
|
+
*/
|
|
98
|
+
const OBSERVE_AUTH_SEGMENT_RE = /^(login|log-in|signin|sign-in|logout|log-out|signout|sign-out|refresh|token|oauth|oauth2|sso|callback|authorize|authenticate)$/i;
|
|
99
|
+
/**
|
|
100
|
+
* Is this request an auth flow that must work even though the mode would
|
|
101
|
+
* otherwise block it?
|
|
102
|
+
*
|
|
103
|
+
* Never for a destructive-looking request, in any mode: the exemption used to
|
|
104
|
+
* be tested first, so `POST /api/session/123/delete` went through in read-only
|
|
105
|
+
* because its path contains "session".
|
|
106
|
+
*
|
|
107
|
+
* In observe mode the exemption is much narrower than elsewhere. Signing up,
|
|
108
|
+
* changing or resetting a password, verifying an email and creating a user all
|
|
109
|
+
* change data on the target, and observe promises that nothing is created.
|
|
110
|
+
*/
|
|
111
|
+
export function isAuthExempt(mode, method, pathname, destructiveWire) {
|
|
112
|
+
if (method !== "POST" || destructiveWire)
|
|
113
|
+
return false;
|
|
114
|
+
if (mode !== "observe")
|
|
115
|
+
return AUTH_FLOW_RE.test(pathname);
|
|
116
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
117
|
+
if (segments.length === 0)
|
|
118
|
+
return false;
|
|
119
|
+
const last = segments[segments.length - 1];
|
|
120
|
+
if (/^sessions?$/i.test(last))
|
|
121
|
+
return true;
|
|
122
|
+
// The matching segment must be at, or next to, the end: /auth/token/refresh, /oauth/token, /login.
|
|
123
|
+
return (segments.slice(-2).some((seg) => OBSERVE_AUTH_SEGMENT_RE.test(seg)) &&
|
|
124
|
+
!/^(users?|accounts?|members?|password|signup|sign-up|register|verify|invite|invitations?)$/i.test(last));
|
|
125
|
+
}
|
|
126
|
+
export function allowsWrite(mode, method, destructiveWire, owned) {
|
|
127
|
+
if (mode === "destructive")
|
|
128
|
+
return true;
|
|
129
|
+
if (mode === "observe")
|
|
130
|
+
return false;
|
|
131
|
+
// POST: creation/RPC passes unless it looks destructive and is not ours.
|
|
132
|
+
if (method === "POST")
|
|
133
|
+
return !destructiveWire || owned;
|
|
134
|
+
// PUT/PATCH/DELETE: only in safe-write, only on this run's own records.
|
|
135
|
+
return mode === "safe-write" && owned;
|
|
136
|
+
}
|
package/dist/engine/report.js
CHANGED
|
@@ -289,7 +289,12 @@ export function computeGaps(memory, extras) {
|
|
|
289
289
|
// and let a mutation anywhere in a wizard clear its sibling steps.
|
|
290
290
|
const { unsubmitted } = classifyFilledStates(memory, facts);
|
|
291
291
|
if (unsubmitted.length > 0) {
|
|
292
|
-
gaps.push(`${unsubmitted.length} route(s) had a form filled but NEVER submitted (no state-changing request left the page): ${unsubmitted.slice(0, 8).join(", ")}${unsubmitted.length > 8 ? " …" : ""}`
|
|
292
|
+
gaps.push(`${unsubmitted.length} route(s) had a form filled but NEVER submitted (no state-changing request left the page): ${unsubmitted.slice(0, 8).join(", ")}${unsubmitted.length > 8 ? " …" : ""}` +
|
|
293
|
+
// In observe mode this is the mode working, not the run falling short —
|
|
294
|
+
// but it is still untested surface, so it stays in the ledger, explained.
|
|
295
|
+
(extras?.mode === "observe"
|
|
296
|
+
? ` — expected in observe mode, which blocks every form submission by design; what the server does with these forms is untested. Cover them in read-only mode against an environment where creating records is acceptable.`
|
|
297
|
+
: ""));
|
|
293
298
|
}
|
|
294
299
|
const journeyTotal = Object.values(facts).reduce((a, f) => a + (f.journeysCompleted ?? 0), 0);
|
|
295
300
|
if (journeyTotal === 0) {
|
package/dist/installer.js
CHANGED
|
@@ -243,24 +243,36 @@ export function parseRegistration(listing) {
|
|
|
243
243
|
const field = (name) => new RegExp(`^[ \\t]*${name}:[ \\t]*(\\S.*?)[ \\t]*$`, "m").exec(listing)?.[1] ?? null;
|
|
244
244
|
return { command: field("Command"), serverPath: field("Args") };
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* The command that repairs a setup, for THIS kind of install. A source checkout
|
|
248
|
+
* has `npm run setup`; someone who installed from npm has no such script, and
|
|
249
|
+
* telling them to run it sends them looking for a package.json they never had.
|
|
250
|
+
*/
|
|
251
|
+
export function repairCommands(packageRoot) {
|
|
252
|
+
const isCheckout = fs.existsSync(path.join(packageRoot, "tsconfig.json")) && fs.existsSync(path.join(packageRoot, "src"));
|
|
253
|
+
return isCheckout
|
|
254
|
+
? { setup: "npm run setup", build: "npm run build" }
|
|
255
|
+
: { setup: "npx -y scenescout install", build: "npx -y scenescout@latest install (the installed package is incomplete; fetch it again)" };
|
|
256
|
+
}
|
|
246
257
|
/** Everything a working setup needs, each with the command that repairs it. */
|
|
247
258
|
export function diagnose(opts) {
|
|
248
259
|
const checks = [];
|
|
260
|
+
const repair = repairCommands(opts.packageRoot);
|
|
249
261
|
const major = Number(opts.nodeVersion.replace(/^v/, "").split(".")[0]);
|
|
250
262
|
checks.push({ name: "node >= 20", ok: major >= 20, detail: opts.nodeVersion, fix: "install Node 20 or newer" });
|
|
251
263
|
const server = path.join(opts.packageRoot, "dist", "mcp-server.js");
|
|
252
|
-
checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix:
|
|
264
|
+
checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix: repair.build });
|
|
253
265
|
const chromiumOk = !!opts.chromiumPath && fs.existsSync(opts.chromiumPath);
|
|
254
266
|
checks.push({
|
|
255
267
|
name: "chromium downloaded",
|
|
256
268
|
ok: chromiumOk,
|
|
257
269
|
detail: opts.chromiumPath ?? "playwright could not name a browser path",
|
|
258
|
-
fix:
|
|
270
|
+
fix: `${repair.setup} (or: npx playwright install chromium)`,
|
|
259
271
|
});
|
|
260
272
|
if (opts.scope === "engine")
|
|
261
273
|
return checks;
|
|
262
274
|
const skill = path.join(opts.claudeDir, "skills", SKILL_NAME, "SKILL.md");
|
|
263
|
-
checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix:
|
|
275
|
+
checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: repair.setup });
|
|
264
276
|
const got = opts.run("claude", ["mcp", "get", MCP_NAME]);
|
|
265
277
|
if (got.missing) {
|
|
266
278
|
checks.push({
|
|
@@ -273,7 +285,7 @@ export function diagnose(opts) {
|
|
|
273
285
|
else {
|
|
274
286
|
const listing = got.stdout + got.stderr;
|
|
275
287
|
if (got.status !== 0) {
|
|
276
|
-
checks.push({ name: "MCP server registered", ok: false, detail: "no server named scenescout", fix:
|
|
288
|
+
checks.push({ name: "MCP server registered", ok: false, detail: "no server named scenescout", fix: repair.setup });
|
|
277
289
|
}
|
|
278
290
|
else {
|
|
279
291
|
const { command, serverPath } = parseRegistration(listing);
|
|
@@ -289,12 +301,12 @@ export function diagnose(opts) {
|
|
|
289
301
|
name: "MCP server registered",
|
|
290
302
|
ok: absolute,
|
|
291
303
|
detail: absolute ? `via ${command} ${serverPath}` : `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
|
|
292
|
-
fix:
|
|
304
|
+
fix: repair.setup,
|
|
293
305
|
});
|
|
294
306
|
}
|
|
295
307
|
else if (!samePath(serverPath, server)) {
|
|
296
308
|
// The usual aftermath of moving or deleting a checkout.
|
|
297
|
-
checks.push({ name: "MCP server registered", ok: false, detail: `registered, but pointing at ${serverPath} — not this install`, fix:
|
|
309
|
+
checks.push({ name: "MCP server registered", ok: false, detail: `registered, but pointing at ${serverPath} — not this install`, fix: repair.setup });
|
|
298
310
|
}
|
|
299
311
|
else if (command !== null && !path.isAbsolute(command)) {
|
|
300
312
|
// A bare "node" resolves in your shell and then fails inside Claude
|
|
@@ -303,7 +315,7 @@ export function diagnose(opts) {
|
|
|
303
315
|
name: "MCP server registered",
|
|
304
316
|
ok: false,
|
|
305
317
|
detail: `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
|
|
306
|
-
fix:
|
|
318
|
+
fix: repair.setup,
|
|
307
319
|
});
|
|
308
320
|
}
|
|
309
321
|
else {
|
package/dist/mcp-server.js
CHANGED
|
@@ -179,13 +179,13 @@ server.registerTool("scout_scan", {
|
|
|
179
179
|
}
|
|
180
180
|
}));
|
|
181
181
|
server.registerTool("scout_attach", {
|
|
182
|
-
description: "Launch a browser and attach to a running web app. Write policy is enforced at the NETWORK layer: mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
|
|
182
|
+
description: "Launch a browser and attach to a running web app. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
|
|
183
183
|
inputSchema: {
|
|
184
184
|
url: z.string().describe("Base URL of the running app, e.g. http://localhost:3000"),
|
|
185
185
|
projectPath: z.string().describe("Absolute path to the project (memory + report live in .scenescout/ here)"),
|
|
186
186
|
storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration"),
|
|
187
187
|
mode: z
|
|
188
|
-
.enum(["read-only", "safe-write", "destructive"])
|
|
188
|
+
.enum(["observe", "read-only", "safe-write", "destructive"])
|
|
189
189
|
.default("read-only")
|
|
190
190
|
.describe("Write policy (see tool description). Never choose 'destructive' yourself — user opt-in only."),
|
|
191
191
|
headed: z.boolean().default(false).describe("Show the browser window"),
|
|
@@ -563,7 +563,7 @@ server.registerTool("scout_note", {
|
|
|
563
563
|
}
|
|
564
564
|
}));
|
|
565
565
|
server.registerTool("scout_screenshot", {
|
|
566
|
-
description: "Take a JPEG screenshot of the current viewport. LAST RESORT: geometry issues are in scout_snapshot and style/contrast/spacing issues are in scout_design_audit — use a screenshot only for pixel-native content (
|
|
566
|
+
description: "Take a JPEG screenshot of the current viewport. LAST RESORT: geometry issues are in scout_snapshot and style/contrast/spacing issues are in scout_design_audit — images that failed to load are listed in scout_snapshot under BROKEN IMAGES — use a screenshot only for pixel-native content (a canvas, visual gestalt) that computed data cannot capture.",
|
|
567
567
|
inputSchema: { session: sessionParam },
|
|
568
568
|
}, serializedPerSession("scout_screenshot", async (_args, session) => {
|
|
569
569
|
try {
|
|
@@ -702,9 +702,13 @@ server.registerTool("scout_report", {
|
|
|
702
702
|
routesTotal: all.length,
|
|
703
703
|
designAudits: eng.designAuditCount,
|
|
704
704
|
unvisitedRoutes: unvisited,
|
|
705
|
+
mode: eng.mode,
|
|
705
706
|
});
|
|
706
707
|
if (lvl === "extensive" && gapList.length > 0) {
|
|
707
708
|
gates.push(`Level 'extensive' claims completeness, so it refuses while the GAP LEDGER is non-empty:\n` +
|
|
709
|
+
(eng.mode === "observe"
|
|
710
|
+
? `(observe mode blocks every form submission, so the unsubmitted-forms gap cannot be closed in this mode: report at level 'medium', which discloses it.)\n`
|
|
711
|
+
: "") +
|
|
708
712
|
gapList.map((g) => ` ⚠ ${g}`).join("\n") +
|
|
709
713
|
`\nClose the gaps (or report at level 'medium', which discloses them instead).`);
|
|
710
714
|
}
|
|
@@ -718,6 +722,7 @@ server.registerTool("scout_report", {
|
|
|
718
722
|
designAudits: eng.designAuditCount,
|
|
719
723
|
createdResources: eng.createdResources,
|
|
720
724
|
unvisitedRoutes: unvisited,
|
|
725
|
+
mode: eng.mode,
|
|
721
726
|
policyAttributed: eng.oracleLog.policyAttributed,
|
|
722
727
|
});
|
|
723
728
|
void p;
|