scenescout 3.10.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.
- package/CHANGELOG.md +10 -0
- package/README.md +12 -3
- package/dist/browsers.js +20 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +252 -19
- package/dist/engine/check.js +251 -33
- package/dist/engine/flow.js +398 -0
- package/dist/engine/memory.js +10 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +2 -2
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved flows: the steps `scenescout check` replays, as data.
|
|
3
|
+
*
|
|
4
|
+
* A flow is a short script a person or an agent wrote down once because it
|
|
5
|
+
* matters that it keeps working: open a page, click a control, see a result.
|
|
6
|
+
* The check replays every flow in `.scenescout/flows/*.json` after its crawl,
|
|
7
|
+
* with no model, so a flow that passed yesterday and breaks today fails the
|
|
8
|
+
* gate naming the flow and the step (ADR 12).
|
|
9
|
+
*
|
|
10
|
+
* The steps are `scout_run_plan`'s steps, so a plan that worked in an
|
|
11
|
+
* exploratory run can be saved as it is, plus three assertions a plan has no
|
|
12
|
+
* need for: text is visible, the URL matches, a request answered with a status.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure: the schema and its error messages, the target
|
|
15
|
+
* grammar, and the rules that decide whether a URL or a request matched. The
|
|
16
|
+
* browser half is BrowserEngine.replayFlow, which only acts and reports.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { beaconResourceType, BROWSER_ENGINES } from "../browsers.js";
|
|
22
|
+
import { MEMORY_DIRNAME, SELF_IGNORE_KEEP } from "./memory.js";
|
|
23
|
+
/** Most steps one flow may hold. A longer flow is several flows, and each would fail more usefully on its own. */
|
|
24
|
+
export const MAX_FLOW_STEPS = 50;
|
|
25
|
+
/** Most flows one check replays. */
|
|
26
|
+
export const MAX_FLOWS = 50;
|
|
27
|
+
/** How long a step waits for its target, text, URL or request before it counts as broken. */
|
|
28
|
+
export const FLOW_STEP_TIMEOUT_MS = 5000;
|
|
29
|
+
/**
|
|
30
|
+
* How long a flow waits after its last step for a write that step set off
|
|
31
|
+
* late (a save on a timer, a debounced autosave), before it counts as passed.
|
|
32
|
+
*/
|
|
33
|
+
export const FLOW_AFTER_LAST_STEP_MS = 750;
|
|
34
|
+
/** Where a project keeps its flows, relative to the project directory. */
|
|
35
|
+
export const FLOWS_SUBDIR = path.join(MEMORY_DIRNAME, SELF_IGNORE_KEEP);
|
|
36
|
+
export const TARGET_HELP = 'testid=…, text=…, label=… or role=<role>[name="…"]';
|
|
37
|
+
/**
|
|
38
|
+
* The ARIA roles a `role=` target may name (WAI-ARIA 1.2, as Playwright's
|
|
39
|
+
* getByRole accepts them). A misspelt role matches nothing, so a flow using
|
|
40
|
+
* one would only ever fail at replay; it is refused when the file is read.
|
|
41
|
+
*/
|
|
42
|
+
export const ARIA_ROLE_LIST = [
|
|
43
|
+
"alert",
|
|
44
|
+
"alertdialog",
|
|
45
|
+
"application",
|
|
46
|
+
"article",
|
|
47
|
+
"banner",
|
|
48
|
+
"blockquote",
|
|
49
|
+
"button",
|
|
50
|
+
"caption",
|
|
51
|
+
"cell",
|
|
52
|
+
"checkbox",
|
|
53
|
+
"code",
|
|
54
|
+
"columnheader",
|
|
55
|
+
"combobox",
|
|
56
|
+
"complementary",
|
|
57
|
+
"contentinfo",
|
|
58
|
+
"definition",
|
|
59
|
+
"deletion",
|
|
60
|
+
"dialog",
|
|
61
|
+
"directory",
|
|
62
|
+
"document",
|
|
63
|
+
"emphasis",
|
|
64
|
+
"feed",
|
|
65
|
+
"figure",
|
|
66
|
+
"form",
|
|
67
|
+
"generic",
|
|
68
|
+
"grid",
|
|
69
|
+
"gridcell",
|
|
70
|
+
"group",
|
|
71
|
+
"heading",
|
|
72
|
+
"img",
|
|
73
|
+
"insertion",
|
|
74
|
+
"link",
|
|
75
|
+
"list",
|
|
76
|
+
"listbox",
|
|
77
|
+
"listitem",
|
|
78
|
+
"log",
|
|
79
|
+
"main",
|
|
80
|
+
"marquee",
|
|
81
|
+
"math",
|
|
82
|
+
"meter",
|
|
83
|
+
"menu",
|
|
84
|
+
"menubar",
|
|
85
|
+
"menuitem",
|
|
86
|
+
"menuitemcheckbox",
|
|
87
|
+
"menuitemradio",
|
|
88
|
+
"navigation",
|
|
89
|
+
"none",
|
|
90
|
+
"note",
|
|
91
|
+
"option",
|
|
92
|
+
"paragraph",
|
|
93
|
+
"presentation",
|
|
94
|
+
"progressbar",
|
|
95
|
+
"radio",
|
|
96
|
+
"radiogroup",
|
|
97
|
+
"region",
|
|
98
|
+
"row",
|
|
99
|
+
"rowgroup",
|
|
100
|
+
"rowheader",
|
|
101
|
+
"scrollbar",
|
|
102
|
+
"search",
|
|
103
|
+
"searchbox",
|
|
104
|
+
"separator",
|
|
105
|
+
"slider",
|
|
106
|
+
"spinbutton",
|
|
107
|
+
"status",
|
|
108
|
+
"strong",
|
|
109
|
+
"subscript",
|
|
110
|
+
"superscript",
|
|
111
|
+
"switch",
|
|
112
|
+
"tab",
|
|
113
|
+
"table",
|
|
114
|
+
"tablist",
|
|
115
|
+
"tabpanel",
|
|
116
|
+
"term",
|
|
117
|
+
"textbox",
|
|
118
|
+
"time",
|
|
119
|
+
"timer",
|
|
120
|
+
"toolbar",
|
|
121
|
+
"tooltip",
|
|
122
|
+
"tree",
|
|
123
|
+
"treegrid",
|
|
124
|
+
"treeitem",
|
|
125
|
+
];
|
|
126
|
+
export const ARIA_ROLES = new Set(ARIA_ROLE_LIST);
|
|
127
|
+
export const ARIA_ROLES_MATCH_PLAYWRIGHT = ARIA_ROLE_LIST;
|
|
128
|
+
/** Parse a target string, or null when it is not one. */
|
|
129
|
+
export function parseTarget(target) {
|
|
130
|
+
for (const by of ["testid", "text", "label"]) {
|
|
131
|
+
if (target.startsWith(`${by}=`)) {
|
|
132
|
+
const value = target.slice(by.length + 1);
|
|
133
|
+
return value ? { by, value } : null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const role = /^role=([a-z]+)(?:\[name=(?:"([^"]*)"|'([^']*)'|([^\]"']*))\])?$/i.exec(target);
|
|
137
|
+
if (!role)
|
|
138
|
+
return null;
|
|
139
|
+
const name = role[2] ?? role[3] ?? role[4];
|
|
140
|
+
return name === undefined ? { by: "role", role: role[1].toLowerCase() } : { by: "role", role: role[1].toLowerCase(), name };
|
|
141
|
+
}
|
|
142
|
+
const REQUEST_RE = /^(GET|POST|PUT|PATCH|DELETE|HEAD) (\/\S*)$/;
|
|
143
|
+
function isRegex(pattern) {
|
|
144
|
+
try {
|
|
145
|
+
new RegExp(pattern);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const target = z
|
|
153
|
+
.string()
|
|
154
|
+
.refine((t) => parseTarget(t) !== null, { message: `must be ${TARGET_HELP}` })
|
|
155
|
+
.superRefine((t, ctx) => {
|
|
156
|
+
const parsed = parseTarget(t);
|
|
157
|
+
if (parsed?.by === "role" && !ARIA_ROLES.has(parsed.role)) {
|
|
158
|
+
ctx.addIssue({
|
|
159
|
+
code: z.ZodIssueCode.custom,
|
|
160
|
+
message: `names the role "${parsed.role}", which is not an ARIA role (e.g. button, link, textbox, checkbox, tab)`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
const STEP_SCHEMAS = [
|
|
165
|
+
z.object({ action: z.literal("navigate"), target: z.string().regex(/^\//, "must be a path on the app, starting with /") }).strict(),
|
|
166
|
+
z.object({ action: z.literal("click"), target }).strict(),
|
|
167
|
+
z.object({ action: z.literal("type"), target, value: z.string(), pressEnter: z.boolean().optional(), replace: z.boolean().optional() }).strict(),
|
|
168
|
+
z.object({ action: z.literal("select"), target, value: z.string() }).strict(),
|
|
169
|
+
z.object({ action: z.literal("press"), value: z.string().min(1, "names the key to press, e.g. Enter") }).strict(),
|
|
170
|
+
z.object({ action: z.literal("expect-text"), text: z.string().min(1, "is the text that must be visible") }).strict(),
|
|
171
|
+
z.object({ action: z.literal("expect-url"), pattern: z.string().min(1).refine(isRegex, { message: "is not a valid regular expression" }) }).strict(),
|
|
172
|
+
z
|
|
173
|
+
.object({
|
|
174
|
+
action: z.literal("expect-request"),
|
|
175
|
+
request: z.string().regex(REQUEST_RE, 'must be a method and a path, e.g. "GET /api/things"'),
|
|
176
|
+
status: z.union([z.number().int().min(100).max(599), z.string().regex(/^[1-5]xx$/, 'must be a status such as 200, or a class such as "2xx"')]),
|
|
177
|
+
})
|
|
178
|
+
.strict(),
|
|
179
|
+
];
|
|
180
|
+
const stepSchema = z.discriminatedUnion("action", [...STEP_SCHEMAS]);
|
|
181
|
+
export const FLOW_ACTIONS = STEP_SCHEMAS.map((s) => s.shape.action.value);
|
|
182
|
+
const flowSchema = z
|
|
183
|
+
.object({
|
|
184
|
+
name: z.string().min(1).max(100).optional(),
|
|
185
|
+
description: z.string().max(500).optional(),
|
|
186
|
+
steps: z.array(stepSchema).min(1, "needs at least one step").max(MAX_FLOW_STEPS, `holds at most ${MAX_FLOW_STEPS} steps`),
|
|
187
|
+
})
|
|
188
|
+
.strict()
|
|
189
|
+
.superRefine((flow, ctx) => {
|
|
190
|
+
// A flow starts from a page it names, never from wherever the crawl left the browser.
|
|
191
|
+
if (flow.steps[0] && flow.steps[0].action !== "navigate") {
|
|
192
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["steps", 0, "action"], message: 'must be "navigate": a flow starts from a page it names' });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
/** `steps[2].target`, the way a person would look for it in the file. */
|
|
196
|
+
function fieldPath(p) {
|
|
197
|
+
if (p.length === 0)
|
|
198
|
+
return "(the whole file)";
|
|
199
|
+
return p.map((part, i) => (typeof part === "number" ? `[${part}]` : i === 0 ? part : `.${part}`)).join("");
|
|
200
|
+
}
|
|
201
|
+
function issueMessage(issue) {
|
|
202
|
+
if (issue.code === "unrecognized_keys")
|
|
203
|
+
return `unknown field(s) ${issue.keys.map((k) => `"${k}"`).join(", ")}`;
|
|
204
|
+
if (issue.code === "invalid_union_discriminator")
|
|
205
|
+
return `must be one of ${FLOW_ACTIONS.join(", ")}`;
|
|
206
|
+
if (issue.code === "invalid_type" && issue.received === "undefined")
|
|
207
|
+
return "is required";
|
|
208
|
+
return issue.message;
|
|
209
|
+
}
|
|
210
|
+
/** Validate one flow file's text. Every mistake names the file and the field. */
|
|
211
|
+
export function parseFlow(text, file) {
|
|
212
|
+
let json;
|
|
213
|
+
try {
|
|
214
|
+
json = JSON.parse(text);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
return { ok: false, error: `${file}: not valid JSON (${err instanceof Error ? err.message : String(err)})` };
|
|
218
|
+
}
|
|
219
|
+
const parsed = flowSchema.safeParse(json);
|
|
220
|
+
if (!parsed.success) {
|
|
221
|
+
const issues = parsed.error.issues.slice(0, 5).map((i) => `${fieldPath(i.path)} ${issueMessage(i)}`);
|
|
222
|
+
const more = parsed.error.issues.length > 5 ? ` (and ${parsed.error.issues.length - 5} more)` : "";
|
|
223
|
+
return { ok: false, error: `${file}: ${issues.join("; ")}${more}` };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, flow: { name: parsed.data.name ?? file.replace(/\.json$/i, ""), file, steps: parsed.data.steps } };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Read every `*.json` in a flows directory, in name order. Throws on the first
|
|
229
|
+
* file that is not a valid flow: a flow that cannot be read would otherwise
|
|
230
|
+
* pass by never running. Everything else in the directory is returned as
|
|
231
|
+
* skipped with its reason, so nothing there is left out without saying so. A
|
|
232
|
+
* symbolic link is followed when it resolves to a file inside the directory,
|
|
233
|
+
* and skipped when it resolves anywhere else.
|
|
234
|
+
*/
|
|
235
|
+
export function loadFlows(dir) {
|
|
236
|
+
const realDir = fs.realpathSync(dir);
|
|
237
|
+
const files = [];
|
|
238
|
+
const skipped = [];
|
|
239
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))) {
|
|
240
|
+
if (e.isDirectory()) {
|
|
241
|
+
skipped.push({ file: e.name, reason: "a directory: flows are not read from subdirectories" });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!e.name.toLowerCase().endsWith(".json")) {
|
|
245
|
+
skipped.push({ file: e.name, reason: "not a .json file" });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (e.isSymbolicLink()) {
|
|
249
|
+
let target;
|
|
250
|
+
try {
|
|
251
|
+
target = fs.realpathSync(path.join(dir, e.name));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
skipped.push({ file: e.name, reason: "a symbolic link to nothing" });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (!target.startsWith(realDir + path.sep)) {
|
|
258
|
+
skipped.push({ file: e.name, reason: "a symbolic link to a file outside the flows directory" });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (!fs.statSync(target).isFile()) {
|
|
262
|
+
skipped.push({ file: e.name, reason: "a symbolic link to something that is not a file" });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (!e.isFile()) {
|
|
267
|
+
skipped.push({ file: e.name, reason: "not a regular file" });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
files.push(e.name);
|
|
271
|
+
}
|
|
272
|
+
if (files.length > MAX_FLOWS)
|
|
273
|
+
throw new Error(`${dir} holds ${files.length} flows; a check replays at most ${MAX_FLOWS}`);
|
|
274
|
+
const flows = files.map((file) => {
|
|
275
|
+
const parsed = parseFlow(fs.readFileSync(path.join(dir, file), "utf8"), file);
|
|
276
|
+
if (!parsed.ok)
|
|
277
|
+
throw new Error(`flow ${parsed.error}`);
|
|
278
|
+
return parsed.flow;
|
|
279
|
+
});
|
|
280
|
+
return { flows, skipped };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Which flows directory a check uses: the one named, none for "off", and
|
|
284
|
+
* otherwise the project's own when it exists. A directory that was named and
|
|
285
|
+
* is missing is an error, never a quiet check with no flows.
|
|
286
|
+
*/
|
|
287
|
+
export function resolveFlowsDir(flag, projectDir, exists) {
|
|
288
|
+
if (flag === "off")
|
|
289
|
+
return { dir: null };
|
|
290
|
+
if (flag !== undefined)
|
|
291
|
+
return exists(flag) ? { dir: flag } : { error: `--flows: no directory at ${flag}` };
|
|
292
|
+
const own = path.join(projectDir, FLOWS_SUBDIR);
|
|
293
|
+
return { dir: exists(own) ? own : null };
|
|
294
|
+
}
|
|
295
|
+
/** A step as the report names it. */
|
|
296
|
+
export function describeStep(step) {
|
|
297
|
+
switch (step.action) {
|
|
298
|
+
case "navigate":
|
|
299
|
+
case "click":
|
|
300
|
+
case "select":
|
|
301
|
+
return `${step.action} ${step.target}`;
|
|
302
|
+
case "type":
|
|
303
|
+
return `type into ${step.target}${step.pressEnter ? " and press Enter" : ""}`;
|
|
304
|
+
case "press":
|
|
305
|
+
return `press ${step.value}`;
|
|
306
|
+
case "expect-text":
|
|
307
|
+
return `expect text ${JSON.stringify(step.text)}`;
|
|
308
|
+
case "expect-url":
|
|
309
|
+
return `expect the URL to match /${step.pattern}/`;
|
|
310
|
+
case "expect-request":
|
|
311
|
+
return `expect ${step.request} → ${step.status}`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Whether a step acts on the page (and so opens a new window for expect-request) rather than reading it. */
|
|
315
|
+
export function isAction(step) {
|
|
316
|
+
return !step.action.startsWith("expect-");
|
|
317
|
+
}
|
|
318
|
+
/** Path, query and hash: what an expect-url pattern is tested against, so the same flow passes on a laptop and a preview deployment. */
|
|
319
|
+
export function urlMatches(pattern, url) {
|
|
320
|
+
let tail;
|
|
321
|
+
try {
|
|
322
|
+
const u = new URL(url);
|
|
323
|
+
tail = `${u.pathname}${u.search}${u.hash}`;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
tail = url;
|
|
327
|
+
}
|
|
328
|
+
return new RegExp(pattern).test(tail);
|
|
329
|
+
}
|
|
330
|
+
/** A path pattern against a request's path: equal segment for segment, where `*` stands for any one segment. A trailing slash is ignored. */
|
|
331
|
+
export function requestPathMatches(pattern, pathname) {
|
|
332
|
+
const split = (p) => p.replace(/\/+$/, "").split("/");
|
|
333
|
+
const want = split(pattern);
|
|
334
|
+
const got = split(pathname);
|
|
335
|
+
return want.length === got.length && want.every((seg, i) => seg === "*" || seg === got[i]);
|
|
336
|
+
}
|
|
337
|
+
export function statusMatches(expected, status) {
|
|
338
|
+
return typeof expected === "number" ? status === expected : String(status)[0] === expected[0];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Whether the requests seen since the last action include the one a step
|
|
342
|
+
* expects. The reason says what was seen instead, which is what a person
|
|
343
|
+
* fixing it needs: the right request with the wrong status, or none at all.
|
|
344
|
+
*/
|
|
345
|
+
export function matchRequest(expect, seen) {
|
|
346
|
+
const [, method, pattern] = REQUEST_RE.exec(expect.request) ?? [];
|
|
347
|
+
const same = seen.filter((r) => {
|
|
348
|
+
if (r.method.toUpperCase() !== method)
|
|
349
|
+
return false;
|
|
350
|
+
try {
|
|
351
|
+
return requestPathMatches(pattern, new URL(r.url).pathname);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
if (same.some((r) => statusMatches(expect.status, r.status)))
|
|
358
|
+
return { ok: true };
|
|
359
|
+
if (same.length > 0) {
|
|
360
|
+
const statuses = [...new Set(same.map((r) => r.status))].join(", ");
|
|
361
|
+
return { ok: false, reason: `${expect.request} answered ${statuses}, expected ${expect.status}` };
|
|
362
|
+
}
|
|
363
|
+
return { ok: false, reason: `no ${expect.request} request was sent since the last action` };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The request kinds a refused write may be excused from a step for: what
|
|
367
|
+
* navigator.sendBeacon and <a ping> send, under every engine's name for it
|
|
368
|
+
* (browsers.ts `beaconResourceType`). They are fire-and-forget by design, a
|
|
369
|
+
* page sends them on its own schedule, and no step waits for one.
|
|
370
|
+
*/
|
|
371
|
+
export const BACKGROUND_REQUEST_TYPES = new Set(["ping", ...BROWSER_ENGINES.map(beaconResourceType)]);
|
|
372
|
+
/**
|
|
373
|
+
* Split what the write policy refused during a step into what the step is
|
|
374
|
+
* charged with and background requests, which are only listed. The rule is
|
|
375
|
+
* the request's kind, never its origin: an app's API is often on another port
|
|
376
|
+
* or subdomain, and deciding by origin either hid a step's own write or
|
|
377
|
+
* blamed one on the app. So a fetch, an XHR or a form post is charged to the
|
|
378
|
+
* step wherever it goes, telemetry sent with fetch included; only a beacon or
|
|
379
|
+
* a ping is excused. A refusal with no kind recorded is charged. The app's
|
|
380
|
+
* origin is dropped from what is charged, for a shorter reason.
|
|
381
|
+
*/
|
|
382
|
+
export function splitRefusals(refusals, appOrigin) {
|
|
383
|
+
const charged = [];
|
|
384
|
+
const background = [];
|
|
385
|
+
for (const { sig, type } of refusals) {
|
|
386
|
+
if (type !== undefined && BACKGROUND_REQUEST_TYPES.has(type))
|
|
387
|
+
background.push(sig);
|
|
388
|
+
else
|
|
389
|
+
charged.push(sig.split(appOrigin).join(""));
|
|
390
|
+
}
|
|
391
|
+
return { charged, background };
|
|
392
|
+
}
|
|
393
|
+
/** The step a run stopped at, as the evidence of its issue or the reason the check could not run. */
|
|
394
|
+
export function flowStepEvidence(run) {
|
|
395
|
+
if (run.outcome.status === "passed")
|
|
396
|
+
return "";
|
|
397
|
+
return `flow "${run.name}" (${run.file}) step ${run.outcome.step} of ${run.steps}, ${run.outcome.did}: ${run.outcome.reason}`;
|
|
398
|
+
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -541,6 +541,8 @@ function sameFinding(a, b) {
|
|
|
541
541
|
}
|
|
542
542
|
/** Per-project directory for memory, session logs, and reports. */
|
|
543
543
|
export const MEMORY_DIRNAME = ".scenescout";
|
|
544
|
+
/** The one directory under it that is committed rather than ignored: saved flows (flow.ts). */
|
|
545
|
+
export const SELF_IGNORE_KEEP = "flows";
|
|
544
546
|
/** What the directory was called before the tool was renamed. */
|
|
545
547
|
export const LEGACY_MEMORY_DIRNAME = ".scenecraft";
|
|
546
548
|
/**
|
|
@@ -611,6 +613,10 @@ function liveOwnerPid(dir, isAlive) {
|
|
|
611
613
|
* `git status`. That gets the same result without editing a file we do not
|
|
612
614
|
* own, and it disappears cleanly when someone deletes the directory.
|
|
613
615
|
*
|
|
616
|
+
* The one exception is `flows/`: saved flows are inputs a team commits so
|
|
617
|
+
* CI replays them, not output. A file written before that exception existed
|
|
618
|
+
* is still left alone; docs/ci.md says which two lines to add to it.
|
|
619
|
+
*
|
|
614
620
|
* Best-effort and idempotent — a read-only checkout must never break attach,
|
|
615
621
|
* and an existing file is left exactly as the user left it.
|
|
616
622
|
*/
|
|
@@ -621,7 +627,10 @@ export function writeSelfIgnore(dir) {
|
|
|
621
627
|
return null;
|
|
622
628
|
fs.writeFileSync(ignorePath, "# SceneScout exploratory-test artifacts: memory, session logs, status, report.\n" +
|
|
623
629
|
"# Self-ignoring so a `git add -A` in the tested project can never commit them.\n" +
|
|
624
|
-
"*\n"
|
|
630
|
+
"*\n" +
|
|
631
|
+
"# Except the flows `scenescout check` replays: they are written to be committed.\n" +
|
|
632
|
+
`!${SELF_IGNORE_KEEP}/\n` +
|
|
633
|
+
`!${SELF_IGNORE_KEEP}/*.json\n`);
|
|
625
634
|
return `Wrote ${MEMORY_DIRNAME}/.gitignore so these test artifacts stay out of your commits.`;
|
|
626
635
|
}
|
|
627
636
|
catch {
|
package/dist/engine/verify.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -849,7 +849,7 @@ server.registerTool("scout_crawl", {
|
|
|
849
849
|
}
|
|
850
850
|
}, 600_000));
|
|
851
851
|
server.registerTool("scout_run_plan", {
|
|
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=…',
|
|
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).",
|
|
853
853
|
inputSchema: {
|
|
854
854
|
steps: z
|
|
855
855
|
.array(z.object({
|
|
@@ -857,7 +857,7 @@ server.registerTool("scout_run_plan", {
|
|
|
857
857
|
target: z
|
|
858
858
|
.string()
|
|
859
859
|
.optional()
|
|
860
|
-
.describe("testid=…, text=…, label
|
|
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)"),
|
|
861
861
|
value: z
|
|
862
862
|
.string()
|
|
863
863
|
.optional()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.
|
|
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,7 +72,7 @@
|
|
|
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 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 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",
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
"install-test": "tsx --test scripts/install-test.ts",
|
|
86
87
|
"live-test": "tsx --test scripts/live-test.ts",
|
|
87
88
|
"demo-test": "tsx --test scripts/demo-test.ts",
|
|
89
|
+
"holdout-test": "tsx --test scripts/holdout-test.ts",
|
|
88
90
|
"hygiene-test": "tsx --test scripts/hygiene-test.ts",
|
|
89
91
|
"pace-test": "tsx --test scripts/pace-test.ts",
|
|
90
92
|
"request-test": "tsx --test scripts/request-test.ts",
|
|
@@ -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
|
|
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.
|