scenescout 1.0.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.
@@ -77,8 +77,60 @@ export const AUTH_FLOW_RE = /\/(auth|login|logout|signin|sign-in|signup|sign-up|
77
77
  export function isDestructive(...labels) {
78
78
  return labels.some((label) => typeof label === "string" && label.length > 0 && DESTRUCTIVE_PATTERNS.some((re) => re.test(label)));
79
79
  }
80
- export function destructiveRefusal(label) {
81
- return (`REFUSED by read-only policy: "${label}" matches a destructive-action pattern. ` +
82
- `This run is read-only; do not attempt this element again. If destructive flows must be tested, ` +
80
+ export function destructiveRefusal(label, mode = "read-only") {
81
+ return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
82
+ `This run is ${mode}; do not attempt this element again. If destructive flows must be tested, ` +
83
83
  `the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
84
84
  }
85
+ export const WRITE_MODES = ["observe", "read-only", "safe-write", "destructive"];
86
+ /**
87
+ * May this non-GET request leave the page? Auth-flow requests are let through
88
+ * before this is asked. `owned` means the request addresses a record this run
89
+ * created (always false outside safe-write, where nothing is tracked).
90
+ */
91
+ /**
92
+ * In observe mode, the only auth requests let through are the ones a session
93
+ * needs in order to exist: logging in, logging out, refreshing a token. Whole
94
+ * path SEGMENTS, never substrings — `/users/login-history/clear` and
95
+ * `/api/tokens` (mint an API token) are not logins — and `session(s)` only as
96
+ * the last segment, where a POST means "log in", not "act on session 123".
97
+ */
98
+ const OBSERVE_AUTH_SEGMENT_RE = /^(login|log-in|signin|sign-in|logout|log-out|signout|sign-out|refresh|token|oauth|oauth2|sso|callback|authorize|authenticate)$/i;
99
+ /**
100
+ * Is this request an auth flow that must work even though the mode would
101
+ * otherwise block it?
102
+ *
103
+ * Never for a destructive-looking request, in any mode: the exemption used to
104
+ * be tested first, so `POST /api/session/123/delete` went through in read-only
105
+ * because its path contains "session".
106
+ *
107
+ * In observe mode the exemption is much narrower than elsewhere. Signing up,
108
+ * changing or resetting a password, verifying an email and creating a user all
109
+ * change data on the target, and observe promises that nothing is created.
110
+ */
111
+ export function isAuthExempt(mode, method, pathname, destructiveWire) {
112
+ if (method !== "POST" || destructiveWire)
113
+ return false;
114
+ if (mode !== "observe")
115
+ return AUTH_FLOW_RE.test(pathname);
116
+ const segments = pathname.split("/").filter(Boolean);
117
+ if (segments.length === 0)
118
+ return false;
119
+ const last = segments[segments.length - 1];
120
+ if (/^sessions?$/i.test(last))
121
+ return true;
122
+ // The matching segment must be at, or next to, the end: /auth/token/refresh, /oauth/token, /login.
123
+ return (segments.slice(-2).some((seg) => OBSERVE_AUTH_SEGMENT_RE.test(seg)) &&
124
+ !/^(users?|accounts?|members?|password|signup|sign-up|register|verify|invite|invitations?)$/i.test(last));
125
+ }
126
+ export function allowsWrite(mode, method, destructiveWire, owned) {
127
+ if (mode === "destructive")
128
+ return true;
129
+ if (mode === "observe")
130
+ return false;
131
+ // POST: creation/RPC passes unless it looks destructive and is not ours.
132
+ if (method === "POST")
133
+ return !destructiveWire || owned;
134
+ // PUT/PATCH/DELETE: only in safe-write, only on this run's own records.
135
+ return mode === "safe-write" && owned;
136
+ }
@@ -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("Tab");
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;
@@ -289,7 +289,12 @@ export function computeGaps(memory, extras) {
289
289
  // and let a mutation anywhere in a wizard clear its sibling steps.
290
290
  const { unsubmitted } = classifyFilledStates(memory, facts);
291
291
  if (unsubmitted.length > 0) {
292
- gaps.push(`${unsubmitted.length} route(s) had a form filled but NEVER submitted (no state-changing request left the page): ${unsubmitted.slice(0, 8).join(", ")}${unsubmitted.length > 8 ? " …" : ""}`);
292
+ gaps.push(`${unsubmitted.length} route(s) had a form filled but NEVER submitted (no state-changing request left the page): ${unsubmitted.slice(0, 8).join(", ")}${unsubmitted.length > 8 ? " …" : ""}` +
293
+ // In observe mode this is the mode working, not the run falling short —
294
+ // but it is still untested surface, so it stays in the ledger, explained.
295
+ (extras?.mode === "observe"
296
+ ? ` — expected in observe mode, which blocks every form submission by design; what the server does with these forms is untested. Cover them in read-only mode against an environment where creating records is acceptable.`
297
+ : ""));
293
298
  }
294
299
  const journeyTotal = Object.values(facts).reduce((a, f) => a + (f.journeysCompleted ?? 0), 0);
295
300
  if (journeyTotal === 0) {
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 missing = r.error?.code === "ENOENT";
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. */
@@ -243,24 +248,47 @@ export function parseRegistration(listing) {
243
248
  const field = (name) => new RegExp(`^[ \\t]*${name}:[ \\t]*(\\S.*?)[ \\t]*$`, "m").exec(listing)?.[1] ?? null;
244
249
  return { command: field("Command"), serverPath: field("Args") };
245
250
  }
251
+ /**
252
+ * The command that repairs a setup, for THIS kind of install. A source checkout
253
+ * has `npm run setup`; someone who installed from npm has no such script, and
254
+ * telling them to run it sends them looking for a package.json they never had.
255
+ */
256
+ export function repairCommands(packageRoot) {
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}`);
261
+ return isCheckout
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
+ };
272
+ }
246
273
  /** Everything a working setup needs, each with the command that repairs it. */
247
274
  export function diagnose(opts) {
248
275
  const checks = [];
276
+ const repair = repairCommands(opts.packageRoot);
249
277
  const major = Number(opts.nodeVersion.replace(/^v/, "").split(".")[0]);
250
278
  checks.push({ name: "node >= 20", ok: major >= 20, detail: opts.nodeVersion, fix: "install Node 20 or newer" });
251
279
  const server = path.join(opts.packageRoot, "dist", "mcp-server.js");
252
- checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix: "npm run build" });
253
- const chromiumOk = !!opts.chromiumPath && fs.existsSync(opts.chromiumPath);
280
+ checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix: repair.build });
281
+ const browser = opts.defaultBrowser;
254
282
  checks.push({
255
- name: "chromium downloaded",
256
- ok: chromiumOk,
257
- detail: opts.chromiumPath ?? "playwright could not name a browser path",
258
- fix: "npm run setup (or: npx playwright install chromium)",
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})`,
259
287
  });
260
288
  if (opts.scope === "engine")
261
289
  return checks;
262
290
  const skill = path.join(opts.claudeDir, "skills", SKILL_NAME, "SKILL.md");
263
- checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: "npm run setup" });
291
+ checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: repair.setup });
264
292
  const got = opts.run("claude", ["mcp", "get", MCP_NAME]);
265
293
  if (got.missing) {
266
294
  checks.push({
@@ -273,7 +301,7 @@ export function diagnose(opts) {
273
301
  else {
274
302
  const listing = got.stdout + got.stderr;
275
303
  if (got.status !== 0) {
276
- checks.push({ name: "MCP server registered", ok: false, detail: "no server named scenescout", fix: "npm run setup" });
304
+ checks.push({ name: "MCP server registered", ok: false, detail: "no server named scenescout", fix: repair.setup });
277
305
  }
278
306
  else {
279
307
  const { command, serverPath } = parseRegistration(listing);
@@ -289,12 +317,12 @@ export function diagnose(opts) {
289
317
  name: "MCP server registered",
290
318
  ok: absolute,
291
319
  detail: absolute ? `via ${command} ${serverPath}` : `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
292
- fix: "scenescout install",
320
+ fix: repair.setup,
293
321
  });
294
322
  }
295
323
  else if (!samePath(serverPath, server)) {
296
324
  // The usual aftermath of moving or deleting a checkout.
297
- checks.push({ name: "MCP server registered", ok: false, detail: `registered, but pointing at ${serverPath} — not this install`, fix: "npm run setup" });
325
+ checks.push({ name: "MCP server registered", ok: false, detail: `registered, but pointing at ${serverPath} — not this install`, fix: repair.setup });
298
326
  }
299
327
  else if (command !== null && !path.isAbsolute(command)) {
300
328
  // A bare "node" resolves in your shell and then fails inside Claude
@@ -303,7 +331,7 @@ export function diagnose(opts) {
303
331
  name: "MCP server registered",
304
332
  ok: false,
305
333
  detail: `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
306
- fix: "npm run setup",
334
+ fix: repair.setup,
307
335
  });
308
336
  }
309
337
  else {
@@ -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 server = new McpServer({ name: "scenescout", version: PKG_VERSION });
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,16 +225,20 @@ 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='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs; 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)"),
186
232
  storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration"),
187
233
  mode: z
188
- .enum(["read-only", "safe-write", "destructive"])
234
+ .enum(["observe", "read-only", "safe-write", "destructive"])
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
  }
@@ -563,7 +613,7 @@ server.registerTool("scout_note", {
563
613
  }
564
614
  }));
565
615
  server.registerTool("scout_screenshot", {
566
- description: "Take a JPEG screenshot of the current viewport. LAST RESORT: geometry issues are in scout_snapshot and style/contrast/spacing issues are in scout_design_audit — use a screenshot only for pixel-native content (broken images, canvas, visual gestalt) that computed data cannot capture.",
616
+ description: "Take a JPEG screenshot of the current viewport. LAST RESORT: geometry issues are in scout_snapshot and style/contrast/spacing issues are in scout_design_audit — images that failed to load are listed in scout_snapshot under BROKEN IMAGES — use a screenshot only for pixel-native content (a canvas, visual gestalt) that computed data cannot capture.",
567
617
  inputSchema: { session: sessionParam },
568
618
  }, serializedPerSession("scout_screenshot", async (_args, session) => {
569
619
  try {
@@ -702,9 +752,13 @@ server.registerTool("scout_report", {
702
752
  routesTotal: all.length,
703
753
  designAudits: eng.designAuditCount,
704
754
  unvisitedRoutes: unvisited,
755
+ mode: eng.mode,
705
756
  });
706
757
  if (lvl === "extensive" && gapList.length > 0) {
707
758
  gates.push(`Level 'extensive' claims completeness, so it refuses while the GAP LEDGER is non-empty:\n` +
759
+ (eng.mode === "observe"
760
+ ? `(observe mode blocks every form submission, so the unsubmitted-forms gap cannot be closed in this mode: report at level 'medium', which discloses it.)\n`
761
+ : "") +
708
762
  gapList.map((g) => ` ⚠ ${g}`).join("\n") +
709
763
  `\nClose the gaps (or report at level 'medium', which discloses them instead).`);
710
764
  }
@@ -718,6 +772,7 @@ server.registerTool("scout_report", {
718
772
  designAudits: eng.designAuditCount,
719
773
  createdResources: eng.createdResources,
720
774
  unvisitedRoutes: unvisited,
775
+ mode: eng.mode,
721
776
  policyAttributed: eng.oracleLog.policyAttributed,
722
777
  });
723
778
  void p;
@@ -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/dist/scan.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { codeRoutes } from "./code-routes.js";
3
4
  /**
4
5
  * What marks a package.json as a frontend workspace.
5
6
  *
@@ -10,6 +11,8 @@ import path from "node:path";
10
11
  * "No frontend workspace found" for a perfectly ordinary project.
11
12
  */
12
13
  const FRONTEND_DEPS = ["next", "nuxt", "@sveltejs/kit", "@remix-run/react", "@remix-run/node", "react", "vue", "svelte", "@angular/core", "vite"];
14
+ /** Frameworks whose routes live in code rather than in the filesystem. */
15
+ const CODE_ROUTED = new Set(["vite+react", "react", "create-react-app", "vue", "vite", "angular"]);
13
16
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "out", "docs", "examples", "e2e-tests"]);
14
17
  function readJson(file) {
15
18
  try {
@@ -272,13 +275,34 @@ export function scanProject(projectDir) {
272
275
  : framework === "nuxt"
273
276
  ? fileRoutes(frontendDir, "nuxt")
274
277
  : [];
278
+ // Routers configured in code (React Router, Vue Router, Angular): read the
279
+ // route records statically. Remix is file-routed in its own way and is not
280
+ // covered here.
281
+ let codeRouteFiles = [];
282
+ if (routes.length === 0 && framework !== null && CODE_ROUTED.has(framework)) {
283
+ const found = codeRoutes(frontendDir);
284
+ routes.push(...found.routes);
285
+ codeRouteFiles = found.files;
286
+ if (found.unresolved.length > 0) {
287
+ notes.push(`${found.unresolved.length} lazily loaded route branch(es) could not be followed (${found.unresolved.slice(0, 3).join(", ")}${found.unresolved.length > 3 ? ", …" : ""}): ` +
288
+ `the branch's own path is listed, the pages under it are not. Links found while exploring add them.`);
289
+ }
290
+ if (found.truncated) {
291
+ notes.push(`The source tree was too large or too deep to list completely, so a router file may have been missed; routes read from source may be incomplete.`);
292
+ }
293
+ }
275
294
  // Say so when filesystem discovery cannot help. Returning [] silently left
276
295
  // the agent to assume the app genuinely had no routes, when in fact nothing
277
296
  // had looked — the completion contract then rested entirely on link
278
297
  // harvesting without ever admitting it.
298
+ if (codeRouteFiles.length > 0) {
299
+ notes.push(`${routes.length} route(s) read statically from the router configuration in ${codeRouteFiles.slice(0, 3).join(", ")}${codeRouteFiles.length > 3 ? ", …" : ""}. ` +
300
+ `Routes built at runtime (from data, a loop, or an identifier this reader could not follow) are not in this list; links found while exploring add to it.`);
301
+ }
279
302
  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.`);
303
+ notes.push(`No routes could be read from source for "${framework}": it is not file-routed, and no router configuration this reader can follow was found ` +
304
+ `(routes built at runtime, or a router it does not know). The route contract will be built from links harvested during exploration, ` +
305
+ `so crawl breadth depends on what the UI links to.`);
282
306
  }
283
307
  const authStates = findAuthStates(frontendDir);
284
308
  const hasPlaywright = fs.existsSync(path.join(frontendDir, "playwright.config.ts")) || fs.existsSync(path.join(frontendDir, "playwright.config.js"));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
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
- "qa",
23
- "ai-agent"
29
+ "cursor",
30
+ "vscode",
31
+ "github-copilot",
32
+ "codex",
33
+ "gemini-cli",
34
+ "windsurf"
24
35
  ],
25
36
  "type": "module",
26
37
  "bin": {
@@ -47,7 +58,7 @@
47
58
  "setup": "node dist/cli.js install",
48
59
  "doctor": "node dist/cli.js doctor",
49
60
  "changeset": "changeset",
50
- "version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only",
61
+ "version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only && prettier --write package.json .claude-plugin/plugin.json",
51
62
  "release": "npm run build && changeset publish",
52
63
  "format": "prettier --write .",
53
64
  "format:check": "prettier --check .",
@@ -60,7 +71,7 @@
60
71
  "mcp-check": "npm run build && npm run mcp-check:run",
61
72
  "mcp-check:run": "tsx scripts/mcp-check.ts",
62
73
  "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",
74
+ "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run contract-test && npm run memory-test && npm run install-test && npm run hygiene-test",
64
75
  "scan-test": "tsx scripts/scan-test.ts",
65
76
  "oracle-test": "tsx --test scripts/oracle-test.ts",
66
77
  "policy-test": "tsx --test scripts/policy-test.ts",
@@ -69,7 +80,8 @@
69
80
  "design-test": "tsx --test scripts/design-test.ts",
70
81
  "contract-test": "tsx --test scripts/contract-test.ts",
71
82
  "memory-test": "tsx --test scripts/memory-test.ts",
72
- "install-test": "tsx --test scripts/install-test.ts"
83
+ "install-test": "tsx --test scripts/install-test.ts",
84
+ "hygiene-test": "tsx --test scripts/hygiene-test.ts"
73
85
  },
74
86
  "dependencies": {
75
87
  "@modelcontextprotocol/sdk": "^1.12.0",