scenescout 3.0.0 → 3.1.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.
@@ -38,7 +38,8 @@ import { MemoryStore, redactSecrets } from "./engine/memory.js";
38
38
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
39
39
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
40
40
  import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard, } from "./engine/live.js";
41
- import { computeGaps, formatRouteCoverage, generateReport } from "./engine/report.js";
41
+ import { computeGaps, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
42
+ import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
42
43
  import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
43
44
  import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
44
45
  import { formatScan, scanProject } from "./scan.js";
@@ -116,12 +117,24 @@ let lastRun = null;
116
117
  function keepReport(eng) {
117
118
  if (!eng.memory)
118
119
  return;
120
+ // The markdown is the record and is kept first. The frames and the one-page
121
+ // version are extras, and building them in the same attempt meant a failure
122
+ // in either threw the report away with them — leaving a finished run with
123
+ // findings in it telling the viewer there was nothing to report.
119
124
  try {
120
125
  const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
121
- lastRun = { markdown: redactSecrets(markdown), at: new Date().toISOString(), dir: eng.memory.dir };
126
+ lastRun = { markdown: redactSecrets(markdown), at: new Date().toISOString(), dir: eng.memory.dir, evidence: [], replay: "" };
122
127
  }
123
- catch {
124
- // Best-effort: a report that cannot be rendered must not fail a close.
128
+ catch (err) {
129
+ console.error(`[scenescout] the report for ${eng.sessionKey} could not be kept: ${err instanceof Error ? err.message : String(err)}`);
130
+ return;
131
+ }
132
+ try {
133
+ lastRun.evidence = reportEvidence(eng.memory);
134
+ lastRun.replay = replayDocument(eng.memory, lastRun.markdown, PKG_VERSION);
135
+ }
136
+ catch (err) {
137
+ console.error(`[scenescout] the one-page version of this run could not be built: ${err instanceof Error ? err.message : String(err)}; the report itself is unaffected`);
125
138
  }
126
139
  }
127
140
  /** Where this run's report belongs, and whether the agent has written it there yet. */
@@ -155,9 +168,58 @@ const liveProvider = {
155
168
  report: () => {
156
169
  const eng = (lastWriter && engines.get(lastWriter.session)) ?? engines.values().next().value;
157
170
  if (!eng?.memory)
158
- return lastRun ? { markdown: lastRun.markdown, at: lastRun.at } : null;
171
+ return lastRun ? { markdown: lastRun.markdown, at: lastRun.at, evidence: lastRun.evidence } : null;
159
172
  const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
160
- return { markdown: redactSecrets(markdown), at: new Date().toISOString() };
173
+ return { markdown: redactSecrets(markdown), at: new Date().toISOString(), evidence: reportEvidence(eng.memory) };
174
+ },
175
+ /**
176
+ * The whole run as one page, served at its own address. The live view sends
177
+ * a finished run here: the address then IS the report, so refreshing works
178
+ * and there is nothing to lose by closing a panel.
179
+ */
180
+ replay: () => {
181
+ const eng = (lastWriter && engines.get(lastWriter.session)) ?? engines.values().next().value;
182
+ if (!eng?.memory)
183
+ return lastRun?.replay || null;
184
+ try {
185
+ const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
186
+ return replayDocument(eng.memory, redactSecrets(markdown), PKG_VERSION);
187
+ }
188
+ catch (err) {
189
+ // This address is one people refresh and bookmark, so a throw here would
190
+ // hand them a blank page. Say so, and fall back to the last rendering.
191
+ console.error(`[scenescout] the run's page could not be rendered: ${err instanceof Error ? err.message : String(err)}`);
192
+ return lastRun?.replay || null;
193
+ }
194
+ },
195
+ /**
196
+ * A recorded frame, read from the run's own recordings directory. The path
197
+ * comes from a viewer, so it is resolved and then required to still be
198
+ * inside that directory: nothing else in the project is reachable this way.
199
+ */
200
+ frame: async (relPath) => {
201
+ // Sessions may hold different project directories, so the frame belongs to
202
+ // the session its own path names — not to whichever engine happens to be
203
+ // first. Falling back keeps a finished run's frames reachable.
204
+ const named = relPath.replace(/^recordings[\\/]/, "").split("/")[0];
205
+ const dir = engines.get(named)?.memory?.dir ?? engines.values().next().value?.memory?.dir ?? lastRun?.dir;
206
+ if (!dir)
207
+ return null;
208
+ const file = resolveFrame(path.join(dir, "recordings"), relPath);
209
+ if (!file)
210
+ return null;
211
+ try {
212
+ return await fs.promises.readFile(file);
213
+ }
214
+ catch (err) {
215
+ // A viewer can ask for anything, so a missing file is ordinary. A frame
216
+ // that exists and cannot be read is not, and would otherwise present as
217
+ // "that frame does not exist".
218
+ if (err.code !== "ENOENT") {
219
+ console.error(`[scenescout] a recorded frame could not be read (${file}): ${err instanceof Error ? err.message : String(err)}`);
220
+ }
221
+ return null;
222
+ }
161
223
  },
162
224
  // Both go around the session queue on purpose: a viewer must never wait
163
225
  // behind the agent's calls, and a session that is stuck is the one most
@@ -177,6 +239,7 @@ function reportExtras(eng) {
177
239
  unvisitedRoutes: unvisited,
178
240
  mode: eng.mode,
179
241
  policyAttributed: eng.oracleLog.policyAttributed,
242
+ version: PKG_VERSION,
180
243
  };
181
244
  }
182
245
  /** Hand the live view's token to `scenescout watch` through a file only the owner can read. */
@@ -456,13 +519,18 @@ server.registerTool("scout_attach", {
456
519
  '"Approve and reject orders as a manager"). It sits above the task, which is what the session is doing at any moment. ' +
457
520
  "Shown to whoever is watching the run; worth setting whenever more than one session is live."),
458
521
  task: z.string().max(300).optional().describe("Old name for `objective` (2.0). Prefer `objective`."),
522
+ record: z
523
+ .boolean()
524
+ .default(false)
525
+ .describe("Keep a frame of the page after every action, under .scenescout/recordings/, and show it beside that step in report.html. " +
526
+ "Off by default: a recording is pictures of the app under test sitting in the project folder. Turn it on for QA work, where the run is evidence and not only a report."),
459
527
  session: z
460
528
  .string()
461
529
  .max(40)
462
530
  .optional()
463
531
  .describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
464
532
  },
465
- }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, session, }) => {
533
+ }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, session, }) => {
466
534
  try {
467
535
  const target = session ?? activeName;
468
536
  if (session) {
@@ -533,6 +601,7 @@ server.registerTool("scout_attach", {
533
601
  viewport,
534
602
  // `task` is what this was called in 2.0; it named the session's whole remit, which is the objective.
535
603
  objective: objective ?? task,
604
+ record,
536
605
  memoryStore: store,
537
606
  });
538
607
  eng.role = storageStatePath ? path.basename(storageStatePath).replace(/\.json$/i, "") : "anonymous";
@@ -543,7 +612,13 @@ server.registerTool("scout_attach", {
543
612
  await ensureLive(eng.memory.dir);
544
613
  writeStatus(target, "idle", "scout_attach");
545
614
  }
546
- return text(out + conflictNote + (engines.size > 1 ? `\n${sessionLines()}` : "") + liveLine(), target);
615
+ // Recording writes pictures of the app under test into the project, so
616
+ // a run doing it says where they go rather than leaving the person to
617
+ // find a folder of screenshots later.
618
+ const recordNote = record && eng.memory?.dir
619
+ ? `\n\n📸 RECORDING: a frame of the page after each action, under ${path.join(eng.memory.dir, "recordings", target)}/ (at most ${RECORD_MAX_FRAMES}). scout_report writes them into report.html beside report.md.`
620
+ : "";
621
+ return text(out + conflictNote + recordNote + (engines.size > 1 ? `\n${sessionLines()}` : "") + liveLine(), target);
547
622
  }
548
623
  catch (err) {
549
624
  return errorText(err);
@@ -922,6 +997,7 @@ server.registerTool("scout_finding", {
922
997
  evidence,
923
998
  url: eng.currentUrl,
924
999
  state: eng.currentState || "(unknown)",
1000
+ session: eng.sessionKey,
925
1001
  });
926
1002
  return text(isNew
927
1003
  ? `Finding recorded: [${finding.severity}] ${finding.title} (id ${finding.id})`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",