scenescout 3.14.1 → 3.16.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 +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +198 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
package/dist/mcp-server.js
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* SceneScout MCP server (stdio).
|
|
4
4
|
*
|
|
5
5
|
* Exposes deterministic browser-exploration tools — Playwright actions, state
|
|
6
|
-
* memory, oracles, findings, report — to any MCP client.
|
|
7
|
-
*
|
|
6
|
+
* memory, oracles, findings, report — to any MCP client. The client (e.g.
|
|
7
|
+
* Claude Code on a subscription) is the brain, and no LLM call happens here
|
|
8
|
+
* unless finding dedup is set to ask a model judge (SCENESCOUT_DEDUP, or
|
|
9
|
+
* scout_attach {dedup}): then a filing the dedup rule keeps apart is put to a
|
|
10
|
+
* model, through the client when it is a `scenescout ci` run, else with a key
|
|
11
|
+
* from this process's environment (engine/dedup.ts).
|
|
8
12
|
*
|
|
9
13
|
* The server process is a per-conversation daemon and behaves like one:
|
|
10
14
|
* - Multi-session, genuinely concurrent: named sessions each own a live
|
|
@@ -25,7 +29,10 @@
|
|
|
25
29
|
* a loopback-only live view shows what each one is looking at
|
|
26
30
|
* (`scenescout watch <project>`, engine/live.ts, ADR 7).
|
|
27
31
|
*/
|
|
32
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
28
34
|
import fs from "node:fs";
|
|
35
|
+
import os from "node:os";
|
|
29
36
|
import path from "node:path";
|
|
30
37
|
import { fileURLToPath } from "node:url";
|
|
31
38
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -33,26 +40,39 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
33
40
|
import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
34
41
|
import { z } from "zod";
|
|
35
42
|
import { BrowserEngine } from "./engine/browser.js";
|
|
43
|
+
import { readyBrowser } from "./engine/launch.js";
|
|
44
|
+
import { defaultEngine } from "./browsers.js";
|
|
45
|
+
import { downloadBrowsers, presentBrowsers } from "./installer.js";
|
|
36
46
|
import { reapOrphanBrowsers } from "./engine/reaper.js";
|
|
37
|
-
import { FINDING_CATEGORIES, isWorthALook, MemoryStore, mergeableCategories, redactSecrets } from "./engine/memory.js";
|
|
47
|
+
import { describeMerge, FINDING_CATEGORIES, isWorthALook, MemoryStore, mergeableCategories, redactSecrets } from "./engine/memory.js";
|
|
48
|
+
import { DEDUP_ENV, DEDUP_MODES, redactKeys, secretValues } from "./engine/ci.js";
|
|
49
|
+
import { DedupJudge, planDedup, samplingAsk } from "./engine/dedup.js";
|
|
50
|
+
import { httpJudgeAsk } from "./ci-run.js";
|
|
38
51
|
import { decodedEntitiesNote, ignoredConventionsNote, LANE_CONVENTION_MAX, LANE_NAME_MAX, LaneLedger, laneCloseGuard, laneReportInstruction, parseLaneReport, summarizeLaneReport, } from "./engine/lane.js";
|
|
39
52
|
import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
|
|
40
53
|
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
41
54
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
42
55
|
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
43
56
|
import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
|
|
57
|
+
import { decideOpen, OPEN_CHOICES, OPEN_ENV, openChoiceFromEnv, openInBrowser } from "./engine/open.js";
|
|
44
58
|
import { DEFAULT_EXPIRY_MARGIN_MINUTES, DEFAULT_RUN_MINUTES, judgeProfileFile } from "./engine/expiry.js";
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
59
|
+
import { parseLoginArgs } from "./engine/profiles.js";
|
|
60
|
+
import { LOGIN_WINDOW_MAX_MS, savedLine, startLoginWindow } from "./login-run.js";
|
|
61
|
+
import { LoginWindows, WAIT_SAYS } from "./engine/signed-in.js";
|
|
62
|
+
import { computeGaps, coverageView, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
|
|
63
|
+
import { DEFAULT_REPORT_AUDIENCE, REPORT_AUDIENCES } from "./engine/plain.js";
|
|
48
64
|
import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
|
|
65
|
+
import { CRITERION_VERDICTS, findCriterion, formatCriteriaForLanes, formatReading, isTicketFileName, judgeCriterion, MAX_CRITERION_FINDINGS, MAX_REASON, MAX_TICKET_FILE_BYTES, MAX_TICKET_TEXT, MAX_TICKETS, NOT_TESTED_REASONS, parseTickets, TICKET_FILE_EXTENSIONS, } from "./engine/tickets.js";
|
|
49
66
|
import { ACTION_TIMEOUT_ENV, DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_CRAWL_NAV_TIMEOUT_MS, DEFAULT_NAV_TIMEOUT_MS, LIMIT_BOUNDS, NAV_TIMEOUT_ENV, watchdogFor, } from "./engine/limits.js";
|
|
67
|
+
import { MAX_READ_POSTS, READ_POSTS_ENV } from "./engine/policy.js";
|
|
68
|
+
import { chooseProjectFolder, PROJECTS_DIR_ENV, workspaceFromRoots } from "./engine/project-folder.js";
|
|
50
69
|
import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
|
|
51
70
|
import { describePace, normalizePace } from "./engine/settle.js";
|
|
52
71
|
import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
|
|
53
72
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
54
73
|
import { formatScan, scanProject } from "./scan.js";
|
|
55
|
-
import { CAPTURE_MARGIN, CAPTURES_DIRNAME, captureFileName, captureResultText, MAX_CAPTURE_MARGIN } from "./engine/capture.js";
|
|
74
|
+
import { CAPTURE_MARGIN, CAPTURES_DIRNAME, captureFileName, captureResultText, describePicture, EVIDENCE_ENV, EVIDENCE_LIMITS, EVIDENCE_MARGIN, EVIDENCE_MODES, evidenceFrame, evidenceSettings, findingPicturePath, MAX_CAPTURE_MARGIN, RECORD_ENV, recordChoice, returnsInline, } from "./engine/capture.js";
|
|
75
|
+
import { decodePng, fitPicture } from "./engine/png.js";
|
|
56
76
|
/** Live sessions: each name owns an independent BrowserEngine (browser + auth). */
|
|
57
77
|
const engines = new Map();
|
|
58
78
|
/**
|
|
@@ -247,8 +267,10 @@ function reportExtras(eng) {
|
|
|
247
267
|
designAudits: eng.memory?.auditsThisRun ?? eng.designAuditCount,
|
|
248
268
|
createdResources: eng.createdResources,
|
|
249
269
|
unvisitedRoutes: unvisited,
|
|
270
|
+
knownRoutes: all,
|
|
250
271
|
mode: eng.mode,
|
|
251
272
|
trustedEmbeds: [...eng.trustedEmbeds],
|
|
273
|
+
readPosts: eng.readPosts.map((e) => e.entry),
|
|
252
274
|
policyAttributed: eng.oracleLog.policyAttributed,
|
|
253
275
|
version: PKG_VERSION,
|
|
254
276
|
attachedSessions: [...engines.keys()],
|
|
@@ -327,6 +349,28 @@ function liveLine() {
|
|
|
327
349
|
return (`\nLive view: http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/ — give this address to the user so they can watch every session ` +
|
|
328
350
|
`(current tool, page thumbnail, optional live stream). It opens on this machine only and cannot act on the run.`);
|
|
329
351
|
}
|
|
352
|
+
/** What each session's attach decided to open (engine/open.ts). scout_report reads it for the report. */
|
|
353
|
+
const openDecisions = new Map();
|
|
354
|
+
/** The live view is one address for every session, so it is opened once per server. */
|
|
355
|
+
let liveOpened = false;
|
|
356
|
+
/** Whether a result has named the setting that turns opening off; named once. */
|
|
357
|
+
let openSettingNamed = false;
|
|
358
|
+
/** Whether an attach has said why the live view was not opened; said once. */
|
|
359
|
+
let openWhySaid = false;
|
|
360
|
+
/** Open a target in the default browser and say so in a line for the tool result; a failure is said, never thrown. */
|
|
361
|
+
function openForUser(what, target) {
|
|
362
|
+
const outcome = openInBrowser(target, {
|
|
363
|
+
platform: process.platform,
|
|
364
|
+
spawn: (command, args, options) => spawn(command, args, options),
|
|
365
|
+
onError: (why) => logLine(`could not open ${what}: ${why}`),
|
|
366
|
+
});
|
|
367
|
+
if (!outcome.ok)
|
|
368
|
+
return `\nCould not open ${what}: ${outcome.why}.`;
|
|
369
|
+
if (openSettingNamed)
|
|
370
|
+
return `\nOpened ${what} in the default browser.`;
|
|
371
|
+
openSettingNamed = true;
|
|
372
|
+
return `\nOpened ${what} in the default browser (${OPEN_ENV}=none, or scout_attach {open: "none"}, turns this off).`;
|
|
373
|
+
}
|
|
330
374
|
function flushStatus(dir) {
|
|
331
375
|
// Not awaited on a tool call's hot path: status is best-effort observability
|
|
332
376
|
// and must never add blocking filesystem latency there. The writer queues
|
|
@@ -444,11 +488,17 @@ function serializedPerSession(label, fn, timeoutMs = 60_000) {
|
|
|
444
488
|
return sessionQueue.run(session, exec);
|
|
445
489
|
};
|
|
446
490
|
}
|
|
491
|
+
const requestExtra = new AsyncLocalStorage();
|
|
492
|
+
function asRequestExtra(value) {
|
|
493
|
+
return value && typeof value.sendNotification === "function" ? value : undefined;
|
|
494
|
+
}
|
|
447
495
|
/** Control-plane tools (scout_scan/scout_session/scout_close-all) don't target one browser — their own tiny chain keeps them off session queues without racing each other. */
|
|
448
496
|
let controlChain = Promise.resolve();
|
|
449
497
|
function serializedControl(fn) {
|
|
450
498
|
return (...args) => {
|
|
451
|
-
|
|
499
|
+
// The SDK passes the request's extra (progress token, notifications) after the arguments; it is kept for the call, as requestExtra.
|
|
500
|
+
const call = () => requestExtra.run(asRequestExtra(args[1]), () => fn(...args));
|
|
501
|
+
const run = controlChain.then(call, call);
|
|
452
502
|
controlChain = run.catch(() => { });
|
|
453
503
|
return run;
|
|
454
504
|
};
|
|
@@ -561,7 +611,7 @@ server.registerTool("scout_lane_brief", {
|
|
|
561
611
|
runMs: (runMinutes ?? DEFAULT_RUN_MINUTES) * 60_000,
|
|
562
612
|
marginMs: (expiryMarginMinutes ?? DEFAULT_EXPIRY_MARGIN_MINUTES) * 60_000,
|
|
563
613
|
role: eng.auth.role,
|
|
564
|
-
rerun:
|
|
614
|
+
rerun: eng.reloginCommand(eng.auth.role),
|
|
565
615
|
});
|
|
566
616
|
if (verdict.kind === "refuse")
|
|
567
617
|
return errorText(new Error(verdict.message));
|
|
@@ -571,7 +621,11 @@ server.registerTool("scout_lane_brief", {
|
|
|
571
621
|
const all = routes && routes.length > 0 ? routes : eng.allKnownRoutes();
|
|
572
622
|
const briefs = planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role });
|
|
573
623
|
laneLedger.nameBriefed(briefs.map((b) => b.lane), (s) => engines.has(s));
|
|
574
|
-
|
|
624
|
+
// A run given tickets tells every lane which criteria it answers.
|
|
625
|
+
const criteria = eng.memory ? formatCriteriaForLanes(eng.memory.ticketsThisRun().tickets) : "";
|
|
626
|
+
return text(expiryNote +
|
|
627
|
+
formatBriefs(briefs, { goal, mode: eng.mode, role: eng.role, roleProfile: eng.auth.kind === "role" }) +
|
|
628
|
+
(criteria ? `\n${criteria}` : ""), session);
|
|
575
629
|
}
|
|
576
630
|
catch (err) {
|
|
577
631
|
return errorText(err);
|
|
@@ -638,7 +692,7 @@ server.registerTool("scout_lane_report", {
|
|
|
638
692
|
.map((u) => ` · ${u}`)
|
|
639
693
|
.join("\n") +
|
|
640
694
|
(unfiled.length > MAX_UNFILED_NAMED ? `\n … +${unfiled.length - MAX_UNFILED_NAMED} more` : "") +
|
|
641
|
-
`\nFile each with scout_finding (the same evidence), or
|
|
695
|
+
`\nFile each with scout_finding (the same evidence), or have the lane name the finding's id in the decision's "finding", before closing the lane's session. A judged defect that is never filed is not in the report.`
|
|
642
696
|
: "";
|
|
643
697
|
laneLedger.fold(lane, engines.get(lane)?.attached === true);
|
|
644
698
|
// A lane that lost its sign-in and re-attached from its role's profile
|
|
@@ -674,10 +728,14 @@ server.registerTool("scout_scan", {
|
|
|
674
728
|
}
|
|
675
729
|
}));
|
|
676
730
|
server.registerTool("scout_attach", {
|
|
677
|
-
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 `role` to sign in with a login the user saved by `scenescout login <url> --role <name>`, or a Playwright storage-state JSON as storageStatePath. 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.",
|
|
731
|
+
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, and POSTs the user named in readPosts) — 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 `role` to sign in with a login the user saved by `scenescout login <url> --role <name>`, or a Playwright storage-state JSON as storageStatePath. 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.",
|
|
678
732
|
inputSchema: {
|
|
679
733
|
url: z.string().describe("Base URL of the running app, e.g. http://localhost:3000"),
|
|
680
|
-
projectPath: z
|
|
734
|
+
projectPath: z
|
|
735
|
+
.string()
|
|
736
|
+
.optional()
|
|
737
|
+
.describe("Absolute path to the project (memory + report live in .scenescout/ here). Pass it whenever you have a project or working folder. " +
|
|
738
|
+
`Omitted: the client's workspace folder, else a folder per tested site under the user's documents folder (Documents/SceneScout/<host>/, or ${PROJECTS_DIR_ENV}), which the result names — tell the user where it is.`),
|
|
681
739
|
storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration. Not with `role`."),
|
|
682
740
|
role: z
|
|
683
741
|
.string()
|
|
@@ -694,7 +752,7 @@ server.registerTool("scout_attach", {
|
|
|
694
752
|
browser: z
|
|
695
753
|
.enum(["chromium", "firefox", "webkit"])
|
|
696
754
|
.optional()
|
|
697
|
-
.describe("Browser to drive. Default: the SCENESCOUT_BROWSER environment variable, else chromium.
|
|
755
|
+
.describe("Browser to drive. Default: the SCENESCOUT_BROWSER environment variable, else chromium. A build that is not on disk is downloaded on this attach, once, except in CI or with SCENESCOUT_BROWSER_DOWNLOAD=off, where the attach names the command to run. Use firefox or webkit for a cross-browser pass; stay on chromium otherwise."),
|
|
698
756
|
viewportWidth: z.number().int().min(320).max(3840).optional().describe("Viewport width (default 1280); use e.g. 390 for a mobile pass"),
|
|
699
757
|
viewportHeight: z.number().int().min(480).max(2400).optional().describe("Viewport height (default 900)"),
|
|
700
758
|
objective: z
|
|
@@ -722,11 +780,22 @@ server.registerTool("scout_attach", {
|
|
|
722
780
|
.optional()
|
|
723
781
|
.describe('Origins of embedded frames (e.g. "https://pay.example.com") whose writes out of the app may go out — ONLY when the user named them, typically a provider in test mode, and only in safe-write mode. ' +
|
|
724
782
|
"Never add one yourself. Hostile input, repeated-click probes and uploads stay refused in them."),
|
|
783
|
+
readPosts: z
|
|
784
|
+
.array(z.string().max(300))
|
|
785
|
+
.max(MAX_READ_POSTS)
|
|
786
|
+
.optional()
|
|
787
|
+
.describe(`POST endpoints that only read, e.g. ["POST /api/search", "POST https://api.example.com/reports/query"], which observe mode then lets out — ONLY when the user named them. Never add one yourself, even when the gap ledger lists a refused POST: ask the user. ` +
|
|
788
|
+
`Exact paths; * stands for one path segment. Still refused when the path or body looks destructive or the body is a GraphQL mutation. Observe mode only. Default: the ${READ_POSTS_ENV} environment variable, else none.`),
|
|
725
789
|
record: z
|
|
726
790
|
.boolean()
|
|
727
|
-
.
|
|
791
|
+
.optional()
|
|
728
792
|
.describe("Keep a frame of the page after every action, under .scenescout/recordings/, and show it beside that step in report.html. " +
|
|
729
|
-
|
|
793
|
+
`Default: the ${RECORD_ENV} environment variable (on or off), else off: 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.`),
|
|
794
|
+
evidence: z
|
|
795
|
+
.enum(EVIDENCE_MODES)
|
|
796
|
+
.optional()
|
|
797
|
+
.describe("What happens to the picture each scout_finding takes of what it is about. 'inline': kept under .scenescout/recordings/, shown in report.html, and returned in the scout_finding result so the conversation shows it. 'file': kept and shown in the report only. 'off': none taken. " +
|
|
798
|
+
`Default: the ${EVIDENCE_ENV} environment variable, else 'inline', or 'file' in a CI job. Pictures are bounded in size and in how many one session returns (see the configuration reference).`),
|
|
730
799
|
actionTimeoutMs: z
|
|
731
800
|
.number()
|
|
732
801
|
.int()
|
|
@@ -746,9 +815,27 @@ server.registerTool("scout_attach", {
|
|
|
746
815
|
.max(40)
|
|
747
816
|
.optional()
|
|
748
817
|
.describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
|
|
818
|
+
dedup: z
|
|
819
|
+
.enum(DEDUP_MODES)
|
|
820
|
+
.optional()
|
|
821
|
+
.describe(`How this run tells a filed finding from one already recorded. Default: the ${DEDUP_ENV} environment variable, else 'rule', the store's rule alone. ` +
|
|
822
|
+
"'judge': the rule first, then, for a filing the rule keeps apart from everything recorded, a model is asked whether it is one of the open findings on its page, and merges it when it says so. " +
|
|
823
|
+
"It needs ANTHROPIC_API_KEY or OPENAI_API_KEY in the server's environment, and sends each pair's titles, categories and evidence, and the page's path, to that provider — ONLY when the user asked for it. " +
|
|
824
|
+
"Applies to every session of the project until the run ends."),
|
|
825
|
+
open: z
|
|
826
|
+
.enum(OPEN_CHOICES)
|
|
827
|
+
.optional()
|
|
828
|
+
.describe(`Open the live view (on this attach) and/or report.html (when scout_report writes it) in the user's default browser: 'live', 'report', 'both' or 'none'. ` +
|
|
829
|
+
`Default: the ${OPEN_ENV} environment variable, else 'both' on a local desktop session (headed or headless) and 'none' in CI, over SSH, or with no display. ` +
|
|
830
|
+
"Pass it only when the user asked for something other than the default."),
|
|
749
831
|
},
|
|
750
|
-
}, serializedControl(async ({ url, projectPath, storageStatePath, role, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, trustedEmbeds, paceMs, actionTimeoutMs, navTimeoutMs, session, }) => {
|
|
832
|
+
}, serializedControl(async ({ url, projectPath, storageStatePath, role, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, evidence, trustedEmbeds, readPosts, paceMs, actionTimeoutMs, navTimeoutMs, session, dedup, open, }) => {
|
|
751
833
|
try {
|
|
834
|
+
// Settled first: a refusal leaves the default session as it was.
|
|
835
|
+
const folder = await projectFolderFor(url, projectPath);
|
|
836
|
+
if ("refused" in folder)
|
|
837
|
+
return errorText(new Error(folder.refused));
|
|
838
|
+
projectPath = folder.dir;
|
|
752
839
|
const target = session ?? activeName;
|
|
753
840
|
if (session) {
|
|
754
841
|
activeName = session;
|
|
@@ -810,13 +897,25 @@ server.registerTool("scout_attach", {
|
|
|
810
897
|
// Moving this session to another project leaves its old one; if it was
|
|
811
898
|
// the last session there, that run is over. Re-attaching to the SAME
|
|
812
899
|
// project is the same run, and keeps what the run has learned.
|
|
900
|
+
// Before anything changes: a SCENESCOUT_OPEN the server cannot use refuses the attach.
|
|
901
|
+
const opening = decideOpen(open ?? openChoiceFromEnv(process.env), {
|
|
902
|
+
env: process.env,
|
|
903
|
+
platform: process.platform,
|
|
904
|
+
});
|
|
813
905
|
const previous = eng.memory;
|
|
814
906
|
if (previous && previous !== store && ![...engines.values()].some((e) => e !== eng && e.memory === previous))
|
|
815
907
|
previous.endRun();
|
|
908
|
+
// Before the browser starts: a value it cannot use refuses the attach rather than being replaced.
|
|
909
|
+
const dedupNote = configureDedup(store, dedup);
|
|
910
|
+
const recording = recordChoice(record, process.env);
|
|
911
|
+
const pictures = evidenceSettings(evidence, process.env);
|
|
816
912
|
const viewport = viewportWidth && viewportHeight ? { width: viewportWidth, height: viewportHeight } : undefined;
|
|
913
|
+
// The first attach on a machine without the browser downloads it here, once, rather than failing with a command to run.
|
|
914
|
+
const browserNote = await readyBrowserFor({ engine: browser ?? defaultEngine(process.env), headed: headed ?? false }, requestExtra.getStore());
|
|
817
915
|
const out = await eng.attach({
|
|
818
916
|
url,
|
|
819
917
|
projectDir: projectPath,
|
|
918
|
+
projectChosen: folder.source === "default",
|
|
820
919
|
storageStatePath,
|
|
821
920
|
role,
|
|
822
921
|
mode,
|
|
@@ -830,8 +929,9 @@ server.registerTool("scout_attach", {
|
|
|
830
929
|
objective: objective ?? task,
|
|
831
930
|
paceMs,
|
|
832
931
|
task: objective ? task : undefined,
|
|
833
|
-
record,
|
|
932
|
+
record: recording,
|
|
834
933
|
trustedEmbeds,
|
|
934
|
+
readPosts,
|
|
835
935
|
actionTimeoutMs,
|
|
836
936
|
navTimeoutMs,
|
|
837
937
|
memoryStore: store,
|
|
@@ -843,18 +943,213 @@ server.registerTool("scout_attach", {
|
|
|
843
943
|
await ensureLive(eng.memory.dir);
|
|
844
944
|
writeStatus(target, "idle", "scout_attach");
|
|
845
945
|
}
|
|
946
|
+
// A new attach is a new count of pictures returned.
|
|
947
|
+
evidenceFor.set(eng, { ...pictures, shown: 0 });
|
|
948
|
+
openDecisions.set(target, opening);
|
|
949
|
+
// The address holds the token, and goes only to this machine's opener: the live view's loopback and token rules are unchanged.
|
|
950
|
+
let openNote = "";
|
|
951
|
+
if (opening.live && liveAddress && !liveOpened) {
|
|
952
|
+
liveOpened = true;
|
|
953
|
+
openNote = openForUser("the live view", `http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/`);
|
|
954
|
+
}
|
|
955
|
+
else if (!opening.live && liveAddress && !openWhySaid) {
|
|
956
|
+
// Once per server, so a person wondering why no window appeared is told how to change it.
|
|
957
|
+
openWhySaid = true;
|
|
958
|
+
openNote = `\nNot opened in a browser (${opening.why}); scout_attach {open} or ${OPEN_ENV} changes that.`;
|
|
959
|
+
}
|
|
846
960
|
// Recording writes pictures of the app under test into the project, so
|
|
847
961
|
// a run doing it says where they go rather than leaving the person to
|
|
848
962
|
// find a folder of screenshots later.
|
|
849
|
-
const recordNote =
|
|
963
|
+
const recordNote = recording && eng.memory?.dir
|
|
850
964
|
? `\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.`
|
|
851
965
|
: "";
|
|
852
|
-
|
|
966
|
+
const picturesNote = pictures.mode !== "off" && eng.memory?.dir
|
|
967
|
+
? `\n📷 FINDING PICTURES: ${pictures.mode} (${pictures.source}): each scout_finding keeps a picture of what it names under ${path.join(eng.memory.dir, "recordings")}/ for report.html${pictures.mode === "inline" ? `, and returns the first ${pictures.inlineMax} in its result` : ""}.`
|
|
968
|
+
: "";
|
|
969
|
+
return text(browserNote +
|
|
970
|
+
out +
|
|
971
|
+
(folder.note ? `\n\n${folder.note}` : "") +
|
|
972
|
+
conflictNote +
|
|
973
|
+
recordNote +
|
|
974
|
+
picturesNote +
|
|
975
|
+
dedupNote +
|
|
976
|
+
describePace(eng.pace) +
|
|
977
|
+
(engines.size > 1 ? `\n${sessionLines()}` : "") +
|
|
978
|
+
liveLine() +
|
|
979
|
+
openNote, target);
|
|
853
980
|
}
|
|
854
981
|
catch (err) {
|
|
855
982
|
return errorText(err);
|
|
856
983
|
}
|
|
857
984
|
}));
|
|
985
|
+
/**
|
|
986
|
+
* readyBrowser with the real download, returning the line that opens the
|
|
987
|
+
* attach's answer when it downloaded (empty when it did not). Each line goes
|
|
988
|
+
* to stderr and, when the client asked for progress, as a progress
|
|
989
|
+
* notification; the last one is repeated while the download runs, so a client
|
|
990
|
+
* that extends its timeout on progress keeps waiting.
|
|
991
|
+
*/
|
|
992
|
+
async function readyBrowserFor(need, extra) {
|
|
993
|
+
const token = extra?._meta?.progressToken;
|
|
994
|
+
let progress = 0;
|
|
995
|
+
let last = "";
|
|
996
|
+
const notify = (message) => {
|
|
997
|
+
if (token === undefined || !extra)
|
|
998
|
+
return;
|
|
999
|
+
extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: ++progress, message } }).catch((err) => {
|
|
1000
|
+
logLine(`progress notification failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1001
|
+
});
|
|
1002
|
+
};
|
|
1003
|
+
const say = (line) => {
|
|
1004
|
+
last = line;
|
|
1005
|
+
logLine(line);
|
|
1006
|
+
notify(line);
|
|
1007
|
+
};
|
|
1008
|
+
const note = await readyBrowser(need, {
|
|
1009
|
+
env: process.env,
|
|
1010
|
+
present: presentBrowsers,
|
|
1011
|
+
say,
|
|
1012
|
+
download: async (targets) => {
|
|
1013
|
+
const heartbeat = setInterval(() => notify(last), 10_000);
|
|
1014
|
+
try {
|
|
1015
|
+
return await downloadBrowsers(targets, "stderr");
|
|
1016
|
+
}
|
|
1017
|
+
finally {
|
|
1018
|
+
clearInterval(heartbeat);
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
return note ? `${note}\n\n` : "";
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* The folder an attach keeps its files in (engine/project-folder.ts): the
|
|
1026
|
+
* projectPath given, else the client's workspace folder, else a folder per
|
|
1027
|
+
* tested site. Only a client that offers roots is asked for them.
|
|
1028
|
+
*/
|
|
1029
|
+
async function projectFolderFor(url, given) {
|
|
1030
|
+
let workspace = null;
|
|
1031
|
+
if (given === undefined && server.server.getClientCapabilities()?.roots) {
|
|
1032
|
+
try {
|
|
1033
|
+
workspace = workspaceFromRoots((await server.server.listRoots(undefined, { timeout: 3000 })).roots);
|
|
1034
|
+
}
|
|
1035
|
+
catch (err) {
|
|
1036
|
+
logLine(`the client offers a workspace but did not list it (${err.message}); using the default folder`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const userDirsFile = path.join(os.homedir(), ".config", "user-dirs.dirs");
|
|
1040
|
+
const userDirs = process.platform === "linux" && fs.existsSync(userDirsFile) ? fs.readFileSync(userDirsFile, "utf8") : undefined;
|
|
1041
|
+
return chooseProjectFolder({
|
|
1042
|
+
given,
|
|
1043
|
+
workspace,
|
|
1044
|
+
url,
|
|
1045
|
+
home: { platform: process.platform, homedir: os.homedir(), env: process.env, userDirs },
|
|
1046
|
+
exists: fs.existsSync,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
/** Keys in this process's environment, and anything shaped like one, taken out of a line before it is shown. */
|
|
1050
|
+
const withoutKeys = (text) => redactKeys(text, secretValues(process.env));
|
|
1051
|
+
/** A line for the operator, on stderr. */
|
|
1052
|
+
function logLine(line) {
|
|
1053
|
+
console.error(withoutKeys(`[scenescout] ${line}`));
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Set how a project's filings are deduplicated for this run (engine/dedup.ts
|
|
1057
|
+
* planDedup): what this attach names, else what an earlier attach of the run
|
|
1058
|
+
* named, else DEDUP_ENV. Returns the line the attach reports, or "".
|
|
1059
|
+
*/
|
|
1060
|
+
function configureDedup(store, asked) {
|
|
1061
|
+
if (asked)
|
|
1062
|
+
store.dedupChoice = asked;
|
|
1063
|
+
const plan = planDedup(store.dedupChoice, process.env, server.server.getClientCapabilities());
|
|
1064
|
+
if (plan.mode === "rule") {
|
|
1065
|
+
store.dedupJudge = null;
|
|
1066
|
+
store.dedupOff = undefined;
|
|
1067
|
+
return "";
|
|
1068
|
+
}
|
|
1069
|
+
// On already for this run: say so only when it has since been switched off.
|
|
1070
|
+
if (store.dedupJudge instanceof DedupJudge)
|
|
1071
|
+
return store.dedupJudge.off ? `\n⚠ DEDUP JUDGE OFF: switched off earlier in this run after ${store.dedupJudge.off}. The rule decides duplicates.` : "";
|
|
1072
|
+
if (plan.via === "off") {
|
|
1073
|
+
store.dedupOff = plan.why;
|
|
1074
|
+
logLine(`dedup judge off: ${plan.why}; the rule decides duplicates`);
|
|
1075
|
+
return plan.note;
|
|
1076
|
+
}
|
|
1077
|
+
const ask = plan.via === "client" ? samplingAsk((params, options) => server.server.createMessage(params, options)) : httpJudgeAsk(plan.resolved, plan.key);
|
|
1078
|
+
store.dedupJudge = new DedupJudge(ask, { label: plan.label, log: logLine, redact: withoutKeys });
|
|
1079
|
+
store.dedupOff = undefined;
|
|
1080
|
+
return plan.note;
|
|
1081
|
+
}
|
|
1082
|
+
/** Each session's finding-picture settings from its last attach, and how many pictures its results have carried since. */
|
|
1083
|
+
const evidenceFor = new WeakMap();
|
|
1084
|
+
/**
|
|
1085
|
+
* Take, bound and keep a filed finding's picture (capture.ts decides whether
|
|
1086
|
+
* and of what; png.ts fits it). Returns the line the result adds and, when the
|
|
1087
|
+
* result carries it, the picture. A picture that cannot be taken or kept never
|
|
1088
|
+
* fails the filing, which is already recorded: the line says why there is none.
|
|
1089
|
+
*/
|
|
1090
|
+
async function findingPicture(eng, filed, ref) {
|
|
1091
|
+
const settings = evidenceFor.get(eng);
|
|
1092
|
+
const dir = eng.memory?.dir;
|
|
1093
|
+
if (!settings || !dir)
|
|
1094
|
+
return { line: "" };
|
|
1095
|
+
const { finding, isNew } = filed;
|
|
1096
|
+
const plan = evidenceFrame({
|
|
1097
|
+
mode: settings.mode,
|
|
1098
|
+
ref,
|
|
1099
|
+
pageOpen: eng.alive,
|
|
1100
|
+
isNew,
|
|
1101
|
+
hasPicture: !!finding.picture,
|
|
1102
|
+
regressed: !isNew && !!finding.regressedAt && finding.regressedAt === finding.foundAt,
|
|
1103
|
+
});
|
|
1104
|
+
if (!plan.take)
|
|
1105
|
+
return { line: settings.mode === "off" ? "" : `\nNo picture taken: ${plan.why}.` };
|
|
1106
|
+
try {
|
|
1107
|
+
const shot = await eng.captureEvidence(plan.frame === "element" ? plan.ref : undefined, EVIDENCE_MARGIN);
|
|
1108
|
+
const fitted = fitPicture(decodePng(shot.png), settings.maxPx, settings.maxBytes);
|
|
1109
|
+
if (!fitted)
|
|
1110
|
+
return { line: `\nNo picture kept: none small enough to be readable fits in ${Math.round(settings.maxBytes / 1024)} KB.` };
|
|
1111
|
+
const rel = findingPicturePath(eng.sessionKey, finding.id);
|
|
1112
|
+
const file = path.join(dir, rel);
|
|
1113
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1114
|
+
fs.writeFileSync(file, fitted.png);
|
|
1115
|
+
const picture = {
|
|
1116
|
+
width: fitted.width,
|
|
1117
|
+
height: fitted.height,
|
|
1118
|
+
frame: shot.frame,
|
|
1119
|
+
...(shot.label ? { label: shot.label } : {}),
|
|
1120
|
+
at: new Date().toISOString(),
|
|
1121
|
+
};
|
|
1122
|
+
eng.memory?.setPicture(finding.id, rel, picture);
|
|
1123
|
+
const said = `\n📷 Picture (${describePicture(picture)}${fitted.shrunk ? ", shrunk to fit" : ""}): ${file}${shot.note ? ` — ${shot.note}` : ""}`;
|
|
1124
|
+
if (!returnsInline(settings.mode, settings.shown, settings.inlineMax)) {
|
|
1125
|
+
const capped = settings.mode === "inline"
|
|
1126
|
+
? ` Not shown here: this session has returned its ${settings.inlineMax} (${EVIDENCE_LIMITS.inline.env} sets how many); it is in report.html.`
|
|
1127
|
+
: "";
|
|
1128
|
+
return { line: said + capped };
|
|
1129
|
+
}
|
|
1130
|
+
settings.shown += 1;
|
|
1131
|
+
return { line: said, image: { type: "image", data: fitted.png.toString("base64"), mimeType: "image/png" } };
|
|
1132
|
+
}
|
|
1133
|
+
catch (err) {
|
|
1134
|
+
return { line: `\nNo picture kept: ${err instanceof Error ? err.message : String(err)}` };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
/** What scout_finding tells the agent about what filing did. */
|
|
1138
|
+
function filedText(filed, category) {
|
|
1139
|
+
const { finding, isNew, promoted } = filed;
|
|
1140
|
+
if (isNew && isWorthALook(finding))
|
|
1141
|
+
return `Recorded as worth a look (not a defect in the report's totals): ${finding.title} (id ${finding.id}) — a defect only if your project uses ${finding.convention}`;
|
|
1142
|
+
if (isNew)
|
|
1143
|
+
return `Finding recorded: [${finding.severity}] ${finding.title} (id ${finding.id})`;
|
|
1144
|
+
if (filed.judged)
|
|
1145
|
+
return (`Not recorded as new: the dedup judge read it as the same defect as finding ${finding.id} (p_same ${filed.judged.pSame.toFixed(2)}) — [${finding.severity}] ${finding.title}, filed as ${finding.category}, seen in ${finding.runs} runs.` +
|
|
1146
|
+
`${promoted ? " That finding was worth a look and is now a defect." : ""} Its title, category, severity and evidence are kept on that finding, so the report shows the merge. If yours is a different defect, file it again with a title that says what differs.`);
|
|
1147
|
+
if (promoted)
|
|
1148
|
+
return `Merged into finding ${finding.id}, which was worth a look, and promoted to a defect: [${finding.severity}] ${finding.title}. It now counts among the report's findings.`;
|
|
1149
|
+
if (finding.regressedAt)
|
|
1150
|
+
return `⟳ REOPENED as a REGRESSION: finding ${finding.id} was previously resolved but the evidence reproduces again (seen in ${finding.runs} runs). Worth calling out to the user.`;
|
|
1151
|
+
return `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, filed as ${finding.category}, seen in ${finding.runs} run${finding.runs === 1 ? "" : "s"}${filed.merge?.sameRun ? " (already filed this run, so the count did not change)" : ""}.${filed.merge ? describeMerge(filed.merge, finding.convention) : ""} If yours is a different bug, file it again: under the category that says what is wrong if it is another kind of defect (a finding filed as ${category} merges only with one filed as ${mergeableCategories(category).join(" or ")}), or with evidence naming the request that failed for you (method and path) — two findings are kept apart when both name requests and none is shared.`;
|
|
1152
|
+
}
|
|
858
1153
|
function sessionLines() {
|
|
859
1154
|
const lines = ["Live sessions:"];
|
|
860
1155
|
for (const [name, eng] of engines) {
|
|
@@ -862,6 +1157,114 @@ function sessionLines() {
|
|
|
862
1157
|
}
|
|
863
1158
|
return lines.join("\n");
|
|
864
1159
|
}
|
|
1160
|
+
// Signing in from the conversation: a window the person signs in in, watched
|
|
1161
|
+
// in the background, so a call need not last as long as the person takes and
|
|
1162
|
+
// a second call picks up the same window. One per project and role.
|
|
1163
|
+
const pendingLogins = new LoginWindows(LOGIN_WINDOW_MAX_MS);
|
|
1164
|
+
/** How long one scout_login call waits for the person, by default and at most, in seconds. */
|
|
1165
|
+
const LOGIN_WAIT_DEFAULT_S = 120;
|
|
1166
|
+
const LOGIN_WAIT_MAX_S = 600;
|
|
1167
|
+
/** How often a waiting call tells a client that asked for progress that it is still going, in ms. */
|
|
1168
|
+
const LOGIN_PROGRESS_EVERY_MS = 10_000;
|
|
1169
|
+
server.registerTool("scout_login", {
|
|
1170
|
+
description: "Open a visible browser window for the USER to sign in to the app as a role, and save that sign-in for scout_attach { role }. " +
|
|
1171
|
+
"Use it when an attach is refused because no sign-in is saved for the role, or the saved one has expired. " +
|
|
1172
|
+
'Tell the user first, in plain words: "A browser window is opening. Sign in there as you normally would; it closes by itself once you are in." ' +
|
|
1173
|
+
"The window saves once the user is back on the app with a new session (a round trip through a single sign-on provider is followed, not taken for the end) and closes. " +
|
|
1174
|
+
"Never type credentials into it yourself. Returns once signed in and saved, or after waitSeconds with the window still open: then call scout_login again with the same role to keep waiting. " +
|
|
1175
|
+
"Closing the window saves nothing. Needs a desktop: on a machine with no display, ask the user to run `scenescout login <url> --role <name>` where they can see the window.",
|
|
1176
|
+
inputSchema: {
|
|
1177
|
+
url: z.string().describe("Where to sign in: the app's address or its sign-in page, e.g. http://localhost:3000/login"),
|
|
1178
|
+
role: z.string().max(40).describe("The name to save the sign-in under, e.g. admin; scout_attach { role } signs in with it"),
|
|
1179
|
+
projectPath: z
|
|
1180
|
+
.string()
|
|
1181
|
+
.optional()
|
|
1182
|
+
.describe("Absolute path to the project (the sign-in is saved in .scenescout/auth/ here), as for scout_attach. Omitted: the same folder an attach with no projectPath uses for this site, which the result names."),
|
|
1183
|
+
browser: z
|
|
1184
|
+
.enum(["chromium", "firefox", "webkit"])
|
|
1185
|
+
.optional()
|
|
1186
|
+
.describe("Browser to open. Default: the SCENESCOUT_BROWSER environment variable, else chromium"),
|
|
1187
|
+
successUrl: z
|
|
1188
|
+
.string()
|
|
1189
|
+
.max(500)
|
|
1190
|
+
.optional()
|
|
1191
|
+
.describe("Only when the user says how to tell: signed in once the URL's path contains this, or the URL starts with it (an absolute URL), instead of when a new session appears"),
|
|
1192
|
+
waitSeconds: z
|
|
1193
|
+
.number()
|
|
1194
|
+
.int()
|
|
1195
|
+
.min(1)
|
|
1196
|
+
.max(LOGIN_WAIT_MAX_S)
|
|
1197
|
+
.optional()
|
|
1198
|
+
.describe(`How long this call waits for the user before returning with the window still open (default ${LOGIN_WAIT_DEFAULT_S})`),
|
|
1199
|
+
},
|
|
1200
|
+
}, async (args, extra) => {
|
|
1201
|
+
try {
|
|
1202
|
+
// The folder an attach with no projectPath would use, so the attach after this finds the sign-in.
|
|
1203
|
+
const folder = await projectFolderFor(args.url, args.projectPath);
|
|
1204
|
+
if ("refused" in folder)
|
|
1205
|
+
return errorText(new Error(folder.refused));
|
|
1206
|
+
const projectDir = path.resolve(folder.dir);
|
|
1207
|
+
const where = folder.note ? `\n\n${folder.note}` : "";
|
|
1208
|
+
const parsed = parseLoginArgs([args.url, "--role", args.role, ...(args.browser ? ["--browser", args.browser] : []), ...(args.successUrl ? ["--success-url", args.successUrl] : [])], projectDir);
|
|
1209
|
+
if (!parsed.ok)
|
|
1210
|
+
return errorText(new Error(parsed.error));
|
|
1211
|
+
const options = { ...parsed.options, projectDir };
|
|
1212
|
+
const key = `${options.projectDir}\0${options.role}`;
|
|
1213
|
+
const { window: pending, resumed } = await pendingLogins.get(key, () => startLoginWindow(options));
|
|
1214
|
+
const waitMs = (args.waitSeconds ?? LOGIN_WAIT_DEFAULT_S) * 1000;
|
|
1215
|
+
const progressToken = extra._meta?.progressToken;
|
|
1216
|
+
let ticks = 0;
|
|
1217
|
+
const ticker = progressToken !== undefined
|
|
1218
|
+
? setInterval(() => {
|
|
1219
|
+
ticks += 1;
|
|
1220
|
+
const p = pending.progress();
|
|
1221
|
+
void extra
|
|
1222
|
+
.sendNotification({
|
|
1223
|
+
method: "notifications/progress",
|
|
1224
|
+
params: {
|
|
1225
|
+
progressToken,
|
|
1226
|
+
progress: ticks,
|
|
1227
|
+
message: `Waiting for the sign-in as "${options.role}": ${p.reason === "starting" ? "the window is opening" : WAIT_SAYS[p.reason]}`,
|
|
1228
|
+
},
|
|
1229
|
+
})
|
|
1230
|
+
.catch((err) => console.error(`[scenescout] scout_login progress: ${err instanceof Error ? err.message : String(err)}`));
|
|
1231
|
+
}, LOGIN_PROGRESS_EVERY_MS)
|
|
1232
|
+
: undefined;
|
|
1233
|
+
let timer;
|
|
1234
|
+
let onAbort;
|
|
1235
|
+
const outcome = await Promise.race([
|
|
1236
|
+
pending.done,
|
|
1237
|
+
new Promise((resolve) => {
|
|
1238
|
+
timer = setTimeout(() => resolve("waiting"), waitMs);
|
|
1239
|
+
}),
|
|
1240
|
+
new Promise((resolve) => {
|
|
1241
|
+
onAbort = () => resolve("cancelled");
|
|
1242
|
+
extra.signal.addEventListener("abort", onAbort, { once: true });
|
|
1243
|
+
}),
|
|
1244
|
+
]).finally(() => {
|
|
1245
|
+
clearTimeout(timer);
|
|
1246
|
+
if (ticker)
|
|
1247
|
+
clearInterval(ticker);
|
|
1248
|
+
if (onAbort)
|
|
1249
|
+
extra.signal.removeEventListener("abort", onAbort);
|
|
1250
|
+
});
|
|
1251
|
+
const differs = resumed && pending.url !== options.url ? ` (the window was opened at ${pending.url} by an earlier call; that one is still the one being watched)` : "";
|
|
1252
|
+
if (outcome === "waiting" || outcome === "cancelled") {
|
|
1253
|
+
const p = pending.progress();
|
|
1254
|
+
return text(`Still waiting for the user to sign in as "${options.role}"${differs}: ${p.reason === "starting" ? "the window is opening" : WAIT_SAYS[p.reason]}. ` +
|
|
1255
|
+
`The window stays open for up to ${LOGIN_WINDOW_MAX_MS / 60_000} minutes from when it opened. Call scout_login again with the same role to keep waiting, once the user says they are done or to check.` +
|
|
1256
|
+
where, activeName);
|
|
1257
|
+
}
|
|
1258
|
+
// This call reports the outcome; the next call for the role opens a new window.
|
|
1259
|
+
pendingLogins.reported(key, pending);
|
|
1260
|
+
if (!outcome.ok)
|
|
1261
|
+
return errorText(new Error(`nothing was saved for role "${options.role}": ${outcome.error}`));
|
|
1262
|
+
return text(`${outcome.detected}\n${savedLine(options, outcome.saved)}${where}`, activeName);
|
|
1263
|
+
}
|
|
1264
|
+
catch (err) {
|
|
1265
|
+
return errorText(err);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
865
1268
|
server.registerTool("scout_session", {
|
|
866
1269
|
description: "List live sessions, or set which one is the DEFAULT (used by any tool call that omits `session`). Prefer passing `session` directly on each tool call for multi-role work — that's what lets concurrent dispatch happen; scout_session is for sequential convenience (skip repeating `session` on every call) and for checking what's live. Both browsers stay live and authenticated regardless of which is default — re-snapshot a session after a break to see what changed while it was away.",
|
|
867
1270
|
inputSchema: {
|
|
@@ -911,7 +1314,7 @@ server.registerTool("scout_session", {
|
|
|
911
1314
|
}
|
|
912
1315
|
}));
|
|
913
1316
|
server.registerTool("scout_snapshot", {
|
|
914
|
-
description: "Capture the current page state: URL, state fingerprint, interactable elements with refs (e1, e2, …), geometry issues, coverage, and oracle violations since the last action. Re-snapshotting
|
|
1317
|
+
description: "Capture the current page state: URL, state fingerprint, a one-line summary of the main area's heading and text, interactable elements with refs (e1, e2, …) and their state (pressed, selected, checked, expanded, current), what the page announces (alert and status regions, by their text), geometry issues, coverage, and oracle violations since the last action. Re-snapshotting a route returns a DIFF against its last snapshot, even after visiting elsewhere, and another tab of the same screen diffs against that screen's last tab; refs stay stable, including across a search or filter that rewrites only the query string. On a dense page, says what past the element cap was cut. Cheap — prefer this over screenshots.",
|
|
915
1318
|
inputSchema: {
|
|
916
1319
|
full: z.boolean().default(false).describe("Force a full element list instead of a diff"),
|
|
917
1320
|
session: sessionParam,
|
|
@@ -925,9 +1328,13 @@ server.registerTool("scout_snapshot", {
|
|
|
925
1328
|
}
|
|
926
1329
|
}));
|
|
927
1330
|
server.registerTool("scout_crawl", {
|
|
928
|
-
description: "Engine-side route sweep in ONE call: visits each path (default: all known routes not yet visited), records states into coverage memory, and returns a per-route health summary (HTTP status, element count, oracle violations, dead-ends, auth-redirects). Navigation-only — safe in read-only mode. Use this FIRST for broad coverage; explore interactively only where it flags problems or where journeys matter.",
|
|
1331
|
+
description: "Engine-side route sweep in ONE call: visits each path (default: all known routes not yet visited), records states into coverage memory, and returns a per-route health summary (HTTP status, element count, what the main area holds, oracle violations, dead-ends, auth-redirects, and ERROR-VIEW or STILL-LOADING for a main area showing only an alert or a loading placeholder). Navigation-only — safe in read-only mode. Use this FIRST for broad coverage; explore interactively only where it flags problems or where journeys matter.",
|
|
929
1332
|
inputSchema: {
|
|
930
|
-
paths: z
|
|
1333
|
+
paths: z
|
|
1334
|
+
.array(z.string())
|
|
1335
|
+
.max(150)
|
|
1336
|
+
.optional()
|
|
1337
|
+
.describe("Paths to visit, e.g. ['/orders','/settings'], or full URLs on the attached origin. A path resolves from the origin's root, whatever page the session attached on. Omit to crawl all unvisited known routes."),
|
|
931
1338
|
session: sessionParam,
|
|
932
1339
|
},
|
|
933
1340
|
}, serializedPerSession("scout_crawl", async ({ paths }, session) => {
|
|
@@ -939,7 +1346,7 @@ server.registerTool("scout_crawl", {
|
|
|
939
1346
|
}
|
|
940
1347
|
}, 600_000));
|
|
941
1348
|
server.registerTool("scout_run_plan", {
|
|
942
|
-
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).",
|
|
1349
|
+
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). For a sweep of independent steps (tabs, filters, pages) pass onViolation \"continue\": a new error status or its console echo is listed on its step's line and the plan goes on; a failed step, a policy refusal or any other violation still stops it. A select step whose value names no option fails at once, listing the options.",
|
|
943
1350
|
inputSchema: {
|
|
944
1351
|
steps: z
|
|
945
1352
|
.array(z.object({
|
|
@@ -957,30 +1364,39 @@ server.registerTool("scout_run_plan", {
|
|
|
957
1364
|
}))
|
|
958
1365
|
.min(1)
|
|
959
1366
|
.max(20),
|
|
1367
|
+
onViolation: z
|
|
1368
|
+
.enum(["stop", "continue"])
|
|
1369
|
+
.default("stop")
|
|
1370
|
+
.describe("stop (default): end the plan at the first new oracle violation, right for a form flow whose steps depend on each other. continue: list a new http_error or console_error on its step's line and run the next step, for a sweep of independent steps"),
|
|
960
1371
|
task: taskParam,
|
|
961
1372
|
objective: legacyObjectiveParam,
|
|
962
1373
|
session: sessionParam,
|
|
963
1374
|
},
|
|
964
|
-
}, serializedPerSession("scout_run_plan", async ({ steps }, session) => {
|
|
1375
|
+
}, serializedPerSession("scout_run_plan", async ({ steps, onViolation }, session) => {
|
|
965
1376
|
try {
|
|
966
|
-
return text(await engineFor(session).runPlan(steps), session);
|
|
1377
|
+
return text(await engineFor(session).runPlan(steps, onViolation ?? "stop"), session);
|
|
967
1378
|
}
|
|
968
1379
|
catch (err) {
|
|
969
1380
|
return errorText(err);
|
|
970
1381
|
}
|
|
971
1382
|
}, 240_000));
|
|
1383
|
+
const leaveParam = z
|
|
1384
|
+
.boolean()
|
|
1385
|
+
.optional()
|
|
1386
|
+
.describe("How to answer if the page asks to confirm leaving (a beforeunload prompt over unsent input): true leaves and discards that input, false stays. Omitted, observe and read-only stay and other modes leave. The result says when the page asked.");
|
|
972
1387
|
server.registerTool("scout_click", {
|
|
973
1388
|
description: "Click an element by its ref from the latest scout_snapshot. Returns the outcome plus any oracle violations triggered. clicks=2 (or 3) probes IMPATIENT-USER behaviour: a rapid multi-click that fires the same state-changing request twice means the control is not guarded against double submission (button stays enabled, endpoint not idempotent) — use it on every important submit/create button once; the result says explicitly whether duplicates fired.",
|
|
974
1389
|
inputSchema: {
|
|
975
1390
|
ref: z.string().describe("Element ref, e.g. e12"),
|
|
976
1391
|
clicks: z.number().int().min(1).max(3).default(1).describe("1 = normal; 2-3 = rapid repeated clicks (double-submit probe)"),
|
|
1392
|
+
leave: leaveParam,
|
|
977
1393
|
task: taskParam,
|
|
978
1394
|
objective: legacyObjectiveParam,
|
|
979
1395
|
session: sessionParam,
|
|
980
1396
|
},
|
|
981
|
-
}, serializedPerSession("scout_click", async ({ ref, clicks }, session) => {
|
|
1397
|
+
}, serializedPerSession("scout_click", async ({ ref, clicks, leave }, session) => {
|
|
982
1398
|
try {
|
|
983
|
-
return text(await engineFor(session).click(ref, clicks ?? 1), session);
|
|
1399
|
+
return text(await engineFor(session).click(ref, clicks ?? 1, leave), session);
|
|
984
1400
|
}
|
|
985
1401
|
catch (err) {
|
|
986
1402
|
return errorText(err);
|
|
@@ -1056,10 +1472,10 @@ server.registerTool("scout_hover", {
|
|
|
1056
1472
|
}
|
|
1057
1473
|
}));
|
|
1058
1474
|
server.registerTool("scout_select", {
|
|
1059
|
-
description: "Select an option in a <select> by ref.",
|
|
1475
|
+
description: "Select an option in a <select> by ref. The value is matched against the options before anything is picked: an exact value, an exact label, either ignoring case, then a label it starts with. A value matching no option, or several, is refused at once with the options listed.",
|
|
1060
1476
|
inputSchema: {
|
|
1061
1477
|
ref: z.string(),
|
|
1062
|
-
value: z.string().describe("Option value or label"),
|
|
1478
|
+
value: z.string().describe("Option value or label (or the start of a label, when only one option has it)"),
|
|
1063
1479
|
task: taskParam,
|
|
1064
1480
|
objective: legacyObjectiveParam,
|
|
1065
1481
|
session: sessionParam,
|
|
@@ -1073,23 +1489,24 @@ server.registerTool("scout_select", {
|
|
|
1073
1489
|
}
|
|
1074
1490
|
}));
|
|
1075
1491
|
server.registerTool("scout_navigate", {
|
|
1076
|
-
description: "Navigate to a
|
|
1492
|
+
description: "Navigate to a path on the attached origin (e.g. '/orders') or a full URL on it. A path resolves from the origin's root, whatever page the session attached on. Also supports 'back' via scout_back.",
|
|
1077
1493
|
inputSchema: {
|
|
1078
1494
|
target: z.string().describe("Absolute URL or path like /settings"),
|
|
1495
|
+
leave: leaveParam,
|
|
1079
1496
|
task: taskParam,
|
|
1080
1497
|
objective: legacyObjectiveParam,
|
|
1081
1498
|
session: sessionParam,
|
|
1082
1499
|
},
|
|
1083
|
-
}, serializedPerSession("scout_navigate", async ({ target }, session) => {
|
|
1500
|
+
}, serializedPerSession("scout_navigate", async ({ target, leave }, session) => {
|
|
1084
1501
|
try {
|
|
1085
|
-
return text(await engineFor(session).navigate(target), session);
|
|
1502
|
+
return text(await engineFor(session).navigate(target, leave), session);
|
|
1086
1503
|
}
|
|
1087
1504
|
catch (err) {
|
|
1088
1505
|
return errorText(err);
|
|
1089
1506
|
}
|
|
1090
1507
|
}));
|
|
1091
1508
|
server.registerTool("scout_request", {
|
|
1092
|
-
description: "Call the app's own API as this session, with the UI bypassed — the check that turns a hidden or disabled control into a proven refusal. A button that is not shown proves nothing; the same action refused by the server does. The fetch runs IN the page, so it carries the session's cookies and replays the Authorization header the app itself last sent, and it passes through the same interception the write policy is enforced on: in safe-write a mutation on a record this session did not create is refused here exactly as it would be for a click, and that refusal is the engine's safety net, not a finding. Returns the status line, the timing, the headers that decide whether two responses are truly identical (content-type, location, www-authenticate, retry-after, cache-control), and the body. Unlike a shell call, every request is recorded in the run's trail and its signature is what a finding should quote. Paths are fenced to the attached origin: use another session to reach another host.",
|
|
1509
|
+
description: "Call the app's own API as this session, with the UI bypassed — the check that turns a hidden or disabled control into a proven refusal. A button that is not shown proves nothing; the same action refused by the server does. The fetch runs IN the page, so it carries the session's cookies and replays the Authorization header the app itself last sent, and it passes through the same interception the write policy is enforced on: in safe-write a mutation on a record this session did not create is refused here exactly as it would be for a click, and that refusal is the engine's safety net, not a finding. Returns the status line, the timing, the headers that decide whether two responses are truly identical (content-type, location, www-authenticate, retry-after, cache-control), and the body — its first 2000 characters, or the part named by select (one JSON value by path) or offset/limit (a window of characters). Unlike a shell call, every request is recorded in the run's trail and its signature is what a finding should quote. Paths are fenced to the attached origin: use another session to reach another host.",
|
|
1093
1510
|
inputSchema: {
|
|
1094
1511
|
path: z.string().min(1).max(2000).describe("Path on the attached origin, e.g. /api/things/12, or a full URL on that same origin"),
|
|
1095
1512
|
method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]).optional().describe("Default GET"),
|
|
@@ -1098,13 +1515,48 @@ server.registerTool("scout_request", {
|
|
|
1098
1515
|
.record(z.string().max(2000))
|
|
1099
1516
|
.optional()
|
|
1100
1517
|
.describe("Extra headers. One given here wins over the app's own, which is how a session tests a different or absent credential."),
|
|
1518
|
+
select: z
|
|
1519
|
+
.string()
|
|
1520
|
+
.max(500)
|
|
1521
|
+
.optional()
|
|
1522
|
+
.describe('Return one value of a JSON response body by its dotted path, e.g. "stats.open" or "items.0.name", pretty-printed and up to 8000 characters. A path that is not there says which keys are.'),
|
|
1523
|
+
offset: z
|
|
1524
|
+
.number()
|
|
1525
|
+
.int()
|
|
1526
|
+
.min(0)
|
|
1527
|
+
.max(1_000_000)
|
|
1528
|
+
.optional()
|
|
1529
|
+
.describe("Return the body (or the selected value) from this character on. The body is cut at 2000 characters by default; the result names the next offset."),
|
|
1530
|
+
limit: z
|
|
1531
|
+
.number()
|
|
1532
|
+
.int()
|
|
1533
|
+
.min(1)
|
|
1534
|
+
.max(8000)
|
|
1535
|
+
.optional()
|
|
1536
|
+
.describe("How many characters to return with offset or select. Default 2000, or 8000 for a select with no offset."),
|
|
1101
1537
|
task: taskParam,
|
|
1102
1538
|
objective: legacyObjectiveParam,
|
|
1103
1539
|
session: sessionParam,
|
|
1104
1540
|
},
|
|
1105
1541
|
}, serializedPerSession("scout_request", async (args, session) => {
|
|
1106
1542
|
try {
|
|
1107
|
-
|
|
1543
|
+
const view = { select: args.select, offset: args.offset, limit: args.limit };
|
|
1544
|
+
return text(await engineFor(session).apiRequest({ method: args.method, path: args.path, body: args.body, headers: args.headers, view }), session);
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
return errorText(err);
|
|
1548
|
+
}
|
|
1549
|
+
}));
|
|
1550
|
+
server.registerTool("scout_network", {
|
|
1551
|
+
description: "List the data requests (fetch and XHR) the current page made since its document loaded: method, path, status and time, oldest first, each marked with the route it was sent from when a client-side route change moved the page since. Use it when the page and the server seem to disagree — an empty list where the API has data, a stale value after a save — to tell a request that failed, one still pending and one that never ran apart. Read-only: it lists what the browser already saw and sends nothing. Credentials in query strings are redacted. A full page load starts a new list; scout_request's own calls are marked.",
|
|
1552
|
+
inputSchema: {
|
|
1553
|
+
contains: z.string().max(200).optional().describe("Only requests whose path contains this text, e.g. /api/things"),
|
|
1554
|
+
limit: z.number().int().min(1).max(200).optional().describe("How many of the newest to list. Default 40."),
|
|
1555
|
+
session: sessionParam,
|
|
1556
|
+
},
|
|
1557
|
+
}, serializedPerSession("scout_network", async (args, session) => {
|
|
1558
|
+
try {
|
|
1559
|
+
return text(engineFor(session).listPageRequests({ contains: args.contains, limit: args.limit }), session);
|
|
1108
1560
|
}
|
|
1109
1561
|
catch (err) {
|
|
1110
1562
|
return errorText(err);
|
|
@@ -1112,10 +1564,10 @@ server.registerTool("scout_request", {
|
|
|
1112
1564
|
}));
|
|
1113
1565
|
server.registerTool("scout_back", {
|
|
1114
1566
|
description: "Go back in browser history (tests back-button resilience).",
|
|
1115
|
-
inputSchema: { task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
1116
|
-
}, serializedPerSession("scout_back", async (
|
|
1567
|
+
inputSchema: { leave: leaveParam, task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
1568
|
+
}, serializedPerSession("scout_back", async ({ leave }, session) => {
|
|
1117
1569
|
try {
|
|
1118
|
-
return text(await engineFor(session).goBack(), session);
|
|
1570
|
+
return text(await engineFor(session).goBack(leave), session);
|
|
1119
1571
|
}
|
|
1120
1572
|
catch (err) {
|
|
1121
1573
|
return errorText(err);
|
|
@@ -1257,7 +1709,7 @@ server.registerTool("scout_capture", {
|
|
|
1257
1709
|
}
|
|
1258
1710
|
}));
|
|
1259
1711
|
server.registerTool("scout_finding", {
|
|
1260
|
-
description: "Record a structured finding (bug, UX issue, or improvement). Deduplicates across runs; automatically captures the recent action trace as the repro. Use for anything worth reporting: crashes, oracle violations you confirmed, dead ends, confusing UX, permission leaks, missing testids — and design-audit improvement opportunities (ux-polish) with their concrete measurements.",
|
|
1712
|
+
description: "Record a structured finding (bug, UX issue, or improvement). Deduplicates across runs; automatically captures the recent action trace as the repro, and a picture of what it is about (the element `ref` names, else the viewport), kept for report.html and returned in this result as an image. Use for anything worth reporting: crashes, oracle violations you confirmed, dead ends, confusing UX, permission leaks, missing testids — and design-audit improvement opportunities (ux-polish) with their concrete measurements.",
|
|
1261
1713
|
inputSchema: {
|
|
1262
1714
|
severity: z.enum(["high", "medium", "low"]),
|
|
1263
1715
|
category: z.enum(FINDING_CATEGORIES).describe("Pick the closest — use 'other' only when nothing fits"),
|
|
@@ -1273,16 +1725,21 @@ server.registerTool("scout_finding", {
|
|
|
1273
1725
|
.max(LANE_CONVENTION_MAX)
|
|
1274
1726
|
.optional()
|
|
1275
1727
|
.describe("Only for a WORTH-A-LOOK finding: the observation is real, and it is a defect only under a convention of this project you cannot see. Name that convention, e.g. 'a 4px spacing scale' or 'test ids on every control'. The report lists it under \"Worth a look\", apart from the defects, and does not count it as one. Not for \"I could not tell\": leave that unfiled or look closer. Omit for a defect."),
|
|
1728
|
+
ref: z
|
|
1729
|
+
.string()
|
|
1730
|
+
.max(20)
|
|
1731
|
+
.optional()
|
|
1732
|
+
.describe("The ref, from the latest scout_snapshot, of the element the finding is about. Its picture (the element plus a margin) is kept with the finding and shown in report.html. Omit it and the picture is the viewport."),
|
|
1276
1733
|
session: sessionParam,
|
|
1277
1734
|
},
|
|
1278
|
-
}, serializedPerSession("scout_finding", async ({ severity, category, title, detail, evidence, convention, }, session) => {
|
|
1735
|
+
}, serializedPerSession("scout_finding", async ({ severity, category, title, detail, evidence, convention, ref, }, session) => {
|
|
1279
1736
|
try {
|
|
1280
1737
|
const eng = engineFor(session);
|
|
1281
1738
|
if (!eng.memory)
|
|
1282
1739
|
throw new Error("Not attached — findings need an active session.");
|
|
1283
|
-
const
|
|
1740
|
+
const filed = await eng.memory.fileFinding({
|
|
1284
1741
|
severity,
|
|
1285
|
-
category
|
|
1742
|
+
category,
|
|
1286
1743
|
title,
|
|
1287
1744
|
detail,
|
|
1288
1745
|
evidence,
|
|
@@ -1291,42 +1748,42 @@ server.registerTool("scout_finding", {
|
|
|
1291
1748
|
state: eng.currentState || "(unknown)",
|
|
1292
1749
|
session: eng.sessionKey,
|
|
1293
1750
|
});
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
? `⟳ REOPENED as a REGRESSION: finding ${finding.id} was previously resolved but the evidence reproduces again (seen in ${finding.runs} runs). Worth calling out to the user.`
|
|
1302
|
-
: `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, filed as ${finding.category}, seen in ${finding.runs} runs. If yours is a different bug, file it again: under the category that says what is wrong if it is another kind of defect (a finding filed as ${category} merges only with one filed as ${mergeableCategories(category).join(" or ")}), or with evidence naming the request that failed for you (method and path) — two findings are kept apart when both name requests and none is shared.`, session);
|
|
1751
|
+
if (filed.judgeError)
|
|
1752
|
+
logLine(`dedup judge: ${filed.judgeError}; the rule decided`);
|
|
1753
|
+
const picture = await findingPicture(eng, filed, ref);
|
|
1754
|
+
const result = text(filedText(filed, category) + picture.line, session);
|
|
1755
|
+
if (picture.image)
|
|
1756
|
+
result.content.push(picture.image);
|
|
1757
|
+
return result;
|
|
1303
1758
|
}
|
|
1304
1759
|
catch (err) {
|
|
1305
1760
|
return errorText(err);
|
|
1306
1761
|
}
|
|
1307
1762
|
}));
|
|
1308
1763
|
server.registerTool("scout_coverage", {
|
|
1309
|
-
description: "Show exploration coverage: states visited
|
|
1310
|
-
inputSchema: {
|
|
1311
|
-
|
|
1764
|
+
description: "Show exploration coverage: states visited, 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. In a parallel run it shows this session's own work by default — the routes it reached this run and the forms it saw — so one lane is not handed another's gaps; scope:\"project\" shows every session's, each form and route tagged with the sessions that saw it.",
|
|
1765
|
+
inputSchema: {
|
|
1766
|
+
scope: z
|
|
1767
|
+
.enum(["session", "project"])
|
|
1768
|
+
.optional()
|
|
1769
|
+
.describe("'session': only the routes this session reached this run and the forms it saw. 'project': every route in the memory, across runs and sessions. Default: 'session' when other sessions share this project, else 'project'."),
|
|
1770
|
+
session: sessionParam,
|
|
1771
|
+
},
|
|
1772
|
+
}, serializedPerSession("scout_coverage", async (args, session) => {
|
|
1312
1773
|
try {
|
|
1313
1774
|
const eng = engineFor(session);
|
|
1314
|
-
|
|
1775
|
+
const memory = eng.memory;
|
|
1776
|
+
if (!memory)
|
|
1315
1777
|
throw new Error("Not attached.");
|
|
1316
|
-
const
|
|
1778
|
+
const shared = [...engines.values()].some((e) => e !== eng && e.memory === memory);
|
|
1317
1779
|
const unvisited = eng.unvisitedKnownRoutes();
|
|
1318
1780
|
const lines = [
|
|
1319
|
-
...(
|
|
1781
|
+
...(memory.lastSaveError
|
|
1320
1782
|
? [
|
|
1321
|
-
`⚠ MEMORY WRITE FAILING: ${
|
|
1783
|
+
`⚠ MEMORY WRITE FAILING: ${memory.lastSaveError} — coverage/findings since the last successful write are NOT persisted to disk. If this doesn't clear on its own, check the project directory still exists and is writable.`,
|
|
1322
1784
|
]
|
|
1323
1785
|
: []),
|
|
1324
|
-
|
|
1325
|
-
formatRouteCoverage(eng.allKnownRoutes(), unvisited),
|
|
1326
|
-
`Unexercised elements by route:`,
|
|
1327
|
-
...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
|
|
1328
|
-
...formatUnchosenOptions(eng.memory.unchosenOptions()),
|
|
1329
|
-
...formatNeverSubmittedEmpty(eng.memory.formsNeverSubmittedEmpty()),
|
|
1786
|
+
...coverageView(memory, eng.sessionKey, args.scope ?? (shared ? "session" : "project"), formatRouteCoverage(eng.allKnownRoutes(), unvisited)),
|
|
1330
1787
|
];
|
|
1331
1788
|
return text(lines.join("\n"), session);
|
|
1332
1789
|
}
|
|
@@ -1346,9 +1803,13 @@ server.registerTool("scout_report", {
|
|
|
1346
1803
|
.enum(["index", "full"])
|
|
1347
1804
|
.default("index")
|
|
1348
1805
|
.describe("How much of the history to print. 'index' lists findings from earlier runs, and resolved ones, as a row each: id, severity, age, title. 'full' prints every one in full as before — on one project that was 1.75 MB against 113 KB, nearly half of it findings already fixed. Use 'full' when handing the document to someone who has no access to the memory."),
|
|
1806
|
+
report: z
|
|
1807
|
+
.enum(REPORT_AUDIENCES)
|
|
1808
|
+
.default(DEFAULT_REPORT_AUDIENCE)
|
|
1809
|
+
.describe("Which parts the report carries. 'both' (default): a plain-language section first — a short summary, then each problem with numbered steps, what was expected, what happened, its picture and its impact (blocks users, annoying, cosmetic), each with its technical detail folded beneath — followed by the technical report. 'qa': the plain section alone, for a tester or anyone not technical. 'dev': the technical report alone, as before the plain section existed. It applies to the files this call writes; the live view always shows both."),
|
|
1349
1810
|
session: sessionParam,
|
|
1350
1811
|
},
|
|
1351
|
-
}, serializedPerSession("scout_report", async ({ force, level, history }, session) => {
|
|
1812
|
+
}, serializedPerSession("scout_report", async ({ force, level, history, report }, session) => {
|
|
1352
1813
|
try {
|
|
1353
1814
|
const eng = engineFor(session);
|
|
1354
1815
|
if (!eng.memory)
|
|
@@ -1385,6 +1846,7 @@ server.registerTool("scout_report", {
|
|
|
1385
1846
|
routesTotal: all.length,
|
|
1386
1847
|
designAudits: auditsThisRun,
|
|
1387
1848
|
unvisitedRoutes: unvisited,
|
|
1849
|
+
knownRoutes: all,
|
|
1388
1850
|
mode: eng.mode,
|
|
1389
1851
|
});
|
|
1390
1852
|
if (lvl === "extensive" && gapList.length > 0) {
|
|
@@ -1399,21 +1861,25 @@ server.registerTool("scout_report", {
|
|
|
1399
1861
|
return text(`NOT GENERATED — the '${lvl}' completion contract is unmet:\n\n${gates.join("\n\n")}\n\n` +
|
|
1400
1862
|
`Then call scout_report again. Pass force=true ONLY if the user explicitly capped the budget.`, session);
|
|
1401
1863
|
}
|
|
1402
|
-
const { path: p, summary } = generateReport(eng.memory, eng.oracleLog.all, {
|
|
1864
|
+
const { path: p, summary, html, } = generateReport(eng.memory, eng.oracleLog.all, {
|
|
1403
1865
|
history,
|
|
1866
|
+
report,
|
|
1404
1867
|
routesVisited: all.length - unvisited.length,
|
|
1405
1868
|
routesTotal: all.length,
|
|
1406
1869
|
designAudits: auditsThisRun,
|
|
1407
1870
|
createdResources: eng.createdResources,
|
|
1408
1871
|
unvisitedRoutes: unvisited,
|
|
1872
|
+
knownRoutes: all,
|
|
1409
1873
|
mode: eng.mode,
|
|
1410
1874
|
trustedEmbeds: [...eng.trustedEmbeds],
|
|
1875
|
+
readPosts: eng.readPosts.map((e) => e.entry),
|
|
1411
1876
|
policyAttributed: eng.oracleLog.policyAttributed,
|
|
1412
1877
|
// Which sessions are still open decides whether a quiet one is holding a browser, and how long its trailing idle runs.
|
|
1413
1878
|
attachedSessions: [...engines.keys()],
|
|
1414
1879
|
});
|
|
1415
1880
|
void p;
|
|
1416
|
-
|
|
1881
|
+
const openNote = html && openDecisions.get(session)?.report ? openForUser("the report", html) : "";
|
|
1882
|
+
return text(summary + openNote, session);
|
|
1417
1883
|
}
|
|
1418
1884
|
catch (err) {
|
|
1419
1885
|
return errorText(err);
|
|
@@ -1477,6 +1943,108 @@ server.registerTool("scout_verify", {
|
|
|
1477
1943
|
return errorText(err);
|
|
1478
1944
|
}
|
|
1479
1945
|
}));
|
|
1946
|
+
// Answering the tickets: read their acceptance criteria, then record a verdict on each.
|
|
1947
|
+
server.registerTool("scout_tickets", {
|
|
1948
|
+
description: "Read the tickets or acceptance criteria the person gave this run, pasted (`text`) or from a file (`path`), and keep them so the report answers each criterion: passed, failed or not tested. " +
|
|
1949
|
+
'Recognises Given/When/Then scenarios, checklists, numbered and "AC1:" criteria, and lists under an "Acceptance criteria" heading; several tickets may be given at once. ' +
|
|
1950
|
+
"A ticket with no recognisable criteria is reported as such, never guessed at. Returns each criterion's id (AC1, AC2, …) to judge it by with scout_criterion. Touches no browser.",
|
|
1951
|
+
inputSchema: {
|
|
1952
|
+
text: z.string().max(MAX_TICKET_TEXT).optional().describe("The tickets as pasted. Pass this or `path`."),
|
|
1953
|
+
path: z
|
|
1954
|
+
.string()
|
|
1955
|
+
.optional()
|
|
1956
|
+
.describe(`A ticket file to read (${TICKET_FILE_EXTENSIONS.join(", ")}), absolute or relative to the project folder. Pass this or \`text\`.`),
|
|
1957
|
+
session: sessionParam,
|
|
1958
|
+
},
|
|
1959
|
+
}, serializedPerSession("scout_tickets", async ({ text: pasted, path: file }, session) => {
|
|
1960
|
+
try {
|
|
1961
|
+
const eng = engineFor(session);
|
|
1962
|
+
if (!eng.memory)
|
|
1963
|
+
throw new Error("Not attached — attach first, so the tickets are kept with the project.");
|
|
1964
|
+
if ((pasted === undefined) === (file === undefined))
|
|
1965
|
+
return text("Pass the tickets as `text`, or a file as `path`: one of the two.", session);
|
|
1966
|
+
let body = pasted ?? "";
|
|
1967
|
+
let source = "pasted text";
|
|
1968
|
+
if (file !== undefined) {
|
|
1969
|
+
// The file a link points at is what is read, so its name is what is checked: a "notes.md" link to a key file is refused.
|
|
1970
|
+
const full = fs.realpathSync(path.isAbsolute(file) ? file : path.resolve(path.dirname(eng.memory.dir), file));
|
|
1971
|
+
if (!isTicketFileName(full))
|
|
1972
|
+
return text(`Not read: a ticket file is one of ${TICKET_FILE_EXTENSIONS.join(", ")}. Paste anything else as \`text\`.`, session);
|
|
1973
|
+
const stat = fs.statSync(full);
|
|
1974
|
+
if (!stat.isFile())
|
|
1975
|
+
return text(`Not read: ${full} is not a file.`, session);
|
|
1976
|
+
if (stat.size > MAX_TICKET_FILE_BYTES)
|
|
1977
|
+
return text(`Not read: ${full} is larger than ${MAX_TICKET_FILE_BYTES} bytes.`, session);
|
|
1978
|
+
body = fs.readFileSync(full, "utf8");
|
|
1979
|
+
source = path.basename(full);
|
|
1980
|
+
}
|
|
1981
|
+
if (!body.trim())
|
|
1982
|
+
return text("Nothing to read: the tickets are empty.", session);
|
|
1983
|
+
const parsed = parseTickets(body, source);
|
|
1984
|
+
const kept = eng.memory.addTickets(parsed);
|
|
1985
|
+
// Say what a bound left out, so a long backlog is never answered in part without a word.
|
|
1986
|
+
const cuts = [
|
|
1987
|
+
...(body.length > MAX_TICKET_TEXT ? [`only the first ${MAX_TICKET_TEXT} characters were read`] : []),
|
|
1988
|
+
...(parsed.length >= MAX_TICKETS ? [`at most ${MAX_TICKETS} tickets are read at once`] : []),
|
|
1989
|
+
];
|
|
1990
|
+
return text(formatReading(kept) + (cuts.length ? `\n\n⚠ Not everything was read: ${cuts.join("; ")}. Read the rest in another call.` : ""), session);
|
|
1991
|
+
}
|
|
1992
|
+
catch (err) {
|
|
1993
|
+
return errorText(err);
|
|
1994
|
+
}
|
|
1995
|
+
}));
|
|
1996
|
+
server.registerTool("scout_criterion", {
|
|
1997
|
+
description: "Record whether one acceptance criterion of a ticket read with scout_tickets passed, failed or was not tested, with how sure you are. " +
|
|
1998
|
+
"The link from a criterion to the findings that show it is YOUR judgement, stated with a confidence — never matched on words. " +
|
|
1999
|
+
'A "fail" names the findings that show it (file them with scout_finding first); "not-tested" says why in untestedBecause. Recording the same criterion again from the same session replaces your earlier verdict. Touches no browser.',
|
|
2000
|
+
inputSchema: {
|
|
2001
|
+
ticket: z.string().min(1).describe('The ticket\'s id as scout_tickets gave it, e.g. "PROJ-12" or "T1"'),
|
|
2002
|
+
criterion: z.string().min(1).describe('The criterion\'s id, e.g. "AC2" (or just "2")'),
|
|
2003
|
+
verdict: z.enum(CRITERION_VERDICTS).describe('"pass", "fail" or "not-tested"'),
|
|
2004
|
+
findings: z
|
|
2005
|
+
.array(z.string())
|
|
2006
|
+
.max(MAX_CRITERION_FINDINGS)
|
|
2007
|
+
.optional()
|
|
2008
|
+
.describe("Ids of the findings that show this criterion failing (required for a fail; may be given for a pass, none for not-tested)"),
|
|
2009
|
+
confidence: z.number().min(0).max(1).describe("How sure you are of this verdict and of the findings linked to it, from 0 to 1. State it honestly"),
|
|
2010
|
+
reason: z.string().min(1).max(MAX_REASON).describe("What you saw, or why it could not be tried, in a sentence"),
|
|
2011
|
+
untestedBecause: z
|
|
2012
|
+
.enum(NOT_TESTED_REASONS)
|
|
2013
|
+
.optional()
|
|
2014
|
+
.describe('Only with verdict "not-tested": "no-access" (the role this run used could not reach it), "observe-blocked" (it needs a change sent and this session is in observe mode), "out-of-scope" (it is outside what this run could check, such as an email or another system)'),
|
|
2015
|
+
session: sessionParam,
|
|
2016
|
+
},
|
|
2017
|
+
}, serializedPerSession("scout_criterion", async (args, session) => {
|
|
2018
|
+
try {
|
|
2019
|
+
const eng = engineFor(session);
|
|
2020
|
+
const memory = eng.memory;
|
|
2021
|
+
if (!memory)
|
|
2022
|
+
throw new Error("Not attached.");
|
|
2023
|
+
const judged = judgeCriterion(args, {
|
|
2024
|
+
tickets: memory.tickets,
|
|
2025
|
+
findings: memory.findings,
|
|
2026
|
+
mode: eng.mode,
|
|
2027
|
+
session: eng.sessionKey,
|
|
2028
|
+
at: new Date().toISOString(),
|
|
2029
|
+
});
|
|
2030
|
+
if (!judged.ok)
|
|
2031
|
+
return text(`Not recorded: ${judged.reason}.`, session);
|
|
2032
|
+
memory.addCriterionVerdict(judged.record);
|
|
2033
|
+
const r = judged.record;
|
|
2034
|
+
const ticket = memory.tickets.find((t) => t.id === r.ticket);
|
|
2035
|
+
const criterion = ticket ? findCriterion(ticket, r.criterion) : undefined;
|
|
2036
|
+
const linked = r.findings.map((id) => memory.findings.find((f) => f.id === id)).filter((f) => f !== undefined);
|
|
2037
|
+
const said = r.verdict === "pass" ? "passes" : r.verdict === "fail" ? "fails" : `was not tested (${r.untestedBecause})`;
|
|
2038
|
+
return text([
|
|
2039
|
+
`Recorded: ${r.ticket} ${r.criterion} ${said}, confidence ${r.confidence.toFixed(2)}.`,
|
|
2040
|
+
...(criterion ? [` ${criterion.text}`] : []),
|
|
2041
|
+
...linked.map((f) => ` linked: [${f.severity}] ${f.title} (${f.id})`),
|
|
2042
|
+
].join("\n"), session);
|
|
2043
|
+
}
|
|
2044
|
+
catch (err) {
|
|
2045
|
+
return errorText(err);
|
|
2046
|
+
}
|
|
2047
|
+
}));
|
|
1480
2048
|
server.registerTool("scout_close", {
|
|
1481
2049
|
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. " +
|
|
1482
2050
|
"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.",
|
|
@@ -1529,6 +2097,7 @@ server.registerTool("scout_close", {
|
|
|
1529
2097
|
await eng.close();
|
|
1530
2098
|
const saveError = eng.memory?.lastSaveError;
|
|
1531
2099
|
engines.delete(name);
|
|
2100
|
+
openDecisions.delete(name);
|
|
1532
2101
|
// The last session on this project ends its run.
|
|
1533
2102
|
if (eng.memory && ![...engines.values()].some((e) => e.memory === eng.memory))
|
|
1534
2103
|
eng.memory.endRun();
|
|
@@ -1589,7 +2158,7 @@ async function shutdown() {
|
|
|
1589
2158
|
console.error(`[scenescout] could not remove the live view's token file in ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1590
2159
|
}
|
|
1591
2160
|
}
|
|
1592
|
-
await Promise.allSettled([live?.stop(), ...[...engines.values()].map((e) => e.close())]);
|
|
2161
|
+
await Promise.allSettled([live?.stop(), ...[...engines.values()].map((e) => e.close()), ...pendingLogins.all().map((p) => p.cancel())]);
|
|
1593
2162
|
}
|
|
1594
2163
|
process.on("SIGINT", () => {
|
|
1595
2164
|
void shutdown().finally(() => process.exit(0));
|