scenescout 1.0.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 +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
package/dist/scan.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* What marks a package.json as a frontend workspace.
|
|
5
|
+
*
|
|
6
|
+
* Meta-frameworks are listed explicitly rather than relying on the UI library
|
|
7
|
+
* underneath them: Nuxt supplies vue and Remix supplies react, so an app that
|
|
8
|
+
* depends only on the meta-framework has neither in its own manifest — and was
|
|
9
|
+
* therefore not recognised as a frontend at all, making the whole scan report
|
|
10
|
+
* "No frontend workspace found" for a perfectly ordinary project.
|
|
11
|
+
*/
|
|
12
|
+
const FRONTEND_DEPS = ["next", "nuxt", "@sveltejs/kit", "@remix-run/react", "@remix-run/node", "react", "vue", "svelte", "@angular/core", "vite"];
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "out", "docs", "examples", "e2e-tests"]);
|
|
14
|
+
function readJson(file) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isFrontendPackage(pkg) {
|
|
23
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
24
|
+
return FRONTEND_DEPS.some((d) => d in deps);
|
|
25
|
+
}
|
|
26
|
+
/** Collect every frontend workspace: the root itself and child dirs (depth ≤ 2) with a React/Vue/etc package.json. */
|
|
27
|
+
function findFrontendDirs(projectDir) {
|
|
28
|
+
const found = [];
|
|
29
|
+
const rootPkg = readJson(path.join(projectDir, "package.json"));
|
|
30
|
+
if (rootPkg && isFrontendPackage(rootPkg))
|
|
31
|
+
found.push(projectDir);
|
|
32
|
+
const queue = [{ dir: projectDir, depth: 0 }];
|
|
33
|
+
while (queue.length > 0) {
|
|
34
|
+
const { dir, depth } = queue.shift();
|
|
35
|
+
if (depth > 2)
|
|
36
|
+
continue;
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
|
|
46
|
+
continue;
|
|
47
|
+
const sub = path.join(dir, entry.name);
|
|
48
|
+
const pkg = readJson(path.join(sub, "package.json"));
|
|
49
|
+
if (pkg && isFrontendPackage(pkg))
|
|
50
|
+
found.push(sub);
|
|
51
|
+
else
|
|
52
|
+
queue.push({ dir: sub, depth: depth + 1 });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
const NAME_BONUS = ["frontend", "web", "app", "client", "ui"];
|
|
58
|
+
/**
|
|
59
|
+
* A monorepo can hold several frontend apps (main app + admin panel + landing
|
|
60
|
+
* page). Pick the primary one: most routes wins, conventional names break ties.
|
|
61
|
+
*/
|
|
62
|
+
function pickPrimary(candidates) {
|
|
63
|
+
const scored = candidates.map((dir) => {
|
|
64
|
+
const routeCount = nextRoutes(dir).length;
|
|
65
|
+
const base = path.basename(dir).toLowerCase();
|
|
66
|
+
const nameBonus = NAME_BONUS.includes(base) ? NAME_BONUS.length - NAME_BONUS.indexOf(base) : 0;
|
|
67
|
+
return { dir, score: routeCount * 10 + nameBonus };
|
|
68
|
+
});
|
|
69
|
+
scored.sort((a, b) => b.score - a.score);
|
|
70
|
+
return { primary: scored[0].dir, others: scored.slice(1).map((s) => s.dir) };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Order matters: the most specific meta-framework wins. Nuxt and SvelteKit both
|
|
74
|
+
* depend on vite, and Nuxt depends on vue — checked later, they were reported as
|
|
75
|
+
* bare "vite"/"vue" with that ecosystem's default port instead of their own.
|
|
76
|
+
*/
|
|
77
|
+
function detectFramework(pkg) {
|
|
78
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
79
|
+
if ("next" in deps)
|
|
80
|
+
return { framework: "next", port: 3000 };
|
|
81
|
+
if ("nuxt" in deps || "nuxt3" in deps)
|
|
82
|
+
return { framework: "nuxt", port: 3000 };
|
|
83
|
+
if ("@sveltejs/kit" in deps)
|
|
84
|
+
return { framework: "sveltekit", port: 5173 };
|
|
85
|
+
if ("@remix-run/react" in deps || "@remix-run/node" in deps)
|
|
86
|
+
return { framework: "remix", port: 3000 };
|
|
87
|
+
if ("@angular/core" in deps)
|
|
88
|
+
return { framework: "angular", port: 4200 };
|
|
89
|
+
if ("react-scripts" in deps)
|
|
90
|
+
return { framework: "create-react-app", port: 3000 };
|
|
91
|
+
if ("vite" in deps)
|
|
92
|
+
return { framework: deps.react ? "vite+react" : "vite", port: 5173 };
|
|
93
|
+
if ("vue" in deps)
|
|
94
|
+
return { framework: "vue", port: 5173 };
|
|
95
|
+
if ("react" in deps)
|
|
96
|
+
return { framework: "react", port: 3000 };
|
|
97
|
+
return { framework: null, port: null };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* SvelteKit / Nuxt file-based routes.
|
|
101
|
+
*
|
|
102
|
+
* Both put pages under a conventional directory and both use bracket params,
|
|
103
|
+
* so one walker covers them. SvelteKit marks a page with `+page.svelte` and
|
|
104
|
+
* uses `(group)` directories exactly as Next's App Router does; Nuxt treats
|
|
105
|
+
* every `.vue` file as a page, with `index.vue` as the directory root.
|
|
106
|
+
*/
|
|
107
|
+
function fileRoutes(frontendDir, kind) {
|
|
108
|
+
const root = kind === "sveltekit" ? path.join(frontendDir, "src", "routes") : firstExisting([path.join(frontendDir, "pages"), path.join(frontendDir, "app", "pages")]);
|
|
109
|
+
if (!root || !fs.existsSync(root))
|
|
110
|
+
return [];
|
|
111
|
+
const routes = [];
|
|
112
|
+
const walk = (dir, base) => {
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.name.startsWith("_") || ROUTE_SKIP_DIRS.has(entry.name))
|
|
122
|
+
continue;
|
|
123
|
+
const full = path.join(dir, entry.name);
|
|
124
|
+
if (entry.isDirectory()) {
|
|
125
|
+
// SvelteKit route groups, like Next's, are layout-only and contribute
|
|
126
|
+
// nothing to the URL.
|
|
127
|
+
const organisational = kind === "sveltekit" && /^\(.*\)$/.test(entry.name);
|
|
128
|
+
walk(full, organisational ? base : `${base}/${entry.name}`);
|
|
129
|
+
}
|
|
130
|
+
else if (kind === "sveltekit" && /^\+page\.(svelte|ts|js)$/.test(entry.name)) {
|
|
131
|
+
routes.push(base || "/");
|
|
132
|
+
}
|
|
133
|
+
else if (kind === "nuxt" && /\.vue$/.test(entry.name)) {
|
|
134
|
+
const name = entry.name.replace(/\.vue$/, "");
|
|
135
|
+
routes.push(name === "index" ? base || "/" : `${base}/${name}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
walk(root, "");
|
|
140
|
+
// SvelteKit spells params [id] and [...rest]; Nuxt [id] and [...slug]. Both
|
|
141
|
+
// collapse to the same :param shape the rest of the engine speaks.
|
|
142
|
+
return [...new Set(routes.map((r) => r.replace(/\[\.\.\.([^\]]+)\]/g, ":$1").replace(/\[([^\]]+)\]/g, ":$1")))].sort();
|
|
143
|
+
}
|
|
144
|
+
function firstExisting(candidates) {
|
|
145
|
+
return candidates.find((c) => fs.existsSync(c)) ?? null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Route-walk skip set — deliberately smaller than SKIP_DIRS: inside pages/ or
|
|
149
|
+
* app/, directories like docs/ or examples/ are REAL routes, not artifacts.
|
|
150
|
+
*/
|
|
151
|
+
const ROUTE_SKIP_DIRS = new Set(["node_modules", "api"]);
|
|
152
|
+
/** Enumerate Next.js routes from pages/ or app/ directory structure. */
|
|
153
|
+
function nextRoutes(frontendDir) {
|
|
154
|
+
const routes = [];
|
|
155
|
+
const pagesDir = path.join(frontendDir, "pages");
|
|
156
|
+
const appDir = path.join(frontendDir, "app");
|
|
157
|
+
const walk = (dir, base, mode) => {
|
|
158
|
+
let entries;
|
|
159
|
+
try {
|
|
160
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (entry.name.startsWith("_") || ROUTE_SKIP_DIRS.has(entry.name))
|
|
167
|
+
continue;
|
|
168
|
+
const full = path.join(dir, entry.name);
|
|
169
|
+
if (entry.isDirectory()) {
|
|
170
|
+
// App Router organisational directories contribute NOTHING to the URL:
|
|
171
|
+
// a route group `(marketing)` exists to share a layout, and a parallel
|
|
172
|
+
// slot `@modal` renders into a named outlet. Concatenating them
|
|
173
|
+
// produced routes like `/(marketing)/about` for a page that actually
|
|
174
|
+
// lives at `/about` — a "known route" no navigation could ever reach,
|
|
175
|
+
// which then sat in the completion contract forever.
|
|
176
|
+
const organisational = mode === "app" && (/^\(.*\)$/.test(entry.name) || entry.name.startsWith("@"));
|
|
177
|
+
walk(full, organisational ? base : `${base}/${entry.name}`, mode);
|
|
178
|
+
}
|
|
179
|
+
else if (mode === "pages" && /\.(tsx|jsx|ts|js)$/.test(entry.name)) {
|
|
180
|
+
const name = entry.name.replace(/\.(tsx|jsx|ts|js)$/, "");
|
|
181
|
+
routes.push(name === "index" ? base || "/" : `${base}/${name}`);
|
|
182
|
+
}
|
|
183
|
+
else if (mode === "app" && /^page\.(tsx|jsx|ts|js)$/.test(entry.name)) {
|
|
184
|
+
routes.push(base || "/");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
if (fs.existsSync(pagesDir))
|
|
189
|
+
walk(pagesDir, "", "pages");
|
|
190
|
+
else if (fs.existsSync(appDir))
|
|
191
|
+
walk(appDir, "", "app");
|
|
192
|
+
return routes.map((r) => r.replace(/\[([^\]]+)\]/g, ":$1")).sort();
|
|
193
|
+
}
|
|
194
|
+
function findAuthStates(frontendDir) {
|
|
195
|
+
const authDir = path.join(frontendDir, "playwright", ".auth");
|
|
196
|
+
try {
|
|
197
|
+
return fs
|
|
198
|
+
.readdirSync(authDir)
|
|
199
|
+
.filter((f) => f.endsWith(".json"))
|
|
200
|
+
.map((f) => path.join(authDir, f));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function grepTestids(frontendDir) {
|
|
207
|
+
// Cheap sample: look for data-testid in a handful of component files.
|
|
208
|
+
const candidates = ["components", "src/components", "src", "app", "pages"];
|
|
209
|
+
for (const rel of candidates) {
|
|
210
|
+
const dir = path.join(frontendDir, rel);
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = fs.readdirSync(dir).slice(0, 40);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
for (const name of entries) {
|
|
219
|
+
const file = path.join(dir, name);
|
|
220
|
+
try {
|
|
221
|
+
if (fs.statSync(file).isFile() && /\.(tsx|jsx)$/.test(name)) {
|
|
222
|
+
if (fs.readFileSync(file, "utf8").includes("data-testid"))
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* ignore */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
export function scanProject(projectDir) {
|
|
234
|
+
const resolved = path.resolve(projectDir);
|
|
235
|
+
if (!fs.existsSync(resolved)) {
|
|
236
|
+
throw new Error(`Project directory does not exist: ${resolved}`);
|
|
237
|
+
}
|
|
238
|
+
const notes = [];
|
|
239
|
+
const candidates = findFrontendDirs(resolved);
|
|
240
|
+
const picked = candidates.length > 0 ? pickPrimary(candidates) : null;
|
|
241
|
+
const frontendDir = picked?.primary ?? null;
|
|
242
|
+
if (picked && picked.others.length > 0) {
|
|
243
|
+
const others = picked.others.map((d) => path.relative(resolved, d) || ".");
|
|
244
|
+
notes.push(`Multiple frontend workspaces found — using the largest (${path.relative(resolved, frontendDir) || "."}). ` +
|
|
245
|
+
`Others: ${others.join(", ")}. Re-scan a specific one by passing its path directly.`);
|
|
246
|
+
}
|
|
247
|
+
if (!frontendDir) {
|
|
248
|
+
return {
|
|
249
|
+
projectDir: resolved,
|
|
250
|
+
frontendDir: null,
|
|
251
|
+
framework: null,
|
|
252
|
+
devCommand: null,
|
|
253
|
+
portGuess: null,
|
|
254
|
+
routes: [],
|
|
255
|
+
authStates: [],
|
|
256
|
+
hasPlaywright: false,
|
|
257
|
+
usesTestids: false,
|
|
258
|
+
readmeExcerpt: null,
|
|
259
|
+
notes: ["No frontend workspace found (no package.json with a known frontend dependency at depth ≤ 2)."],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (frontendDir !== resolved)
|
|
263
|
+
notes.push(`Monorepo: frontend workspace is ${path.relative(resolved, frontendDir)}/`);
|
|
264
|
+
const pkg = readJson(path.join(frontendDir, "package.json")) ?? {};
|
|
265
|
+
const { framework, port } = detectFramework(pkg);
|
|
266
|
+
const scripts = (pkg.scripts ?? {});
|
|
267
|
+
const devCommand = scripts.dev ? "dev" : scripts.start ? "start" : null;
|
|
268
|
+
const routes = framework === "next"
|
|
269
|
+
? nextRoutes(frontendDir)
|
|
270
|
+
: framework === "sveltekit"
|
|
271
|
+
? fileRoutes(frontendDir, "sveltekit")
|
|
272
|
+
: framework === "nuxt"
|
|
273
|
+
? fileRoutes(frontendDir, "nuxt")
|
|
274
|
+
: [];
|
|
275
|
+
// Say so when filesystem discovery cannot help. Returning [] silently left
|
|
276
|
+
// the agent to assume the app genuinely had no routes, when in fact nothing
|
|
277
|
+
// had looked — the completion contract then rested entirely on link
|
|
278
|
+
// harvesting without ever admitting it.
|
|
279
|
+
if (routes.length === 0 && framework !== null && !["next", "sveltekit", "nuxt"].includes(framework)) {
|
|
280
|
+
notes.push(`No filesystem route discovery for "${framework}" (routes are defined in code, not files) — ` +
|
|
281
|
+
`the route contract will be built from links harvested during exploration. Crawl breadth depends on what the UI links to.`);
|
|
282
|
+
}
|
|
283
|
+
const authStates = findAuthStates(frontendDir);
|
|
284
|
+
const hasPlaywright = fs.existsSync(path.join(frontendDir, "playwright.config.ts")) || fs.existsSync(path.join(frontendDir, "playwright.config.js"));
|
|
285
|
+
// Full-stack heuristics: launching the frontend alone probably isn't enough.
|
|
286
|
+
for (const marker of ["docker-compose.yml", "docker", "Makefile", "backend"]) {
|
|
287
|
+
if (fs.existsSync(path.join(resolved, marker))) {
|
|
288
|
+
notes.push(`Full-stack marker found (${marker}) — the app likely needs its backend running. Prefer attaching to an already-running instance with --url.`);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (authStates.length > 0) {
|
|
293
|
+
notes.push(`Playwright auth storage states found — pass one to scout_attach as storageStatePath to explore as that role.`);
|
|
294
|
+
}
|
|
295
|
+
let readmeExcerpt = null;
|
|
296
|
+
for (const candidate of [path.join(resolved, "README.md"), path.join(frontendDir, "README.md")]) {
|
|
297
|
+
try {
|
|
298
|
+
readmeExcerpt = fs.readFileSync(candidate, "utf8").split("\n").slice(0, 30).join("\n");
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
/* keep looking */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
projectDir: resolved,
|
|
307
|
+
frontendDir,
|
|
308
|
+
framework,
|
|
309
|
+
devCommand,
|
|
310
|
+
portGuess: port,
|
|
311
|
+
routes,
|
|
312
|
+
authStates,
|
|
313
|
+
hasPlaywright,
|
|
314
|
+
usesTestids: grepTestids(frontendDir),
|
|
315
|
+
readmeExcerpt,
|
|
316
|
+
notes,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
export function formatScan(result) {
|
|
320
|
+
const lines = [
|
|
321
|
+
`Project: ${result.projectDir}`,
|
|
322
|
+
`Frontend: ${result.frontendDir ?? "NOT FOUND"}`,
|
|
323
|
+
`Framework: ${result.framework ?? "unknown"}${result.devCommand ? ` · dev script: "${result.devCommand}"` : ""}${result.portGuess ? ` · likely port ${result.portGuess}` : ""}`,
|
|
324
|
+
`Playwright config: ${result.hasPlaywright ? "yes" : "no"} · data-testid convention: ${result.usesTestids ? "yes" : "not detected"}`,
|
|
325
|
+
`Auth storage states (${result.authStates.length}): ${result.authStates
|
|
326
|
+
.slice(0, 8)
|
|
327
|
+
.map((p) => path.basename(p))
|
|
328
|
+
.join(", ") || "none"}`,
|
|
329
|
+
`Routes (${result.routes.length}):`,
|
|
330
|
+
...result.routes.slice(0, 60).map((r) => ` ${r}`),
|
|
331
|
+
...(result.routes.length > 60 ? [` … and ${result.routes.length - 60} more`] : []),
|
|
332
|
+
...(result.notes.length > 0 ? ["Notes:", ...result.notes.map((n) => ` - ${n}`)] : []),
|
|
333
|
+
];
|
|
334
|
+
return lines.join("\n");
|
|
335
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scenescout",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SceneScout — AI-agent exploratory UI testing engine: an MCP server exposing Playwright browser tools with state-fingerprint memory, invariant oracles, structured findings and reports. Claude Code (or any MCP client) is the brain.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "brunoboto96",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/brunoboto96/SceneScout.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/brunoboto96/SceneScout#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/brunoboto96/SceneScout/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"exploratory-testing",
|
|
19
|
+
"ui-testing",
|
|
20
|
+
"playwright",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"qa",
|
|
23
|
+
"ai-agent"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"scenescout": "dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"main": "dist/mcp-server.js",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"provenance": true
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"skills",
|
|
37
|
+
"README.md",
|
|
38
|
+
"CHANGELOG.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc",
|
|
46
|
+
"prepare": "npm run build",
|
|
47
|
+
"setup": "node dist/cli.js install",
|
|
48
|
+
"doctor": "node dist/cli.js doctor",
|
|
49
|
+
"changeset": "changeset",
|
|
50
|
+
"version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only",
|
|
51
|
+
"release": "npm run build && changeset publish",
|
|
52
|
+
"format": "prettier --write .",
|
|
53
|
+
"format:check": "prettier --check .",
|
|
54
|
+
"demo": "npm run build && tsx scripts/demo.ts",
|
|
55
|
+
"demo:serve": "node demo-app/server.mjs",
|
|
56
|
+
"dev": "tsx src/cli.ts",
|
|
57
|
+
"serve": "node dist/mcp-server.js",
|
|
58
|
+
"smoke": "npm run build && npm run smoke:run",
|
|
59
|
+
"smoke:run": "tsx scripts/smoke.ts",
|
|
60
|
+
"mcp-check": "npm run build && npm run mcp-check:run",
|
|
61
|
+
"mcp-check:run": "tsx scripts/mcp-check.ts",
|
|
62
|
+
"test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
|
|
63
|
+
"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 contract-test && npm run memory-test && npm run install-test",
|
|
64
|
+
"scan-test": "tsx scripts/scan-test.ts",
|
|
65
|
+
"oracle-test": "tsx --test scripts/oracle-test.ts",
|
|
66
|
+
"policy-test": "tsx --test scripts/policy-test.ts",
|
|
67
|
+
"fixture-test": "tsx --test scripts/fixture-test.ts",
|
|
68
|
+
"dispatch-test": "tsx --test scripts/dispatch-test.ts",
|
|
69
|
+
"design-test": "tsx --test scripts/design-test.ts",
|
|
70
|
+
"contract-test": "tsx --test scripts/contract-test.ts",
|
|
71
|
+
"memory-test": "tsx --test scripts/memory-test.ts",
|
|
72
|
+
"install-test": "tsx --test scripts/install-test.ts"
|
|
73
|
+
},
|
|
74
|
+
"dependencies": {
|
|
75
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
76
|
+
"playwright": "^1.63.0",
|
|
77
|
+
"zod": "^3.25.0"
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@changesets/cli": "3.0.3",
|
|
81
|
+
"@types/node": "^20.19.0",
|
|
82
|
+
"prettier": "3.9.8",
|
|
83
|
+
"tsx": "^4.23.13",
|
|
84
|
+
"typescript": "^5.9.3"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scenescout
|
|
3
|
+
description: AI exploratory UI testing — drive the SceneScout MCP browser tools to explore a running web app like a real user, find bugs/UX issues via oracles and judgment, track coverage across runs, and produce a report. Use when the user asks to exploratory-test, "click around", stress or QA a web UI, or invokes /scenescout. Requires the SceneScout MCP server to be registered.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SceneScout — exploratory UI testing agent
|
|
7
|
+
|
|
8
|
+
You are the brain of an exploratory UI tester. The SceneScout MCP server gives you deterministic browser tools (the `scout_*` tools — listed as `mcp__scenescout__scout_*`, or `mcp__plugin_scenescout_scenescout__scout_*` when installed as a plugin); you provide intent, judgment, and curiosity. The engine gives you structured render-state (elements, geometry, oracles) — never parse pixels when text will do. Argument hint: `[--level minimal|medium|extensive] [--url URL] [--role NAME] [--safe-write | --allow-destructive]`.
|
|
9
|
+
|
|
10
|
+
**The mission is wider than pass/fail.** Scripted e2e suites answer "does it still work?" as a binary and say nothing about what they don't cover; a human can't manually exercise a large app. You cover both gaps: find what's broken (oracles, dead ends, permission leaks) AND report how the product could be *better* — confusing flows, weak hierarchy, design-system drift, friction. Improvement feedback with concrete measurements is a first-class deliverable, not garnish; a run that finds no crashes but produces sharp `ux-polish`/`visual` suggestions is a successful run.
|
|
11
|
+
|
|
12
|
+
## Setup (in order)
|
|
13
|
+
|
|
14
|
+
1. **Check the tools exist.** Look for a `scout_scan` tool under either prefix above. If there is none, stop and tell the user how to get it, then to start a fresh session:
|
|
15
|
+
- as a plugin: `/plugin marketplace add brunoboto96/SceneScout` then `/plugin install scenescout@scenescout-marketplace`
|
|
16
|
+
- or by hand: `claude mcp add --scope user scenescout -- npx -y scenescout serve` (from a source checkout, register with an **absolute node path** instead — a bare `node` fails with "Executable not found in $PATH" under nvm/fnm: `claude mcp add --scope user scenescout -- "$(which node)" <checkout>/dist/mcp-server.js`)
|
|
17
|
+
If attach later reports that Chromium has not been downloaded, relay the one-time command it names.
|
|
18
|
+
2. **`scout_scan`** the project's absolute path. Read routes, framework, auth states, notes.
|
|
19
|
+
- **No source here?** When the target is a remote URL (staging, a deployed site) and the scan reports no frontend workspace, that is a supported mode, not an error: you are a black-box QA tester. Skip step 3's launch logic and keep the current directory as `projectPath` (memory and the report still need a home). Link harvesting builds the route list, and `scout_crawl` has nothing to crawl until it does — so snapshot the landing page and main navigation first, then `scout_crawl`, and `scout_crawl` again to pick up what those pages linked to. With no scanned auth states, `--role` is a path to a Playwright storage-state JSON. A remote target is far more likely to hold real data: confirm the user is authorized to test it if that is not evident, never leave `read-only` unless they say the environment is disposable — and remember `read-only` still lets ordinary create/submit POSTs through, so do NOT submit forms that create real records (contact, order, signup, invite) without the user's okay; disclose the skipped forms as gaps instead. Claims that something is *absent* cannot be source-checked here: file them as behaviour-only and say so. `extensive` still needs ≥2 login states. The report cannot record where routes came from, so say it in your summary to the user and in an `scout_note`: routes were discovered from same-origin links only, and pages nothing links to are outside the contract.
|
|
20
|
+
- **Source available?** Use it beyond the scan: when you file a finding, read the component or handler behind it and name the file and the likely fix — that is the difference between "the save button does nothing" and a finding a developer can act on in one step.
|
|
21
|
+
3. **Ensure the app is running.** Full-stack markers in the scan → do NOT launch the stack yourself; confirm the URL responds (you may curl it) or ask the user. Auto-launch only simple single-package frontends.
|
|
22
|
+
4. **Pick auth.** `--role X` → pass that storage-state file. Unspecified → least-privileged role; say so.
|
|
23
|
+
5. **`scout_attach`** with url, projectPath, storageStatePath, and the write **mode** — the DB behind the app may be live, so the engine enforces this at the network layer:
|
|
24
|
+
- `read-only` (default): destructive labels blocked in the UI AND all PUT/PATCH/DELETE + destructive POSTs blocked on the wire. Use unless told otherwise.
|
|
25
|
+
- `safe-write` (`--safe-write`, or the user asks to test creating/editing things): create freely — **prioritize testing CREATE flows** — then edit/delete ONLY the records you created (the engine tracks your creations and blocks mutations on anything else). Never attempt to clean up or modify pre-existing data.
|
|
26
|
+
- `destructive` (`--allow-destructive` only): everything allowed. Requires the user to explicitly confirm the environment is disposable/seeded. Never decide this yourself.
|
|
27
|
+
A `🛡 WRITE-POLICY blocked` notice in a tool result is the engine's safety net, NOT an app bug — never file a finding for the error UI it causes; note it and move on (or suggest the user re-run with a laxer mode if that flow matters).
|
|
28
|
+
**`⚠ AUTH FAILED` on attach means the storage state is stale** — its token has expired and the browser is sitting on a login page. Stop and ask the user to regenerate it (usually their Playwright auth-setup project); do not explore, and do not file findings from a logged-out session. Mid-run, `⚠ SESSION AUTH LOST` means the same thing happened after N navigations: everything since is meaningless, so re-attach rather than pressing on. Routes bounced this way are recorded as NOT covered, so the gap ledger will still show them.
|
|
29
|
+
6. **`scout_note {action:'read'}` immediately after attaching.** ASSUMPTIONS.md is the run-over-run written memory: what the app is, who each role is FOR, constraints discovered the hard way ("an order can only ship once approved"). Starting without reading it means re-learning what a previous run already paid to find out. Throughout the run, `scout_note {action:'add'}` every DURABLE learning the moment you confirm it — app model, role personas, conventions, constraints, risks, glossary. Not session facts (ids, counts); knowledge a future run should start with.
|
|
30
|
+
|
|
31
|
+
## The efficient loop (this is the core method)
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
|
|
35
|
+
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=` 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.
|
|
36
|
+
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 (broken images, canvas); geometry problems (overlap, off-screen) are already in the snapshot as GEOMETRY issues.
|
|
37
|
+
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.
|
|
38
|
+
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.
|
|
39
|
+
7. **Design-connoisseur pass without pixels: `scout_design_audit`.** Run it once per representative page (dashboard, a form, a detail view, a data table). Its output has two tiers: **⚠ measurable defects** (WCAG contrast, tiny targets, clipped text, aspect-distorted images, horizontal overflow, keyboard tab stops with no visible focus indicator — sampled with real Tab presses) and **→ craft suggestions** (line measure and line-height rhythm, spacing-grid adherence, typography entropy, gray census and accent-hue count, pure-#000 body text, elevation/control consistency, heading structure, indistinguishable links, AI-slop tells like gradient text/glassmorphism/side-stripe borders/identical card grids), closing with a SYSTEM SUMMARY of design-system coherence. Judge every line with product context (dense tables legitimately have small targets; a chart page legitimately uses many hues). File ⚠ defects as `visual`/`a11y`, and genuine → opportunities as `ux-polish` findings **quoting the concrete numbers** — "~142 characters per line (65–75 ideal)" beats "text feels wide". Every audit ends with a **PAGE SCORE** (0–100 overall + a11y/craft/consistency/task-clarity subscores) persisted per route — the report ranks pages worst-first, so re-runs show whether pages got better or worse. Separately, every `scout_snapshot` runs an **overlay/modal probe** automatically: an empty dialog over a grayed page, a backdrop with no dialog, a far-off-centre dialog leaving a blank band, or a dialog extending unreachably below the viewport appear as OVERLAY lines in GEOMETRY issues — treat these as high-value findings (the user is visually stuck). This is where "how could this page be better" gets answered, not just "is it broken".
|
|
40
|
+
8. **Measure task EASE with `scout_journey`, not just correctness.** Wrap each module's primary task (`{action:"start", goal:"Create an order"}` → do it → `{action:"end", completed:…}`). Navigate by CLICKING like a first-time user — typing a known deep URL shortcuts the very thing being measured (a route you can only reach by editing the address bar is itself a finding). The result gives interaction count, distinct screens, the path taken, and BACKTRACKS — returning to a screen already left is the clearest evidence the next step wasn't discoverable. An abandoned journey (`completed:false`) is a high-severity finding: the task is blocked or undiscoverable, which no passing e2e suite would ever reveal.
|
|
41
|
+
9. **Walk the auth surface too — anonymously.** Attach a second session WITHOUT a storage-state file (a fresh logged-out profile) and exercise signup, login failure states, and forgot/reset-password **as far as they physically go**. The mailbox wall is expected — reaching "check your email" IS the success condition; everything before it is what you're testing: does submit actually fire (a dead signup button is a high finding), are errors specific and actionable, can the user resend or recover from a typo, does the flow dead-end. Use plausible synthetic identities only (invent `qa-<runid>@example.com`-style addresses, never a real person's), submit each form valid AND invalid, and judge the feedback. Two classic findings live here: a forgot-password that answers "no account with that email" is an **account-enumeration leak** (file as security; "if an account exists, we sent a link" is the correct shape), and a signup that accepts the form then lands on a blank or logged-out page with no guidance is a **journey dead-end**. Signup creates a record — run this pass in safe-write mode; the engine tracks the created account like any other creation.
|
|
42
|
+
10. **`scout_coverage` decides what's next** — it lists unvisited routes and unexercised elements. Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
|
|
43
|
+
|
|
44
|
+
## Levels (completion contracts — the engine ENFORCES them via `scout_report {level}`)
|
|
45
|
+
|
|
46
|
+
| Level | Contract (gate-checked at report time) | Typical shape |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `minimal` | Every known route visited (crawl does this) + ≥1 design audit + 1–2 primary journeys as plans + crawl-flagged problems triaged. The report DISCLOSES remaining gaps in its Gap Ledger — minimal is honest-but-fast, not silent. | 1 crawl + 2–4 plans + a few snapshots |
|
|
49
|
+
| `medium` (default) | minimal + design audits on several distinct routes (gate: ≥ min(3, visited/10)) + every unexercised interactable class exercised once + every form submitted valid AND invalid | + interactive passes per module |
|
|
50
|
+
| `extensive` | medium + fuzzing (empty, 1000-char, unicode, `<script>`; for uploads, `scout_upload {name}` with a wrong extension against `accept`, a 255-char name, unicode), back/refresh/deep-link resilience, keyboard-only pass, **an `scout_journey` per module's primary task**, **the impatient-user pass** (below), **≥2 roles compared**, **the anonymous auth-surface pass** (signup / forgot-password walked to the mailbox wall, step 8 above), and source-grounding for every absence-claim. **`scout_report {level:'extensive'}` REFUSES while the Gap Ledger is non-empty** — that refusal IS the completeness guarantee: an extensive report can only exist when every known route is visited, exercised, audited, journey-measured, and role-compared. | budget-capped by user |
|
|
51
|
+
|
|
52
|
+
**The Gap Ledger is the trust mechanism.** The engine tracks per-route facts (visited / exercised / audited / mutated / journeyed) and per-role access; the report enumerates everything NOT done. Work the ledger down (`scout_report` tells you exactly what's missing), don't argue with it. Never pass `force=true` unless the user explicitly capped the budget — a forced report still prints its gaps.
|
|
53
|
+
|
|
54
|
+
Route knowledge is generic: scanned filesystem routes ∪ links harvested from every snapshot (including `?tab=` screens) form the contract — it works on any app, not just Next. For a responsive pass, re-attach with `viewportWidth: 390, viewportHeight: 844` and re-run the design audit on key pages.
|
|
55
|
+
|
|
56
|
+
## Multi-role collaboration (named sessions)
|
|
57
|
+
|
|
58
|
+
Some flows need a TEAM — a document one role submits and another approves, a review one role assigns and another completes, permission checks that only mean something side-by-side. `scout_attach {session: "admin", storageStatePath: …}` then `scout_attach {session: "qa", storageStatePath: …}` keeps BOTH browsers live and authenticated. Coverage and findings from all roles merge into one project memory.
|
|
59
|
+
|
|
60
|
+
**Every per-session tool takes a `session` param — use it.** Calls targeting DIFFERENT sessions run genuinely CONCURRENTLY (issue several in one turn and they execute in parallel); calls targeting the SAME session still serialize, because one browser's ref table can't process overlapping actions. `scout_session {name}` only sets the default for calls that omit `session` — convenience for single-role stretches, not the mechanism for multi-role work. Once more than one session is live the default is genuinely unsafe to rely on: it points at whichever session attached most recently, so a *concurrent* attach elsewhere silently re-points your un-parameterized calls at another role's browser. The engine appends `⚠ AMBIGUOUS SESSION` when that happens — treat it as a mistake to fix, not a note.
|
|
61
|
+
|
|
62
|
+
- **Parallel independent work** (the common case, and the fast one): give each role its own objective and dispatch in one turn — `scout_crawl {session:"admin"}` alongside `scout_crawl {session:"qa"}`, or each role auditing different modules. Two roles exploring different surfaces have no reason to take turns.
|
|
63
|
+
- **Sequential handoff** (only where the flow genuinely requires it): role A performs its half (submits for approval), then `scout_snapshot {session:"qa"}` to SEE the handed-off state arrive, B performs its half, and re-snapshot as A to verify the outcome. The snapshot is the "wait for the other" step — if the state hasn't arrived, do other work as that role and re-check rather than idling.
|
|
64
|
+
- Refs are per-session: `e12` from an admin snapshot means nothing in the qa session. Snapshot the session you're about to act on.
|
|
65
|
+
- While roles are NOT collaborating, use each one productively where its permissions matter (admin in /admin surfaces, low-privilege probing for permission leaks) — same coverage contract, different vantage points.
|
|
66
|
+
- **Infer the PERSONA behind each role, and write it down.** From what a role can see and do (its nav, its dashboard, the capability matrix in the report), state what this person is FOR: "qa = reviewer — approves orders, assigns reviewers, no admin" / "user = front-line user — reads documents, completes reviews, raises orders". Record it with `scout_note {section:'roles'}`. Then test the persona's WORLD, not just the permissions: does the operator's landing page serve an operator? Is anything they need N clicks deep? The capability matrix's divergent rows are questions, not verdicts — each is either a correct boundary or a gap ("should this role be able to do this?"); say which you believe it is and why.
|
|
67
|
+
- `scout_close {all: true}` at the end of a multi-role run; `scout_close {session}` to drop one role early.
|
|
68
|
+
|
|
69
|
+
## The impatient-user pass (extensive)
|
|
70
|
+
|
|
71
|
+
Polite, precise testing misses how real users behave. Once per module's key flow, act like a hurried or inexperienced user:
|
|
72
|
+
|
|
73
|
+
- **Double-submit probe:** `scout_click {clicks: 2}` on every important submit/create button. The result states explicitly whether the same state-changing request fired twice (unguarded — check for a duplicate record, file as `data-inconsistency`) or once (guarded). This is a top real-world bug class that polite single clicks can never reveal.
|
|
74
|
+
- **Rage-click check:** an element that did nothing once (`ZERO network requests` note) — click it 3× (`clicks: 3`). Still nothing? That's a dead control users will hammer; file it with both signals.
|
|
75
|
+
- **Wrong-order behaviour:** press Enter mid-form before required fields are filled; go `scout_back` mid-wizard and return; submit, then immediately back-button. State should survive all three without data loss or duplicate records.
|
|
76
|
+
- Keep attribution honest: these are deliberate probes — say so in findings ("under rapid double-click…"), so a developer can reproduce exactly.
|
|
77
|
+
|
|
78
|
+
The engine is self-healing (orphaned browsers reaped, wedged calls time out with guidance instead of hanging) and observable: `.scenescout/status.json` + `scenescout status <project>` show what it's doing right now — point the user there if they ask for live progress.
|
|
79
|
+
|
|
80
|
+
## Judgment (what the engine can't do)
|
|
81
|
+
|
|
82
|
+
- **Ground absence-claims in the source before filing.** Black-box behaviour tells you what happened, never *why*. Any finding asserting a control is MISSING ("no permission check", "no validation", "no segregation of duties", "not implemented") must be checked against the codebase first — you have Read/Grep/Bash, use them. Three outcomes, all valuable: (1) genuinely absent → file it with the file:line you checked, which makes it actionable instead of speculative; (2) implemented but not reached on your path → you found a *routing/config* bug, a different and often better finding; (3) implemented elsewhere in the same codebase but not here → **the strongest kind of finding**, an inconsistency against the team's own established pattern, with the reference implementation attached as the fix. A grep costs seconds and is the difference between "I think this is wrong" and "here is the line". Behaviour-only findings (a crash, a 500, a dead end) need no such check — the evidence is self-contained.
|
|
83
|
+
- Oracle violations are *evidence* — reproduce/contextualize, then `scout_finding`. **Severity anchors:** crash, data loss, security/permission breach, or a flow the user cannot complete or escape (infinite spinner, no-way-out page, broken primary journey) = **high** — a dead end is high even without a crash. Broken but recoverable = medium. Polish/friction = low.
|
|
84
|
+
- **Provenance for deep-link findings:** if you reached a state by direct URL (crawl or scout_navigate) and it looks broken (no app shell, dead end), check whether real users can reach it through the UI before filing — many apps render tab content at internal URLs that users only ever reach via `?tab=` links. Always state in the finding how the state was reached.
|
|
85
|
+
- Tabs and views (`?tab=…`) are distinct states in coverage — prefer clicking the actual tab controls when testing a flow; deep-link only to re-verify.
|
|
86
|
+
- **Lifecycle:** if the user says something is fixed, or the evidence no longer reproduces on a route you re-tested, mark it with `scout_resolve {id}` (spelled `findingId` if you prefer the long form). Historical findings in the report are unverified — don't re-report them as new.
|
|
87
|
+
- **A `SHARED CHROME` block in a design audit is ONE finding, not one per page.** Those elements (sidebar, header, breadcrumb bar) are the app shell; the audit already excludes them from the page's score and reports them once. File a single finding for the shell and move on — filing per-page produced five separate tickets for two CSS declarations in a real run.
|
|
88
|
+
- **`scout_finding` takes `severity`, `category`, `title`, `detail` and `evidence`.** `category` is the finding's kind — `http-error`, `console-error`, `dead-end`, `data-inconsistency`, `ux-confusing`, `ux-polish`, `visual`, `a11y`, `missing-testid`, `security`, `page-error`, `other` — and is what groups the report.
|
|
89
|
+
- **Always pass `evidence`** to `scout_finding` — a canonical machine signature like `GET /api/reports/dashboard 403` or `widget dashboard-summary-widget shows 0`. It's what deduplicates the same bug across runs when titles get rephrased.
|
|
90
|
+
- File judgment findings too: confusing flows, no-feedback actions, state lost on refresh, permission leaks (low-privilege role reaching admin surface), `missing-testid` (low), unnamed interactables (a11y, low).
|
|
91
|
+
- Respect refusals — never retry or route around a policy refusal; note it and move on.
|
|
92
|
+
- Duplicates are fine; `scout_finding` dedups across runs.
|
|
93
|
+
|
|
94
|
+
## Finishing
|
|
95
|
+
|
|
96
|
+
`scout_report` (satisfy the contract first) → `scout_close` → summarize in chat: worst findings first, coverage numbers, report path (`.scenescout/report.md`), and suggest promoting high findings to real Playwright regression tests (skeletons are in the report).
|