scenescout 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +88 -20
- package/dist/browsers.js +185 -0
- package/dist/cli.js +167 -39
- package/dist/clients.js +213 -0
- package/dist/engine/browser.js +21 -17
- package/dist/engine/hover.js +42 -0
- package/dist/engine/launch.js +12 -5
- package/dist/engine/probes.js +4 -1
- package/dist/installer.js +24 -8
- package/dist/mcp-server.js +54 -4
- package/dist/playbook.js +83 -0
- package/package.json +15 -4
- package/skills/scenescout/SKILL.md +3 -3
package/dist/engine/launch.js
CHANGED
|
@@ -2,21 +2,28 @@
|
|
|
2
2
|
* Turning a failed browser launch into something the person can act on.
|
|
3
3
|
*
|
|
4
4
|
* For anyone who installed from npm and never ran the setup step, the first
|
|
5
|
-
* attach fails because
|
|
5
|
+
* attach fails because the browser was never downloaded — and Playwright reports
|
|
6
6
|
* that as a multi-line box of text with a path in it. That is the single most
|
|
7
7
|
* likely first-run failure, so it gets one plain instruction instead.
|
|
8
8
|
*/
|
|
9
|
+
import { APPROX_DISK_MB, launchTarget } from "../browsers.js";
|
|
9
10
|
/** Playwright's wording when the browser binary is not on disk. */
|
|
10
11
|
const MISSING_BROWSER_RE = /Executable doesn't exist|playwright install|browserType\.launch:.*(not found|ENOENT)/i;
|
|
11
12
|
export function isMissingBrowser(message) {
|
|
12
13
|
return MISSING_BROWSER_RE.test(message);
|
|
13
14
|
}
|
|
14
15
|
/** The error text for a launch that failed. `reaped` is how many orphaned browsers were cleaned up between attempts. */
|
|
15
|
-
export function explainLaunchFailure(message, reaped) {
|
|
16
|
+
export function explainLaunchFailure(message, reaped, need = { engine: "chromium", headed: false }) {
|
|
16
17
|
if (isMissingBrowser(message)) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const target = launchTarget(need.engine, need.headed);
|
|
19
|
+
// Someone who installed only the headless shell did run install, so say what is different about a headed run.
|
|
20
|
+
const why = target === "chromium" && need.headed
|
|
21
|
+
? "A headed run needs the full Chromium browser, which has not been downloaded (the headless shell alone cannot open a window)"
|
|
22
|
+
: `The ${target} build has not been downloaded yet`;
|
|
23
|
+
return (`${why} (one-time, about ${APPROX_DISK_MB[target]} MB on disk). Run this once, then attach again:\n` +
|
|
24
|
+
` npx -y scenescout install --browser-only --browsers ${target}\n` +
|
|
25
|
+
`(from a clone: node dist/cli.js install --browser-only --browsers ${target}). ` +
|
|
26
|
+
`On Linux, if system libraries are missing: npx playwright install --with-deps ${target}`);
|
|
20
27
|
}
|
|
21
28
|
const firstLine = message.split("\n")[0];
|
|
22
29
|
return (`browser launch failed twice (${firstLine})` +
|
package/dist/engine/probes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DIALOG_LIKE_SEL } from "./collector.js";
|
|
2
|
+
import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
|
|
2
3
|
/** Per-action Playwright timeout, shared with the engine so a scroll into view fails as fast as a click would. */
|
|
3
4
|
export const ACTION_TIMEOUT_MS = 5000;
|
|
4
5
|
/**
|
|
@@ -252,11 +253,13 @@ export async function probeOverlays(page) {
|
|
|
252
253
|
* than failing the audit.
|
|
253
254
|
*/
|
|
254
255
|
export async function probeFocusIndicators(page) {
|
|
256
|
+
const name = page.context().browser()?.browserType().name();
|
|
257
|
+
const advanceKey = focusAdvanceKey(name && isBrowserEngine(name) ? name : "chromium", process.platform);
|
|
255
258
|
const styleSig = "s.outlineStyle + '|' + s.outlineWidth + '|' + s.outlineColor + '|' + s.boxShadow + '|' + s.borderColor + '|' + s.backgroundColor";
|
|
256
259
|
const stops = [];
|
|
257
260
|
try {
|
|
258
261
|
for (let i = 0; i < 15; i++) {
|
|
259
|
-
await page.keyboard.press(
|
|
262
|
+
await page.keyboard.press(advanceKey);
|
|
260
263
|
const info = (await page.evaluate(`(() => {
|
|
261
264
|
const el = document.activeElement;
|
|
262
265
|
if (!el || el === document.body || el === document.documentElement) return null;
|
package/dist/installer.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { engineOf } from "./browsers.js";
|
|
12
13
|
export const SKILL_NAME = "scenescout";
|
|
13
14
|
export const MCP_NAME = "scenescout";
|
|
14
15
|
/** Names this tool's skill and MCP server had before renames; cleaned up on install so the old slash command and a duplicate tool set do not linger. */
|
|
@@ -17,7 +18,11 @@ const LEGACY_MCP_NAMES = ["scenecraft"];
|
|
|
17
18
|
/** Real runner. `missing` separates "the binary is not installed" from "it ran and failed". */
|
|
18
19
|
export const spawnRunner = (command, args) => {
|
|
19
20
|
const r = spawnSync(command, args, { encoding: "utf8", timeout: 60_000 });
|
|
20
|
-
const
|
|
21
|
+
const code = r.error?.code;
|
|
22
|
+
// On Windows node refuses to start a .cmd or .bat file directly (EINVAL). For
|
|
23
|
+
// the caller that is the same situation as a missing binary: nothing ran, and
|
|
24
|
+
// the command has to be handed to the person instead.
|
|
25
|
+
const missing = code === "ENOENT" || (process.platform === "win32" && code === "EINVAL");
|
|
21
26
|
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error.message) : ""), missing };
|
|
22
27
|
};
|
|
23
28
|
/** Written into a copy-mode install so a later install can tell its own copy from a user's directory. */
|
|
@@ -250,9 +255,20 @@ export function parseRegistration(listing) {
|
|
|
250
255
|
*/
|
|
251
256
|
export function repairCommands(packageRoot) {
|
|
252
257
|
const isCheckout = fs.existsSync(path.join(packageRoot, "tsconfig.json")) && fs.existsSync(path.join(packageRoot, "src"));
|
|
258
|
+
// Installing "chromium" brings the headless shell with it, so the plain
|
|
259
|
+
// setup command already repairs either Chromium build.
|
|
260
|
+
const browserFlags = (target) => (engineOf(target) === "chromium" ? "" : ` --browser-only --browsers ${target}`);
|
|
253
261
|
return isCheckout
|
|
254
|
-
? {
|
|
255
|
-
|
|
262
|
+
? {
|
|
263
|
+
setup: "npm run setup",
|
|
264
|
+
build: "npm run build",
|
|
265
|
+
browser: (target) => (browserFlags(target) ? `node dist/cli.js install${browserFlags(target)}` : "npm run setup"),
|
|
266
|
+
}
|
|
267
|
+
: {
|
|
268
|
+
setup: "npx -y scenescout install",
|
|
269
|
+
build: "npx -y scenescout@latest install (the installed package is incomplete; fetch it again)",
|
|
270
|
+
browser: (target) => `npx -y scenescout install${browserFlags(target)}`,
|
|
271
|
+
};
|
|
256
272
|
}
|
|
257
273
|
/** Everything a working setup needs, each with the command that repairs it. */
|
|
258
274
|
export function diagnose(opts) {
|
|
@@ -262,12 +278,12 @@ export function diagnose(opts) {
|
|
|
262
278
|
checks.push({ name: "node >= 20", ok: major >= 20, detail: opts.nodeVersion, fix: "install Node 20 or newer" });
|
|
263
279
|
const server = path.join(opts.packageRoot, "dist", "mcp-server.js");
|
|
264
280
|
checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix: repair.build });
|
|
265
|
-
const
|
|
281
|
+
const browser = opts.defaultBrowser;
|
|
266
282
|
checks.push({
|
|
267
|
-
name:
|
|
268
|
-
ok:
|
|
269
|
-
detail:
|
|
270
|
-
fix: `${repair.
|
|
283
|
+
name: `browser downloaded (${browser.target})`,
|
|
284
|
+
ok: browser.path !== null,
|
|
285
|
+
detail: browser.path ?? (browser.expected ? `not found at ${browser.expected}` : "playwright could not name a browser path"),
|
|
286
|
+
fix: `${repair.browser(browser.target)} (or: npx playwright install ${browser.target})`,
|
|
271
287
|
});
|
|
272
288
|
if (opts.scope === "engine")
|
|
273
289
|
return checks;
|
package/dist/mcp-server.js
CHANGED
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
28
29
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
29
30
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
31
|
+
import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
30
32
|
import { z } from "zod";
|
|
31
33
|
import { BrowserEngine } from "./engine/browser.js";
|
|
32
34
|
import { reapOrphanBrowsers } from "./engine/reaper.js";
|
|
@@ -34,6 +36,7 @@ import { MemoryStore, redactSecrets } from "./engine/memory.js";
|
|
|
34
36
|
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
35
37
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
36
38
|
import { computeGaps, formatRouteCoverage, generateReport } from "./engine/report.js";
|
|
39
|
+
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
37
40
|
import { formatScan, scanProject } from "./scan.js";
|
|
38
41
|
/** Live sessions: each name owns an independent BrowserEngine (browser + auth). */
|
|
39
42
|
const engines = new Map();
|
|
@@ -71,7 +74,8 @@ const PKG_VERSION = (() => {
|
|
|
71
74
|
return "0.0.0";
|
|
72
75
|
}
|
|
73
76
|
})();
|
|
74
|
-
const
|
|
77
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
78
|
+
const server = new McpServer({ name: "scenescout", version: PKG_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
75
79
|
function text(t, session) {
|
|
76
80
|
const eng = engines.get(session);
|
|
77
81
|
const prefix = engines.size > 1 ? `[session ${session}${eng ? ` · ${eng.role}` : ""}]\n` : "";
|
|
@@ -167,6 +171,48 @@ const sessionParam = z
|
|
|
167
171
|
.max(40)
|
|
168
172
|
.optional()
|
|
169
173
|
.describe("Target this session directly instead of the active one — pass it explicitly when dispatching to MULTIPLE sessions in one turn (e.g. two scout_click calls with different `session`), which then run CONCURRENTLY rather than queueing. Omit for single-session sequential use.");
|
|
174
|
+
// The method, for every client that has no skill loader. It is read per call,
|
|
175
|
+
// not cached: a source checkout's skill file can change under a running server.
|
|
176
|
+
server.registerTool(PLAYBOOK_TOOL, {
|
|
177
|
+
description: "Return the SceneScout testing method: setup order, write modes, how to explore, what counts as done, how to report. " +
|
|
178
|
+
"Call this ONCE before the first scout_attach in a conversation, then follow it. " +
|
|
179
|
+
"If this client offers a SceneScout skill, load that instead — it is the same text, so never read both. Takes no input and touches no browser.",
|
|
180
|
+
// No inputSchema on purpose: with one, a call that carries no `arguments` field is rejected as invalid.
|
|
181
|
+
}, async () => {
|
|
182
|
+
try {
|
|
183
|
+
return { content: [{ type: "text", text: loadPlaybook(PACKAGE_ROOT) }] };
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
return errorText(err);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// The same method as a prompt, for clients that list server prompts as commands.
|
|
190
|
+
// Registered on the protocol server directly: the SDK's prompt helper rejects a
|
|
191
|
+
// request that carries no `arguments` object, which is exactly what a client
|
|
192
|
+
// sends when the person typed none, and every argument here is optional.
|
|
193
|
+
server.server.registerCapabilities({ prompts: {} });
|
|
194
|
+
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
|
195
|
+
prompts: [
|
|
196
|
+
{
|
|
197
|
+
name: PLAYBOOK_PROMPT,
|
|
198
|
+
title: "Explore a web app with SceneScout",
|
|
199
|
+
description: "Start an exploratory test session: loads the SceneScout method and states the target.",
|
|
200
|
+
arguments: EXPLORE_PROMPT_ARGUMENTS,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
}));
|
|
204
|
+
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
|
205
|
+
if (request.params.name !== PLAYBOOK_PROMPT)
|
|
206
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${request.params.name}`);
|
|
207
|
+
let message;
|
|
208
|
+
try {
|
|
209
|
+
message = explorePrompt(loadPlaybook(PACKAGE_ROOT), request.params.arguments);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
throw new McpError(ErrorCode.InvalidParams, err instanceof Error ? err.message : String(err));
|
|
213
|
+
}
|
|
214
|
+
return { messages: [{ role: "user", content: { type: "text", text: message } }] };
|
|
215
|
+
});
|
|
170
216
|
server.registerTool("scout_scan", {
|
|
171
217
|
description: "Scan a project directory to discover the frontend workspace, framework, routes, dev command, Playwright auth storage states, and testid conventions. Run this first.",
|
|
172
218
|
inputSchema: { projectPath: z.string().describe("Absolute path to the project root") },
|
|
@@ -179,7 +225,7 @@ server.registerTool("scout_scan", {
|
|
|
179
225
|
}
|
|
180
226
|
}));
|
|
181
227
|
server.registerTool("scout_attach", {
|
|
182
|
-
description: "Launch a browser and attach to a running web app. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
|
|
228
|
+
description: "Launch a browser and attach to a running web app. First attach in this conversation and you have read neither the SceneScout skill nor scout_playbook? Call scout_playbook before this. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
|
|
183
229
|
inputSchema: {
|
|
184
230
|
url: z.string().describe("Base URL of the running app, e.g. http://localhost:3000"),
|
|
185
231
|
projectPath: z.string().describe("Absolute path to the project (memory + report live in .scenescout/ here)"),
|
|
@@ -189,6 +235,10 @@ server.registerTool("scout_attach", {
|
|
|
189
235
|
.default("read-only")
|
|
190
236
|
.describe("Write policy (see tool description). Never choose 'destructive' yourself — user opt-in only."),
|
|
191
237
|
headed: z.boolean().default(false).describe("Show the browser window"),
|
|
238
|
+
browser: z
|
|
239
|
+
.enum(["chromium", "firefox", "webkit"])
|
|
240
|
+
.optional()
|
|
241
|
+
.describe("Browser to drive. Default: the SCENESCOUT_BROWSER environment variable, else chromium. firefox and webkit must be downloaded first (scenescout install --browser-only --browsers firefox). Use them for a cross-browser pass; stay on chromium otherwise."),
|
|
192
242
|
viewportWidth: z.number().int().min(320).max(3840).optional().describe("Viewport width (default 1280); use e.g. 390 for a mobile pass"),
|
|
193
243
|
viewportHeight: z.number().int().min(480).max(2400).optional().describe("Viewport height (default 900)"),
|
|
194
244
|
session: z
|
|
@@ -197,7 +247,7 @@ server.registerTool("scout_attach", {
|
|
|
197
247
|
.optional()
|
|
198
248
|
.describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
|
|
199
249
|
},
|
|
200
|
-
}, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, viewportWidth, viewportHeight, session, }) => {
|
|
250
|
+
}, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, session, }) => {
|
|
201
251
|
try {
|
|
202
252
|
const target = session ?? activeName;
|
|
203
253
|
if (session) {
|
|
@@ -258,7 +308,7 @@ server.registerTool("scout_attach", {
|
|
|
258
308
|
/* conflict detection is best-effort */
|
|
259
309
|
}
|
|
260
310
|
const viewport = viewportWidth && viewportHeight ? { width: viewportWidth, height: viewportHeight } : undefined;
|
|
261
|
-
const out = await eng.attach({ url, projectDir: projectPath, storageStatePath, mode, headed, viewport, memoryStore: store });
|
|
311
|
+
const out = await eng.attach({ url, projectDir: projectPath, storageStatePath, mode, headed, browser, viewport, memoryStore: store });
|
|
262
312
|
eng.role = storageStatePath ? path.basename(storageStatePath).replace(/\.json$/i, "") : "anonymous";
|
|
263
313
|
return text(out + conflictNote + (engines.size > 1 ? `\n${sessionLines()}` : ""), target);
|
|
264
314
|
}
|
package/dist/playbook.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The testing method, served by the MCP server itself.
|
|
3
|
+
*
|
|
4
|
+
* The method lives in skills/scenescout/SKILL.md. Claude Code loads that file
|
|
5
|
+
* as a skill; no other client does, so an agent there gets the tools and none
|
|
6
|
+
* of the method: the setup order, the write modes, what counts as done. The
|
|
7
|
+
* server hands the same text to any client through a tool, a prompt and a short
|
|
8
|
+
* pointer in its instructions. One file, so the two can never disagree.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
export const PLAYBOOK_TOOL = "scout_playbook";
|
|
13
|
+
export const PLAYBOOK_PROMPT = "explore";
|
|
14
|
+
/** Where the method is kept, relative to the package root. It is in the package's `files`. */
|
|
15
|
+
export const PLAYBOOK_RELATIVE_PATH = path.join("skills", "scenescout", "SKILL.md");
|
|
16
|
+
/**
|
|
17
|
+
* Sent to every client when it connects. Short on purpose: clients cut long
|
|
18
|
+
* instructions, and one that is cut in the middle is worse than a pointer.
|
|
19
|
+
*/
|
|
20
|
+
export const SERVER_INSTRUCTIONS = `SceneScout explores a running web app in a real browser and reports bugs, UX problems and coverage. ` +
|
|
21
|
+
`The scout_* tools are deterministic; the method for using them well is a separate text. ` +
|
|
22
|
+
`If this client offers a SceneScout skill, load that. Otherwise call ${PLAYBOOK_TOOL} once, before the first scout_attach in a conversation, and follow what it returns. ` +
|
|
23
|
+
`They are the same text, so never read both. ` +
|
|
24
|
+
`Never choose mode="destructive" yourself: that needs the user's explicit opt-in.`;
|
|
25
|
+
export const LEVELS = ["minimal", "medium", "extensive"];
|
|
26
|
+
/** What the `explore` prompt accepts, as MCP lists it. Every argument is optional. */
|
|
27
|
+
export const EXPLORE_PROMPT_ARGUMENTS = [
|
|
28
|
+
{ name: "url", description: "URL of the running app, e.g. http://localhost:3000", required: false },
|
|
29
|
+
{ name: "level", description: `How far to go: ${LEVELS.join(", ")}`, required: false },
|
|
30
|
+
{ name: "focus", description: "An area or flow to concentrate on", required: false },
|
|
31
|
+
];
|
|
32
|
+
/** The skill file without its YAML front matter, which only a skill loader reads. */
|
|
33
|
+
export function stripFrontMatter(markdown) {
|
|
34
|
+
// An editor may save the file with a byte-order mark; it would hide the opening fence.
|
|
35
|
+
const text = markdown.replace(/^\uFEFF/, "");
|
|
36
|
+
// The fences may hold nothing between them, and the closing one may be the last line of the file.
|
|
37
|
+
const m = /^---[ \t]*\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
38
|
+
// Leading blank LINES go; indentation on the first line of the body stays.
|
|
39
|
+
return (m ? text.slice(m[0].length) : text).replace(/^(?:[ \t]*\r?\n)+/, "");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The method text. Throws when the file is not where the package puts it: an
|
|
43
|
+
* agent told "here is the method" and handed an empty string would proceed
|
|
44
|
+
* without one and never say so.
|
|
45
|
+
*/
|
|
46
|
+
export function loadPlaybook(packageRoot) {
|
|
47
|
+
const file = path.join(packageRoot, PLAYBOOK_RELATIVE_PATH);
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw new Error(`The SceneScout playbook is missing from this install (${file}): ${err instanceof Error ? err.message : String(err)}. Reinstall the package.`);
|
|
54
|
+
}
|
|
55
|
+
const body = stripFrontMatter(raw);
|
|
56
|
+
if (body.trim().length === 0)
|
|
57
|
+
throw new Error(`The SceneScout playbook at ${file} is empty. Reinstall the package.`);
|
|
58
|
+
// Front matter that was not recognised would be served to the agent as if it were the method.
|
|
59
|
+
if (/^---[ \t]*(\r?\n|$)/.test(body))
|
|
60
|
+
throw new Error(`The SceneScout playbook at ${file} has front matter that could not be read. Reinstall the package.`);
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The opening message of the `explore` prompt: the method, then what the person
|
|
65
|
+
* asked for. `args` is whatever the client sent, which may be nothing at all.
|
|
66
|
+
* A level the method does not know is refused, not passed on for the agent to guess at.
|
|
67
|
+
*/
|
|
68
|
+
export function explorePrompt(playbook, args) {
|
|
69
|
+
const given = (name) => {
|
|
70
|
+
const v = args?.[name];
|
|
71
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
72
|
+
};
|
|
73
|
+
const level = given("level");
|
|
74
|
+
if (level !== undefined && !LEVELS.includes(level)) {
|
|
75
|
+
throw new Error(`level "${level}" is not one the method knows. Use one of: ${LEVELS.join(", ")}.`);
|
|
76
|
+
}
|
|
77
|
+
const asks = [
|
|
78
|
+
given("url") ? `Target: ${given("url")}` : "Target: ask me for the URL of the running app, or find it from the project.",
|
|
79
|
+
level ? `Level: ${level}` : "",
|
|
80
|
+
given("focus") ? `Focus: ${given("focus")}` : "",
|
|
81
|
+
].filter(Boolean);
|
|
82
|
+
return `${playbook}\n\n---\n\nRun an exploratory test session following the method above.\n${asks.join("\n")}`;
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "SceneScout —
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -14,13 +14,24 @@
|
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"mcp",
|
|
17
|
+
"mcp-server",
|
|
17
18
|
"model-context-protocol",
|
|
18
19
|
"exploratory-testing",
|
|
19
20
|
"ui-testing",
|
|
21
|
+
"e2e-testing",
|
|
22
|
+
"qa",
|
|
20
23
|
"playwright",
|
|
24
|
+
"browser-automation",
|
|
25
|
+
"ai-agent",
|
|
26
|
+
"ai-testing",
|
|
27
|
+
"accessibility",
|
|
21
28
|
"claude-code",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
29
|
+
"cursor",
|
|
30
|
+
"vscode",
|
|
31
|
+
"github-copilot",
|
|
32
|
+
"codex",
|
|
33
|
+
"gemini-cli",
|
|
34
|
+
"windsurf"
|
|
24
35
|
],
|
|
25
36
|
"type": "module",
|
|
26
37
|
"bin": {
|
|
@@ -5,16 +5,16 @@ description: AI exploratory UI testing — drive the SceneScout MCP browser tool
|
|
|
5
5
|
|
|
6
6
|
# SceneScout — exploratory UI testing agent
|
|
7
7
|
|
|
8
|
-
You are the brain of an exploratory UI tester. The SceneScout MCP server gives you deterministic browser tools (the `scout_*` tools —
|
|
8
|
+
You are the brain of an exploratory UI tester. The SceneScout MCP server gives you deterministic browser tools (the `scout_*` tools — some clients show them under a prefix: Claude Code lists `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
9
|
|
|
10
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
11
|
|
|
12
12
|
## Setup (in order)
|
|
13
13
|
|
|
14
|
-
1. **Check the tools exist.**
|
|
14
|
+
1. **Check the tools exist.** Reading this as the result of `scout_playbook` or of the `explore` prompt? Then they do: go to step 2. Otherwise 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 (these are Claude Code's commands; the README has the config for other clients):
|
|
15
15
|
- as a plugin: `/plugin marketplace add brunoboto96/SceneScout` then `/plugin install scenescout@scenescout-marketplace`
|
|
16
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
|
|
17
|
+
If attach later reports that a browser build has not been downloaded, relay the one-time command it names. Attach drives Chromium unless you pass `browser: "firefox"` or `"webkit"`; do that only when the user asks for a cross-browser pass, and say in each finding which browser showed it.
|
|
18
18
|
2. **`scout_scan`** the project's absolute path. Read routes, framework, auth states, notes.
|
|
19
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, attach in `observe` mode unless the user says form submissions are acceptable on this target (then `read-only`), and never go past `read-only` unless they say the environment is disposable. In `observe` you may fill and submit forms freely: the engine blocks the request, so you still see client-side validation, and nothing is created. 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
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.
|