scenescout 3.1.1 → 3.3.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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Log entries that are not actions: a stated task, an attach, the note that a
3
+ * resource was created. They carry no browser work and take no time, so
4
+ * counting them inflates the action count and drags the median gap toward
5
+ * zero. One real run logged 325 entries of which 107 were stated tasks.
6
+ */
7
+ const MARKER_ACTIONS = new Set(["task", "attach", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
8
+ /** Whether a log entry represents work the browser actually did. */
9
+ export function isActing(action) {
10
+ return !MARKER_ACTIONS.has(action);
11
+ }
12
+ /** A gap longer than this is the agent thinking, not the browser working. */
13
+ export const IDLE_GAP_MS = 30_000;
14
+ /** A session with no action for this long is probably forgotten, and is holding a browser for nothing. */
15
+ export const STALE_SESSION_MS = 5 * 60_000;
16
+ function median(sorted) {
17
+ if (sorted.length === 0)
18
+ return 0;
19
+ const mid = Math.floor(sorted.length / 2);
20
+ return sorted.length % 2 === 1 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
21
+ }
22
+ /**
23
+ * The pace of each session in the log, and of the run as a whole. `nowMs` is
24
+ * passed rather than read so the numbers are the same every time they are
25
+ * computed from the same log.
26
+ */
27
+ export function measurePace(log, nowMs, attached = []) {
28
+ const stillOpen = new Set(attached);
29
+ const bySession = new Map();
30
+ for (const entry of log) {
31
+ if (!isActing(entry.action))
32
+ continue;
33
+ const name = entry.session ?? "default";
34
+ const list = bySession.get(name);
35
+ if (list)
36
+ list.push(entry);
37
+ else
38
+ bySession.set(name, [entry]);
39
+ }
40
+ const sessions = [];
41
+ let first = Number.POSITIVE_INFINITY;
42
+ let last = 0;
43
+ for (const [session, entries] of bySession) {
44
+ const times = entries.map((e) => Date.parse(e.at)).filter((t) => Number.isFinite(t));
45
+ if (times.length === 0)
46
+ continue;
47
+ times.sort((a, b) => a - b);
48
+ first = Math.min(first, times[0]);
49
+ last = Math.max(last, times[times.length - 1]);
50
+ const gaps = [];
51
+ for (let i = 1; i < times.length; i += 1)
52
+ gaps.push(times[i] - times[i - 1]);
53
+ const spanMs = times[times.length - 1] - times[0];
54
+ const idleMs = gaps.filter((g) => g > IDLE_GAP_MS).reduce((sum, g) => sum + g, 0);
55
+ sessions.push({
56
+ session,
57
+ actions: entries.length,
58
+ spanMs,
59
+ medianGapMs: median([...gaps].sort((a, b) => a - b)),
60
+ maxGapMs: gaps.length > 0 ? Math.max(...gaps) : 0,
61
+ idleShare: spanMs > 0 ? idleMs / spanMs : 0,
62
+ quietMs: Math.max(0, nowMs - times[times.length - 1]),
63
+ framed: entries.filter((e) => e.frame).length,
64
+ });
65
+ }
66
+ sessions.sort((a, b) => b.actions - a.actions);
67
+ return {
68
+ sessions,
69
+ spanMs: sessions.length > 0 ? last - first : 0,
70
+ actions: sessions.reduce((sum, x) => sum + x.actions, 0),
71
+ // Only a session that is STILL ATTACHED can be holding a browser. A lane
72
+ // that finished and closed is quiet because it is gone, and warning about
73
+ // it told the reader to close something that no longer exists — which is
74
+ // what the first run of this report did for six of its eleven sessions.
75
+ quiet: sessions.filter((s) => s.quietMs > STALE_SESSION_MS && stillOpen.has(s.session)).map((s) => s.session),
76
+ };
77
+ }
78
+ /** Milliseconds as a person says them: 45s, 4m12s, 1h03m. */
79
+ export function sayDuration(ms) {
80
+ const total = Math.max(0, Math.round(ms / 1000));
81
+ if (total < 60)
82
+ return `${total}s`;
83
+ const minutes = Math.floor(total / 60);
84
+ if (minutes < 60)
85
+ return `${minutes}m${String(total % 60).padStart(2, "0")}s`;
86
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
87
+ }
88
+ /**
89
+ * The run's pace as report lines. Says what the numbers mean, because "idle
90
+ * 84%" reads as a fault in the engine when it is a description of how the
91
+ * agent paced itself.
92
+ */
93
+ export function formatPace(pace) {
94
+ if (pace.sessions.length === 0)
95
+ return [];
96
+ const lines = [
97
+ `## How the run was paced`,
98
+ ``,
99
+ `${pace.actions} action(s) over ${sayDuration(pace.spanMs)}. Stated tasks and attaches are not counted: they take no time. Idle share is time the browser stood still waiting for the agent, not time the engine spent working.`,
100
+ ``,
101
+ `| Session | Actions | Span | Median gap | Longest gap | Idle | Frames |`,
102
+ `|---|---:|---:|---:|---:|---:|---:|`,
103
+ ];
104
+ for (const s of pace.sessions) {
105
+ lines.push(`| ${s.session} | ${s.actions} | ${sayDuration(s.spanMs)} | ${sayDuration(s.medianGapMs)} | ${sayDuration(s.maxGapMs)} | ${Math.round(s.idleShare * 100)}% | ${s.framed} |`);
106
+ }
107
+ lines.push(``);
108
+ if (pace.quiet.length > 0) {
109
+ lines.push(`⚠ Held a browser with nothing to do for over ${sayDuration(STALE_SESSION_MS)}: ${pace.quiet.join(", ")}. A session waiting for its turn should be closed and re-attached when it is needed.`, ``);
110
+ }
111
+ return lines;
112
+ }
@@ -22,7 +22,11 @@ const DESTRUCTIVE_PATTERNS = [
22
22
  /\breset\b(?!\s+(filters?|zoom|search|view|sort|order|form|password|layout|columns?|selection|preferences?|defaults?))/i,
23
23
  /\bdiscard\b/i,
24
24
  /\bcancel subscription\b/i,
25
- /\bsign(?: |-)?off\b/i,
25
+ // The verb: a control that signs the user off, alone or after another verb
26
+ // ("Save and sign off"). The noun is how approval apps label things, and it
27
+ // is told by the word before it ("Needs sign-off", "Awaiting sign-off",
28
+ // "Send for sign-off"); those destroy nothing. Same shape as the reset rule.
29
+ /(?<!\b(?:needs?|awaiting|awaits|pending|requires?|required|for|before|after|without|of|the|a|an|its|their|your|my)\s)\bsign(?: |-)?off\b/i,
26
30
  // The tool is generic — destructive labels come in many languages.
27
31
  /\b(eliminar|borrar|suprimir)\b/i, // es
28
32
  /\b(excluir|apagar|remover)\b/i, // pt
@@ -74,8 +78,55 @@ export function isDestructiveWire(url, body) {
74
78
  }
75
79
  /** Auth/session flows must work even under strict write policies (login, token refresh, logout). */
76
80
  export const AUTH_FLOW_RE = /\/(auth|login|logout|signin|sign-in|signup|sign-up|session|token|verify|oauth|sso|password)\b/i;
81
+ /**
82
+ * A control's label is a verb phrase: "Delete", "Remove user", "Archive the
83
+ * project". A card or tile that is a button carries prose in its accessible
84
+ * name, the title then a sentence about it ("Manager Approves or rejects
85
+ * orders that need sign-off."), and a destructive word in that sentence
86
+ * describes what the thing is for; it is not the command the click sends, the
87
+ * same distinction the wire policy draws for words inside a POST body.
88
+ *
89
+ * So a label that is prose, longer than this many words AND containing a
90
+ * sentence, is judged by its head, the first few words, where the imperative
91
+ * lives: a match counts only when it STARTS there. The pattern still sees the
92
+ * whole label, so "reset filters" straddling the boundary keeps the context
93
+ * its exemption looks ahead at. Everything else is judged whole: a short
94
+ * label, a long one with no sentence in it ("Yes, I am sure I want to delete
95
+ * this"), a script written without spaces.
96
+ *
97
+ * A confirm button whose long label ends in a full stop and puts the verb
98
+ * late is the residue this lets through. The wire policy below still blocks
99
+ * the DELETE, PUT, PATCH or verb-path POST it would send; a plain POST to a
100
+ * neutral path passes in read-only, as it does for any control this list does
101
+ * not name.
102
+ */
103
+ export const LABEL_HEAD_WORDS = 6;
104
+ /** The head: up to LABEL_HEAD_WORDS whitespace-separated words from the start. */
105
+ const HEAD_RE = new RegExp(`^\\s*(?:\\S+\\s+){0,${LABEL_HEAD_WORDS - 1}}\\S+`);
106
+ /** A sentence ends in the label: a full stop, question or exclamation mark followed by space or the end. */
107
+ const SENTENCE_RE = /[.!?](?:\s|$)/;
108
+ /**
109
+ * Where a match must start to count: the end of the head for prose, the end
110
+ * of the label for everything else.
111
+ */
112
+ function matchLimit(label) {
113
+ if (!SENTENCE_RE.test(label))
114
+ return label.length;
115
+ const head = HEAD_RE.exec(label);
116
+ if (!head || head[0].length === label.trimEnd().length)
117
+ return label.length;
118
+ return head[0].length;
119
+ }
77
120
  export function isDestructive(...labels) {
78
- return labels.some((label) => typeof label === "string" && label.length > 0 && DESTRUCTIVE_PATTERNS.some((re) => re.test(label)));
121
+ return labels.some((label) => {
122
+ if (typeof label !== "string" || label.length === 0)
123
+ return false;
124
+ const limit = matchLimit(label);
125
+ return DESTRUCTIVE_PATTERNS.some((re) => {
126
+ const m = re.exec(label);
127
+ return m !== null && m.index < limit;
128
+ });
129
+ });
79
130
  }
80
131
  export function destructiveRefusal(label, mode = "read-only") {
81
132
  return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { SHARED_CHROME_ROUTE } from "./memory.js";
4
+ import { sayVerification } from "./verify.js";
4
5
  import { feedForSession } from "./live.js";
5
6
  import { buildReplayHtml, evidenceFor } from "./replay.js";
7
+ import { formatPace, measurePace } from "./pace.js";
6
8
  function playwrightSkeleton(f) {
7
9
  const routeClass = f.state.split("#")[0].split("?")[0];
8
10
  let gotoPath = routeClass;
@@ -68,6 +70,24 @@ function projectName(dir) {
68
70
  const parent = path.dirname(dir);
69
71
  return path.basename(parent) || path.basename(dir) || "project";
70
72
  }
73
+ /**
74
+ * How long ago a finding was last seen, for the historical index. A finding
75
+ * nobody has re-confirmed in four months is a different thing from one seen
76
+ * last week, and the report should not make a reader open both to find out.
77
+ */
78
+ export function describeAge(foundAt, nowMs) {
79
+ const seen = Date.parse(foundAt);
80
+ if (!Number.isFinite(seen))
81
+ return "?";
82
+ const days = Math.floor((nowMs - seen) / 86_400_000);
83
+ if (days <= 0)
84
+ return "today";
85
+ if (days === 1)
86
+ return "1 day";
87
+ if (days < 60)
88
+ return `${days} days`;
89
+ return `${Math.floor(days / 30)} months`;
90
+ }
71
91
  /** Every session that did anything, with its steps in order — what the HTML replays. */
72
92
  function replaySessions(memory) {
73
93
  const names = [...new Set(memory.actionLog.map((e) => e.session ?? "default"))];
@@ -378,6 +398,7 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
378
398
  const lines = [];
379
399
  const resolved = findings.filter((f) => f.status === "resolved");
380
400
  const open = findings.filter((f) => f.status !== "resolved");
401
+ const now = Date.now();
381
402
  const current = open.filter((f) => f.foundAt >= memory.sessionStart);
382
403
  const historical = open.filter((f) => f.foundAt < memory.sessionStart);
383
404
  lines.push(`# SceneScout Report`);
@@ -496,6 +517,11 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
496
517
  lines.push(`- **Evidence:** \`${f.evidence}\``);
497
518
  lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
498
519
  lines.push(`- **Seen in runs:** ${f.runs}`);
520
+ // Only printed once somebody has re-tested it. A finding nobody has looked
521
+ // at again says nothing here, which is the honest thing for it to say.
522
+ if (f.verdict && f.verifiedAt) {
523
+ lines.push(`- **Re-tested:**${sayVerification(f).replace(/^ · /, " ")}${f.verifyNote ? ` — ${f.verifyNote}` : ""}`);
524
+ }
499
525
  lines.push(``);
500
526
  lines.push(f.detail);
501
527
  lines.push(``);
@@ -521,18 +547,47 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
521
547
  if (historical.length > 0) {
522
548
  lines.push(`## Historical findings — not re-verified this session (${historical.length})`);
523
549
  lines.push(``);
524
- lines.push(`Recorded in earlier runs and not re-confirmed. Re-test before acting; resolve fixed ones with \`scout_resolve <id>\`.`);
525
- lines.push(``);
526
- for (const f of historical)
527
- renderFinding(f);
550
+ if (extras?.history === "full") {
551
+ lines.push(`Recorded in earlier runs and not re-confirmed. Re-test before acting; \`scout_verify\` hands them back in re-test order and records what each re-test found.`);
552
+ lines.push(``);
553
+ for (const f of historical)
554
+ renderFinding(f);
555
+ }
556
+ else {
557
+ // An index, not the findings themselves. One project reached 412
558
+ // historical findings and printing each in full made the report 1.75 MB
559
+ // — a document nobody opens, in which the eleven findings the run
560
+ // actually made were buried. Each row carries what decides whether to
561
+ // re-test it; the detail is one scout_report {history:"full"} away.
562
+ lines.push(`Recorded in earlier runs and NOT re-confirmed by this one, so none of it is evidence about the build under test. Listed as an index: ` +
563
+ `work it down with \`scout_verify\`, which hands back these findings in re-test order and records what each re-test found, ` +
564
+ `and pass \`history: "full"\` to scout_report for the full text.`, ``, `| Sev | Id | Age | Runs | Re-tested | Title |`, `|---|---|---:|---:|---|---|`);
565
+ for (const f of historical) {
566
+ const retested = f.verdict && f.verifiedAt ? `${f.verdict} ${f.verifiedAt.slice(0, 10)}` : "never";
567
+ lines.push(`| ${SEVERITY_ICON[f.severity]} | \`${f.id}\` | ${describeAge(f.foundAt, now)} | ${f.runs} | ${retested} | ${escapeTableCell(f.title)} |`);
568
+ }
569
+ lines.push(``);
570
+ }
528
571
  }
529
572
  if (resolved.length > 0) {
530
573
  lines.push(`## ✅ Resolved (${resolved.length})`);
531
574
  lines.push(``);
532
575
  lines.push(`Fixed and verified (or confirmed no longer reproducing). A resolved finding that is re-found reopens automatically and is flagged as a regression above.`);
533
576
  lines.push(``);
534
- for (const f of resolved)
535
- renderFinding(f, true);
577
+ if (extras?.history === "full") {
578
+ for (const f of resolved)
579
+ renderFinding(f, true);
580
+ }
581
+ else {
582
+ // The least actionable content in the document: these are fixed. On one
583
+ // project they were 314 findings and 783 KB — nearly half the report,
584
+ // none of it anything to do. The index keeps the record without the bulk.
585
+ lines.push(`| Sev | Id | Fixed | Title |`, `|---|---|---:|---|`);
586
+ for (const f of resolved) {
587
+ lines.push(`| ${SEVERITY_ICON[f.severity]} | \`${f.id}\` | ${describeAge(f.foundAt, now)} ago | ${escapeTableCell(f.title)} |`);
588
+ }
589
+ lines.push(``);
590
+ }
536
591
  }
537
592
  if (extras?.createdResources && extras.createdResources.length > 0) {
538
593
  lines.push(`## Data created by this session (cleanup list)`);
@@ -552,6 +607,15 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
552
607
  }
553
608
  lines.push(``);
554
609
  }
610
+ // How the run was paced. A reader who sees a session that did four actions
611
+ // in an hour learns more from that than from another coverage percentage.
612
+ // Omitted where the report is compared byte for byte, since it is timing.
613
+ // The sessions still attached, so the stale-browser warning names only the
614
+ // ones that could act on it. A lane that finished and closed is quiet
615
+ // because it is gone.
616
+ if (extras?.pace !== false) {
617
+ lines.push(...formatPace(measurePace(memory.actionLog, Date.now(), extras?.attachedSessions ?? [])));
618
+ }
555
619
  const markdown = lines.join("\n");
556
620
  const outPath = path.join(memory.dir, "report.md");
557
621
  const htmlPath = path.join(memory.dir, "report.html");
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Calling the app's own API with the UI bypassed.
3
+ *
4
+ * A refusal shown by hiding or disabling a button is not a refusal. Confirming
5
+ * that the server refuses the same action is the single most valuable check a
6
+ * permission pass makes, and until now it could only be done outside the tool
7
+ * — in a shell, with curl and a hand-extracted token — so none of that
8
+ * evidence reached the report. A whole validation run's permission matrices
9
+ * lived in shell history and vanished with it.
10
+ *
11
+ * The request is made by the PAGE, not beside it. That matters twice over:
12
+ * it goes through the same interception the write policy is enforced on, so a
13
+ * safe-write session cannot delete a record it does not own by calling the
14
+ * endpoint instead of clicking; and it carries the session's own credentials,
15
+ * because it is the same origin with the same cookies.
16
+ *
17
+ * Bearer schemes are handled by replaying the Authorization header the app
18
+ * itself last sent, which the engine already sees on every intercepted
19
+ * request. Nothing here knows what a token looks like or where an app keeps
20
+ * one, so nothing here is tuned to any app.
21
+ *
22
+ * Everything in this file is pure so it can be table-tested; the one
23
+ * `page.evaluate` lives in browser.ts.
24
+ */
25
+ /** Methods a session may replay. Anything else is refused before it reaches the page. */
26
+ export const REPLAYABLE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
27
+ /** Most of a response body that reaches the agent. A JSON list can be megabytes; the signature is in the first lines. */
28
+ export const BODY_MAX = 2000;
29
+ /** Response headers worth reporting. "Identical response" means status, body AND headers, so the ones that commonly differ are kept. */
30
+ export const REPORTED_HEADERS = ["content-type", "content-length", "location", "www-authenticate", "retry-after", "x-request-id", "cache-control"];
31
+ /**
32
+ * The path to call, resolved against the attached origin and fenced to it.
33
+ * Navigation is fenced the same way: a run attached to one app must not be
34
+ * able to make its browser talk to another host just because a path was
35
+ * spelled as a full URL.
36
+ */
37
+ export function resolveRequestUrl(baseUrl, path) {
38
+ const trimmed = path.trim();
39
+ if (!trimmed)
40
+ return { problem: "No path given. Pass a path such as /api/things, or a full URL on the attached origin." };
41
+ let target;
42
+ let base;
43
+ try {
44
+ base = new URL(baseUrl);
45
+ target = new URL(trimmed, base);
46
+ }
47
+ catch {
48
+ return { problem: `Could not read ${JSON.stringify(trimmed)} as a path or a URL.` };
49
+ }
50
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
51
+ return { problem: `Only http and https can be requested; ${target.protocol} cannot.` };
52
+ }
53
+ if (target.origin !== base.origin) {
54
+ return {
55
+ problem: `${target.origin} is not the origin this session is attached to (${base.origin}). A session talks to its own app only; attach another session to test another host.`,
56
+ };
57
+ }
58
+ return { url: target.toString() };
59
+ }
60
+ /** The method, upper-cased, or the reason it cannot be replayed. */
61
+ export function resolveMethod(method) {
62
+ const upper = (method ?? "GET").trim().toUpperCase();
63
+ if (!REPLAYABLE_METHODS.includes(upper)) {
64
+ return { problem: `${upper} is not a method this can replay. Use one of: ${REPLAYABLE_METHODS.join(", ")}.` };
65
+ }
66
+ return { method: upper };
67
+ }
68
+ /**
69
+ * The script the page runs. Built as one expression so it can be evaluated
70
+ * directly, with every value passed through JSON rather than interpolated as
71
+ * code: a header value or a body is data from the agent, and a quote in it
72
+ * must not be able to end the string it sits in.
73
+ *
74
+ * `credentials: "include"` so the session's cookies go with it, and the
75
+ * app's own Authorization header is replayed when one has been seen.
76
+ */
77
+ export function buildRequestScript(input) {
78
+ const { url, method, body, headers } = input;
79
+ const init = { method, credentials: "include", headers };
80
+ if (body !== undefined && method !== "GET" && method !== "HEAD")
81
+ init.body = body;
82
+ return (`(async () => { const started = Date.now();` +
83
+ ` const res = await fetch(${JSON.stringify(url)}, ${JSON.stringify(init)});` +
84
+ ` const text = await res.text();` +
85
+ ` const headers = {}; res.headers.forEach((v, k) => { headers[k] = v; });` +
86
+ ` return { status: res.status, statusText: res.statusText, headers, body: text.slice(0, ${BODY_MAX * 2}), full: text.length, ms: Date.now() - started, url: res.url }; })()`);
87
+ }
88
+ /** The headers to send: what the caller asked for, plus the app's own auth and a JSON content type when a body is present. */
89
+ export function requestHeaders(input) {
90
+ const out = {};
91
+ // The app's own header first, so an explicit one from the caller wins — that
92
+ // is how a session tests what happens with a different or absent credential.
93
+ if (input.auth)
94
+ out["authorization"] = input.auth;
95
+ if (input.body !== undefined)
96
+ out["content-type"] = "application/json";
97
+ for (const [name, value] of Object.entries(input.given ?? {}))
98
+ out[name.toLowerCase()] = value;
99
+ return out;
100
+ }
101
+ /** The raw result of the in-page fetch, reduced to what the agent and the report need. */
102
+ export function toReplayResult(raw) {
103
+ const kept = {};
104
+ for (const name of REPORTED_HEADERS) {
105
+ const value = raw.headers[name];
106
+ if (value !== undefined)
107
+ kept[name] = value;
108
+ }
109
+ return {
110
+ status: raw.status,
111
+ statusText: raw.statusText,
112
+ headers: kept,
113
+ body: raw.body.slice(0, BODY_MAX),
114
+ truncated: raw.full > BODY_MAX,
115
+ ms: raw.ms,
116
+ url: raw.url,
117
+ };
118
+ }
119
+ /** The signature a finding carries for this call: the line that dedups the same refusal across runs. */
120
+ export function replaySignature(method, url, status) {
121
+ let path = url;
122
+ try {
123
+ const parsed = new URL(url);
124
+ path = parsed.pathname + parsed.search;
125
+ }
126
+ catch {
127
+ // Not a URL we can shorten; the whole string is the signature.
128
+ }
129
+ return `${method} ${path} ${status}`;
130
+ }
131
+ /** What the agent reads back. Leads with the signature, because that is what a finding quotes. */
132
+ export function formatReplay(method, result) {
133
+ const lines = [replaySignature(method, result.url, result.status) + (result.statusText ? ` ${result.statusText}` : ""), `took ${result.ms} ms`];
134
+ const headers = Object.entries(result.headers);
135
+ if (headers.length > 0)
136
+ lines.push(headers.map(([k, v]) => `${k}: ${v}`).join(" · "));
137
+ if (result.body.length > 0) {
138
+ lines.push("", result.body + (result.truncated ? `\n… truncated at ${BODY_MAX} characters` : ""));
139
+ }
140
+ else {
141
+ lines.push("", "(empty body)");
142
+ }
143
+ return lines.join("\n");
144
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * How long to wait after an action before reading the page.
3
+ *
4
+ * This used to be a flat 400 ms sleep on every action, for a good reason:
5
+ * `waitForLoadState("networkidle")` latches once reached and then resolves
6
+ * instantly forever after, so it cannot be used to wait out the request an
7
+ * action just fired, and draining the oracles before that request lands would
8
+ * misattribute its violations to the next step.
9
+ *
10
+ * The floor was still the wrong shape. Measured against a demo app, a snapshot
11
+ * cost 740 ms of which 400 ms was the sleep — 54% of the wall time — and a run
12
+ * of 203 actions spent 81 seconds asleep. Meanwhile a page slower than 400 ms
13
+ * got cut off exactly when it mattered.
14
+ *
15
+ * The engine already intercepts every request, so it knows what is in flight.
16
+ * Waiting on THAT, with a quiet window after the last one starts, is both
17
+ * faster on a page that answers quickly and more patient with one that does
18
+ * not. The old constant survives as a ceiling rather than a floor.
19
+ *
20
+ * The same rule carries the deliberate slow-down: a run being watched by a
21
+ * person taking notes wants a fixed pace, not the fastest one. `paceMs` is a
22
+ * floor a session asks for; unset, a session goes as fast as its page allows.
23
+ */
24
+ /** No request has started for this long and none is in flight: the page has settled. */
25
+ export const QUIET_MS = 120;
26
+ /** Longest to wait for requests to drain, however busy the page is. A page that never goes quiet must not stall the run. */
27
+ export const SETTLE_CAP_MS = 2000;
28
+ /** How often the wait re-checks. */
29
+ export const SETTLE_TICK_MS = 20;
30
+ /**
31
+ * Whether to keep waiting. Split out from the browser so the rule can be
32
+ * table-tested: it is the difference between a run that is fast and one that
33
+ * reads a page before it has finished changing.
34
+ */
35
+ export function shouldKeepWaiting(state) {
36
+ // A pace a session asked for is a floor on the whole wait, and it is the
37
+ // only reason to wait once everything else says the page is ready.
38
+ if (state.elapsedMs < state.paceMs)
39
+ return true;
40
+ // Never longer than the cap, whatever the page is doing.
41
+ if (state.elapsedMs >= SETTLE_CAP_MS)
42
+ return false;
43
+ // Something is still out: wait for it, up to the cap.
44
+ if (state.inFlight > 0)
45
+ return true;
46
+ // Nothing in flight and nothing recent. The action itself is the last event
47
+ // that could still produce a request, so the quiet window runs from whichever
48
+ // of the two is later. Without this, an action whose request comes back
49
+ // through a hop — a click that posts to a service worker, which then fetches —
50
+ // is read while the page is between the two, and the request lands during the
51
+ // NEXT action, which is then blamed for it.
52
+ return Math.min(state.sinceLastStartMs, state.elapsedMs) < QUIET_MS;
53
+ }
54
+ /** A pace a session asked for, clamped. Negative or absurd values are a typo, not an instruction. */
55
+ export const PACE_MAX_MS = 60_000;
56
+ export function normalizePace(paceMs) {
57
+ if (typeof paceMs !== "number" || !Number.isFinite(paceMs) || paceMs <= 0)
58
+ return 0;
59
+ return Math.min(Math.round(paceMs), PACE_MAX_MS);
60
+ }
61
+ /** What the attach result says about a deliberate pace, or nothing when the session runs at full speed. */
62
+ export function describePace(paceMs) {
63
+ if (paceMs <= 0)
64
+ return "";
65
+ return `\n⏱ PACE: at least ${paceMs} ms between actions, so a person can follow along. Unset it with paceMs: 0 to go as fast as the page allows.`;
66
+ }
67
+ /**
68
+ * Waiting for a client-side redirect that has not happened yet.
69
+ *
70
+ * A guard that redirects on a timer after hydration issues no request until it
71
+ * fires, so there is nothing for the request-based settle above to wait on:
72
+ * it goes quiet, the URL is read, and the page reports the route it was asked
73
+ * for rather than the login page it actually bounced to. The old flat 400 ms
74
+ * sleep covered this by accident, and removing it made the bounce invisible
75
+ * whenever a loaded machine delayed the timer past the quiet window.
76
+ *
77
+ * So where a BOUNCE VERDICT is about to be made — attach judging a storage
78
+ * state, navigate judging coverage — the URL is watched until it has held
79
+ * still, rather than read once. This is paid per navigation, not per action.
80
+ */
81
+ /**
82
+ * How long the URL must hold still before a bounce verdict is believed.
83
+ *
84
+ * This is a window, not a guarantee: a guard slower than it still lands after
85
+ * the verdict. What it buys is the realistic case — a guard written to fire
86
+ * promptly, delayed by a loaded machine — which is the one that made this
87
+ * project's own CI fail intermittently on a 40 ms timer.
88
+ */
89
+ export const URL_QUIET_MS = 400;
90
+ /** Longest to watch. A page redirecting in a loop must not hold up the run. */
91
+ export const URL_CAP_MS = 3000;
92
+ /** Whether to keep watching the URL before judging where the page landed. */
93
+ export function keepWatchingUrl(w) {
94
+ if (w.elapsedMs >= URL_CAP_MS)
95
+ return false;
96
+ return w.sinceChangeMs < URL_QUIET_MS;
97
+ }