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.
Files changed (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +20 -3
  3. package/dist/browsers.js +7 -6
  4. package/dist/ci-run.js +3 -2
  5. package/dist/cli.js +2 -1
  6. package/dist/clients.js +3 -3
  7. package/dist/code-routes.js +2 -2
  8. package/dist/commands.js +4 -4
  9. package/dist/engine/args.js +21 -0
  10. package/dist/engine/bench.js +1 -1
  11. package/dist/engine/brief.js +1 -1
  12. package/dist/engine/browser.js +59 -15
  13. package/dist/engine/capture.js +4 -4
  14. package/dist/engine/check-replay.js +82 -8
  15. package/dist/engine/check-report.js +8 -8
  16. package/dist/engine/check.js +15 -15
  17. package/dist/engine/ci.js +20 -20
  18. package/dist/engine/claims.js +4 -4
  19. package/dist/engine/collector.js +7 -7
  20. package/dist/engine/crawl.js +1 -1
  21. package/dist/engine/dedup.js +4 -4
  22. package/dist/engine/design.js +7 -7
  23. package/dist/engine/expiry.js +1 -1
  24. package/dist/engine/export.js +7 -7
  25. package/dist/engine/flow.js +10 -10
  26. package/dist/engine/forms.js +1 -1
  27. package/dist/engine/from-run.js +6 -6
  28. package/dist/engine/injection.js +1 -1
  29. package/dist/engine/lane.js +1 -1
  30. package/dist/engine/limits.js +2 -2
  31. package/dist/engine/live.js +30 -0
  32. package/dist/engine/memory.js +24 -7
  33. package/dist/engine/open.js +1 -1
  34. package/dist/engine/oracles.js +2 -2
  35. package/dist/engine/plain.js +1 -1
  36. package/dist/engine/png.js +2 -2
  37. package/dist/engine/policy.js +2 -2
  38. package/dist/engine/postmessage.js +7 -7
  39. package/dist/engine/project-folder.js +1 -1
  40. package/dist/engine/provider.js +1 -1
  41. package/dist/engine/replay.js +3 -1
  42. package/dist/engine/report.js +3 -3
  43. package/dist/engine/sarif.js +2 -2
  44. package/dist/engine/script-literal.js +11 -0
  45. package/dist/engine/sensitive.js +254 -0
  46. package/dist/engine/signed-in.js +2 -2
  47. package/dist/engine/status-pane-page.js +4 -3
  48. package/dist/engine/task.js +1 -1
  49. package/dist/engine/teardown.js +2 -0
  50. package/dist/engine/tickets.js +6 -6
  51. package/dist/engine/verify.js +3 -3
  52. package/dist/export-run.js +1 -1
  53. package/dist/installer.js +4 -4
  54. package/dist/login-run.js +2 -2
  55. package/dist/mcp-server.js +6 -9
  56. package/dist/playbook.js +1 -1
  57. 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
- # 🔭 SceneScout
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: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
12
14
  ![node >= 20](https://img.shields.io/badge/node-%E2%89%A5%2020-339933?logo=node.js&logoColor=white)
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, the page it is on, a live thumbnail, and a feed of the actions it just took, tinted one colour per task" width="880" /></p>
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
- export const INSTALL_TARGETS = ["chromium", "chromium-headless-shell", "firefox", "webkit"];
19
- export const DEFAULT_ENGINE = "chromium";
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
- export const DEFAULT_ENGINE_ENV = "SCENESCOUT_BROWSER";
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
- export function installCommandFor(target, fromCheckout) {
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
- export const BROWSER_DOWNLOAD_SETTINGS = ["auto", "on", "off"];
291
- export const DEFAULT_BROWSER_DOWNLOAD = "auto";
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
- export const MODEL_CALL_MS = 180_000;
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
- export const FINISH_MS = 180_000;
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
- const result = spawnSync(command, args, { stdio: "ignore" });
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
- export const OTHER_CLIENTS = ["cursor", "vscode", "codex", "gemini", "copilot", "windsurf"];
17
- export const CLIENTS = ["claude-code", ...OTHER_CLIENTS];
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
- export function serverEntry(launch) {
58
+ function serverEntry(launch) {
59
59
  return { command: launch[0], args: launch.slice(1) };
60
60
  }
61
61
  /**
@@ -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
- export function maskComments(src) {
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
- export function recordPath(records, idx, prefix = "", absoluteTopOnly = false) {
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
- export function wantsHelp(args) {
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
- export function unknownArgument(args, spec) {
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
- export function isSubcommand(command) {
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
- export function preflight(command, args) {
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
+ }
@@ -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
- export function ownershipRemark(d, key, laneRoutes) {
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);
@@ -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
- export const LANE_NAME_MAX = 40;
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
  *
@@ -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
- return ((date.note ? ` (${date.note})` : "") +
3125
- (existing && existing !== date.value ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))}: a ${type} field holds one value)` : ""));
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 ${JSON.stringify(existing.slice(0, 60))})` : "";
3165
+ return existing && (replace || text === "") ? ` (replaced existing content ${quoted(existing)})` : "";
3130
3166
  }
3131
- const appendNote = ` (APPENDED after existing content ${JSON.stringify(existing.slice(0, 60))} — pass replace=true to overwrite instead)`;
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
- let fillNote = await this.fillOrAppend(locator, text, replace);
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
- return this.afterAction("type", `${el.role} "${el.name}" ← ${JSON.stringify(text.slice(0, 60))}${pressEnter ? " + Enter" : ""}${fillNote}`);
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
- const desc = `${i + 1}. ${step.action} ${step.target ?? step.value ?? ""}`;
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
- const fillNote = await this.fillOrAppend(loc, step.value ?? "", step.replace ?? false);
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({ action: `plan:${step.action}`, target: step.target ?? step.value, url: page.url(), ...(await this.frameFor(`plan-${step.action}`)) });
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
@@ -63,7 +63,7 @@ export function capturedName(file) {
63
63
  return null;
64
64
  return name.slice(0, -".png".length);
65
65
  }
66
- export const CAPTURED_PREFIX = "CAPTURED ";
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
- export const EVIDENCE_MAX_PX_ENV = "SCENESCOUT_EVIDENCE_MAX_PX";
151
- export const EVIDENCE_MAX_KB_ENV = "SCENESCOUT_EVIDENCE_MAX_KB";
152
- export const EVIDENCE_INLINE_ENV = "SCENESCOUT_EVIDENCE_INLINE";
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
- export function expectedOf(step) {
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
- /** The report's redaction (memory.ts redactRoute) on every path, caption and reason the page shows. */
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: redactRoute(v.path), ...(v.reason !== undefined ? { reason: redactRoute(v.reason) } : {}) })),
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: redactRoute(s.caption),
131
- ...(s.expected !== undefined ? { expected: redactRoute(s.expected) } : {}),
132
- ...(s.reason !== undefined ? { reason: redactRoute(s.reason) } : {}),
133
- ...(s.path !== undefined ? { path: redactRoute(s.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
- export const DEFAULT_REPORT_FILE = "report.html";
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
- export const REPORT_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.html$/;
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
- export const WINDOWS_DEVICE_RE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])\./i;
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
- export function isReportFileName(name) {
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
- export const REPORT_COLUMNS = ["testId", "requirements", "step", "expected", "actual", "result", "evidence"];
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
- export const REPORT_SECTIONS = ["summary", "tests", "deviations", "manifest", "signoff", "text"];
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
- export const REPORT_TOKENS = {
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
- export const DEFAULT_LABELS = {
70
+ const DEFAULT_LABELS = {
71
71
  pass: "Pass",
72
72
  fail: "Fail",
73
73
  refused: "Could not run",