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.
- package/CHANGELOG.md +32 -0
- package/README.md +50 -8
- package/dist/engine/browser.js +61 -1
- package/dist/engine/live-page.js +230 -17
- package/dist/engine/live.js +40 -2
- package/dist/engine/replay.js +348 -0
- package/dist/engine/report.js +101 -1
- package/dist/mcp-server.js +84 -8
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|