scenescout 3.23.3 → 3.23.4
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 +11 -0
- package/README.md +20 -3
- package/dist/browsers.js +7 -6
- package/dist/ci-run.js +3 -2
- package/dist/cli.js +2 -1
- package/dist/clients.js +3 -3
- package/dist/code-routes.js +2 -2
- package/dist/commands.js +4 -4
- package/dist/engine/args.js +21 -0
- package/dist/engine/bench.js +1 -1
- package/dist/engine/brief.js +1 -1
- package/dist/engine/browser.js +59 -15
- package/dist/engine/capture.js +4 -4
- package/dist/engine/check-replay.js +82 -8
- package/dist/engine/check-report.js +8 -8
- package/dist/engine/check.js +15 -15
- package/dist/engine/ci.js +20 -20
- package/dist/engine/claims.js +4 -4
- package/dist/engine/collector.js +7 -7
- package/dist/engine/crawl.js +1 -1
- package/dist/engine/dedup.js +4 -4
- package/dist/engine/design.js +7 -7
- package/dist/engine/expiry.js +1 -1
- package/dist/engine/export.js +7 -7
- package/dist/engine/flow.js +10 -10
- package/dist/engine/forms.js +1 -1
- package/dist/engine/from-run.js +6 -6
- package/dist/engine/injection.js +1 -1
- package/dist/engine/lane.js +1 -1
- package/dist/engine/limits.js +2 -2
- package/dist/engine/live.js +30 -0
- package/dist/engine/memory.js +24 -7
- package/dist/engine/open.js +1 -1
- package/dist/engine/oracles.js +2 -2
- package/dist/engine/plain.js +1 -1
- package/dist/engine/png.js +2 -2
- package/dist/engine/policy.js +2 -2
- package/dist/engine/postmessage.js +7 -7
- package/dist/engine/project-folder.js +1 -1
- package/dist/engine/provider.js +1 -1
- package/dist/engine/replay.js +3 -1
- package/dist/engine/report.js +3 -3
- package/dist/engine/sarif.js +2 -2
- package/dist/engine/script-literal.js +11 -0
- package/dist/engine/sensitive.js +254 -0
- package/dist/engine/signed-in.js +2 -2
- package/dist/engine/status-pane-page.js +4 -3
- package/dist/engine/task.js +1 -1
- package/dist/engine/teardown.js +2 -0
- package/dist/engine/tickets.js +6 -6
- package/dist/engine/verify.js +3 -3
- package/dist/export-run.js +1 -1
- package/dist/installer.js +4 -4
- package/dist/login-run.js +2 -2
- package/dist/mcp-server.js +6 -9
- package/dist/playbook.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.23.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 83d639e: The live view's token files are now always owner-only: a token file left in the project with wider permissions, or a link left at its name, is replaced rather than written through.
|
|
8
|
+
- ca10b22: Typed passwords and other secret fields are now masked in logs, reports and exported issues. A value typed into a password field, a field whose `autocomplete` marks a credential, a one-time code or a card, or a field whose name or label says so, is shown as `•••• (masked: password field)` in the tool's result, the action log, memory, the report, the live view's feed and a finding's steps, and so is what the field held before. Text the run keeps is also cleared of email addresses and card numbers, of a value typed after a label that names a secret, whatever its length, and of the value of a parameter named for a secret (`?password=…`, `?pin=…`).
|
|
9
|
+
- a2fd38f: The `/scenescout qa` reply no longer lets a finding's title, a journey's name or anything else from the run render as maths (`$…$`), reference an issue or pull request (`#12`, `GH-12`, `owner/repo/issues/12`), or reorder the line with bidirectional control characters. GitHub acted on these even when backslash-escaped; the reply now breaks each with a zero-width space, so what the reader sees is unchanged. Names shown as code (the branch, the workflow, the artifact) drop bidirectional and C1 control characters too.
|
|
10
|
+
- d1d2efc: The check replay page redacts email addresses and card numbers in the paths, captions, expectations and failure reasons it shows, as it already did for secrets in addresses. Addresses on reserved example domains and digit runs that are not card numbers stay readable.
|
|
11
|
+
- 67fcab7: The live view's run page escapes the saved report's path where its script names it, so a project folder whose name contains `</script>` can no longer end that script early.
|
|
12
|
+
- b2b1481: Refresh the transitive dependencies of the MCP SDK (express, hono, jose and others) within their existing ranges. No change in behaviour.
|
|
13
|
+
|
|
3
14
|
## 3.23.3
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="site/favicon.svg" alt="" width="88" height="88" />
|
|
4
|
+
|
|
5
|
+
# SceneScout
|
|
4
6
|
|
|
5
7
|
**Your coding agent, turned into an exploratory QA tester for any web app.**
|
|
6
8
|
|
|
@@ -11,7 +13,7 @@ Works with Claude Code · Cursor · VS Code (Copilot) · Codex CLI · Gemini CLI
|
|
|
11
13
|
[](LICENSE)
|
|
12
14
|

|
|
13
15
|
|
|
14
|
-
[📖 Guide](docs/guide/Home.md) · [🐛 What it catches](#-what-it-catches) · [🎬 QA with evidence](#-automated-qa-with-evidence) · [🚀 Get started](#-get-started) · [🚦 CI](#-in-ci) · [🔒 Safety](#-safe-by-default) · [📚 Docs](#-documentation)
|
|
16
|
+
[🌐 Website](https://brunoboto96.github.io/SceneScout/) · [📖 Guide](docs/guide/Home.md) · [🐛 What it catches](#-what-it-catches) · [🎬 QA with evidence](#-automated-qa-with-evidence) · [🚀 Get started](#-get-started) · [🚦 CI](#-in-ci) · [🔒 Safety](#-safe-by-default) · [📚 Docs](#-documentation)
|
|
15
17
|
|
|
16
18
|
</div>
|
|
17
19
|
|
|
@@ -134,7 +136,7 @@ Everything is one self-contained HTML page per report, with no scripts, nothing
|
|
|
134
136
|
|
|
135
137
|
Each exploratory run opens a live view on your machine, with one card per agent: what it is doing, the page it is on, and a feed of every action. Three agents are testing the demo app in parallel here:
|
|
136
138
|
|
|
137
|
-
<p align="center"><img src="examples/screenshots/live-view.png" alt="The live view during a run of three parallel agents against the demo app: one card per session, each with its role and objective, the task it is on, the tool it is running,
|
|
139
|
+
<p align="center"><img src="examples/screenshots/live-view.png" alt="The live view during a run of three parallel agents against the demo app, every one streaming its page live: one card per session, each with its role and objective, the task it is on, the tool it is running, a live picture of its page, and a feed of the actions it just took, tinted one colour per task" width="880" /></p>
|
|
138
140
|
|
|
139
141
|
You can read the report while the agents are still working, and scrub back through any session's timeline. Beside `report.md`, every run writes `report.html`, the whole run as one self-contained page; ask for a recorded run and it also keeps a frame after every action.
|
|
140
142
|
|
|
@@ -218,6 +220,21 @@ A gate on every pull request that keeps its evidence, as a GitHub Action:
|
|
|
218
220
|
|
|
219
221
|
[docs/ci.md](docs/ci.md) has complete workflows, every option, and the same check on GitLab CI, CircleCI or any shell.
|
|
220
222
|
|
|
223
|
+
### Ask for it on a pull request
|
|
224
|
+
|
|
225
|
+
Type a command in a pull request comment and SceneScout answers in the thread, with pictures taken by a real browser:
|
|
226
|
+
|
|
227
|
+
| Comment | Reply |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `/scenescout qa` | Explores the pull request's preview and replies with what it found |
|
|
230
|
+
| `/scenescout qa show the Save button` | A picture of that element |
|
|
231
|
+
| `/scenescout qa compare the Save button` | The element on the base and on the pull request, with the changed pixels highlighted |
|
|
232
|
+
| `/scenescout qa check` | Runs your saved journeys on the branch and replies with each one's result; the video of every journey is in the run's files |
|
|
233
|
+
|
|
234
|
+
<p align="center"><img src="examples/screenshots/pr-qa-compare.png" alt="A pull request comment, /scenescout qa compare the Save button, and SceneScout's reply: the button on the base branch in blue and on the pull request in green, with 61.2% of pixels changed highlighted" width="520" /></p>
|
|
235
|
+
|
|
236
|
+
The preview commands test a deployed preview, so the pull request's code never runs beside the model's key; `qa check` runs your own workflow and uses no key. [Setting it up](docs/ci.md#a-qa-review-from-a-pull-request-comment).
|
|
237
|
+
|
|
221
238
|
## 🔒 Safe by default
|
|
222
239
|
|
|
223
240
|
| Mode | What may leave the page |
|
package/dist/browsers.js
CHANGED
|
@@ -15,10 +15,10 @@ export const BROWSER_ENGINES = ["chromium", "firefox", "webkit"];
|
|
|
15
15
|
* a headed run opens, and the headless shell, which every headless run uses.
|
|
16
16
|
* Installing "chromium" brings both, which is what install has always done.
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const INSTALL_TARGETS = ["chromium", "chromium-headless-shell", "firefox", "webkit"];
|
|
19
|
+
const DEFAULT_ENGINE = "chromium";
|
|
20
20
|
/** Environment variable naming the browser an attach uses when it does not name one. */
|
|
21
|
-
|
|
21
|
+
const DEFAULT_ENGINE_ENV = "SCENESCOUT_BROWSER";
|
|
22
22
|
/** Approximate size on disk, so the install output can say what it is about to fetch. */
|
|
23
23
|
export const APPROX_DISK_MB = {
|
|
24
24
|
chromium: 550,
|
|
@@ -124,7 +124,7 @@ export function browserPresence(executables, exists = fs.existsSync) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
/** The command that downloads one build, for the way this copy of SceneScout was installed. */
|
|
127
|
-
|
|
127
|
+
function installCommandFor(target, fromCheckout) {
|
|
128
128
|
const flags = `--browser-only --browsers ${target}`;
|
|
129
129
|
return fromCheckout ? `node dist/cli.js install ${flags}` : `npx -y scenescout install ${flags}`;
|
|
130
130
|
}
|
|
@@ -260,6 +260,7 @@ export function writeRedirectHopsJudged(engine) {
|
|
|
260
260
|
* the policy blocks nothing and the person has opted in to everything.
|
|
261
261
|
*/
|
|
262
262
|
export const REMOVE_SHARED_WORKER_SCRIPT = `(() => {
|
|
263
|
+
// Runs before the page's scripts, so the page cannot have locked the property. WebIDL makes it configurable, so the delete works in every engine and the redefinition is only a fallback; neither is expected to throw, and a throw must not stop the page loading.
|
|
263
264
|
try { delete globalThis.SharedWorker; } catch {}
|
|
264
265
|
if ("SharedWorker" in globalThis) {
|
|
265
266
|
try { Object.defineProperty(globalThis, "SharedWorker", { value: undefined, configurable: true, writable: true }); } catch {}
|
|
@@ -287,8 +288,8 @@ export function defaultAttachNote(opts) {
|
|
|
287
288
|
/** Environment variable deciding whether an attach downloads a missing browser build itself. */
|
|
288
289
|
export const BROWSER_DOWNLOAD_ENV = "SCENESCOUT_BROWSER_DOWNLOAD";
|
|
289
290
|
/** `auto` downloads except in CI; `on` downloads in CI too; `off` never downloads, for a machine where nothing may be fetched. */
|
|
290
|
-
|
|
291
|
-
|
|
291
|
+
const BROWSER_DOWNLOAD_SETTINGS = ["auto", "on", "off"];
|
|
292
|
+
const DEFAULT_BROWSER_DOWNLOAD = "auto";
|
|
292
293
|
/**
|
|
293
294
|
* What an attach does when the build it needs is not on disk: download it
|
|
294
295
|
* itself, refuse because the setting says never, or tell CI to keep its
|
package/dist/ci-run.js
CHANGED
|
@@ -35,7 +35,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
35
35
|
const packageRoot = path.resolve(here, "..");
|
|
36
36
|
const serverPath = path.join(packageRoot, "dist", "mcp-server.js");
|
|
37
37
|
/** The longest one attempt at a model call may take. An attempt that runs longer, with wall time left, is retried like a server error. */
|
|
38
|
-
|
|
38
|
+
const MODEL_CALL_MS = 180_000;
|
|
39
39
|
/** Retries of one model call after its first attempt. */
|
|
40
40
|
export const MAX_RETRIES = 3;
|
|
41
41
|
/**
|
|
@@ -44,7 +44,7 @@ export const MAX_RETRIES = 3;
|
|
|
44
44
|
* the browser share this one budget, so a run ends at most this long after
|
|
45
45
|
* its time cap, plus the few seconds the files take.
|
|
46
46
|
*/
|
|
47
|
-
|
|
47
|
+
const FINISH_MS = 180_000;
|
|
48
48
|
/** Attaching before the exploration, which counts towards the time cap. */
|
|
49
49
|
const ATTACH_MS = 120_000;
|
|
50
50
|
/** Tool calls run from one model reply; any beyond are answered as not run. Bounds what one turn can spend. Set here only. */
|
|
@@ -947,6 +947,7 @@ export async function runCi(options, resolved, deps) {
|
|
|
947
947
|
await host
|
|
948
948
|
.call("scout_close", { all: true }, finishLeft())
|
|
949
949
|
.catch((err) => log(`Closing the browser failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
950
|
+
// The run is over and its outcome decided; a host that fails to close adds nothing to report.
|
|
950
951
|
await host.close().catch(() => { });
|
|
951
952
|
}
|
|
952
953
|
}
|
package/dist/cli.js
CHANGED
|
@@ -330,7 +330,8 @@ function watch(projectPath, open) {
|
|
|
330
330
|
if (!open)
|
|
331
331
|
return;
|
|
332
332
|
const { command, args } = browserOpener(target.url);
|
|
333
|
-
|
|
333
|
+
// An opener that never returns (no desktop session) must not hold the command; the address is printed above either way.
|
|
334
|
+
const result = spawnSync(command, args, { stdio: "ignore", timeout: 10_000 });
|
|
334
335
|
if (result.error || result.status !== 0)
|
|
335
336
|
console.log("Could not open a browser from here. Open the address above yourself.");
|
|
336
337
|
}
|
package/dist/clients.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { MCP_NAME } from "./installer.js";
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const OTHER_CLIENTS = ["cursor", "vscode", "codex", "gemini", "copilot", "windsurf"];
|
|
17
|
+
const CLIENTS = ["claude-code", ...OTHER_CLIENTS];
|
|
18
18
|
export const CLIENT_LABELS = {
|
|
19
19
|
"claude-code": "Claude Code",
|
|
20
20
|
cursor: "Cursor",
|
|
@@ -55,7 +55,7 @@ const STRATEGIES = {
|
|
|
55
55
|
};
|
|
56
56
|
const quote = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
57
57
|
/** The entry every `mcpServers`-style file takes. */
|
|
58
|
-
|
|
58
|
+
function serverEntry(launch) {
|
|
59
59
|
return { command: launch[0], args: launch.slice(1) };
|
|
60
60
|
}
|
|
61
61
|
/**
|
package/dist/code-routes.js
CHANGED
|
@@ -183,7 +183,7 @@ export function readRouteObjects(src) {
|
|
|
183
183
|
* The source with comments blanked out, offsets preserved. `<Route>` tags are
|
|
184
184
|
* found by pattern, so a commented-out route would otherwise be read as live.
|
|
185
185
|
*/
|
|
186
|
-
|
|
186
|
+
function maskComments(src) {
|
|
187
187
|
const out = src.split("");
|
|
188
188
|
let i = 0;
|
|
189
189
|
const blank = (from, to) => {
|
|
@@ -268,7 +268,7 @@ export function readRouteElements(source) {
|
|
|
268
268
|
}
|
|
269
269
|
const tidy = (p) => `/${p}`.replace(/\/+/g, "/").replace(/(.)\/$/, "$1");
|
|
270
270
|
/** The absolute path of one record, or null when it (or an ancestor) is a wildcard. */
|
|
271
|
-
|
|
271
|
+
function recordPath(records, idx, prefix = "", absoluteTopOnly = false) {
|
|
272
272
|
const r = records[idx];
|
|
273
273
|
if (/[*]/.test(r.path))
|
|
274
274
|
return null;
|
package/dist/commands.js
CHANGED
|
@@ -43,11 +43,11 @@ export const HAND_PARSED = {
|
|
|
43
43
|
watch: { switches: ["--no-open"], valued: [], positional: 1 },
|
|
44
44
|
};
|
|
45
45
|
/** True when the arguments ask for help. */
|
|
46
|
-
|
|
46
|
+
function wantsHelp(args) {
|
|
47
47
|
return args.includes("--help") || args.includes("-h");
|
|
48
48
|
}
|
|
49
49
|
/** The first argument `spec` does not accept, as an error sentence; null when every one is accepted. */
|
|
50
|
-
|
|
50
|
+
function unknownArgument(args, spec) {
|
|
51
51
|
let positional = 0;
|
|
52
52
|
for (let i = 0; i < args.length; i++) {
|
|
53
53
|
const arg = args[i];
|
|
@@ -71,7 +71,7 @@ export function unknownArgument(args, spec) {
|
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
73
|
/** Whether `command` names a subcommand. */
|
|
74
|
-
|
|
74
|
+
function isSubcommand(command) {
|
|
75
75
|
return command !== undefined && Object.hasOwn(HANDLERS_OF, command);
|
|
76
76
|
}
|
|
77
77
|
/** A host with no scheme: a name or address, an optional port, then optionally a path, query or fragment. */
|
|
@@ -103,7 +103,7 @@ export function looksLikeUrl(arg) {
|
|
|
103
103
|
return host.includes(".") || host.includes(":") || host.toLowerCase() === "localhost";
|
|
104
104
|
}
|
|
105
105
|
/** Settle help and flag errors for a subcommand before its handler is reached. */
|
|
106
|
-
|
|
106
|
+
function preflight(command, args) {
|
|
107
107
|
if (wantsHelp(args))
|
|
108
108
|
return { kind: "help" };
|
|
109
109
|
const spec = Object.hasOwn(HAND_PARSED, command) ? HAND_PARSED[command] : undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule for a valued command-line option, shared by the parsers of
|
|
3
|
+
* `check`, `ci` and `export`: `--name value` or `--name=value`, and a value
|
|
4
|
+
* that is missing or looks like the next option is a mistake, not a value.
|
|
5
|
+
* Each parser keeps its own rules for which names exist and which are switches.
|
|
6
|
+
*/
|
|
7
|
+
/** The option name in `--name` or `--name=value`. */
|
|
8
|
+
export function flagName(arg) {
|
|
9
|
+
const eq = arg.indexOf("=");
|
|
10
|
+
return eq > 0 ? arg.slice(2, eq) : arg.slice(2);
|
|
11
|
+
}
|
|
12
|
+
/** The value of the option at `args[i]` and the index of the last argument it used. */
|
|
13
|
+
export function readFlagValue(args, i) {
|
|
14
|
+
const a = args[i];
|
|
15
|
+
const eq = a.indexOf("=");
|
|
16
|
+
const name = flagName(a);
|
|
17
|
+
const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
|
|
18
|
+
if (value === undefined || (eq < 0 && value.startsWith("--")))
|
|
19
|
+
return { ok: false, error: `--${name} needs a value` };
|
|
20
|
+
return { ok: true, name, value, last: eq < 0 ? i + 1 : i };
|
|
21
|
+
}
|
package/dist/engine/bench.js
CHANGED
|
@@ -337,7 +337,7 @@ function lanePaths(lane, laneRoutes) {
|
|
|
337
337
|
* any lane that reported no route — the wording rule decides, unchanged, so
|
|
338
338
|
* those runs score as they always have.
|
|
339
339
|
*/
|
|
340
|
-
|
|
340
|
+
function ownershipRemark(d, key, laneRoutes) {
|
|
341
341
|
if (d.verdict !== "not_a_defect")
|
|
342
342
|
return null;
|
|
343
343
|
const own = lanePaths(d.lane, laneRoutes);
|
package/dist/engine/brief.js
CHANGED
|
@@ -26,7 +26,7 @@ import { normalizePath } from "./fingerprint.js";
|
|
|
26
26
|
/** Most lanes worth running at once. Past this the planner spends longer reading reports than the lanes spend testing. */
|
|
27
27
|
export const MAX_LANES = 8;
|
|
28
28
|
/** A lane name is a session name: short, and it shows in the live view. */
|
|
29
|
-
|
|
29
|
+
const LANE_NAME_MAX = 40;
|
|
30
30
|
/**
|
|
31
31
|
* The module a route belongs to: its first path segment, or "/" for the root.
|
|
32
32
|
*
|
package/dist/engine/browser.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, keyAliasSteps, normalizePath, ordinalKeys, refsSurviveUrlChange, routeBase, } from "./fingerprint.js";
|
|
5
5
|
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, NAME_RULE, reachedRoutes, redactSecrets, TASK_SET, } from "./memory.js";
|
|
6
|
+
import { maskedValue, maskFillCalls, secretFieldKind } from "./sensitive.js";
|
|
6
7
|
import { normalizeTask } from "./task.js";
|
|
7
8
|
import { ACTED_CONTROL_SRC, CLAIM_SCAN_SCRIPT, findContradictions, INFRASTRUCTURE_WRITE_RE, isBackgroundRequest, OPEN_DIALOGS_SCRIPT, quietAnswer, saidSince, } from "./claims.js";
|
|
8
9
|
import { POSTMESSAGE_BINDING, describeTokenPost, postMessageCaptureScript, tokenHits, tokenPostKey } from "./postmessage.js";
|
|
@@ -3092,26 +3093,60 @@ export class BrowserEngine {
|
|
|
3092
3093
|
* testing), and reading the existing content is load-bearing: if it fails,
|
|
3093
3094
|
* the action aborts rather than risk a silent content-destroying replace.
|
|
3094
3095
|
*/
|
|
3095
|
-
async fillOrAppend(locator, text, replace) {
|
|
3096
|
+
async fillOrAppend(locator, text, replace, label = "") {
|
|
3096
3097
|
let state;
|
|
3097
3098
|
try {
|
|
3098
3099
|
state = (await locator.evaluate((node) => {
|
|
3099
3100
|
const n = node;
|
|
3100
3101
|
const type = n.tagName === "INPUT" ? n.type.toLowerCase() : "";
|
|
3102
|
+
// What the page says about the field, for sensitive.ts to decide whether its value is a secret.
|
|
3103
|
+
const byIds = (ids) => (ids ?? "")
|
|
3104
|
+
.split(/\s+/)
|
|
3105
|
+
.map((id) => (id ? (document.getElementById(id)?.textContent ?? "") : ""))
|
|
3106
|
+
.join(" ");
|
|
3107
|
+
const facts = {
|
|
3108
|
+
type: n.getAttribute("type"),
|
|
3109
|
+
autocomplete: n.getAttribute("autocomplete"),
|
|
3110
|
+
name: n.getAttribute("name"),
|
|
3111
|
+
id: n.id || null,
|
|
3112
|
+
placeholder: n.getAttribute("placeholder"),
|
|
3113
|
+
maxLength: typeof n.maxLength === "number" && n.maxLength >= 0 ? n.maxLength : null,
|
|
3114
|
+
labels: [
|
|
3115
|
+
n.getAttribute("aria-label") ?? "",
|
|
3116
|
+
byIds(n.getAttribute("aria-labelledby")),
|
|
3117
|
+
...Array.from(n.labels ?? []).map((l) => l.textContent ?? ""),
|
|
3118
|
+
].filter((t) => t.trim() !== ""),
|
|
3119
|
+
};
|
|
3101
3120
|
if (n.isContentEditable)
|
|
3102
|
-
return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true, type };
|
|
3121
|
+
return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true, type, facts };
|
|
3103
3122
|
if (typeof n.value === "string") {
|
|
3104
3123
|
// selectionStart is null on number/date/email-style inputs — caret
|
|
3105
3124
|
// placement is unsupported there, so append must go via fill().
|
|
3106
|
-
return { existing: n.value, caretAppendable: typeof n.selectionStart === "number", type };
|
|
3125
|
+
return { existing: n.value, caretAppendable: typeof n.selectionStart === "number", type, facts };
|
|
3107
3126
|
}
|
|
3108
|
-
return { existing: "", caretAppendable: false, type };
|
|
3127
|
+
return { existing: "", caretAppendable: false, type, facts };
|
|
3109
3128
|
}, undefined, { timeout: this.limits.actionMs }));
|
|
3110
3129
|
}
|
|
3111
3130
|
catch (err) {
|
|
3112
3131
|
throw new Error(`Could not read the field's existing content before typing — aborting rather than risk overwriting it (${err instanceof Error ? err.message.split("\n")[0] : err}). Take a new scout_snapshot and retry.`);
|
|
3113
3132
|
}
|
|
3133
|
+
const { facts, existing } = state;
|
|
3134
|
+
// A secret field's value, and what it held before, are never quoted: the note goes to the log, the report and exported issues.
|
|
3135
|
+
const secret = secretFieldKind({ ...facts, label: [label, ...facts.labels] });
|
|
3136
|
+
try {
|
|
3137
|
+
return { note: await this.writeField(locator, text, replace, state, secret), secret };
|
|
3138
|
+
}
|
|
3139
|
+
catch (err) {
|
|
3140
|
+
// Playwright's call log quotes the value it was asked to fill.
|
|
3141
|
+
if (secret && err instanceof Error)
|
|
3142
|
+
err.message = maskFillCalls(err.message, [text, existing, existing + text]);
|
|
3143
|
+
throw err;
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
/** fillOrAppend's write, once the field has been read; returns the note. */
|
|
3147
|
+
async writeField(locator, text, replace, state, secret) {
|
|
3114
3148
|
const { existing, caretAppendable, type } = state;
|
|
3149
|
+
const quoted = (value) => (secret ? `(masked: ${secret} field)` : JSON.stringify(value.slice(0, 60)));
|
|
3115
3150
|
if (APP_FILLED_TYPES.has(type)) {
|
|
3116
3151
|
// A date or time field holds one value in one format: anything else is
|
|
3117
3152
|
// refused by the browser as a bare "Malformed value", and appending to
|
|
@@ -3119,16 +3154,17 @@ export class BrowserEngine {
|
|
|
3119
3154
|
// field's format, or refused naming it, and always replaces.
|
|
3120
3155
|
const date = normaliseDateValue(type, text);
|
|
3121
3156
|
if ("refused" in date)
|
|
3122
|
-
throw new Error(`Not typed: ${date.refused}`);
|
|
3157
|
+
throw new Error(`Not typed: ${secret ? date.refused.replace(JSON.stringify(text), "the value") : date.refused}`);
|
|
3123
3158
|
await locator.fill(date.value, { timeout: this.limits.actionMs });
|
|
3124
|
-
|
|
3125
|
-
(existing && existing !== date.value ? ` (replaced existing content ${
|
|
3159
|
+
const note = (date.note && !secret ? ` (${date.note})` : "") +
|
|
3160
|
+
(existing && existing !== date.value ? ` (replaced existing content ${quoted(existing)}: a ${type} field holds one value)` : "");
|
|
3161
|
+
return note;
|
|
3126
3162
|
}
|
|
3127
3163
|
if (replace || !existing || text === "") {
|
|
3128
3164
|
await locator.fill(text, { timeout: this.limits.actionMs });
|
|
3129
|
-
return existing && (replace || text === "") ? ` (replaced existing content ${
|
|
3165
|
+
return existing && (replace || text === "") ? ` (replaced existing content ${quoted(existing)})` : "";
|
|
3130
3166
|
}
|
|
3131
|
-
const appendNote = ` (APPENDED after existing content ${
|
|
3167
|
+
const appendNote = ` (APPENDED after existing content ${quoted(existing)} — pass replace=true to overwrite instead)`;
|
|
3132
3168
|
if (!caretAppendable) {
|
|
3133
3169
|
// Data-typed inputs (number, email, date): concatenate without a
|
|
3134
3170
|
// separator — an injected space would invalidate the value.
|
|
@@ -3188,7 +3224,8 @@ export class BrowserEngine {
|
|
|
3188
3224
|
// the element afterwards. Never for another site's frame: no probe is placed there.
|
|
3189
3225
|
if (!embed)
|
|
3190
3226
|
await this.noteProbe(text, `${el.role} "${el.name}"`);
|
|
3191
|
-
|
|
3227
|
+
const filled = await this.fillOrAppend(locator, text, replace, el.name);
|
|
3228
|
+
let fillNote = filled.note;
|
|
3192
3229
|
// What another site's field already held is its business, not the report's.
|
|
3193
3230
|
if (embed)
|
|
3194
3231
|
fillNote = fillNote.replace(/existing content "(?:[^"\\]|\\.)*"/g, "existing content (masked: another site's frame)");
|
|
@@ -3214,7 +3251,9 @@ export class BrowserEngine {
|
|
|
3214
3251
|
this.noteFormSubmit(formState, "enter", form);
|
|
3215
3252
|
}
|
|
3216
3253
|
this.memory.markExercised(this.currentFingerprint, el.key, "type");
|
|
3217
|
-
|
|
3254
|
+
// A secret field's value is never written down, not even its length; clearing one ("") reveals nothing.
|
|
3255
|
+
const shown = filled.secret && text !== "" ? maskedValue(filled.secret) : JSON.stringify(text.slice(0, 60));
|
|
3256
|
+
return this.afterAction("type", `${el.role} "${el.name}" ← ${shown}${pressEnter ? " + Enter" : ""}${fillNote}`);
|
|
3218
3257
|
}
|
|
3219
3258
|
/**
|
|
3220
3259
|
* Attach a file to an upload control the way a user does. `ref` may be the
|
|
@@ -4758,7 +4797,8 @@ export class BrowserEngine {
|
|
|
4758
4797
|
// A navigation or a scroll is not input: navigate() moves the action mark itself, and what a scroll loads is the page's own doing.
|
|
4759
4798
|
if (step.action !== "navigate" && step.action !== "scroll")
|
|
4760
4799
|
await this.beginInput();
|
|
4761
|
-
|
|
4800
|
+
// A type step is described by its target, never its value: the value may be a password.
|
|
4801
|
+
const desc = `${i + 1}. ${step.action} ${step.target ?? (step.action === "type" ? "(no target)" : (step.value ?? ""))}`;
|
|
4762
4802
|
// Identity captured BEFORE the action — buttons that relabel themselves
|
|
4763
4803
|
// (Add to Cart → View Cart) are unmatchable in the post-action DOM.
|
|
4764
4804
|
let preTestid = null;
|
|
@@ -4868,8 +4908,7 @@ export class BrowserEngine {
|
|
|
4868
4908
|
}
|
|
4869
4909
|
else if (step.action === "type") {
|
|
4870
4910
|
await this.noteProbe(step.value ?? "", step.target ?? "the field");
|
|
4871
|
-
|
|
4872
|
-
note = fillNote;
|
|
4911
|
+
note = (await this.fillOrAppend(loc, step.value ?? "", step.replace ?? false, label)).note;
|
|
4873
4912
|
if (step.pressEnter) {
|
|
4874
4913
|
if (this.readOnly) {
|
|
4875
4914
|
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
@@ -4909,7 +4948,12 @@ export class BrowserEngine {
|
|
|
4909
4948
|
// steps are where most of a recorded run actually happens. Leaving
|
|
4910
4949
|
// them unframed reproduced, inside run_plan, the same hole that crawl
|
|
4911
4950
|
// had: a form filled and submitted with no picture of any of it.
|
|
4912
|
-
this.logAction({
|
|
4951
|
+
this.logAction({
|
|
4952
|
+
action: `plan:${step.action}`,
|
|
4953
|
+
target: step.target ?? (step.action === "type" ? undefined : step.value),
|
|
4954
|
+
url: page.url(),
|
|
4955
|
+
...(await this.frameFor(`plan-${step.action}`)),
|
|
4956
|
+
});
|
|
4913
4957
|
// Plans must feed coverage like ref-based actions do: record the
|
|
4914
4958
|
// state and mark the acted-on element class as exercised.
|
|
4915
4959
|
// Hover is deliberately excluded: a hover is a look, not an
|
package/dist/engine/capture.js
CHANGED
|
@@ -63,7 +63,7 @@ export function capturedName(file) {
|
|
|
63
63
|
return null;
|
|
64
64
|
return name.slice(0, -".png".length);
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
const CAPTURED_PREFIX = "CAPTURED ";
|
|
67
67
|
/** scout_capture's result: a line for whoever reads it, then the details as one line of JSON. */
|
|
68
68
|
export function captureResultText(info) {
|
|
69
69
|
return (`Saved a ${info.width}×${info.height} picture of ${info.label ? `"${info.label.replace(/\s+/g, " ")}"` : info.key} on ${info.url} to ${info.file}.\n` +
|
|
@@ -147,9 +147,9 @@ export const SHOTS_DIRNAME = "shots";
|
|
|
147
147
|
*/
|
|
148
148
|
export const EVIDENCE_MODES = ["inline", "file", "off"];
|
|
149
149
|
export const EVIDENCE_ENV = "SCENESCOUT_EVIDENCE";
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
const EVIDENCE_MAX_PX_ENV = "SCENESCOUT_EVIDENCE_MAX_PX";
|
|
151
|
+
const EVIDENCE_MAX_KB_ENV = "SCENESCOUT_EVIDENCE_MAX_KB";
|
|
152
|
+
const EVIDENCE_INLINE_ENV = "SCENESCOUT_EVIDENCE_INLINE";
|
|
153
153
|
export const RECORD_ENV = "SCENESCOUT_RECORD";
|
|
154
154
|
/**
|
|
155
155
|
* The bounds a finding's picture is held to, and their defaults. The longer
|
|
@@ -34,7 +34,7 @@ function shotFields(shot) {
|
|
|
34
34
|
return shot?.pastCap ? { pastCap: true } : {};
|
|
35
35
|
}
|
|
36
36
|
/** What a step should bring about: its own `expected`, else an expect-* step's assertion, else nothing. */
|
|
37
|
-
|
|
37
|
+
function expectedOf(step) {
|
|
38
38
|
if (step.expected !== undefined)
|
|
39
39
|
return step.expected;
|
|
40
40
|
return step.action.startsWith("expect-") ? describeStep(step) : undefined;
|
|
@@ -116,21 +116,95 @@ export function capFrames(replay, max = replay.frameCap ?? RECORD_MAX_FRAMES) {
|
|
|
116
116
|
});
|
|
117
117
|
return { ...replay, roles, framesLeftOut: leftOut, frameCap: max };
|
|
118
118
|
}
|
|
119
|
-
/**
|
|
119
|
+
/** Domains reserved for documentation (RFC 2606 / 6761) name nobody, so an address on one stays readable. */
|
|
120
|
+
const RESERVED_DOMAIN_RE = /^(?:[\p{L}\p{N}-]+\.)*(?:example\.(?:com|org|net)|example|test|invalid|localhost)$/iu;
|
|
121
|
+
/** An image's pixel-density suffix (`@2x` before its extension), which reads like an address but is a file name. */
|
|
122
|
+
const DENSITY_DOMAIN_RE = /^\d+(?:\.\d+)?x\./i;
|
|
123
|
+
/**
|
|
124
|
+
* An address in any script, its @ written out or percent-encoded (`%40`), as a
|
|
125
|
+
* path's query string carries it. Group 1 is the domain.
|
|
126
|
+
*/
|
|
127
|
+
const EMAIL_RE = /[\p{L}\p{N}._%+-]+(?:@|%40)([\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.(?:\p{L}{2,}|xn--[a-z0-9-]+))/giu;
|
|
128
|
+
/** A run of digit groups split by single spaces or hyphens; a card number is some of its groups. */
|
|
129
|
+
const DIGIT_RUN_RE = /(?<![\d.])\d+(?:[ -]\d+)*/g;
|
|
130
|
+
/** A card number's first digit: the networks issue from 2 to 6 and 8, so a millisecond timestamp (a leading 1) is never one. */
|
|
131
|
+
const CARD_FIRST_DIGIT_RE = /^[2-68]/;
|
|
132
|
+
function luhnValid(digits) {
|
|
133
|
+
let sum = 0;
|
|
134
|
+
for (let i = 0; i < digits.length; i++) {
|
|
135
|
+
let d = Number(digits[digits.length - 1 - i]);
|
|
136
|
+
if (i % 2 === 1) {
|
|
137
|
+
d *= 2;
|
|
138
|
+
if (d > 9)
|
|
139
|
+
d -= 9;
|
|
140
|
+
}
|
|
141
|
+
sum += d;
|
|
142
|
+
}
|
|
143
|
+
return sum % 10 === 0;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Personal data a page or a flow can put in text the report shows: an email
|
|
147
|
+
* address, its @ written out or percent-encoded (a reserved example address
|
|
148
|
+
* and an image's `@2x` name excepted), and a card number (digits that start as
|
|
149
|
+
* a card network's do and pass the Luhn check, so an order number or a
|
|
150
|
+
* timestamp stays).
|
|
151
|
+
*/
|
|
152
|
+
export function redactPersonal(text) {
|
|
153
|
+
return text
|
|
154
|
+
.replace(EMAIL_RE, (m, domain) => (RESERVED_DOMAIN_RE.test(domain) || DENSITY_DOMAIN_RE.test(domain) ? m : "[email redacted]"))
|
|
155
|
+
.replace(DIGIT_RUN_RE, redactCardsInRun);
|
|
156
|
+
}
|
|
157
|
+
function isCardNumber(digits) {
|
|
158
|
+
return digits.length >= 13 && digits.length <= 19 && CARD_FIRST_DIGIT_RE.test(digits) && luhnValid(digits);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The card numbers among a run's digit groups, each the longest that starts at
|
|
162
|
+
* its group, so a date or a quantity written just before a card number does
|
|
163
|
+
* not hide it. A group is never split: digits inside a longer id stay.
|
|
164
|
+
*/
|
|
165
|
+
function redactCardsInRun(run) {
|
|
166
|
+
const parts = run.split(/([ -])/);
|
|
167
|
+
const groups = parts.filter((_, i) => i % 2 === 0);
|
|
168
|
+
let out = "";
|
|
169
|
+
let i = 0;
|
|
170
|
+
while (i < groups.length) {
|
|
171
|
+
let end = -1;
|
|
172
|
+
let digits = "";
|
|
173
|
+
for (let j = i; j < groups.length && digits.length + groups[j].length <= 19; j++) {
|
|
174
|
+
digits += groups[j];
|
|
175
|
+
if (isCardNumber(digits))
|
|
176
|
+
end = j;
|
|
177
|
+
}
|
|
178
|
+
if (i > 0)
|
|
179
|
+
out += parts[2 * i - 1];
|
|
180
|
+
if (end === -1) {
|
|
181
|
+
out += groups[i];
|
|
182
|
+
i += 1;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
out += "[card number redacted]";
|
|
186
|
+
i = end + 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/** Secrets in addresses (memory.ts redactRoute) and personal data (redactPersonal), for any text the page shows. */
|
|
192
|
+
const redactText = (text) => redactPersonal(redactRoute(text));
|
|
193
|
+
/** The report's redaction on every path, caption and reason the page shows. */
|
|
120
194
|
export function redactReplay(replay) {
|
|
121
195
|
return {
|
|
122
196
|
...replay,
|
|
123
197
|
roles: replay.roles.map((role) => ({
|
|
124
198
|
...role,
|
|
125
|
-
visits: role.visits.map((v) => ({ ...v, path:
|
|
199
|
+
visits: role.visits.map((v) => ({ ...v, path: redactText(v.path), ...(v.reason !== undefined ? { reason: redactText(v.reason) } : {}) })),
|
|
126
200
|
journeys: role.journeys.map((j) => ({
|
|
127
201
|
...j,
|
|
128
202
|
steps: j.steps.map((s) => ({
|
|
129
203
|
...s,
|
|
130
|
-
caption:
|
|
131
|
-
...(s.expected !== undefined ? { expected:
|
|
132
|
-
...(s.reason !== undefined ? { reason:
|
|
133
|
-
...(s.path !== undefined ? { path:
|
|
204
|
+
caption: redactText(s.caption),
|
|
205
|
+
...(s.expected !== undefined ? { expected: redactText(s.expected) } : {}),
|
|
206
|
+
...(s.reason !== undefined ? { reason: redactText(s.reason) } : {}),
|
|
207
|
+
...(s.path !== undefined ? { path: redactText(s.path) } : {}),
|
|
134
208
|
})),
|
|
135
209
|
})),
|
|
136
210
|
})),
|
|
@@ -314,7 +388,7 @@ export function buildCheckReplayHtml(replay, meta) {
|
|
|
314
388
|
<dl>${pair("App", meta.origin)}${pair("Started", stamp(meta.startedAt))}${pair("Ended", stamp(meta.endedAt))}${meta.commit ? pair("Commit", meta.commit) : ""}${pair("SceneScout", `v${meta.version}`)}${pair("Frames", String(frames))}${replayVideos(replay).length > 0 ? pair("Videos", String(replayVideos(replay).length)) : ""}</dl>
|
|
315
389
|
</header>
|
|
316
390
|
<main>
|
|
317
|
-
<p class="note">Each role, then each journey it walked, step by step, with the page as it was after the step${frames === 0 ? " when the check was recorded (--record)" : ""}. Typed values are never shown, and secrets in addresses are redacted as in the report.${replay.framesLeftOut > 0
|
|
391
|
+
<p class="note">Each role, then each journey it walked, step by step, with the page as it was after the step${frames === 0 ? " when the check was recorded (--record)" : ""}. Typed values are never shown, and secrets in addresses are redacted as in the report, as are email addresses and card numbers.${replay.framesLeftOut > 0
|
|
318
392
|
? ` ${replay.framesLeftOut} step(s) and visit(s) have no frame because their session had already kept ${replay.frameCap ?? RECORD_MAX_FRAMES}, the most one session keeps.`
|
|
319
393
|
: ""}</p>
|
|
320
394
|
${replay.roles.map((r, i) => roleHtml(r, i, replay.frameCap ?? RECORD_MAX_FRAMES)).join("\n") || '<p class="none">Nothing was recorded.</p>'}
|
|
@@ -27,15 +27,15 @@ import { escapeHtml } from "./replay.js";
|
|
|
27
27
|
/** The page's generator mark: an earlier run's report is removed or replaced only when it carries it. */
|
|
28
28
|
export const REPORT_GENERATOR = "scenescout-check-report";
|
|
29
29
|
/** The file the report is written to when the template names none. */
|
|
30
|
-
|
|
30
|
+
const DEFAULT_REPORT_FILE = "report.html";
|
|
31
31
|
/** What the cell of a missing value shows. */
|
|
32
32
|
export const MISSING = "—";
|
|
33
33
|
/** A report's file name: plain, in the output folder itself, ending .html. */
|
|
34
|
-
|
|
34
|
+
const REPORT_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.html$/;
|
|
35
35
|
/** A name Windows reserves for a device whatever its extension (CON.html opens the console): never a report's file. */
|
|
36
|
-
|
|
36
|
+
const WINDOWS_DEVICE_RE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])\./i;
|
|
37
37
|
/** Whether a name may be a report's file: plain, ending .html, not replay.html and not a Windows device name. */
|
|
38
|
-
|
|
38
|
+
function isReportFileName(name) {
|
|
39
39
|
return REPORT_FILE_RE.test(name) && !WINDOWS_DEVICE_RE.test(name) && name.toLowerCase() !== REPLAY_FILE;
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
@@ -55,11 +55,11 @@ export function isGeneratedReport(html) {
|
|
|
55
55
|
}
|
|
56
56
|
// ── the template ────────────────────────────────────────────────────────────
|
|
57
57
|
/** The columns a test table may have, in any order. */
|
|
58
|
-
|
|
58
|
+
const REPORT_COLUMNS = ["testId", "requirements", "step", "expected", "actual", "result", "evidence"];
|
|
59
59
|
/** The sections a template may order. Each data section appears at most once; `text` as often as wanted. */
|
|
60
|
-
|
|
60
|
+
const REPORT_SECTIONS = ["summary", "tests", "deviations", "manifest", "signoff", "text"];
|
|
61
61
|
/** The tokens the title block may use, each replaced by a fact of the run. */
|
|
62
|
-
|
|
62
|
+
const REPORT_TOKENS = {
|
|
63
63
|
date: "the day the run started, YYYY-MM-DD (UTC)",
|
|
64
64
|
time: "the time the run started, HH:MM:SS (UTC)",
|
|
65
65
|
run: "the run's start as YYYYMMDD-HHMMSS (UTC)",
|
|
@@ -67,7 +67,7 @@ export const REPORT_TOKENS = {
|
|
|
67
67
|
commit: "the commit the run was of (GITHUB_SHA), shortened to 12 characters, or — when none",
|
|
68
68
|
};
|
|
69
69
|
/** Every word the page writes that is not data. A template may replace any of them, so it carries its own vocabulary. */
|
|
70
|
-
|
|
70
|
+
const DEFAULT_LABELS = {
|
|
71
71
|
pass: "Pass",
|
|
72
72
|
fail: "Fail",
|
|
73
73
|
refused: "Could not run",
|