scenescout 3.10.0 → 3.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +12 -3
- package/dist/browsers.js +64 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +426 -26
- package/dist/engine/calibration.js +99 -14
- package/dist/engine/check.js +251 -33
- package/dist/engine/fingerprint.js +12 -7
- package/dist/engine/flow.js +398 -0
- package/dist/engine/lane.js +81 -0
- package/dist/engine/memory.js +48 -6
- package/dist/engine/unload.js +201 -0
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +4 -4
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the context's route handler never sees, and how the engine judges
|
|
3
|
+
* them anyway.
|
|
4
|
+
*
|
|
5
|
+
* In Chromium, a request a page sends while it is being left (a
|
|
6
|
+
* `navigator.sendBeacon` or a `fetch(..., { keepalive: true })` on
|
|
7
|
+
* `pagehide`, `visibilitychange` or `unload`, or one still leaving as a page
|
|
8
|
+
* is closed) is never handed to the context's route handler: it arrives with
|
|
9
|
+
* no network id, or after the page's own DevTools session has gone, and the
|
|
10
|
+
* driver lets it through unseen. Left alone it reached the server in every
|
|
11
|
+
* mode, observe included. The engine therefore also intercepts at the
|
|
12
|
+
* browser level (the DevTools Fetch domain on the browser target, which does
|
|
13
|
+
* pause these requests) and judges there every write the route handler did
|
|
14
|
+
* not already judge, by the same rules (ADR 2). Which engines need this is
|
|
15
|
+
* `unloadWriteInterception` in `browsers.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is pure, so the rules are table-tested in policy-test; the
|
|
18
|
+
* wiring to the browser lives in `browser.ts`.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { allowsForeignWriteOnSignIn, allowsWrite, foreignWrite, isAuthExempt, offAppPageWrite, trustsForeignWrite } from "./policy.js";
|
|
22
|
+
/** Whether a method sends nothing the write policy judges. */
|
|
23
|
+
export function isReadMethod(method) {
|
|
24
|
+
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Who sent a write the route handler never saw, as far as its headers tell:
|
|
28
|
+
* the embed it is charged to (`foreign`) and the off-app page it came from
|
|
29
|
+
* (`offApp`), the two inputs `judgeUnseenWrite` takes from the sender.
|
|
30
|
+
*
|
|
31
|
+
* The sending frame may be gone, so its URL is not known. The page it was
|
|
32
|
+
* sent from is its Referer; where there is none, the page the session drives
|
|
33
|
+
* stands in for `offApp` only.
|
|
34
|
+
*
|
|
35
|
+
* - `foreign`: policy.ts `foreignWrite` with no frame chain, so a write out of
|
|
36
|
+
* the app whose Origin header names another site is that site's embed's. A
|
|
37
|
+
* sign-in provider's page reached as the whole page, beaconing to itself as
|
|
38
|
+
* it is left, is therefore refused outside destructive; the route handler,
|
|
39
|
+
* which knows the frame, would let it through. The cautious direction. An
|
|
40
|
+
* `Origin: null` write out of the app is taken to be an embed's too, whether
|
|
41
|
+
* or not the page embeds anything, since the page can no longer be asked.
|
|
42
|
+
* - The sign-in exception (a captcha on the app's own sign-in page) is judged
|
|
43
|
+
* against the Referer, the page the request was sent from, never against
|
|
44
|
+
* whatever page the session has moved to by the time the request is seen.
|
|
45
|
+
* With no Referer it does not apply.
|
|
46
|
+
* - Trusted embeds (safe-write only) are judged by the Origin header alone.
|
|
47
|
+
*/
|
|
48
|
+
export function unseenWriteSource(input) {
|
|
49
|
+
const { appUrl, mode, url, originHeader, referer } = input;
|
|
50
|
+
let foreign = foreignWrite(appUrl, { url, frameChain: [], frameUrl: null, originHeader, pageHasForeignFrame: true });
|
|
51
|
+
if (foreign && referer && allowsForeignWriteOnSignIn(mode, referer, appUrl))
|
|
52
|
+
foreign = null;
|
|
53
|
+
if (foreign && trustsForeignWrite(mode, input.trustedEmbeds, appUrl, { frameChain: [], originHeader, unadoptedPageUrl: null }))
|
|
54
|
+
foreign = null;
|
|
55
|
+
const offApp = offAppPageWrite(appUrl, referer ?? input.currentPageUrl, url, input.movedByEmbed);
|
|
56
|
+
return { foreign, offApp };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The route handler's verdict on a write, from what a browser-level
|
|
60
|
+
* interception can know about it. Same rules in the same order: a write
|
|
61
|
+
* another site's frame sends outside the app is refused; so is one sent from
|
|
62
|
+
* a page an embed moved off the app, a sign-in excepted; a sign-in passes;
|
|
63
|
+
* then the mode decides. `foreign` and `offApp` come from
|
|
64
|
+
* `unseenWriteSource`, since the sending frame may no longer exist.
|
|
65
|
+
* The one step it does not take is safe-write's short wait for a creation
|
|
66
|
+
* still in flight: a page being left has nothing more to wait for.
|
|
67
|
+
*/
|
|
68
|
+
export function judgeUnseenWrite(input) {
|
|
69
|
+
const { mode, method, pathname, destructiveWire } = input;
|
|
70
|
+
if (mode === "destructive" || isReadMethod(method))
|
|
71
|
+
return { allow: true };
|
|
72
|
+
if (input.foreign)
|
|
73
|
+
return { allow: false, why: `sent from a frame of ${input.foreign}` };
|
|
74
|
+
const authExempt = isAuthExempt(mode, method, pathname, destructiveWire);
|
|
75
|
+
if (input.offApp && !authExempt)
|
|
76
|
+
return { allow: false, why: `sent from a page of ${input.offApp}, outside the app` };
|
|
77
|
+
if (authExempt)
|
|
78
|
+
return { allow: true };
|
|
79
|
+
return allowsWrite(mode, method, destructiveWire, input.owned) ? { allow: true } : { allow: false };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* How long a write the route handler let through stays on the ledger. The
|
|
83
|
+
* browser-level pause follows the route handler's `continue` within
|
|
84
|
+
* milliseconds; an entry that outlives this is a request that never reached
|
|
85
|
+
* the network (answered by a service worker, cancelled by the page).
|
|
86
|
+
*/
|
|
87
|
+
export const ROUTED_WRITE_TTL_MS = 5000;
|
|
88
|
+
/** Entries past this many are dropped oldest first: a page that posts in a loop cannot grow the ledger without bound. */
|
|
89
|
+
const MAX_ROUTED_WRITES = 200;
|
|
90
|
+
/** A request body's identity for the ledger: the SHA-256 of its bytes, or "" for no body. */
|
|
91
|
+
export function bodyDigest(body) {
|
|
92
|
+
return body && body.length > 0 ? createHash("sha256").update(body).digest("hex") : "";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The writes the route handler has already judged and let through, so the
|
|
96
|
+
* browser-level interception, which sees them again, lets them pass without
|
|
97
|
+
* judging them twice. The key is the mode, the method, the URL and the body's
|
|
98
|
+
* digest (`bodyDigest`), so a write let through can only excuse the very same
|
|
99
|
+
* request: never one with another body to the same URL, such as a delete
|
|
100
|
+
* command sent where a harmless save was allowed a moment before, and never
|
|
101
|
+
* one sent after a flow switched to a stricter rule. A write is claimed once:
|
|
102
|
+
* two identical requests, one routed and one not, leave the second to be
|
|
103
|
+
* judged. Requests the route handler refused never reach the browser level,
|
|
104
|
+
* so they are not recorded.
|
|
105
|
+
*/
|
|
106
|
+
export class RoutedWrites {
|
|
107
|
+
ttlMs;
|
|
108
|
+
entries = [];
|
|
109
|
+
constructor(ttlMs = ROUTED_WRITE_TTL_MS) {
|
|
110
|
+
this.ttlMs = ttlMs;
|
|
111
|
+
}
|
|
112
|
+
static key(mode, method, url, digest) {
|
|
113
|
+
return `${mode} ${method} ${url.split("#")[0]} ${digest}`;
|
|
114
|
+
}
|
|
115
|
+
prune(now) {
|
|
116
|
+
this.entries = this.entries.filter((e) => now - e.at <= this.ttlMs);
|
|
117
|
+
if (this.entries.length > MAX_ROUTED_WRITES)
|
|
118
|
+
this.entries = this.entries.slice(-MAX_ROUTED_WRITES);
|
|
119
|
+
}
|
|
120
|
+
note(mode, method, url, digest, now = Date.now()) {
|
|
121
|
+
this.prune(now);
|
|
122
|
+
this.entries.push({ key: RoutedWrites.key(mode, method, url, digest), at: now });
|
|
123
|
+
}
|
|
124
|
+
/** Whether the route handler let this write through a moment ago; each note is claimed at most once. */
|
|
125
|
+
claim(mode, method, url, digest, now = Date.now()) {
|
|
126
|
+
this.prune(now);
|
|
127
|
+
const key = RoutedWrites.key(mode, method, url, digest);
|
|
128
|
+
const i = this.entries.findIndex((e) => e.key === key);
|
|
129
|
+
if (i < 0)
|
|
130
|
+
return false;
|
|
131
|
+
this.entries.splice(i, 1);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
clear() {
|
|
135
|
+
this.entries = [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** How long a write refused at the browser level stays recognisable to the driver's request events. */
|
|
139
|
+
export const UNSEEN_REFUSAL_TTL_MS = 10000;
|
|
140
|
+
/**
|
|
141
|
+
* Writes refused at the browser level, which the driver may still report as
|
|
142
|
+
* requests of its own: a 307 or 308 hop is a request Playwright knows, and it
|
|
143
|
+
* then sees it fail (ERR_BLOCKED_BY_CLIENT). Without this the refusal was also
|
|
144
|
+
* filed as the app's failed request and listed as a possible mutation. Keyed
|
|
145
|
+
* by method and URL (fragment dropped), since the driver's request object is
|
|
146
|
+
* not the paused one; kept briefly, and not consumed, since several listeners
|
|
147
|
+
* ask about the same request.
|
|
148
|
+
*/
|
|
149
|
+
export class UnseenRefusals {
|
|
150
|
+
ttlMs;
|
|
151
|
+
entries = [];
|
|
152
|
+
constructor(ttlMs = UNSEEN_REFUSAL_TTL_MS) {
|
|
153
|
+
this.ttlMs = ttlMs;
|
|
154
|
+
}
|
|
155
|
+
static key(method, url) {
|
|
156
|
+
return `${method} ${url.split("#")[0]}`;
|
|
157
|
+
}
|
|
158
|
+
prune(now) {
|
|
159
|
+
this.entries = this.entries.filter((e) => now - e.at <= this.ttlMs);
|
|
160
|
+
if (this.entries.length > MAX_ROUTED_WRITES)
|
|
161
|
+
this.entries = this.entries.slice(-MAX_ROUTED_WRITES);
|
|
162
|
+
}
|
|
163
|
+
note(method, url, now = Date.now()) {
|
|
164
|
+
this.prune(now);
|
|
165
|
+
this.entries.push({ key: UnseenRefusals.key(method, url), at: now });
|
|
166
|
+
}
|
|
167
|
+
has(method, url, now = Date.now()) {
|
|
168
|
+
this.prune(now);
|
|
169
|
+
const key = UnseenRefusals.key(method, url);
|
|
170
|
+
return this.entries.some((e) => e.key === key);
|
|
171
|
+
}
|
|
172
|
+
clear() {
|
|
173
|
+
this.entries = [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The body of a request paused by the DevTools Fetch domain, as bytes: the
|
|
178
|
+
* base64 entries decoded and joined, else the plain `postData`. Undefined when
|
|
179
|
+
* the protocol sent neither (a body too large to include). Its digest then
|
|
180
|
+
* matches no routed write, so the request is judged, on its URL alone.
|
|
181
|
+
*/
|
|
182
|
+
export function pausedRequestBytes(request) {
|
|
183
|
+
const entries = request.postDataEntries?.filter((e) => typeof e.bytes === "string");
|
|
184
|
+
if (entries && entries.length > 0)
|
|
185
|
+
return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, "base64")));
|
|
186
|
+
return request.postData === undefined ? undefined : Buffer.from(request.postData, "utf8");
|
|
187
|
+
}
|
|
188
|
+
/** The same body as text, for the policy's body rules. */
|
|
189
|
+
export function pausedRequestBody(request) {
|
|
190
|
+
return pausedRequestBytes(request)?.toString("utf8");
|
|
191
|
+
}
|
|
192
|
+
/** A header's value from a DevTools header map, whose names keep the case they were sent with. */
|
|
193
|
+
export function headerOf(headers, name) {
|
|
194
|
+
if (!headers)
|
|
195
|
+
return undefined;
|
|
196
|
+
const wanted = name.toLowerCase();
|
|
197
|
+
for (const [k, v] of Object.entries(headers))
|
|
198
|
+
if (k.toLowerCase() === wanted)
|
|
199
|
+
return v;
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
package/dist/engine/verify.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* Pure, so the ordering and the wording can be table-tested.
|
|
24
24
|
*/
|
|
25
25
|
import { normalizePath } from "./fingerprint.js";
|
|
26
|
+
import { failingSignatures } from "./memory.js";
|
|
26
27
|
/** What a re-test found. */
|
|
27
28
|
export const VERDICTS = ["gone", "present", "changed"];
|
|
28
29
|
export const VERDICT_MEANING = {
|
|
@@ -140,3 +141,121 @@ export function sayVerification(f) {
|
|
|
140
141
|
return ` · re-tested ${on}, behaviour has changed since it was filed`;
|
|
141
142
|
return ` · re-tested ${on}`;
|
|
142
143
|
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Re-testing by page load, for `scenescout check`.
|
|
146
|
+
//
|
|
147
|
+
// The campaign above hands findings to an agent, who re-walks them with
|
|
148
|
+
// judgement. A check has none, so it re-tests only the findings whose
|
|
149
|
+
// reproduction is mechanical: the evidence names a GET that failed with a
|
|
150
|
+
// status, and the finding's repro shows nothing done on its page but looking
|
|
151
|
+
// at it. Loading that page again and watching the same request is then the
|
|
152
|
+
// whole reproduction, and its answer is a fact rather than an opinion.
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
/**
|
|
155
|
+
* Actions that only look at a page. A repro whose steps on the finding's page
|
|
156
|
+
* are all of these was reproduced by loading the page; any other step (a
|
|
157
|
+
* click, a typed value, a replayed request, anything this list does not know)
|
|
158
|
+
* means loading it is not the reproduction.
|
|
159
|
+
*/
|
|
160
|
+
const LOOK_ONLY = new Set([
|
|
161
|
+
"attach",
|
|
162
|
+
"navigate",
|
|
163
|
+
"plan:navigate",
|
|
164
|
+
"crawl",
|
|
165
|
+
"back",
|
|
166
|
+
"snapshot",
|
|
167
|
+
"screenshot",
|
|
168
|
+
"design-audit",
|
|
169
|
+
"task",
|
|
170
|
+
"journey:start",
|
|
171
|
+
"journey:end",
|
|
172
|
+
"record:full",
|
|
173
|
+
]);
|
|
174
|
+
/** How the finding's page is reached again, or null when a check cannot re-test it deterministically. */
|
|
175
|
+
export function loadRetest(item) {
|
|
176
|
+
const signatures = [...failingSignatures(item.evidence)];
|
|
177
|
+
// A POST that failed is not re-sent by loading a page, and a check would refuse to send it anyway.
|
|
178
|
+
if (signatures.length === 0 || signatures.some((s) => !s.startsWith("GET ")))
|
|
179
|
+
return null;
|
|
180
|
+
// The first repro line is how the page was reached; every later one happened on it.
|
|
181
|
+
if (item.repro.length === 0)
|
|
182
|
+
return null;
|
|
183
|
+
for (const line of item.repro.slice(1)) {
|
|
184
|
+
if (!LOOK_ONLY.has(line.split(/\s/, 1)[0]))
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
let url;
|
|
188
|
+
try {
|
|
189
|
+
url = new URL(item.url);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
// A secret was redacted out of the stored URL: loading what is left is a different page.
|
|
195
|
+
if (/redacted/i.test(item.url))
|
|
196
|
+
return null;
|
|
197
|
+
return { item, path: `${url.pathname}${url.search}`, signatures };
|
|
198
|
+
}
|
|
199
|
+
export const RETEST_VERDICTS = ["reproduces", "possibly-fixed", "not-reached"];
|
|
200
|
+
/**
|
|
201
|
+
* Each re-testable finding against the page load that re-tests it.
|
|
202
|
+
*
|
|
203
|
+
* `reproduces` when the load saw one of the finding's failing requests fail
|
|
204
|
+
* with the same status; `possibly-fixed` when the page loaded and none did —
|
|
205
|
+
* possibly, because the page may simply not have asked this time; and
|
|
206
|
+
* `not-reached` when the page did not load or bounced to sign-in, which says
|
|
207
|
+
* nothing about the finding either way.
|
|
208
|
+
*/
|
|
209
|
+
export function retestResults(candidates, pages) {
|
|
210
|
+
return candidates.map(({ item, path, signatures }) => {
|
|
211
|
+
const base = { id: item.id, severity: item.severity, title: item.title, path, signatures };
|
|
212
|
+
const page = pages.find((p) => p.path === path);
|
|
213
|
+
if (!page)
|
|
214
|
+
return { ...base, verdict: "not-reached", note: "the page was not visited" };
|
|
215
|
+
if (page.loadError !== undefined)
|
|
216
|
+
return { ...base, verdict: "not-reached", note: "the page did not load" };
|
|
217
|
+
if (page.loginRedirect)
|
|
218
|
+
return { ...base, verdict: "not-reached", note: "the page sent the browser to sign-in" };
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
for (const detail of page.httpErrors)
|
|
221
|
+
for (const s of failingSignatures(detail))
|
|
222
|
+
seen.add(s);
|
|
223
|
+
// The document's own failure is left out of the violations (withoutOwnResponse), so it is added back here.
|
|
224
|
+
if (page.status !== null && page.status >= 400)
|
|
225
|
+
for (const s of failingSignatures(`GET ${page.path.split("?")[0]} ${page.status}`))
|
|
226
|
+
seen.add(s);
|
|
227
|
+
return { ...base, verdict: signatures.some((s) => seen.has(s)) ? "reproduces" : "possibly-fixed" };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The findings a check re-tests: the open ones it can reproduce by loading a
|
|
232
|
+
* page, in the campaign's order and within its cap, plus how many open
|
|
233
|
+
* findings there are in all, so the report can say how many it left to a run.
|
|
234
|
+
*/
|
|
235
|
+
export function checkRetestPlan(findings) {
|
|
236
|
+
const open = findings.filter((f) => (f.status ?? "open") !== "resolved");
|
|
237
|
+
const eligible = open.filter((f) => loadRetest(item(f)) !== null);
|
|
238
|
+
const candidates = verifyWorklist(eligible)
|
|
239
|
+
.map(loadRetest)
|
|
240
|
+
.filter((c) => c !== null);
|
|
241
|
+
return { candidates, open: open.length };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The entries of a memory file's findings list a re-test can read. The file is
|
|
245
|
+
* JSON someone may have edited; an entry missing the fields the rules read is
|
|
246
|
+
* left out rather than failing the check, which only ever reports re-tests.
|
|
247
|
+
*/
|
|
248
|
+
export function wellFormedFindings(list) {
|
|
249
|
+
return list.filter((f) => {
|
|
250
|
+
if (typeof f !== "object" || f === null)
|
|
251
|
+
return false;
|
|
252
|
+
const x = f;
|
|
253
|
+
return (typeof x.id === "string" &&
|
|
254
|
+
typeof x.url === "string" &&
|
|
255
|
+
typeof x.title === "string" &&
|
|
256
|
+
typeof x.severity === "string" &&
|
|
257
|
+
(x.evidence === undefined || typeof x.evidence === "string") &&
|
|
258
|
+
Array.isArray(x.repro) &&
|
|
259
|
+
x.repro.every((r) => typeof r === "string"));
|
|
260
|
+
});
|
|
261
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -34,7 +34,7 @@ import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError }
|
|
|
34
34
|
import { z } from "zod";
|
|
35
35
|
import { BrowserEngine } from "./engine/browser.js";
|
|
36
36
|
import { reapOrphanBrowsers } from "./engine/reaper.js";
|
|
37
|
-
import { FINDING_CATEGORIES, MemoryStore, redactSecrets } from "./engine/memory.js";
|
|
37
|
+
import { FINDING_CATEGORIES, MemoryStore, mergeableCategories, redactSecrets } from "./engine/memory.js";
|
|
38
38
|
import { decodedEntitiesNote, LANE_NAME_MAX, LaneLedger, laneCloseGuard, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
|
|
39
39
|
import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
|
|
40
40
|
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
@@ -849,7 +849,7 @@ server.registerTool("scout_crawl", {
|
|
|
849
849
|
}
|
|
850
850
|
}, 600_000));
|
|
851
851
|
server.registerTool("scout_run_plan", {
|
|
852
|
-
description: "Execute up to 20 actions in ONE call — use for mechanical sequences (fill a form, walk a wizard) so each step doesn't cost a round-trip. Targets resolve at execution time by semantic locator: 'testid=…', 'text=…',
|
|
852
|
+
description: "Execute up to 20 actions in ONE call — use for mechanical sequences (fill a form, walk a wizard) so each step doesn't cost a round-trip. Targets resolve at execution time by semantic locator: 'testid=…', 'text=…', 'label=…' or 'role=button[name=Save]' (never snapshot refs). The same steps, saved to .scenescout/flows/<name>.json with expect-text / expect-url / expect-request steps added, are replayed by `scenescout check` on every pull request. An `upload` step attaches a file as scout_upload does (target required — the file input or the control that opens its chooser; value = a fixture kind or a project-relative path). The plan ABORTS at the first NEW oracle violation, policy refusal, or failed step, returning a transcript of how far it got; repeats of already-reported violations do not abort (they stay logged for the report).",
|
|
853
853
|
inputSchema: {
|
|
854
854
|
steps: z
|
|
855
855
|
.array(z.object({
|
|
@@ -857,7 +857,7 @@ server.registerTool("scout_run_plan", {
|
|
|
857
857
|
target: z
|
|
858
858
|
.string()
|
|
859
859
|
.optional()
|
|
860
|
-
.describe("testid=…, text=…, label
|
|
860
|
+
.describe("testid=…, text=…, label=…, role=<role>[name=\"…\"] (or a path for navigate; 'top'/'bottom'/±px for scroll; for upload: the file input or the control that opens its chooser)"),
|
|
861
861
|
value: z
|
|
862
862
|
.string()
|
|
863
863
|
.optional()
|
|
@@ -1168,7 +1168,7 @@ server.registerTool("scout_finding", {
|
|
|
1168
1168
|
? `Finding recorded: [${finding.severity}] ${finding.title} (id ${finding.id})`
|
|
1169
1169
|
: finding.regressedAt
|
|
1170
1170
|
? `⟳ 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.`
|
|
1171
|
-
: `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)
|
|
1171
|
+
: `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, filed as ${finding.category}, seen in ${finding.runs} runs. If yours is a different bug, file it again: under the category that says what is wrong if it is another kind of defect (a finding filed as ${category} merges only with one filed as ${mergeableCategories(category).join(" or ")}), or 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);
|
|
1172
1172
|
}
|
|
1173
1173
|
catch (err) {
|
|
1174
1174
|
return errorText(err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"format:check": "prettier --check .",
|
|
65
65
|
"demo": "npm run build && tsx scripts/demo.ts",
|
|
66
66
|
"demo:serve": "node demo-app/server.mjs",
|
|
67
|
+
"holdout:serve": "node holdout-app/server.mjs",
|
|
67
68
|
"dev": "tsx src/cli.ts",
|
|
68
69
|
"serve": "node dist/mcp-server.js",
|
|
69
70
|
"smoke": "npm run build && npm run smoke:run",
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
"mcp-check": "npm run build && npm run mcp-check:run",
|
|
72
73
|
"mcp-check:run": "tsx scripts/mcp-check.ts",
|
|
73
74
|
"test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
|
|
74
|
-
"test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run check-test && npm run contract-test && npm run pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run hygiene-test",
|
|
75
|
+
"test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run check-test && npm run contract-test && npm run pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run holdout-test && npm run hygiene-test",
|
|
75
76
|
"scan-test": "tsx scripts/scan-test.ts",
|
|
76
77
|
"oracle-test": "tsx --test scripts/oracle-test.ts",
|
|
77
78
|
"policy-test": "tsx --test scripts/policy-test.ts",
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
"install-test": "tsx --test scripts/install-test.ts",
|
|
86
87
|
"live-test": "tsx --test scripts/live-test.ts",
|
|
87
88
|
"demo-test": "tsx --test scripts/demo-test.ts",
|
|
89
|
+
"holdout-test": "tsx --test scripts/holdout-test.ts",
|
|
88
90
|
"hygiene-test": "tsx --test scripts/hygiene-test.ts",
|
|
89
91
|
"pace-test": "tsx --test scripts/pace-test.ts",
|
|
90
92
|
"request-test": "tsx --test scripts/request-test.ts",
|
|
@@ -33,7 +33,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
|
|
|
33
33
|
|
|
34
34
|
1. **`scout_crawl` first, always.** One call visits every known route (pass `paths` to sweep a specific subset instead), records coverage, and returns per-route health. This is the whole breadth pass — do not visit routes one-by-one with navigate+snapshot.
|
|
35
35
|
2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
|
|
36
|
-
3. **Run journeys with `scout_run_plan {steps}`.** Mechanical sequences (fill form → submit → check) go in ONE plan call — `steps` is an ordered list of `{action, target, value}` — with `testid=`/`text=`/`label
|
|
36
|
+
3. **Run journeys with `scout_run_plan {steps}`.** Mechanical sequences (fill form → submit → check) go in ONE plan call — `steps` is an ordered list of `{action, target, value}` — with `testid=`/`text=`/`label=`/`role=button[name="Save"]` targets — not one LLM turn per click. The plan aborts at the first violation and tells you where; that's your cue to investigate interactively. When the user wants a flow kept working, save the plan that walked it as `.scenescout/flows/<name>.json` — `{"name", "steps"}`, the same steps, starting with a `navigate`, plus `{"action":"expect-text","text"}`, `{"action":"expect-url","pattern"}` or `{"action":"expect-request","request":"GET /api/…","status":200}` where the outcome shows — and `scenescout check` replays it on every pull request. Save only flows that read: a check replays flows under observe's rule unless the project passes `--flow-writes allow`, so a step that sends a write is reported as "could not run" and the check exits 2.
|
|
37
37
|
4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line. Controls inside embeds (`<iframe>`) are listed with the page's, each marked `⟨in … frame⟩`, and act by ref like any other. A frame of the app's own origin is the app: test it fully. A frame of another site (`cross-origin`) is someone else's system that the user has not authorised you to test: click and type there as a user would, to see the embed render and respond, but never send it hostile input — the engine refuses markup, values over 200 characters, control characters, repeated-click probes and uploads there, and you must not try SQL, template or other injection shapes either — and file what you find there as the embed's behaviour, naming its origin, not as the app's bug. A failing request such a frame sent outside the app says so (`in an embed of …`) and is kept at medium; console errors cannot be told apart by frame and stay the app's; its controls are counted apart and never enter the app's coverage or gap ledger. Its container text is masked, and the writes it sends outside the app are refused in every mode but destructive — unless the user names that origin as a trusted embed (a provider in test mode, say): pass it in `scout_attach {trustedEmbeds: ["https://…"]}` and, in safe-write only, its writes go out. Never add an origin the user did not name. The FRAMES line names frames that were not read; say in your summary that their contents were not explored.
|
|
38
38
|
5. **Native-user behaviours.** `scout_type {ref, textValue}` (or its alias `value`, matching `scout_select` and a plan step) APPENDS when a field already has content (menu clicks often insert @-mention chips or commands into composers — appending preserves them; the result reports what was already there); pass `replace=true` only to deliberately clear, and `pressEnter=true` to submit from the field the way a user would. Before concluding a badge, icon, or "N errors" indicator *does nothing*, `scout_hover` it — tooltips and hover cards are invisible to snapshots and clicks, and hover output includes what appeared. In HEADED mode (`scout_attach {headed:true}`, which the user asks for when they want to watch) the user's physical mouse competes with the synthetic pointer: if a hover reveals nothing and the finding matters, ask the user to move their mouse off the browser window and retry before filing. **Scroll long pages with `scout_scroll`** — the design audit and snapshot measure at the current scroll position, so judge deep sections by scrolling then re-auditing; it refuses to scroll where a real user couldn't and reports SCROLL LOCKED (the leaked modal scroll-lock that silently amputates everything below the fold — snapshots also flag it passively as an OVERLAY line), and scrolling triggers lazy-loaded content whose failures surface as fresh oracle violations. Elements fully clipped inside an overflow-hidden container are flagged UNREACHABLE in GEOMETRY issues — no amount of scrolling reveals them; that's a high-value layout bug, distinct from merely below-the-fold content. **A page can hold SEVERAL independent scroll regions** and plain `scout_scroll` moves the largest one, so a sidebar nav beside a taller main pane never budges: pass `scout_scroll {target:"testid=…"}` to scroll one region. Never report a nav item, tab or list row as missing/truncated until you have scrolled ITS container — content scrolled out of a secondary pane looks exactly like content that was cut off.
|
|
39
39
|
6. **The rest of the input vocabulary.** `scout_select` sets a `<select>` option by value or visible label — use it rather than clicking a native dropdown open, which does not render as page DOM. `scout_press` sends a real key to the focused element (`Escape` to dismiss a modal, `Tab` to walk focus order, `Enter` to submit from a field); it is also how the keyboard-only pass at `extensive` is performed, and it vets the focused control first so a destructive action cannot be triggered blind in read-only mode. **`scout_upload {ref}` attaches a file the way a user does** — `ref` is a visible `<input type=file>` (snapshots list these with role `file`; `scout_type` on one redirects here) OR the button/label/dropzone that opens the file chooser (the chooser is intercepted and answered — that is how the hidden input behind a styled "Choose file" control is reached); omit `ref` when the page has exactly one file input, hidden or not (snapshots disclose hidden ones on a FILE INPUTS line). Nothing needs to exist on disk: a small VALID fixture is generated in memory, its kind inferred from the input's `accept` attribute or chosen with `fixture` (`pdf`, `png`, `txt`, `csv`, `json`); `filePath` uploads a real file but must live inside the attached project (fenced, like navigation is fenced to the origin); `name` overrides the filename. The result flags a file that violates `accept` (a mismatch the app then ACCEPTS is a validation finding), warns when the app cleared the input after selection, and says whether a state-changing request fired on selection — if none did, either click the form's submit or read the next snapshot for a client-side rejection. Plans take `{action:"upload", target, value:"pdf"}` steps (`target` required). When the input or its trigger was addressed by `ref`, the gap ledger counts an attached-but-unsent file as filled-never-submitted; the ref-less path has no listed element to mark.
|