scenescout 3.17.0 → 3.18.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 +17 -0
- package/README.md +6 -3
- package/dist/check-run.js +14 -7
- package/dist/engine/browser.js +108 -32
- package/dist/engine/check.js +92 -7
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +73 -3
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/first-run.js +7 -1
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +41 -0
- package/dist/playbook.js +1 -1
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/probes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ACCESSIBLE_NAME_SRC, DIALOG_LIKE_SEL } from "./collector.js";
|
|
2
2
|
import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
|
|
3
|
+
import { focusedControl } from "./design.js";
|
|
3
4
|
/**
|
|
4
5
|
* Scroll ONE named region rather than the page. The page-level heuristic
|
|
5
6
|
* picks the largest scrollable pane, so a smaller independently-scrolling
|
|
@@ -158,6 +159,12 @@ export async function probeOverlays(page) {
|
|
|
158
159
|
const s = getComputedStyle(el);
|
|
159
160
|
return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
|
|
160
161
|
};
|
|
162
|
+
// A translucent background or a blur: what dims the page behind a modal.
|
|
163
|
+
const darkens = (s) => {
|
|
164
|
+
const m = (s.backgroundColor || "").match(/rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([0-9.]+))?/);
|
|
165
|
+
const alpha = m ? (m[4] === undefined ? 1 : Number(m[4])) : 0;
|
|
166
|
+
return (alpha > 0.05 && alpha < 0.98) || ((s.backdropFilter || "") + "").includes("blur");
|
|
167
|
+
};
|
|
161
168
|
// Backdrops: fixed, near-full-viewport, visually darkening/blurring.
|
|
162
169
|
const backdrops = [];
|
|
163
170
|
for (const el of document.querySelectorAll("body *")) {
|
|
@@ -166,10 +173,7 @@ export async function probeOverlays(page) {
|
|
|
166
173
|
if (s.position !== "fixed") continue;
|
|
167
174
|
const r = el.getBoundingClientRect();
|
|
168
175
|
if (r.width < vw * 0.9 || r.height < vh * 0.9) continue;
|
|
169
|
-
|
|
170
|
-
const alpha = m ? (m[4] === undefined ? 1 : Number(m[4])) : 0;
|
|
171
|
-
const darkens = (alpha > 0.05 && alpha < 0.98) || ((s.backdropFilter || "") + "").includes("blur");
|
|
172
|
-
if (darkens) backdrops.push(el);
|
|
176
|
+
if (darkens(s)) backdrops.push(el);
|
|
173
177
|
}
|
|
174
178
|
const hasContent = (el) => {
|
|
175
179
|
const t = (el.textContent || "").trim();
|
|
@@ -183,12 +187,47 @@ export async function probeOverlays(page) {
|
|
|
183
187
|
const dialogsAll = [...document.querySelectorAll('${DIALOG_LIKE_SEL}')].filter((d) => visible(d));
|
|
184
188
|
// Leaked modal scroll-lock: content extends past the fold, the page
|
|
185
189
|
// itself cannot scroll (overflow hidden on body/html), and NO overlay
|
|
186
|
-
// of any kind — dialog-like panel
|
|
190
|
+
// of any kind — dialog-like panel, backdrop or framed modal — is up to justify the
|
|
187
191
|
// lock; everything below the fold is unreachable and the page looks
|
|
188
192
|
// perfectly healthy otherwise.
|
|
193
|
+
// A modal can also live inside a frame: the top document then holds only
|
|
194
|
+
// a pinned wrapper around a full-viewport iframe, and the dialog and its
|
|
195
|
+
// backdrop are in the frame's document, out of reach of the selectors
|
|
196
|
+
// above. Such an iframe justifies the lock when it is visible, covers the
|
|
197
|
+
// viewport, is pinned (itself or through a fixed ancestor) and is not
|
|
198
|
+
// faded out or click-through, and, when its document can be read (same
|
|
199
|
+
// origin), something in it is open: a dialog-like panel or a dimming
|
|
200
|
+
// backdrop. A frame left mounted after its modal closed holds neither.
|
|
201
|
+
// Another site's frame cannot be read, so its geometry has to do.
|
|
202
|
+
const frameOverlay = (f) => {
|
|
203
|
+
if (!visible(f)) return false;
|
|
204
|
+
const r = f.getBoundingClientRect();
|
|
205
|
+
if (r.width < vw * 0.9 || r.height < vh * 0.9) return false;
|
|
206
|
+
let pinned = false;
|
|
207
|
+
for (let e = f; e && e !== document.body; e = e.parentElement) {
|
|
208
|
+
const s = getComputedStyle(e);
|
|
209
|
+
if (Number(s.opacity) === 0 || s.pointerEvents === "none") return false;
|
|
210
|
+
if (s.position === "fixed") pinned = true;
|
|
211
|
+
}
|
|
212
|
+
if (!pinned) return false;
|
|
213
|
+
// contentDocument is null for another site's frame.
|
|
214
|
+
const doc = f.contentDocument;
|
|
215
|
+
const win = doc && doc.defaultView;
|
|
216
|
+
if (!win) return true;
|
|
217
|
+
const fw = win.innerWidth, fh = win.innerHeight;
|
|
218
|
+
const shown = (el) => { const b = el.getBoundingClientRect(); const s = win.getComputedStyle(el); return b.width > 0 && b.height > 0 && s.visibility !== "hidden" && s.display !== "none"; };
|
|
219
|
+
if ([...doc.querySelectorAll('${DIALOG_LIKE_SEL}')].some(shown)) return true;
|
|
220
|
+
return [...doc.querySelectorAll("body *")].some((el) => {
|
|
221
|
+
const s = win.getComputedStyle(el);
|
|
222
|
+
if (s.position !== "fixed" || !shown(el)) return false;
|
|
223
|
+
const b = el.getBoundingClientRect();
|
|
224
|
+
return b.width >= fw * 0.9 && b.height >= fh * 0.9 && darkens(s);
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
const framedOverlay = [...document.querySelectorAll("iframe")].some(frameOverlay);
|
|
189
228
|
const docH = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
|
190
229
|
const ovLock = [getComputedStyle(document.documentElement).overflowY, document.body ? getComputedStyle(document.body).overflowY : ""].some((o) => o === "hidden" || o === "clip");
|
|
191
|
-
if (ovLock && docH > vh + 50 && dialogsAll.length === 0 && backdrops.length === 0) {
|
|
230
|
+
if (ovLock && docH > vh + 50 && dialogsAll.length === 0 && backdrops.length === 0 && !framedOverlay) {
|
|
192
231
|
issues.push("OVERLAY: page scrolling is DISABLED (overflow hidden on body/html) with " + Math.round(docH - vh) + "px of content below the fold and NO open dialog to justify it — likely a leaked modal scroll-lock; users cannot reach the rest of the page");
|
|
193
232
|
}
|
|
194
233
|
if (backdrops.length === 0) return issues;
|
|
@@ -249,8 +288,11 @@ export async function probeOverlays(page) {
|
|
|
249
288
|
* focusless. As Tab advances, the previous stop is naturally blurred, so
|
|
250
289
|
* each stop's focused style (captured at visit time) can be diffed against
|
|
251
290
|
* its blurred style (captured in one pass at the end) without fighting the
|
|
252
|
-
* tab order.
|
|
253
|
-
*
|
|
291
|
+
* tab order. A Tab onto a frame is followed into a same-origin frame's
|
|
292
|
+
* document and the control focused there is sampled; a press that leaves
|
|
293
|
+
* focus inside a frame it cannot read is skipped (design.ts focusedControl).
|
|
294
|
+
* Best-effort: any failure returns an empty sample set rather than failing
|
|
295
|
+
* the audit.
|
|
254
296
|
*/
|
|
255
297
|
export async function probeFocusIndicators(page) {
|
|
256
298
|
const name = page.context().browser()?.browserType().name();
|
|
@@ -261,11 +303,11 @@ export async function probeFocusIndicators(page) {
|
|
|
261
303
|
for (let i = 0; i < 15; i++) {
|
|
262
304
|
await page.keyboard.press(advanceKey);
|
|
263
305
|
const info = (await page.evaluate(`(() => {
|
|
264
|
-
const el = document
|
|
265
|
-
if (
|
|
306
|
+
const el = (${focusedControl.toString()})(document);
|
|
307
|
+
if (el === null || el === "frame") return el;
|
|
266
308
|
if (el.hasAttribute("data-scout-focus-probe")) return "wrapped";
|
|
267
309
|
el.setAttribute("data-scout-focus-probe", "${i}");
|
|
268
|
-
const s = getComputedStyle(el);
|
|
310
|
+
const s = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
269
311
|
const tid = el.getAttribute("data-testid");
|
|
270
312
|
// Named as the snapshot names it, so an icon button reads by its aria-label here too.
|
|
271
313
|
const name = (${ACCESSIBLE_NAME_SRC})(el).slice(0, 30);
|
|
@@ -273,15 +315,32 @@ export async function probeFocusIndicators(page) {
|
|
|
273
315
|
})()`));
|
|
274
316
|
if (info === null || info === "wrapped")
|
|
275
317
|
break;
|
|
318
|
+
if (info === "frame")
|
|
319
|
+
continue;
|
|
276
320
|
stops.push({ i, ...info });
|
|
277
321
|
}
|
|
278
|
-
|
|
322
|
+
// Blur from the innermost focused element out, so a control focused inside a frame loses focus too.
|
|
323
|
+
await page.evaluate(`(() => {
|
|
324
|
+
const chain = [];
|
|
325
|
+
for (let d = document; d; ) {
|
|
326
|
+
const a = d.activeElement;
|
|
327
|
+
if (!a || a === d.body || a === d.documentElement) break;
|
|
328
|
+
chain.push(a);
|
|
329
|
+
d = /^i?frame$/i.test(a.tagName) ? a.contentDocument : null;
|
|
330
|
+
}
|
|
331
|
+
for (const a of chain.reverse()) if (a.blur) a.blur();
|
|
332
|
+
})()`);
|
|
333
|
+
// The marked stops live in the page and in every same-origin frame the walk followed focus into.
|
|
279
334
|
const blurred = (await page.evaluate(`(() => {
|
|
280
335
|
const out = {};
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
el.
|
|
336
|
+
const docs = [document];
|
|
337
|
+
for (let k = 0; k < docs.length; k++) {
|
|
338
|
+
for (const f of docs[k].querySelectorAll("iframe, frame")) if (f.contentDocument) docs.push(f.contentDocument);
|
|
339
|
+
for (const el of docs[k].querySelectorAll("[data-scout-focus-probe]")) {
|
|
340
|
+
const s = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
341
|
+
out[el.getAttribute("data-scout-focus-probe")] = ${styleSig};
|
|
342
|
+
el.removeAttribute("data-scout-focus-probe");
|
|
343
|
+
}
|
|
285
344
|
}
|
|
286
345
|
return out;
|
|
287
346
|
})()`));
|
package/dist/first-run.js
CHANGED
|
@@ -24,7 +24,7 @@ import fs from "node:fs";
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { APPROX_DISK_MB, launchTarget } from "./browsers.js";
|
|
26
26
|
import { hasScheme, hostOf } from "./commands.js";
|
|
27
|
-
import { CHECK_OPTION_NAMES, CHECK_RULES, countBySeverity, issueLine, issueSections, MAX_CHECK_ROUTES, MAX_DISCOVERY_ROUNDS, resolveArgPath, routesTable, SEVERITY_RANK, SHARED_CHROME_ROUTE, unauditedRoutes, unvisitedLine, worthALookSection, } from "./engine/check.js";
|
|
27
|
+
import { CHECK_OPTION_NAMES, CHECK_RULES, countBySeverity, issueLine, issueSections, MAX_CHECK_ROUTES, MAX_DISCOVERY_ROUNDS, needsSignInText, resolveArgPath, resourcesTable, routesTable, SEVERITY_RANK, SHARED_CHROME_ROUTE, unauditedRoutes, unvisitedLine, worthALookSection, } from "./engine/check.js";
|
|
28
28
|
import { httpStatusOf } from "./engine/oracles.js";
|
|
29
29
|
/** Every `--option` a first look accepts. Anything more is `scenescout check`'s. */
|
|
30
30
|
export const FIRST_RUN_OPTION_NAMES = ["max-routes", "max-minutes", "mode", "out"];
|
|
@@ -510,6 +510,11 @@ function notes({ result, options }, code) {
|
|
|
510
510
|
out.push(`The start page moved to ${to}, so its links count as another site's and were not followed. To look further, run it there: ${code(`npx -y scenescout ${shellArg(`${to}/`)}`)}.`);
|
|
511
511
|
}
|
|
512
512
|
}
|
|
513
|
+
// A first look never has a session, so a page behind sign-in is a page it could not cover, not an issue.
|
|
514
|
+
const walled = result.needsSignIn?.length ?? 0;
|
|
515
|
+
if (walled > 0 && !start?.loginRedirect) {
|
|
516
|
+
out.push(needsSignInText(walled, `${code(`npx -y scenescout login ${shellArg(result.url)} --role <name>`)} saves a sign-in, and an agent run then attaches as that role`, "page"));
|
|
517
|
+
}
|
|
513
518
|
const left = result.unvisited.length;
|
|
514
519
|
switch (stopReason(result, options.maxRoutes)) {
|
|
515
520
|
case "time":
|
|
@@ -575,6 +580,7 @@ export function formatFirstRun(facts) {
|
|
|
575
580
|
lines.push(...issueSections(result.issues));
|
|
576
581
|
lines.push(...worthALookSection(result.worthALook, false));
|
|
577
582
|
lines.push(...routesTable(result));
|
|
583
|
+
lines.push(...resourcesTable(result.resources));
|
|
578
584
|
const why = stopReason(result, facts.options.maxRoutes);
|
|
579
585
|
lines.push(...unvisitedLine(result.unvisited, why ? UNVISITED_WHY[why] : "not reached"));
|
|
580
586
|
lines.push("", "## What to try next", "", `- **Explore it with your coding agent.** ${code("npx -y scenescout install")} sets up the skill and the MCP server; then ask your agent to use SceneScout to test ${result.url}. It clicks, fills forms and compares roles, files what it finds and reports what it did not test.`, `- **Gate pull requests.** ${code(`npx -y scenescout check ${shellArg(result.url)}`)} is this look as a pass or a fail, with SARIF for code scanning; it also runs as a GitHub Action.`, `- **Pages behind a sign-in.** ${code(`npx -y scenescout login ${shellArg(result.url)} --role <name>`)} saves a sign-in once, and an agent run then attaches as that role.`, "", `The guide: ${GUIDE_URL}`, "", `_A first look opens pages and measures what loads, and fills and submits no form. ${modeSentence(result.mode)} (${SAFETY_URL}) It does not click through flows or compare roles; an exploratory run does that._`);
|
package/dist/intake.js
CHANGED
|
@@ -102,3 +102,164 @@ export function settingsFromAnswers(a) {
|
|
|
102
102
|
export function introQuestions() {
|
|
103
103
|
return INTAKE_QUESTIONS.map((q, i) => `${i + 1}. ${q.ask}`).join("\n");
|
|
104
104
|
}
|
|
105
|
+
// ── The questions as one form ────────────────────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// A client that can show a form (MCP elicitation, form mode) gets the four
|
|
108
|
+
// questions as one, through scout_intake; every other client, and a person who
|
|
109
|
+
// declines or dismisses the form, gets them as text for the agent to ask in
|
|
110
|
+
// chat. Specification: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
|
111
|
+
// (the version the SDK speaks; the 2026-07-28 revision keeps the same capability,
|
|
112
|
+
// schema subset and three actions). Two of its rules shape this:
|
|
113
|
+
// - a server MUST NOT send a mode the client did not declare, and an empty
|
|
114
|
+
// `elicitation` capability means form mode only (formElicitationSupported);
|
|
115
|
+
// - a server MUST NOT ask for passwords, API keys or tokens in form mode. The
|
|
116
|
+
// form asks HOW the person signs in, never with what: the sign-in itself is
|
|
117
|
+
// scout_login's window.
|
|
118
|
+
/** The tool that asks the questions, as a form where the client can show one. */
|
|
119
|
+
export const INTAKE_TOOL = "scout_intake";
|
|
120
|
+
/**
|
|
121
|
+
* Whether the client can show a form. Declared `form` says so; an empty
|
|
122
|
+
* `elicitation` object is form mode by the specification's backwards-compatibility
|
|
123
|
+
* rule; `url` alone, or no `elicitation` at all, is not.
|
|
124
|
+
*/
|
|
125
|
+
export function formElicitationSupported(capabilities) {
|
|
126
|
+
const e = capabilities?.elicitation;
|
|
127
|
+
if (!e || typeof e !== "object")
|
|
128
|
+
return false;
|
|
129
|
+
return e.form !== undefined || e.url === undefined;
|
|
130
|
+
}
|
|
131
|
+
const choice = (value, title) => ({ const: value, title });
|
|
132
|
+
const askOf = (id) => INTAKE_QUESTIONS.find((q) => q.id === id).ask;
|
|
133
|
+
/** Every value the form offers for a choice, with the words the person sees. */
|
|
134
|
+
export const FORM_CHOICES = {
|
|
135
|
+
signIn: [
|
|
136
|
+
choice("none", "No sign-in"),
|
|
137
|
+
choice("sso", "Google or Microsoft single sign-on"),
|
|
138
|
+
choice("password", "An email and password"),
|
|
139
|
+
choice("one-time-code", "A one-time code"),
|
|
140
|
+
],
|
|
141
|
+
whatToCheck: [
|
|
142
|
+
choice("everything", "Everything: the whole site"),
|
|
143
|
+
choice("description", "What I describe below"),
|
|
144
|
+
choice("tickets", "The tickets I paste below"),
|
|
145
|
+
],
|
|
146
|
+
realData: [choice("yes", "Yes"), choice("no", "No"), choice("unsure", "Not sure")],
|
|
147
|
+
};
|
|
148
|
+
/** What the form says above its fields. */
|
|
149
|
+
export const INTAKE_FORM_MESSAGE = "Four questions before SceneScout starts testing. It never asks for a password here: if the site needs one, a browser window opens later for you to sign in as you normally would.";
|
|
150
|
+
/**
|
|
151
|
+
* The form, as an `elicitation/create` request's `requestedSchema`: a flat object
|
|
152
|
+
* of strings and single-choice lists, the only shapes form mode allows. `address`
|
|
153
|
+
* starts filled in when the agent already knows it. Real data starts at "Not sure",
|
|
154
|
+
* which tests as carefully as "Yes".
|
|
155
|
+
*/
|
|
156
|
+
export function intakeFormSchema(knownAddress) {
|
|
157
|
+
const address = knownAddress?.trim();
|
|
158
|
+
return {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
address: { type: "string", title: "Address", description: askOf("address"), minLength: 1, ...(address ? { default: address } : {}) },
|
|
162
|
+
signIn: { type: "string", title: "Sign-in", description: askOf("sign-in"), oneOf: [...FORM_CHOICES.signIn] },
|
|
163
|
+
whatToCheck: {
|
|
164
|
+
type: "string",
|
|
165
|
+
title: "What to check",
|
|
166
|
+
description: "What should I check?",
|
|
167
|
+
oneOf: [...FORM_CHOICES.whatToCheck],
|
|
168
|
+
default: "everything",
|
|
169
|
+
},
|
|
170
|
+
details: {
|
|
171
|
+
type: "string",
|
|
172
|
+
title: "Description or tickets",
|
|
173
|
+
description: "Describe what to check in a sentence, or paste the tickets with a line of --- between them. To upload a file of tickets instead, leave this empty and I will ask for it.",
|
|
174
|
+
},
|
|
175
|
+
realData: { type: "string", title: "Real data", description: askOf("real-data"), oneOf: [...FORM_CHOICES.realData], default: "unsure" },
|
|
176
|
+
},
|
|
177
|
+
required: ["address", "signIn", "whatToCheck", "realData"],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const oneOf = (list, value) => list.find((c) => c.const === value)?.const;
|
|
181
|
+
/** Pasted tickets, split on a line of three or more dashes, empty ones dropped. */
|
|
182
|
+
export function splitTickets(text) {
|
|
183
|
+
return text
|
|
184
|
+
.split(/^[ \t]*-{3,}[ \t]*$/m)
|
|
185
|
+
.map((t) => t.trim())
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
const SIGN_IN_WORDS = {
|
|
189
|
+
none: "no sign-in",
|
|
190
|
+
sso: "Google or Microsoft single sign-on",
|
|
191
|
+
password: "an email and password",
|
|
192
|
+
"one-time-code": "a one-time code",
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* What a form round comes to. Accepted answers become settings through
|
|
196
|
+
* settingsFromAnswers. Anything else asks in chat: all four questions when
|
|
197
|
+
* there was no form or the person turned it down, and only what is still
|
|
198
|
+
* missing when the form came back without it (tickets to upload, an empty
|
|
199
|
+
* description). An answer is never guessed.
|
|
200
|
+
*/
|
|
201
|
+
export function intakeFromForm(outcome) {
|
|
202
|
+
const all = (why) => ({ kind: "ask", questions: [...INTAKE_QUESTIONS], known: [], why });
|
|
203
|
+
if (outcome.kind === "unsupported")
|
|
204
|
+
return all("this client cannot show a form");
|
|
205
|
+
if (outcome.kind === "declined")
|
|
206
|
+
return all("the person declined the form");
|
|
207
|
+
if (outcome.kind === "cancelled")
|
|
208
|
+
return all("the person closed the form without answering");
|
|
209
|
+
if (outcome.kind === "failed")
|
|
210
|
+
return all(`the form failed: ${outcome.error}`);
|
|
211
|
+
const c = outcome.content;
|
|
212
|
+
const address = typeof c.address === "string" ? c.address.trim() : "";
|
|
213
|
+
const signIn = oneOf(FORM_CHOICES.signIn, c.signIn);
|
|
214
|
+
const kind = oneOf(FORM_CHOICES.whatToCheck, c.whatToCheck);
|
|
215
|
+
const realData = oneOf(FORM_CHOICES.realData, c.realData);
|
|
216
|
+
const details = typeof c.details === "string" ? c.details.trim() : "";
|
|
217
|
+
let whatToCheck;
|
|
218
|
+
// "Everything" with text in the box is a contradiction (the choice starts on everything, so pasted tickets
|
|
219
|
+
// would be dropped unseen): asked again rather than guessed either way.
|
|
220
|
+
if (kind === "everything" && !details)
|
|
221
|
+
whatToCheck = "everything";
|
|
222
|
+
else if (kind === "description" && details)
|
|
223
|
+
whatToCheck = { description: details };
|
|
224
|
+
else if (kind === "tickets" && splitTickets(details).length > 0)
|
|
225
|
+
whatToCheck = { tickets: splitTickets(details) };
|
|
226
|
+
const missing = INTAKE_QUESTIONS.filter((q) => (q.id === "address" && !address) || (q.id === "sign-in" && !signIn) || (q.id === "what-to-check" && !whatToCheck) || (q.id === "real-data" && !realData));
|
|
227
|
+
if (!address || !signIn || !whatToCheck || !realData) {
|
|
228
|
+
const known = [
|
|
229
|
+
address ? `Address: ${address}` : "",
|
|
230
|
+
signIn ? `Sign-in: ${SIGN_IN_WORDS[signIn]}` : "",
|
|
231
|
+
kind === "everything" && details
|
|
232
|
+
? "What to check: everything was chosen, but there is text in the box too"
|
|
233
|
+
: kind
|
|
234
|
+
? `What to check: ${kind}${whatToCheck ? "" : ", not given yet"}`
|
|
235
|
+
: "",
|
|
236
|
+
realData ? `Real data: ${FORM_CHOICES.realData.find((r) => r.const === realData).title.toLowerCase()}` : "",
|
|
237
|
+
].filter(Boolean);
|
|
238
|
+
return { kind: "ask", questions: missing, known, why: "the form came back without every answer" };
|
|
239
|
+
}
|
|
240
|
+
return { kind: "settings", settings: settingsFromAnswers({ address, signIn, whatToCheck, realData }) };
|
|
241
|
+
}
|
|
242
|
+
/** scout_intake's reply as text: the calls to make in order, or the questions to ask in chat. */
|
|
243
|
+
export function intakeReplyText(reply) {
|
|
244
|
+
if (reply.kind === "ask") {
|
|
245
|
+
const numbered = reply.questions.map((q, i) => `${i + 1}. ${q.ask}`).join("\n");
|
|
246
|
+
return [
|
|
247
|
+
`No settings from a form (${reply.why}). Ask the person ${reply.questions.length === 1 ? "this question" : "these questions"} in chat, in one message, then choose the settings from the answers as the method says.`,
|
|
248
|
+
...(reply.known.length > 0 ? ["", "Already answered in the form:", ...reply.known.map((k) => `- ${k}`)] : []),
|
|
249
|
+
"",
|
|
250
|
+
numbered,
|
|
251
|
+
].join("\n");
|
|
252
|
+
}
|
|
253
|
+
const s = reply.settings;
|
|
254
|
+
const steps = [];
|
|
255
|
+
if (s.login) {
|
|
256
|
+
steps.push(`Tell the person: "${s.login.tellUser}"`);
|
|
257
|
+
steps.push(`Call ${s.login.tool} ${JSON.stringify({ url: s.login.url, role: s.login.role })}, which returns once they are signed in.`);
|
|
258
|
+
}
|
|
259
|
+
steps.push(`Call scout_attach ${JSON.stringify(s.attach)}, with projectPath as the method's setup says.`);
|
|
260
|
+
if (s.readTickets)
|
|
261
|
+
steps.push(`Once attached, read the tickets: ${s.readTickets.tool} ${JSON.stringify({ text: s.readTickets.text })}.`);
|
|
262
|
+
if (s.focus)
|
|
263
|
+
steps.push(`Keep to this area: ${s.focus}`);
|
|
264
|
+
return ["The person answered the form. Use these settings; do not ask the questions again.", ...steps.map((step, i) => `${i + 1}. ${step}`)].join("\n");
|
|
265
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -71,6 +71,7 @@ import { chooseProjectFolder, PROJECTS_DIR_ENV, workspaceFromRoots } from "./eng
|
|
|
71
71
|
import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
|
|
72
72
|
import { describePace, normalizePace } from "./engine/settle.js";
|
|
73
73
|
import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
|
|
74
|
+
import { formElicitationSupported, INTAKE_FORM_MESSAGE, INTAKE_TOOL, intakeFormSchema, intakeFromForm, intakeReplyText } from "./intake.js";
|
|
74
75
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
75
76
|
import { livePrompt, loginPrompt, LOGIN_PROMPT, LIVE_PROMPT, LOGIN_PROMPT_ARGUMENTS, LIVE_PROMPT_ARGUMENTS } from "./prompts.js";
|
|
76
77
|
import { formatScan, scanProject } from "./scan.js";
|
|
@@ -543,6 +544,46 @@ server.registerTool(PLAYBOOK_TOOL, {
|
|
|
543
544
|
return errorText(err);
|
|
544
545
|
}
|
|
545
546
|
});
|
|
547
|
+
// The start-of-run questions as one form, where the client can show one (MCP
|
|
548
|
+
// elicitation, form mode); otherwise, or when the person declines or closes it,
|
|
549
|
+
// the questions come back as text for the agent to ask in chat. The mapping is
|
|
550
|
+
// intake.ts's; this only shows the form and reports how it ended.
|
|
551
|
+
/** How long the form waits for the person before the questions go back to chat. */
|
|
552
|
+
const INTAKE_FORM_WAIT_MS = 10 * 60_000;
|
|
553
|
+
server.registerTool(INTAKE_TOOL, {
|
|
554
|
+
description: "Ask the start-of-run questions (the site's address, whether and how to sign in, what to check, whether the site holds real data) when the person gave no settings. " +
|
|
555
|
+
"Where this client can show a form, the person answers there and this returns the settings to use: the scout_login and scout_attach calls, in order. " +
|
|
556
|
+
"Otherwise, or when they decline or close the form, it returns the questions for you to ask in chat. Never asks for a password. Touches no browser.",
|
|
557
|
+
inputSchema: {
|
|
558
|
+
url: z.string().max(2048).optional().describe("The site's address, when the person already said it: the form starts with it filled in"),
|
|
559
|
+
},
|
|
560
|
+
}, async ({ url }, extra) => {
|
|
561
|
+
let outcome;
|
|
562
|
+
if (!formElicitationSupported(server.server.getClientCapabilities()))
|
|
563
|
+
outcome = { kind: "unsupported" };
|
|
564
|
+
else {
|
|
565
|
+
try {
|
|
566
|
+
const result = await server.server.elicitInput({ mode: "form", message: INTAKE_FORM_MESSAGE, requestedSchema: intakeFormSchema(url) }, { timeout: INTAKE_FORM_WAIT_MS, signal: extra.signal, relatedRequestId: extra.requestId });
|
|
567
|
+
outcome =
|
|
568
|
+
result.action === "accept"
|
|
569
|
+
? { kind: "accepted", content: result.content ?? {} }
|
|
570
|
+
: result.action === "decline"
|
|
571
|
+
? { kind: "declined" }
|
|
572
|
+
: { kind: "cancelled" };
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
// A client that advertised forms and then failed one: the questions still get asked, in chat, with why.
|
|
576
|
+
outcome = { kind: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
577
|
+
console.error(`[scenescout] ${INTAKE_TOOL}: the form failed, asking in chat instead: ${outcome.error}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
return { content: [{ type: "text", text: intakeReplyText(intakeFromForm(outcome)) }] };
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
return errorText(err);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
546
587
|
// Prompts, for clients that list server prompts as commands. Registered on the
|
|
547
588
|
// protocol server directly: the SDK's prompt helper rejects a request that
|
|
548
589
|
// carries no `arguments` object, which is exactly what a client sends when the
|
package/dist/playbook.js
CHANGED
|
@@ -77,7 +77,7 @@ export function explorePrompt(playbook, args) {
|
|
|
77
77
|
}
|
|
78
78
|
// Nothing asked for at all: the person has chosen no settings, so the run starts with the plain questions.
|
|
79
79
|
if (given("url") === undefined && level === undefined && given("focus") === undefined) {
|
|
80
|
-
return `${playbook}\n\n---\n\nRun an exploratory test session following the method above. I gave no settings: before setup,
|
|
80
|
+
return `${playbook}\n\n---\n\nRun an exploratory test session following the method above. I gave no settings: before setup, call scout_intake, which asks me these questions in a form where this client can show one; when it returns them as text instead, ask me them in one message, then choose the settings from my answers as the method says.\n${introQuestions()}`;
|
|
81
81
|
}
|
|
82
82
|
const asks = [
|
|
83
83
|
given("url") ? `Target: ${given("url")}` : "Target: ask me for the URL of the running app, or find it from the project.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.0",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|
|
@@ -11,7 +11,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
|
|
|
11
11
|
|
|
12
12
|
## Starting a run: plain questions, or flags
|
|
13
13
|
|
|
14
|
-
**No flags given** (none of `--url`, `--role`, `--focus`, `--level`, `--observe`, `--read-only`, `--safe-write`, `--allow-destructive`): before setup,
|
|
14
|
+
**No flags given** (none of `--url`, `--role`, `--focus`, `--level`, `--observe`, `--read-only`, `--safe-write`, `--allow-destructive`): before setup, call `scout_intake` (with `url` when the person's request already names the address, so the form starts with it). Where the client can show a form, the person answers the four questions there and it returns the settings: make the calls it lists, in order, and ask nothing more. Where it cannot, or the person declines or closes the form, it returns the questions instead: ask them in one message, in plain words, and choose the settings from the answers by the table below. Skip a question only when the person's request already answers it in words ("test http://localhost:3000" answers the first). With no `scout_intake` tool, ask them yourself.
|
|
15
15
|
|
|
16
16
|
1. What is the address of the site?
|
|
17
17
|
2. Do you need to sign in to use it? If so, how: Google or Microsoft single sign-on, an email and password, or a one-time code?
|