scenescout 3.17.0 → 3.19.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 +37 -0
- package/README.md +9 -4
- package/dist/check-run.js +78 -15
- package/dist/cli.js +6 -4
- package/dist/engine/baseline.js +43 -0
- package/dist/engine/browser.js +318 -144
- package/dist/engine/check.js +116 -12
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +77 -3
- package/dist/engine/export.js +200 -1
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/flow.js +171 -4
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/export-run.js +231 -55
- 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 +2 -2
- package/skills/scenescout/SKILL.md +2 -2
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.19.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",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"setup": "node dist/cli.js install",
|
|
59
59
|
"doctor": "node dist/cli.js doctor",
|
|
60
60
|
"changeset": "changeset",
|
|
61
|
-
"version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only && prettier --write package.json .claude-plugin/plugin.json desktop-extension/manifest.json",
|
|
61
|
+
"version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only && prettier --write package.json .claude-plugin/plugin.json mods/scenescout-mod/.claude-plugin/plugin.json desktop-extension/manifest.json",
|
|
62
62
|
"release": "npm run build && changeset publish",
|
|
63
63
|
"mcpb": "node scripts/build-mcpb.mjs",
|
|
64
64
|
"format": "prettier --write .",
|
|
@@ -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?
|
|
@@ -53,7 +53,7 @@ Never ask the person to choose a mode, a role name or a level, and never use the
|
|
|
53
53
|
|
|
54
54
|
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.
|
|
55
55
|
2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects, an ERROR-VIEW or a page STILL-LOADING): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
|
|
56
|
-
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. For a sweep of INDEPENDENT steps (every tab of a record, each filter, each page of a list), pass `onViolation: "continue"`: a new error status is listed on its step's line and the next step runs, so one refused tab does not cost a re-plan of the rest; a failed step, a policy refusal or any other violation still stops it. Keep the default for a form flow, where a later step depends on an earlier one. 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.
|
|
56
|
+
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. For a sweep of INDEPENDENT steps (every tab of a record, each filter, each page of a list), pass `onViolation: "continue"`: a new error status is listed on its step's line and the next step runs, so one refused tab does not cost a re-plan of the rest; a failed step, a policy refusal or any other violation still stops it. Keep the default for a form flow, where a later step depends on an earlier one. 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-element","target","state":"hidden"}` (also `visible`, `enabled`, `disabled`, `checked`, `unchecked`: for what must be gone, or a control's state), `{"action":"expect-url","pattern"}` or `{"action":"expect-request","request":"GET /api/…","status":200}` where the outcome shows, and `{"action":"repeat","steps":[…],"until":{expect step},"max":N}` where the same click is needed an unknown number of times (paging to the end of a document), and `{"action":"upload"}` (optionally `target`, `fixture`, `name`) where the journey starts from an attached file — 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.
|
|
57
57
|
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. A route you come back to diffs against its own last snapshot, and another tab of the same screen (`?tab=`) against that screen's last tab (`(tab changed)`). Refs survive a search or filter that rewrites only the query string (`same screen, query changed — refs still apply`), and a control a re-render replaced is found again by its unique test id (`re-bound after a re-render`); a real route change still needs a new snapshot. On a dense page the `TRUNCATED` line says what was cut by role and test-id family (`cut: 38 link [row-*], …`); pagers and "Load more" are always kept in the list. `[clickable]` and `[focusable]` mark an element with no control role that a click or Tab still reaches (a table row that opens a record): try it before concluding a list has no way in. **Reading a snapshot:** each line is `ref role "name" [flags]`. State flags come first — `pressed`, `selected`, `checked`, `expanded`, `current` — so the active filter, the chosen tab or the open section is in the text, and the diff says when one moves (`now [pressed]`, `no longer [pressed]`); `exercised` means this project already acted on that control, not that it is in any state. What the page announces is listed by its text whether or not it carries a test id: `alert "…"` and `status "…"` lines (role=alert/status, aria-live regions, `<output>`) — read them after every submit before concluding an action gave no feedback; a region that says something new shows in the diff as `~ ref alert "new text" (was …)`. The `main:` line summarises the main area besides its controls (`main: h1 "Orders" · 2 paragraphs · 180 chars of static text`, or `main: EMPTY`): a page whose main area holds only text is not a blank page, and `main: EMPTY` is the line that is. Elements listed only for their test id (wrappers, headings, decorative badges) are not controls: they are never counted as unnamed or as coverage gaps. `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. `scout_capture {ref}` saves a PNG of one element (plus a `margin` of pixels around it) only when the user asks to see how something looks; it shows, it does not judge. 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 same holds for `scout_attach {readPosts: ["POST /api/search"]}`, which lets observe send POST endpoints that only read (a search or query sent as POST): pass only what the user named. When the gap ledger lists a page whose POST observe refused, tell the user and let them decide. The FRAMES line names frames that were not read; say in your summary that their contents were not explored.
|
|
58
58
|
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. Controls held outside the visible width of a horizontally scrolling container (a wide table's action column) are one GEOMETRY line per container, `N controls are scrolled out of view inside a horizontally scrolling container …`: reachable by scrolling that container sideways, and worth a look rather than a defect unless the project keeps row actions in view. **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.
|
|
59
59
|
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.
|