scenescout 3.9.0 → 3.11.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.
@@ -1,4 +1,13 @@
1
1
  import { redactSecrets } from "./memory.js";
2
+ /** How an HTTP error violation states its request. One formatter, so a reader of the detail (the check) cannot drift from its writer. */
3
+ export function httpErrorDetail(method, url, status) {
4
+ return `${method} ${url.slice(0, 200)} → HTTP ${status}`;
5
+ }
6
+ /** The status an http_error detail names, or null when the detail is not one. */
7
+ export function httpStatusOf(detail) {
8
+ const m = /→ HTTP (\d{3})$/.exec(detail.replace(/ \[\d+ secrets? redacted\]$/, ""));
9
+ return m ? Number(m[1]) : null;
10
+ }
2
11
  /** URLs whose failures are noise, not findings (favicons, source maps). */
3
12
  const BENIGN_URL_RE = /favicon|\.map($|\?)/i;
4
13
  /**
@@ -35,6 +44,68 @@ export function isPolicyInduced(v, msSincePolicyBlock) {
35
44
  return false;
36
45
  return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i.test(v.detail);
37
46
  }
47
+ /**
48
+ * The request a console message is the browser's own echo of, or null when it
49
+ * is not one.
50
+ *
51
+ * Chromium and WebKit print "Failed to load resource: …" for a subresource
52
+ * that answered with an error status or got no answer, and give the
53
+ * resource's address as the message's location (Firefox prints nothing; see
54
+ * `echoesFailedLoads` in browsers.ts). The line carries no address in its
55
+ * text, so without this it could only be charged to the page. Returned in the
56
+ * form `requestKey` gives a request's URL, so the two can be matched.
57
+ */
58
+ export function failedLoadEchoOf(text, locationUrl, pageUrl) {
59
+ if (!/^Failed to load resource: /.test(text) || !locationUrl)
60
+ return null;
61
+ try {
62
+ return requestKey(new URL(locationUrl, pageUrl).href);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** A request's address as the echo lookup keys it: absolute, and without its fragment, which is never sent. */
69
+ export function requestKey(url) {
70
+ try {
71
+ const u = new URL(url);
72
+ u.hash = "";
73
+ return u.href;
74
+ }
75
+ catch {
76
+ return url;
77
+ }
78
+ }
79
+ /**
80
+ * Which recent requests an embed sent, by address, so the browser's echo of
81
+ * one that failed is charged where the request was.
82
+ *
83
+ * Only addresses an embed requested are kept, and the latest request to an
84
+ * address decides: when the app then requests the same address, its echo is
85
+ * the app's again. Bounded, dropping the oldest, so a long run does not hold
86
+ * every address it saw.
87
+ */
88
+ export class EmbedRequestLog {
89
+ cap;
90
+ byKey = new Map();
91
+ constructor(cap = 500) {
92
+ this.cap = cap;
93
+ }
94
+ note(url, embed) {
95
+ const key = requestKey(url);
96
+ this.byKey.delete(key);
97
+ if (!embed)
98
+ return;
99
+ this.byKey.set(key, embed);
100
+ if (this.byKey.size > this.cap)
101
+ this.byKey.delete(this.byKey.keys().next().value);
102
+ }
103
+ /** The embed whose request a console message echoes, or null when it echoes none of theirs. */
104
+ embedOfEcho(text, locationUrl, pageUrl) {
105
+ const key = failedLoadEchoOf(text, locationUrl, pageUrl);
106
+ return key === null ? null : (this.byKey.get(key) ?? null);
107
+ }
108
+ }
38
109
  /**
39
110
  * Invariant oracles: passive listeners that record violations regardless of
40
111
  * what the agent is doing. The engine drains the buffer after every action and
@@ -58,8 +129,12 @@ export class OracleMonitor {
58
129
  severity: "high",
59
130
  detail: text.slice(0, 500),
60
131
  url: page.url(),
132
+ // The one console line that names its request: the browser's echo of an embed's failed load is the embed's too.
133
+ embed: this.embedRequests.embedOfEcho(text, msg.location().url, page.url()) ?? undefined,
61
134
  });
62
135
  });
136
+ // Noted as each request starts, which is always before the browser can echo its failure.
137
+ page.on("request", (req) => this.embedRequests.note(req.url(), this.embedOfRequest(req)));
63
138
  page.on("pageerror", (err) => {
64
139
  this.record({
65
140
  kind: "page_error",
@@ -105,7 +180,7 @@ export class OracleMonitor {
105
180
  this.record({
106
181
  kind: "http_error",
107
182
  severity: status >= 500 ? "high" : "medium",
108
- detail: `${res.request().method()} ${res.url().slice(0, 200)} → HTTP ${status}`,
183
+ detail: httpErrorDetail(res.request().method(), res.url(), status),
109
184
  url: page.url(),
110
185
  embed: this.embedOfRequest(res.request()) ?? undefined,
111
186
  });
@@ -131,12 +206,14 @@ export class OracleMonitor {
131
206
  */
132
207
  policyAttributed = 0;
133
208
  embedOfRequest = () => null;
209
+ embedRequests = new EmbedRequestLog();
134
210
  /**
135
211
  * The engine knows which frame a request came from; a failing request is
136
212
  * attributed to an embed through this. Console and page errors are not
137
213
  * attributed: a console message says where its script was served from, not
138
214
  * which frame ran it, so an SDK the app's page loads from the embed's own
139
- * site would be taken for the embed.
215
+ * site would be taken for the embed. The exception is the browser's echo of
216
+ * a failed load, which names its request and goes where the request went.
140
217
  */
141
218
  setEmbedAttribution(ofRequest) {
142
219
  this.embedOfRequest = ofRequest;
@@ -1,10 +1,23 @@
1
+ import { FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED } from "./forms.js";
1
2
  /**
2
3
  * Log entries that are not actions: a stated task, an attach, the note that a
3
4
  * resource was created. They carry no browser work and take no time, so
4
5
  * counting them inflates the action count and drags the median gap toward
5
6
  * zero. One real run logged 325 entries of which 107 were stated tasks.
6
7
  */
7
- const MARKER_ACTIONS = new Set(["task", "attach", "close", "lane-report", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
8
+ const MARKER_ACTIONS = new Set([
9
+ "task",
10
+ "attach",
11
+ "close",
12
+ "lane-report",
13
+ "created-resource",
14
+ "journey:start",
15
+ "journey:end",
16
+ "record:full",
17
+ "record:failed",
18
+ FORMS_READ_FAILED,
19
+ FORMS_SUBMIT_UNMATCHED,
20
+ ]);
8
21
  /** Whether a log entry represents work the browser actually did. */
9
22
  export function isActing(action) {
10
23
  return !MARKER_ACTIONS.has(action);
@@ -23,6 +23,7 @@
23
23
  * Pure, so the ordering and the wording can be table-tested.
24
24
  */
25
25
  import { normalizePath } from "./fingerprint.js";
26
+ import { failingSignatures } from "./memory.js";
26
27
  /** What a re-test found. */
27
28
  export const VERDICTS = ["gone", "present", "changed"];
28
29
  export const VERDICT_MEANING = {
@@ -140,3 +141,121 @@ export function sayVerification(f) {
140
141
  return ` · re-tested ${on}, behaviour has changed since it was filed`;
141
142
  return ` · re-tested ${on}`;
142
143
  }
144
+ // ---------------------------------------------------------------------------
145
+ // Re-testing by page load, for `scenescout check`.
146
+ //
147
+ // The campaign above hands findings to an agent, who re-walks them with
148
+ // judgement. A check has none, so it re-tests only the findings whose
149
+ // reproduction is mechanical: the evidence names a GET that failed with a
150
+ // status, and the finding's repro shows nothing done on its page but looking
151
+ // at it. Loading that page again and watching the same request is then the
152
+ // whole reproduction, and its answer is a fact rather than an opinion.
153
+ // ---------------------------------------------------------------------------
154
+ /**
155
+ * Actions that only look at a page. A repro whose steps on the finding's page
156
+ * are all of these was reproduced by loading the page; any other step (a
157
+ * click, a typed value, a replayed request, anything this list does not know)
158
+ * means loading it is not the reproduction.
159
+ */
160
+ const LOOK_ONLY = new Set([
161
+ "attach",
162
+ "navigate",
163
+ "plan:navigate",
164
+ "crawl",
165
+ "back",
166
+ "snapshot",
167
+ "screenshot",
168
+ "design-audit",
169
+ "task",
170
+ "journey:start",
171
+ "journey:end",
172
+ "record:full",
173
+ ]);
174
+ /** How the finding's page is reached again, or null when a check cannot re-test it deterministically. */
175
+ export function loadRetest(item) {
176
+ const signatures = [...failingSignatures(item.evidence)];
177
+ // A POST that failed is not re-sent by loading a page, and a check would refuse to send it anyway.
178
+ if (signatures.length === 0 || signatures.some((s) => !s.startsWith("GET ")))
179
+ return null;
180
+ // The first repro line is how the page was reached; every later one happened on it.
181
+ if (item.repro.length === 0)
182
+ return null;
183
+ for (const line of item.repro.slice(1)) {
184
+ if (!LOOK_ONLY.has(line.split(/\s/, 1)[0]))
185
+ return null;
186
+ }
187
+ let url;
188
+ try {
189
+ url = new URL(item.url);
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ // A secret was redacted out of the stored URL: loading what is left is a different page.
195
+ if (/redacted/i.test(item.url))
196
+ return null;
197
+ return { item, path: `${url.pathname}${url.search}`, signatures };
198
+ }
199
+ export const RETEST_VERDICTS = ["reproduces", "possibly-fixed", "not-reached"];
200
+ /**
201
+ * Each re-testable finding against the page load that re-tests it.
202
+ *
203
+ * `reproduces` when the load saw one of the finding's failing requests fail
204
+ * with the same status; `possibly-fixed` when the page loaded and none did —
205
+ * possibly, because the page may simply not have asked this time; and
206
+ * `not-reached` when the page did not load or bounced to sign-in, which says
207
+ * nothing about the finding either way.
208
+ */
209
+ export function retestResults(candidates, pages) {
210
+ return candidates.map(({ item, path, signatures }) => {
211
+ const base = { id: item.id, severity: item.severity, title: item.title, path, signatures };
212
+ const page = pages.find((p) => p.path === path);
213
+ if (!page)
214
+ return { ...base, verdict: "not-reached", note: "the page was not visited" };
215
+ if (page.loadError !== undefined)
216
+ return { ...base, verdict: "not-reached", note: "the page did not load" };
217
+ if (page.loginRedirect)
218
+ return { ...base, verdict: "not-reached", note: "the page sent the browser to sign-in" };
219
+ const seen = new Set();
220
+ for (const detail of page.httpErrors)
221
+ for (const s of failingSignatures(detail))
222
+ seen.add(s);
223
+ // The document's own failure is left out of the violations (withoutOwnResponse), so it is added back here.
224
+ if (page.status !== null && page.status >= 400)
225
+ for (const s of failingSignatures(`GET ${page.path.split("?")[0]} ${page.status}`))
226
+ seen.add(s);
227
+ return { ...base, verdict: signatures.some((s) => seen.has(s)) ? "reproduces" : "possibly-fixed" };
228
+ });
229
+ }
230
+ /**
231
+ * The findings a check re-tests: the open ones it can reproduce by loading a
232
+ * page, in the campaign's order and within its cap, plus how many open
233
+ * findings there are in all, so the report can say how many it left to a run.
234
+ */
235
+ export function checkRetestPlan(findings) {
236
+ const open = findings.filter((f) => (f.status ?? "open") !== "resolved");
237
+ const eligible = open.filter((f) => loadRetest(item(f)) !== null);
238
+ const candidates = verifyWorklist(eligible)
239
+ .map(loadRetest)
240
+ .filter((c) => c !== null);
241
+ return { candidates, open: open.length };
242
+ }
243
+ /**
244
+ * The entries of a memory file's findings list a re-test can read. The file is
245
+ * JSON someone may have edited; an entry missing the fields the rules read is
246
+ * left out rather than failing the check, which only ever reports re-tests.
247
+ */
248
+ export function wellFormedFindings(list) {
249
+ return list.filter((f) => {
250
+ if (typeof f !== "object" || f === null)
251
+ return false;
252
+ const x = f;
253
+ return (typeof x.id === "string" &&
254
+ typeof x.url === "string" &&
255
+ typeof x.title === "string" &&
256
+ typeof x.severity === "string" &&
257
+ (x.evidence === undefined || typeof x.evidence === "string") &&
258
+ Array.isArray(x.repro) &&
259
+ x.repro.every((r) => typeof r === "string"));
260
+ });
261
+ }
@@ -35,12 +35,13 @@ import { z } from "zod";
35
35
  import { BrowserEngine } from "./engine/browser.js";
36
36
  import { reapOrphanBrowsers } from "./engine/reaper.js";
37
37
  import { FINDING_CATEGORIES, MemoryStore, redactSecrets } from "./engine/memory.js";
38
- import { LANE_NAME_MAX, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
38
+ import { decodedEntitiesNote, LANE_NAME_MAX, LaneLedger, laneCloseGuard, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
39
39
  import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
40
40
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
41
41
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
42
42
  import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
43
43
  import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
44
+ import { formatNeverSubmittedEmpty } from "./engine/forms.js";
44
45
  import { computeGaps, formatRouteCoverage, formatUnchosenOptions, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
45
46
  import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
46
47
  import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
@@ -500,6 +501,9 @@ server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
500
501
  // Splitting the app between lanes: the other half of the parallel protocol.
501
502
  // scout_lane_report is how a lane hands its answers back; this is what the
502
503
  // planner hands it in the first place.
504
+ // Which sessions those two tools have named as lanes, and whose report was
505
+ // folded, so scout_close can refuse to drop a lane's decisions.
506
+ const laneLedger = new LaneLedger();
503
507
  server.registerTool("scout_lane_brief", {
504
508
  description: "For a run split across parallel agents (lanes). Divides the app's known routes between N lanes and returns each lane's session name, the `objective` to attach it with, and the routes it owns — whole modules per lane, balanced by route count, so no two lanes audit the same area and none is left unopened. Call it after the first crawl, when route knowledge is complete. Touches no browser; pass the briefs to your lane agents, then use scout_lane_report for what they hand back.",
505
509
  inputSchema: {
@@ -512,7 +516,9 @@ server.registerTool("scout_lane_brief", {
512
516
  try {
513
517
  const eng = engineFor(session);
514
518
  const all = routes && routes.length > 0 ? routes : eng.allKnownRoutes();
515
- return text(formatBriefs(planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role }), { goal, mode: eng.mode, role: eng.role }), session);
519
+ const briefs = planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role });
520
+ laneLedger.nameBriefed(briefs.map((b) => b.lane), (s) => engines.has(s));
521
+ return text(formatBriefs(briefs, { goal, mode: eng.mode, role: eng.role }), session);
516
522
  }
517
523
  catch (err) {
518
524
  return errorText(err);
@@ -527,10 +533,13 @@ server.registerTool("scout_lane_report", {
527
533
  },
528
534
  }, async ({ lane, reply }) => {
529
535
  try {
530
- if (reply === undefined)
536
+ if (reply === undefined) {
537
+ laneLedger.name(lane);
531
538
  return { content: [{ type: "text", text: laneReportInstruction(lane) }] };
539
+ }
532
540
  const parsed = parseLaneReport(reply, lane);
533
541
  if (!parsed.ok) {
542
+ laneLedger.refuse(lane, engines.get(lane)?.attached === true);
534
543
  return {
535
544
  content: [
536
545
  { type: "text", text: `Lane report REFUSED: ${parsed.reason}. Ask the lane once for the corrected object; do not re-judge its prose.` },
@@ -575,8 +584,12 @@ server.registerTool("scout_lane_report", {
575
584
  (unfiled.length > MAX_UNFILED_NAMED ? `\n … +${unfiled.length - MAX_UNFILED_NAMED} more` : "") +
576
585
  `\nFile each with scout_finding (the same evidence), or confirm which finding already covers it, before closing the lane's session. A judged defect that is never filed is not in the report.`
577
586
  : "";
587
+ laneLedger.fold(lane, engines.get(lane)?.attached === true);
578
588
  const around = parsed.aroundIgnored ? `\n(The text around the report's JSON block was discarded unread.)` : "";
579
- return { content: [{ type: "text", text: `Lane report accepted — ${summarizeLaneReport(parsed.report)}${note}${around}${followUp}` }] };
589
+ const decoded = decodedEntitiesNote(parsed.entitiesDecoded);
590
+ return {
591
+ content: [{ type: "text", text: `Lane report accepted — ${summarizeLaneReport(parsed.report)}${note}${around}${decoded}${followUp}` }],
592
+ };
580
593
  }
581
594
  catch (err) {
582
595
  return errorText(err);
@@ -836,7 +849,7 @@ server.registerTool("scout_crawl", {
836
849
  }
837
850
  }, 600_000));
838
851
  server.registerTool("scout_run_plan", {
839
- description: "Execute up to 20 actions in ONE call — use for mechanical sequences (fill a form, walk a wizard) so each step doesn't cost a round-trip. Targets resolve at execution time by semantic locator: 'testid=…', 'text=…', or 'label=…' (never snapshot refs). An `upload` step attaches a file as scout_upload does (target required — the file input or the control that opens its chooser; value = a fixture kind or a project-relative path). The plan ABORTS at the first NEW oracle violation, policy refusal, or failed step, returning a transcript of how far it got; repeats of already-reported violations do not abort (they stay logged for the report).",
852
+ description: "Execute up to 20 actions in ONE call — use for mechanical sequences (fill a form, walk a wizard) so each step doesn't cost a round-trip. Targets resolve at execution time by semantic locator: 'testid=…', 'text=…', 'label=…' or 'role=button[name=Save]' (never snapshot refs). The same steps, saved to .scenescout/flows/<name>.json with expect-text / expect-url / expect-request steps added, are replayed by `scenescout check` on every pull request. An `upload` step attaches a file as scout_upload does (target required — the file input or the control that opens its chooser; value = a fixture kind or a project-relative path). The plan ABORTS at the first NEW oracle violation, policy refusal, or failed step, returning a transcript of how far it got; repeats of already-reported violations do not abort (they stay logged for the report).",
840
853
  inputSchema: {
841
854
  steps: z
842
855
  .array(z.object({
@@ -844,7 +857,7 @@ server.registerTool("scout_run_plan", {
844
857
  target: z
845
858
  .string()
846
859
  .optional()
847
- .describe("testid=…, text=…, label=… (or a path for navigate; 'top'/'bottom'/±px for scroll; for upload: the file input or the control that opens its chooser)"),
860
+ .describe("testid=…, text=…, label=…, role=<role>[name=\"…\"] (or a path for navigate; 'top'/'bottom'/±px for scroll; for upload: the file input or the control that opens its chooser)"),
848
861
  value: z
849
862
  .string()
850
863
  .optional()
@@ -1162,7 +1175,7 @@ server.registerTool("scout_finding", {
1162
1175
  }
1163
1176
  }));
1164
1177
  server.registerTool("scout_coverage", {
1165
- description: "Show exploration coverage: states visited across all runs, which elements remain unexercised, and which options of a dropdown used this run no session has chosen yet. Use to decide where to explore next and when the level's budget is satisfied.",
1178
+ description: "Show exploration coverage: states visited across all runs, which elements remain unexercised, which options of a dropdown used this run no session has chosen yet, and which forms seen this run no session has submitted with every text field blank. Use to decide where to explore next and when the level's budget is satisfied.",
1166
1179
  inputSchema: { session: sessionParam },
1167
1180
  }, serializedPerSession("scout_coverage", async (_args, session) => {
1168
1181
  try {
@@ -1182,6 +1195,7 @@ server.registerTool("scout_coverage", {
1182
1195
  `Unexercised elements by route:`,
1183
1196
  ...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
1184
1197
  ...formatUnchosenOptions(eng.memory.unchosenOptions()),
1198
+ ...formatNeverSubmittedEmpty(eng.memory.formsNeverSubmittedEmpty()),
1185
1199
  ];
1186
1200
  return text(lines.join("\n"), session);
1187
1201
  }
@@ -1333,15 +1347,23 @@ server.registerTool("scout_verify", {
1333
1347
  }
1334
1348
  }));
1335
1349
  server.registerTool("scout_close", {
1336
- description: "Close a session's browser (memory persists on disk). Default: the DEFAULT session. Pass session to close a specific one, or all=true to close every live session at the end of a multi-role run.",
1350
+ description: "Close a session's browser (memory persists on disk). Default: the DEFAULT session. Pass session to close a specific one, or all=true to close every live session at the end of a multi-role run. " +
1351
+ "A session that is a lane of a parallel run (named by scout_lane_brief or scout_lane_report) is not closed until its lane report has been accepted by scout_lane_report, since folding needs the session attached; the refusal names every such lane.",
1337
1352
  inputSchema: {
1338
1353
  session: z.string().max(40).optional().describe("Session to close (default: the default session)"),
1339
1354
  all: z.boolean().default(false).describe("Close every live session"),
1355
+ force: z
1356
+ .boolean()
1357
+ .default(false)
1358
+ .describe("Close even a lane whose report has not been folded yet. Its decisions are then lost: nothing is kept for calibration and nothing checks its defects were filed. Prefer folding it with scout_lane_report first."),
1340
1359
  },
1341
- }, serializedControl(async ({ session, all }) => {
1360
+ }, serializedControl(async ({ session, all, force }) => {
1342
1361
  try {
1343
1362
  if (all) {
1344
1363
  const names = [...engines.keys()];
1364
+ const guard = laneCloseGuard(names, laneLedger, force);
1365
+ if (!guard.ok)
1366
+ return text(`${guard.message}\nNo session was closed.`, activeName);
1345
1367
  // Closes are independent per-browser — run them in parallel so N wedged
1346
1368
  // sessions cost one 8s teardown cap total, not N of them.
1347
1369
  const dirs = new Set();
@@ -1359,6 +1381,7 @@ server.registerTool("scout_close", {
1359
1381
  store.endRun();
1360
1382
  sessionQueue.clear();
1361
1383
  board.clear();
1384
+ laneLedger.clear();
1362
1385
  lastWriter = null;
1363
1386
  for (const dir of dirs)
1364
1387
  flushStatus(dir);
@@ -1368,6 +1391,9 @@ server.registerTool("scout_close", {
1368
1391
  const eng = engines.get(name);
1369
1392
  if (!eng)
1370
1393
  return text(`No live session "${name}".`, name);
1394
+ const guard = laneCloseGuard([name], laneLedger, force);
1395
+ if (!guard.ok)
1396
+ return text(guard.message, name);
1371
1397
  keepReport(eng);
1372
1398
  live?.dropSession(name);
1373
1399
  await eng.close();
@@ -1378,6 +1404,11 @@ server.registerTool("scout_close", {
1378
1404
  eng.memory.endRun();
1379
1405
  sessionQueue.forget(name);
1380
1406
  board.remove(name);
1407
+ laneLedger.forget(name);
1408
+ // The last session closing ends the run, and its lanes with it: a brief's
1409
+ // names that never attached must not make a later run's session a lane.
1410
+ if (engines.size === 0)
1411
+ laneLedger.clear();
1381
1412
  if (lastWriter?.session === name)
1382
1413
  lastWriter = null;
1383
1414
  if (eng.memory?.dir)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.9.0",
3
+ "version": "3.11.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",
@@ -64,6 +64,7 @@
64
64
  "format:check": "prettier --check .",
65
65
  "demo": "npm run build && tsx scripts/demo.ts",
66
66
  "demo:serve": "node demo-app/server.mjs",
67
+ "holdout:serve": "node holdout-app/server.mjs",
67
68
  "dev": "tsx src/cli.ts",
68
69
  "serve": "node dist/mcp-server.js",
69
70
  "smoke": "npm run build && npm run smoke:run",
@@ -71,19 +72,21 @@
71
72
  "mcp-check": "npm run build && npm run mcp-check:run",
72
73
  "mcp-check:run": "tsx scripts/mcp-check.ts",
73
74
  "test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
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 pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run hygiene-test",
75
+ "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 check-test && npm run contract-test && npm run pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run holdout-test && npm run hygiene-test",
75
76
  "scan-test": "tsx scripts/scan-test.ts",
76
77
  "oracle-test": "tsx --test scripts/oracle-test.ts",
77
78
  "policy-test": "tsx --test scripts/policy-test.ts",
78
79
  "fixture-test": "tsx --test scripts/fixture-test.ts",
79
80
  "dispatch-test": "tsx --test scripts/dispatch-test.ts",
80
81
  "design-test": "tsx --test scripts/design-test.ts",
82
+ "check-test": "tsx --test scripts/check-test.ts",
81
83
  "contract-test": "tsx --test scripts/contract-test.ts",
82
84
  "lane-test": "tsx --test scripts/lane-test.ts",
83
85
  "memory-test": "tsx --test scripts/memory-test.ts",
84
86
  "install-test": "tsx --test scripts/install-test.ts",
85
87
  "live-test": "tsx --test scripts/live-test.ts",
86
88
  "demo-test": "tsx --test scripts/demo-test.ts",
89
+ "holdout-test": "tsx --test scripts/holdout-test.ts",
87
90
  "hygiene-test": "tsx --test scripts/hygiene-test.ts",
88
91
  "pace-test": "tsx --test scripts/pace-test.ts",
89
92
  "request-test": "tsx --test scripts/request-test.ts",
@@ -104,7 +107,8 @@
104
107
  "@changesets/cli": "3.0.3",
105
108
  "@types/node": "^20.19.0",
106
109
  "prettier": "3.9.8",
107
- "tsx": "^4.23.13",
108
- "typescript": "^5.9.3"
110
+ "tsx": "^4.23.15",
111
+ "typescript": "^5.9.3",
112
+ "yaml": "^2.9.1"
109
113
  }
110
114
  }
@@ -33,7 +33,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
33
33
 
34
34
  1. **`scout_crawl` first, always.** One call visits every known route (pass `paths` to sweep a specific subset instead), records coverage, and returns per-route health. This is the whole breadth pass — do not visit routes one-by-one with navigate+snapshot.
35
35
  2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
36
- 3. **Run journeys with `scout_run_plan {steps}`.** Mechanical sequences (fill form → submit → check) go in ONE plan call — `steps` is an ordered list of `{action, target, value}` — with `testid=`/`text=`/`label=` targets — not one LLM turn per click. The plan aborts at the first violation and tells you where; that's your cue to investigate interactively.
36
+ 3. **Run journeys with `scout_run_plan {steps}`.** Mechanical sequences (fill form → submit → check) go in ONE plan call — `steps` is an ordered list of `{action, target, value}` — with `testid=`/`text=`/`label=`/`role=button[name="Save"]` targets — not one LLM turn per click. The plan aborts at the first violation and tells you where; that's your cue to investigate interactively. When the user wants a flow kept working, save the plan that walked it as `.scenescout/flows/<name>.json` — `{"name", "steps"}`, the same steps, starting with a `navigate`, plus `{"action":"expect-text","text"}`, `{"action":"expect-url","pattern"}` or `{"action":"expect-request","request":"GET /api/…","status":200}` where the outcome shows — and `scenescout check` replays it on every pull request. Save only flows that read: a check replays flows under observe's rule unless the project passes `--flow-writes allow`, so a step that sends a write is reported as "could not run" and the check exits 2.
37
37
  4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line. Controls inside embeds (`<iframe>`) are listed with the page's, each marked `⟨in … frame⟩`, and act by ref like any other. A frame of the app's own origin is the app: test it fully. A frame of another site (`cross-origin`) is someone else's system that the user has not authorised you to test: click and type there as a user would, to see the embed render and respond, but never send it hostile input — the engine refuses markup, values over 200 characters, control characters, repeated-click probes and uploads there, and you must not try SQL, template or other injection shapes either — and file what you find there as the embed's behaviour, naming its origin, not as the app's bug. A failing request such a frame sent outside the app says so (`in an embed of …`) and is kept at medium; console errors cannot be told apart by frame and stay the app's; its controls are counted apart and never enter the app's coverage or gap ledger. Its container text is masked, and the writes it sends outside the app are refused in every mode but destructive — unless the user names that origin as a trusted embed (a provider in test mode, say): pass it in `scout_attach {trustedEmbeds: ["https://…"]}` and, in safe-write only, its writes go out. Never add an origin the user did not name. The FRAMES line names frames that were not read; say in your summary that their contents were not explored.
38
38
  5. **Native-user behaviours.** `scout_type {ref, textValue}` (or its alias `value`, matching `scout_select` and a plan step) APPENDS when a field already has content (menu clicks often insert @-mention chips or commands into composers — appending preserves them; the result reports what was already there); pass `replace=true` only to deliberately clear, and `pressEnter=true` to submit from the field the way a user would. Before concluding a badge, icon, or "N errors" indicator *does nothing*, `scout_hover` it — tooltips and hover cards are invisible to snapshots and clicks, and hover output includes what appeared. In HEADED mode (`scout_attach {headed:true}`, which the user asks for when they want to watch) the user's physical mouse competes with the synthetic pointer: if a hover reveals nothing and the finding matters, ask the user to move their mouse off the browser window and retry before filing. **Scroll long pages with `scout_scroll`** — the design audit and snapshot measure at the current scroll position, so judge deep sections by scrolling then re-auditing; it refuses to scroll where a real user couldn't and reports SCROLL LOCKED (the leaked modal scroll-lock that silently amputates everything below the fold — snapshots also flag it passively as an OVERLAY line), and scrolling triggers lazy-loaded content whose failures surface as fresh oracle violations. Elements fully clipped inside an overflow-hidden container are flagged UNREACHABLE in GEOMETRY issues — no amount of scrolling reveals them; that's a high-value layout bug, distinct from merely below-the-fold content. **A page can hold SEVERAL independent scroll regions** and plain `scout_scroll` moves the largest one, so a sidebar nav beside a taller main pane never budges: pass `scout_scroll {target:"testid=…"}` to scroll one region. Never report a nav item, tab or list row as missing/truncated until you have scrolled ITS container — content scrolled out of a secondary pane looks exactly like content that was cut off.
39
39
  6. **The rest of the input vocabulary.** `scout_select` sets a `<select>` option by value or visible label — use it rather than clicking a native dropdown open, which does not render as page DOM. `scout_press` sends a real key to the focused element (`Escape` to dismiss a modal, `Tab` to walk focus order, `Enter` to submit from a field); it is also how the keyboard-only pass at `extensive` is performed, and it vets the focused control first so a destructive action cannot be triggered blind in read-only mode. **`scout_upload {ref}` attaches a file the way a user does** — `ref` is a visible `<input type=file>` (snapshots list these with role `file`; `scout_type` on one redirects here) OR the button/label/dropzone that opens the file chooser (the chooser is intercepted and answered — that is how the hidden input behind a styled "Choose file" control is reached); omit `ref` when the page has exactly one file input, hidden or not (snapshots disclose hidden ones on a FILE INPUTS line). Nothing needs to exist on disk: a small VALID fixture is generated in memory, its kind inferred from the input's `accept` attribute or chosen with `fixture` (`pdf`, `png`, `txt`, `csv`, `json`); `filePath` uploads a real file but must live inside the attached project (fenced, like navigation is fenced to the origin); `name` overrides the filename. The result flags a file that violates `accept` (a mismatch the app then ACCEPTS is a validation finding), warns when the app cleared the input after selection, and says whether a state-changing request fired on selection — if none did, either click the form's submit or read the next snapshot for a client-side rejection. Plans take `{action:"upload", target, value:"pdf"}` steps (`target` required). When the input or its trigger was addressed by `ref`, the gap ledger counts an attached-but-unsent file as filled-never-submitted; the ref-less path has no listed element to mark.
@@ -42,7 +42,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
42
42
  8. **Design-connoisseur pass without pixels: `scout_design_audit`.** Run it once per representative page (dashboard, a form, a detail view, a data table). Its output has two tiers: **⚠ measurable defects** (WCAG contrast, tiny targets, clipped text, aspect-distorted images, horizontal overflow, keyboard tab stops with no visible focus indicator — sampled with real Tab presses) and **→ craft suggestions** (line measure and line-height rhythm, spacing-grid adherence, typography entropy, gray census and accent-hue count, pure-#000 body text, elevation/control consistency, heading structure, indistinguishable links, AI-slop tells like gradient text/glassmorphism/side-stripe borders/identical card grids), closing with a SYSTEM SUMMARY of design-system coherence. Judge every line with product context (dense tables legitimately have small targets; a chart page legitimately uses many hues). File ⚠ defects as `visual`/`a11y`, and genuine → opportunities as `ux-polish` findings **quoting the concrete numbers** — "~142 characters per line (65–75 ideal)" beats "text feels wide". Every audit ends with a **PAGE SCORE** (0–100 overall + a11y/craft/consistency/task-clarity subscores) persisted per route — the report ranks pages worst-first, so re-runs show whether pages got better or worse. Separately, every `scout_snapshot` runs an **overlay/modal probe** automatically: an empty dialog over a grayed page, a backdrop with no dialog, a far-off-centre dialog leaving a blank band, or a dialog extending unreachably below the viewport appear as OVERLAY lines in GEOMETRY issues — treat these as high-value findings (the user is visually stuck). This is where "how could this page be better" gets answered, not just "is it broken".
43
43
  9. **Measure task EASE with `scout_journey`, not just correctness.** Wrap each module's primary task (`{action:"start", goal:"Create an order"}` → do it → `{action:"end", completed:…}`). Navigate by CLICKING like a first-time user — typing a known deep URL shortcuts the very thing being measured (a route you can only reach by editing the address bar is itself a finding). The result gives interaction count, distinct screens, the path taken, and BACKTRACKS — returning to a screen already left is the clearest evidence the next step wasn't discoverable. An abandoned journey (`completed:false`) is a high-severity finding: the task is blocked or undiscoverable, which no passing e2e suite would ever reveal.
44
44
  10. **Walk the auth surface too — anonymously.** Attach a second session WITHOUT a storage-state file (a fresh logged-out profile) and exercise signup, login failure states, and forgot/reset-password **as far as they physically go**. The mailbox wall is expected — reaching "check your email" IS the success condition; everything before it is what you're testing: does submit actually fire (a dead signup button is a high finding), are errors specific and actionable, can the user resend or recover from a typo, does the flow dead-end. Use plausible synthetic identities only (invent `qa-<runid>@example.com`-style addresses, never a real person's), submit each form valid AND invalid, and judge the feedback. Two classic findings live here: a forgot-password that answers "no account with that email" is an **account-enumeration leak** (file as security; "if an account exists, we sent a link" is the correct shape), and a signup that accepts the form then lands on a blank or logged-out page with no guidance is a **journey dead-end**. Signup creates a record, so what this pass may do depends on the mode. In `observe`, fill and submit the auth forms for their CLIENT-SIDE behaviour only: the engine blocks signup, password change and reset, and lets only a login itself go out. Disclose the server-side half as a gap. Actually creating an account needs the user's explicit okay and safe-write mode; the engine tracks the created account like any other creation.
45
- 11. **`scout_coverage` decides what's next** — it lists unvisited routes, unexercised elements, and the options of each dropdown you used that no session has chosen this run (a filter counts as exercised after one choice, and the option you skipped can be the one whose request fails). Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
45
+ 11. **`scout_coverage` decides what's next** — it lists unvisited routes, unexercised elements, and the options of each dropdown you used that no session has chosen this run (a filter counts as exercised after one choice, and the option you skipped can be the one whose request fails), and each `<form>` seen this run that no session has submitted with every text field blank (submit it once empty: a submit that silently does nothing — no request, no message — is the commonest defect there, and filling a form in first never finds it). Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
46
46
 
47
47
  ## Levels (completion contracts — the engine ENFORCES them via `scout_report {level}`)
48
48
 
@@ -72,7 +72,7 @@ Some flows need a TEAM — a document one role submits and another approves, a r
72
72
  - While roles are NOT collaborating, use each one productively where its permissions matter (admin in /admin surfaces, low-privilege probing for permission leaks) — same coverage contract, different vantage points.
73
73
  - **Infer the PERSONA behind each role, and write it down.** From what a role can see and do (its nav, its dashboard, the capability matrix in the report), state what this person is FOR: "qa = reviewer — approves orders, assigns reviewers, no admin" / "user = front-line user — reads documents, completes reviews, raises orders". Record it with `scout_note {section:'roles'}`. Then test the persona's WORLD, not just the permissions: does the operator's landing page serve an operator? Is anything they need N clicks deep? The capability matrix's divergent rows are questions, not verdicts — each is either a correct boundary or a gap ("should this role be able to do this?"); say which you believe it is and why.
74
74
  - `scout_close {all: true}` at the end of a multi-role run; `scout_close {session}` to drop one role early.
75
- - **Several agents in parallel** (subagents or a workflow, each driving its own session): each agent attaches its session when it STARTS, and the PLANNER closes it by name once it has folded that lane's report. A lane must NOT close its own session before reporting: `scout_lane_report` keeps its decisions against that session's project memory, so a lane that closes first hands back decisions with nowhere to write — the tool says so rather than accepting silently, and the run's calibration section ends up with nothing to measure. Fold, then close. Never open sessions ahead for agents that have not started, and never hand an open session from one agent to the next: an agent waiting for its turn should hold no browser. Give each session an `objective` when you attach it (`scout_attach {session, objective:"Approve and reject orders as a manager"}`) and wrap each goal in `scout_journey`: the live view shows that objective and the task it is on beside the session's feed, which is how the person watching knows what every agent is for. Keep that goal TRUE: one journey per goal, one goal per thing you are checking ("Save a settings change as the auditor", not "Check every page"), ended the moment it is decided and the next one started before you move on. A journey that outlives its goal shows the viewer an objective the session left behind minutes ago. Run roughly as many agents at once as the machine has cores, less two, since each drives a real browser; beyond that they only queue. Exploring one area is well within a mid-tier model, so run these agents on one (Sonnet or its equivalent in your client) unless the user names a model; keep the larger model for the agent that plans the split and writes the report. No agent may call `scout_close {all: true}` while others run — only the last step, once every agent has finished.
75
+ - **Several agents in parallel** (subagents or a workflow, each driving its own session): each agent attaches its session when it STARTS, and the PLANNER closes it by name once it has folded that lane's report. A lane must NOT close its own session before reporting: `scout_lane_report` keeps its decisions against that session's project memory, so a lane that closes first hands back decisions with nowhere to write — the tool says so rather than accepting silently, and the run's calibration section ends up with nothing to measure. Fold, then close. `scout_close` enforces it: a session that `scout_lane_brief` or `scout_lane_report` named as a lane is not closed until a report from it has been ACCEPTED (a refused one does not count, so re-send the corrected object before closing), and `scout_close {all: true}` names every such lane and closes nothing. Pass `force: true` only when a lane will never report, knowing its decisions are lost. Keep the planner's own session open until the last lane is folded: closing every session ends the run, and with it the record of which sessions are lanes. Never open sessions ahead for agents that have not started, and never hand an open session from one agent to the next: an agent waiting for its turn should hold no browser. Give each session an `objective` when you attach it (`scout_attach {session, objective:"Approve and reject orders as a manager"}`) and wrap each goal in `scout_journey`: the live view shows that objective and the task it is on beside the session's feed, which is how the person watching knows what every agent is for. Keep that goal TRUE: one journey per goal, one goal per thing you are checking ("Save a settings change as the auditor", not "Check every page"), ended the moment it is decided and the next one started before you move on. A journey that outlives its goal shows the viewer an objective the session left behind minutes ago. Run roughly as many agents at once as the machine has cores, less two, since each drives a real browser; beyond that they only queue. Exploring one area is well within a mid-tier model, so run these agents on one (Sonnet or its equivalent in your client) unless the user names a model; keep the larger model for the agent that plans the split and writes the report. No agent may call `scout_close {all: true}` while others run — only the last step, once every agent has finished.
76
76
  - **Let the engine divide the app: `scout_lane_brief {lanes, goal}`.** Call it after the first crawl, when route knowledge is complete. It splits the known routes into whole modules — everything under `/orders` goes to one lane, so that lane carries state between its own steps instead of re-learning the app on every route — deals the modules out so the lanes come out within a route or two of each other, and returns each lane's session name, the `objective` to attach it with, the routes it owns, the route it lands on (its own first route: lanes that all attached on the home page all met its defects first, and several filed the same one), and the rules each lane must follow — file each defect as it is judged, check a list's request status before calling it empty or stuck, submit one markup value on a create form and open where it is listed, probe a withheld control's endpoint, check coverage, and leave the session open for you to fold. Pass `routes` to split a subset instead. Hand each brief to its agent verbatim. Dividing by hand fails in two ways that a finished run cannot tell apart from success: two lanes audit the same register while a third module is never opened, and route coverage reads complete either way; and lanes launch without an objective, so the person watching sees browsers clicking through their app with nothing to say why.
77
77
  - **Lanes report in typed decisions, not prose.** Each lane files its findings with `scout_finding` as it goes, so the report already has them; what the lane hands BACK to the planner is one JSON object, the lane report, and nothing else. Get the paragraph to put in a lane's prompt from `scout_lane_report {lane}`: it names the shape (`status`, one decision per observation with `verdict`, `severity`, `category`, a calibrated `confidence` and a machine-signature `evidence`, the `routes` covered, `blocked_by`), every value's closed set (the categories are `scout_finding`'s) and every length limit, because a limit a lane is not told refuses good replies. When the lane hands back, pass its text to `scout_lane_report {lane, reply}`: it returns the one-line fold (defects, highs, unsure, mean confidence, routes, what blocked it) or the reason the reply was refused, and keeps each decision so the confidence the lane stated can be checked against what the run went on to file — the report's calibration section is built from that, and it only appears once enough decisions exist to mean something. **So the confidence is worth stating honestly**: a lane that writes 0.95 on everything makes the section say so. The fold also lists every defect the lane judged that no finding on the project matches yet: have the lane file each with the same `evidence` (a finding filed without evidence cannot be matched), or name the finding that covers it, before you close its session, because a defect that is only in a lane report never reaches the run's report. Prose around ONE fenced JSON block is discarded unread and the report accepted; anything less clear-cut is refused. Relay a refusal to the lane once and ask for the corrected object; never re-judge its prose yourself, and tell every lane that the object IS its final report, since a lane that writes the object and then hands back a summary of it has failed. The planner then folds lanes by counting, not by reading: a lane's reply is a few hundred tokens whatever it found, and a value the schema refuses is caught at the boundary instead of becoming a severity like "Low-Medium" in the report. Where the client lets you set a lane's reasoning effort, use **medium**: in this project's benchmark it judged as well as high in three-quarters of the time and under half of xhigh, low inflated severities, and max took three minutes per lane for no better agreement.
78
78