scenescout 3.23.0 → 3.23.2
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 +12 -0
- package/README.md +154 -747
- package/dist/engine/check-replay.js +1 -1
- package/dist/engine/check-report.js +3 -2
- package/dist/engine/dispatch.js +70 -0
- package/dist/engine/memory.js +12 -0
- package/dist/mcp-server.js +45 -6
- package/package.json +2 -2
|
@@ -285,7 +285,7 @@ ol.steps { list-style:none; margin:10px 0 0; padding:0; }
|
|
|
285
285
|
.why { margin:4px 0; font-size:13px; overflow-wrap:anywhere; }
|
|
286
286
|
a.frame { display:grid; margin:6px 0 2px; max-width:min(100%,720px); color:var(--muted); font-size:12px; }
|
|
287
287
|
a.frame > * { grid-area:1 / 1; }
|
|
288
|
-
a.frame img { position:relative;
|
|
288
|
+
a.frame img { position:relative; width:100%; max-height:400px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
|
|
289
289
|
.gone-note { align-self:end; padding:2.6em 12px 12px; border:1px dashed var(--line); border-radius:6px; }
|
|
290
290
|
.noframe, .none { color:var(--muted); font-size:12px; font-style:italic; margin:4px 0; }
|
|
291
291
|
.note { color:var(--muted); font-size:13px; }
|
|
@@ -431,7 +431,7 @@ header.title h1 { font-size:22px; margin:0 0 4px; }
|
|
|
431
431
|
header.title .subtitle { margin:0 0 8px; color:var(--muted); }
|
|
432
432
|
h2 { font-size:17px; margin:28px 0 8px; border-bottom:1px solid var(--line); padding-bottom:4px; }
|
|
433
433
|
table { border-collapse:collapse; width:100%; margin:8px 0; }
|
|
434
|
-
th, td { border:1px solid var(--line); padding:5px 8px; text-align:left; vertical-align:top; overflow-wrap:
|
|
434
|
+
th, td { border:1px solid var(--line); padding:5px 8px; text-align:left; vertical-align:top; overflow-wrap:break-word; }
|
|
435
435
|
thead th, table.facts th { background:var(--head); }
|
|
436
436
|
table.facts { width:auto; min-width:50%; }
|
|
437
437
|
tr.test-head th { background:var(--head); font-weight:400; }
|
|
@@ -444,7 +444,8 @@ td.r-passed { color:var(--pass); font-weight:700; } td.r-failed, td.r-refused {
|
|
|
444
444
|
tr.failed td, tr.refused td { background:var(--fail-bg); }
|
|
445
445
|
a.frame { display:block; color:var(--muted); font-size:11px; }
|
|
446
446
|
a.frame img { display:block; max-width:220px; max-height:150px; object-fit:cover; object-position:top; border:1px solid var(--line); }
|
|
447
|
-
td.hash, td.path { font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
447
|
+
td.hash, td.path { font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
|
|
448
|
+
a.frame { overflow-wrap:anywhere; }
|
|
448
449
|
table.sign td.blank { height:2.6em; min-width:8em; }
|
|
449
450
|
.none { color:var(--muted); font-style:italic; }
|
|
450
451
|
@media print { main { max-width:none; padding:0; } a { color:inherit; text-decoration:none; } tbody, tr { break-inside:avoid; } }
|
package/dist/engine/dispatch.js
CHANGED
|
@@ -98,3 +98,73 @@ export class SessionQueue {
|
|
|
98
98
|
this.forget(key);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The work of every tool call still running, per session.
|
|
103
|
+
*
|
|
104
|
+
* The watchdog answers a slow call, but it cannot stop the call: the work goes
|
|
105
|
+
* on in the background and can still write into the project (its action log,
|
|
106
|
+
* a frame, a debounced memory save). Left alone, those writes land after
|
|
107
|
+
* `scout_close` has answered, and after another session has attached to the
|
|
108
|
+
* same folder. So close waits for them: it closes the browser first, which
|
|
109
|
+
* makes every page-bound step of the abandoned work fail at once, then waits
|
|
110
|
+
* here for the work to finish unwinding. The wait is bounded, so work that is
|
|
111
|
+
* stuck on something other than the browser cannot hold a close forever; the
|
|
112
|
+
* caller is told how many calls were still running when it gave up.
|
|
113
|
+
*/
|
|
114
|
+
export class CallWork {
|
|
115
|
+
running = new Map();
|
|
116
|
+
/** Record `work` as running for `key` until it settles, whether it resolves or rejects. */
|
|
117
|
+
track(key, work) {
|
|
118
|
+
let set = this.running.get(key);
|
|
119
|
+
if (!set) {
|
|
120
|
+
set = new Set();
|
|
121
|
+
this.running.set(key, set);
|
|
122
|
+
}
|
|
123
|
+
set.add(work);
|
|
124
|
+
const done = () => {
|
|
125
|
+
const left = this.running.get(key);
|
|
126
|
+
if (!left)
|
|
127
|
+
return;
|
|
128
|
+
left.delete(work);
|
|
129
|
+
if (left.size === 0)
|
|
130
|
+
this.running.delete(key);
|
|
131
|
+
};
|
|
132
|
+
// The rejection is the call's own caller's to handle; here it only means "finished".
|
|
133
|
+
work.then(done, done);
|
|
134
|
+
}
|
|
135
|
+
/** Calls still running for `key`. */
|
|
136
|
+
count(key) {
|
|
137
|
+
return this.running.get(key)?.size ?? 0;
|
|
138
|
+
}
|
|
139
|
+
/** Wait up to `ms` for `key`'s running calls to finish; resolves with how many are still running. */
|
|
140
|
+
async settle(key, ms) {
|
|
141
|
+
return this.settleKeys([key], ms);
|
|
142
|
+
}
|
|
143
|
+
/** The same for every session (close-all). */
|
|
144
|
+
async settleAll(ms) {
|
|
145
|
+
return this.settleKeys([...this.running.keys()], ms);
|
|
146
|
+
}
|
|
147
|
+
async settleKeys(keys, ms) {
|
|
148
|
+
const work = keys.flatMap((k) => [...(this.running.get(k) ?? [])]);
|
|
149
|
+
if (work.length > 0) {
|
|
150
|
+
let timer;
|
|
151
|
+
await Promise.race([Promise.allSettled(work), new Promise((resolve) => (timer = setTimeout(resolve, ms)))]);
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
return keys.reduce((n, k) => n + this.count(k), 0);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The cap on every tool call's watchdog, from SCENESCOUT_WATCHDOG_MS. Unset
|
|
159
|
+
* means each tool keeps its own limit. Anything else must be a whole number
|
|
160
|
+
* of milliseconds of at least 100, or the server refuses to start: a typo
|
|
161
|
+
* here would otherwise run with limits nobody chose.
|
|
162
|
+
*/
|
|
163
|
+
export function watchdogCap(raw) {
|
|
164
|
+
if (raw === undefined || raw.trim() === "")
|
|
165
|
+
return undefined;
|
|
166
|
+
const ms = Number(raw);
|
|
167
|
+
if (!Number.isInteger(ms) || ms < 100)
|
|
168
|
+
throw new Error(`SCENESCOUT_WATCHDOG_MS must be a whole number of milliseconds, at least 100; got "${raw}"`);
|
|
169
|
+
return ms;
|
|
170
|
+
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -1815,6 +1815,18 @@ export class MemoryStore {
|
|
|
1815
1815
|
// Deliberately NOT unref'd: a pending coverage write briefly holds the
|
|
1816
1816
|
// process open so an exit without scout_close still lands the last save.
|
|
1817
1817
|
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Write now if a debounced save is waiting. A close calls it after the work
|
|
1820
|
+
* it waited for has finished, so a save that work scheduled lands before the
|
|
1821
|
+
* close answers rather than half a second after. Throws as flush() does.
|
|
1822
|
+
*/
|
|
1823
|
+
flushPending() {
|
|
1824
|
+
if (!this.saveTimer)
|
|
1825
|
+
return;
|
|
1826
|
+
clearTimeout(this.saveTimer);
|
|
1827
|
+
this.saveTimer = null;
|
|
1828
|
+
this.flush();
|
|
1829
|
+
}
|
|
1818
1830
|
/** How many states the last open pruned. Reported once, so a shrinking history is never silent. */
|
|
1819
1831
|
prunedStates = 0;
|
|
1820
1832
|
/** Set when a debounced background write failed — cleared on the next successful write. Surfaced by scout_coverage/scout_close so a broken persistence path is never silently invisible. */
|
package/dist/mcp-server.js
CHANGED
|
@@ -50,7 +50,7 @@ import { DedupJudge, planDedup, samplingAsk } from "./engine/dedup.js";
|
|
|
50
50
|
import { httpJudgeAsk } from "./ci-run.js";
|
|
51
51
|
import { decodedEntitiesNote, ignoredConventionsNote, LANE_CONVENTION_MAX, LANE_NAME_MAX, LaneLedger, laneCloseGuard, laneReportInstruction, parseLaneReport, summarizeLaneReport, } from "./engine/lane.js";
|
|
52
52
|
import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
|
|
53
|
-
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
53
|
+
import { CallWork, SessionQueue, watchdogCap, withWatchdog } from "./engine/dispatch.js";
|
|
54
54
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
55
55
|
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
56
56
|
import { liveViewUrl, MCP_APP_MIME, paneData, paneText, STATUS_PANE_URI, STATUS_POLL_TOOL, STATUS_TOOL, } from "./engine/status-pane.js";
|
|
@@ -451,6 +451,31 @@ function watchdogTimeout(label, ms) {
|
|
|
451
451
|
* each other. The queue itself lives in engine/dispatch.ts, where it is tested.
|
|
452
452
|
*/
|
|
453
453
|
const sessionQueue = new SessionQueue();
|
|
454
|
+
/** Every call's work until it settles, so a close can wait for what a watchdog let run on (engine/dispatch.ts). */
|
|
455
|
+
const callWork = new CallWork();
|
|
456
|
+
/** How long a close waits for that work once the browser is gone; anything page-bound has failed well before this. */
|
|
457
|
+
const CLOSE_WORK_WAIT_MS = 10_000;
|
|
458
|
+
/**
|
|
459
|
+
* Write the save that work finishing during a close's wait scheduled. The engine's own close has already flushed;
|
|
460
|
+
* this catches what came after it. A failure is recorded where the close reports it, as the engine's flush does.
|
|
461
|
+
*/
|
|
462
|
+
function flushAfterClose(eng) {
|
|
463
|
+
try {
|
|
464
|
+
eng.memory?.flushPending();
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
if (eng.memory)
|
|
468
|
+
eng.memory.lastSaveError = err instanceof Error ? err.message : String(err);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** What a close says when work it waited for was still running at the end of the wait. */
|
|
472
|
+
function stillRunningNote(count) {
|
|
473
|
+
if (count === 0)
|
|
474
|
+
return "";
|
|
475
|
+
return `\n⚠ ${count} tool call${count === 1 ? " was" : "s were"} still running ${CLOSE_WORK_WAIT_MS / 1000} s after the browser closed, so ${count === 1 ? "it" : "they"} may still write into .scenescout/.`;
|
|
476
|
+
}
|
|
477
|
+
/** SCENESCOUT_WATCHDOG_MS caps every tool's watchdog. Read once, at start, and a bad value stops the server here. */
|
|
478
|
+
const WATCHDOG_CAP_MS = watchdogCap(process.env.SCENESCOUT_WATCHDOG_MS);
|
|
454
479
|
function serializedPerSession(label, fn, timeoutMs = 60_000) {
|
|
455
480
|
return (args) => {
|
|
456
481
|
const session = args.session ?? activeName;
|
|
@@ -469,10 +494,13 @@ function serializedPerSession(label, fn, timeoutMs = 60_000) {
|
|
|
469
494
|
const exec = async () => {
|
|
470
495
|
// A session that raised its time limits gets its watchdog raised by as much (limits.ts).
|
|
471
496
|
const current = engines.get(session);
|
|
472
|
-
const
|
|
497
|
+
const raised = current ? watchdogFor(timeoutMs, current.timeLimits) : timeoutMs;
|
|
498
|
+
const watchdogMs = Math.min(raised, WATCHDOG_CAP_MS ?? raised);
|
|
473
499
|
writeStatus(session, "running", label, watchdogMs);
|
|
474
500
|
try {
|
|
475
|
-
const
|
|
501
|
+
const work = fn(args, session);
|
|
502
|
+
callWork.track(session, work);
|
|
503
|
+
const out = await withWatchdog(label, work, watchdogMs, watchdogTimeout);
|
|
476
504
|
// `activeName` is process-global and every scout_attach moves it. With
|
|
477
505
|
// several sessions live — the multi-role runs this tool encourages —
|
|
478
506
|
// an omitted `session` silently binds to whichever browser attached
|
|
@@ -2258,8 +2286,15 @@ server.registerTool("scout_close", {
|
|
|
2258
2286
|
for (const name of engines.keys())
|
|
2259
2287
|
live?.dropSession(name);
|
|
2260
2288
|
const stores = new Set([...engines.values()].map((e) => e.memory).filter((m) => m !== null && m !== undefined));
|
|
2261
|
-
|
|
2289
|
+
const closing = [...engines.values()];
|
|
2290
|
+
await Promise.allSettled(closing.map((e) => e.close()));
|
|
2291
|
+
// Out of the live list before the wait, so a call queued behind abandoned work finds no session instead of
|
|
2292
|
+
// reaching a closed one. Then wait for work a watchdog let run on to unwind, and write what it left pending,
|
|
2293
|
+
// so nothing it writes lands after this answer.
|
|
2262
2294
|
engines.clear();
|
|
2295
|
+
const stillRunning = await callWork.settleAll(CLOSE_WORK_WAIT_MS);
|
|
2296
|
+
for (const e of closing)
|
|
2297
|
+
flushAfterClose(e);
|
|
2263
2298
|
for (const store of stores)
|
|
2264
2299
|
store.endRun();
|
|
2265
2300
|
sessionQueue.clear();
|
|
@@ -2267,7 +2302,7 @@ server.registerTool("scout_close", {
|
|
|
2267
2302
|
laneLedger.clear();
|
|
2268
2303
|
lastWriter = null;
|
|
2269
2304
|
await Promise.all([...dirs].map((dir) => settleProjectWrites(dir)));
|
|
2270
|
-
return text(`All sessions closed (${names.join(", ") || "none were live"}). Memory and reports remain in .scenescout
|
|
2305
|
+
return text(`All sessions closed (${names.join(", ") || "none were live"}). Memory and reports remain in .scenescout/.` + stillRunningNote(stillRunning), activeName);
|
|
2271
2306
|
}
|
|
2272
2307
|
const name = session ?? activeName;
|
|
2273
2308
|
const eng = engines.get(name);
|
|
@@ -2279,8 +2314,11 @@ server.registerTool("scout_close", {
|
|
|
2279
2314
|
keepReport(eng);
|
|
2280
2315
|
live?.dropSession(name);
|
|
2281
2316
|
await eng.close();
|
|
2282
|
-
|
|
2317
|
+
// As above: out of the live list, then wait for work a watchdog let run on and write what it left pending.
|
|
2283
2318
|
engines.delete(name);
|
|
2319
|
+
const stillRunning = await callWork.settle(name, CLOSE_WORK_WAIT_MS);
|
|
2320
|
+
flushAfterClose(eng);
|
|
2321
|
+
const saveError = eng.memory?.lastSaveError;
|
|
2284
2322
|
openDecisions.delete(name);
|
|
2285
2323
|
// The last session on this project ends its run.
|
|
2286
2324
|
if (eng.memory && ![...engines.values()].some((e) => e.memory === eng.memory))
|
|
@@ -2300,6 +2338,7 @@ server.registerTool("scout_close", {
|
|
|
2300
2338
|
activeName = engines.keys().next().value ?? "default";
|
|
2301
2339
|
return text(`Session "${name}" closed. Memory and report remain in .scenescout/.` +
|
|
2302
2340
|
(engines.size > 0 ? ` Default session → ${activeName}.` : "") +
|
|
2341
|
+
stillRunningNote(stillRunning) +
|
|
2303
2342
|
(saveError ? `\n⚠ The final memory write failed (${saveError}) — some coverage/findings from this session may not have been persisted to disk.` : ""), activeName);
|
|
2304
2343
|
}
|
|
2305
2344
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.23.
|
|
3
|
+
"version": "3.23.2",
|
|
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",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"dedup-bench": "tsx scripts/dedup-bench.ts"
|
|
113
113
|
},
|
|
114
114
|
"dependencies": {
|
|
115
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
115
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
116
116
|
"playwright": "^1.63.0",
|
|
117
117
|
"zod": "^3.25.0"
|
|
118
118
|
},
|