scenescout 3.15.0 → 3.17.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Opening the live view and the report in the user's default browser.
3
+ *
4
+ * The live view's address and the report's path are printed in tool results,
5
+ * but a person in a chat client may never see or follow them. The `open`
6
+ * setting has the engine open them itself: the live view when a session
7
+ * attaches, the report when scout_report writes it.
8
+ *
9
+ * Whether to open is decided here, by a pure function of the setting and the
10
+ * environment, so every case is table-tested. Opening goes through the
11
+ * platform opener with no shell, and only ever hands it a loopback URL or an
12
+ * absolute file path. The live view's own rules (loopback only, a token on
13
+ * every path, ADR 7) are unchanged: opening an address reaches nobody new.
14
+ *
15
+ * Nothing here imports Playwright, and the spawn is injected, so a test never
16
+ * launches a browser.
17
+ */
18
+ import path from "node:path";
19
+ import { isCiEnv } from "./capture.js";
20
+ /** The environment variable that sets `open` for every session of the server. A `scout_attach` `open` wins over it. */
21
+ export const OPEN_ENV = "SCENESCOUT_OPEN";
22
+ export const OPEN_CHOICES = ["live", "report", "both", "none"];
23
+ /** `SCENESCOUT_OPEN`, or undefined when unset. A value outside the choices is refused rather than ignored. */
24
+ export function openChoiceFromEnv(env) {
25
+ const raw = (env[OPEN_ENV] ?? "").trim();
26
+ if (raw === "")
27
+ return undefined;
28
+ if (!OPEN_CHOICES.includes(raw))
29
+ throw new Error(`${OPEN_ENV} must be one of ${OPEN_CHOICES.join(", ")}, not ${JSON.stringify(raw)}`);
30
+ return raw;
31
+ }
32
+ /**
33
+ * Nobody would see a window opened from here: a session over SSH, where the
34
+ * browser would open on the remote machine if at all, or a Linux or other
35
+ * Unix machine with no display server. macOS and Windows always have one.
36
+ */
37
+ export function noDisplay(env, platform) {
38
+ if (env.SSH_CONNECTION || env.SSH_TTY)
39
+ return true;
40
+ if (platform === "darwin" || platform === "win32")
41
+ return false;
42
+ return !env.DISPLAY && !env.WAYLAND_DISPLAY;
43
+ }
44
+ const both = (choice, why) => ({
45
+ live: choice === "live" || choice === "both",
46
+ report: choice === "report" || choice === "both",
47
+ why,
48
+ });
49
+ /**
50
+ * What opens. A setting, from the attach or the environment, is followed as
51
+ * given. With none, both open on a local desktop session, whether the
52
+ * session's browser is headed or headless and whatever the MCP client
53
+ * declares: what a client declares is outside this project's control, and a
54
+ * person in a chat client is the one who most needs the page opened. Nothing
55
+ * opens in CI or where no display would show it. A developer who wants no
56
+ * tabs sets SCENESCOUT_OPEN=none.
57
+ */
58
+ export function decideOpen(choice, ctx) {
59
+ if (choice)
60
+ return both(choice, `open is set to ${choice}`);
61
+ if (isCiEnv(ctx.env))
62
+ return both("none", "a CI run");
63
+ if (noDisplay(ctx.env, ctx.platform))
64
+ return both("none", "no display to show a window on");
65
+ return both("both", "a local desktop session");
66
+ }
67
+ /**
68
+ * Only what the engine itself produced may be opened: the live view's
69
+ * loopback address (plain http, no credentials) or an absolute path to a file.
70
+ * Anything else — a remote URL, a relative path, something that could read as
71
+ * an option to the opener — is refused.
72
+ */
73
+ export function openableTarget(target) {
74
+ if (target === "" || /[\0-\x1f\x7f]/.test(target) || target.startsWith("-"))
75
+ return false;
76
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) {
77
+ let u;
78
+ try {
79
+ u = new URL(target);
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ return u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname) && !u.username && !u.password;
85
+ }
86
+ return path.isAbsolute(target) || path.win32.isAbsolute(target);
87
+ }
88
+ /**
89
+ * The platform's opener, run directly with the target as its only argument.
90
+ * Windows' `start` is a cmd.exe built-in and cannot run without a shell, so
91
+ * Windows uses the URL protocol handler `start` itself relies on.
92
+ */
93
+ export function openerCommand(platform, target) {
94
+ if (platform === "darwin")
95
+ return { command: "open", args: [target] };
96
+ if (platform === "win32")
97
+ return { command: "rundll32", args: ["url.dll,FileProtocolHandler", target] };
98
+ return { command: "xdg-open", args: [target] };
99
+ }
100
+ /**
101
+ * Hand a target to the platform opener. Refuses a target openableTarget does
102
+ * not accept. The opener runs detached and is not waited for; a failure to
103
+ * start it (no xdg-open installed, say) arrives later through onError.
104
+ */
105
+ export function openInBrowser(target, deps) {
106
+ if (!openableTarget(target))
107
+ return { ok: false, why: "only the live view's loopback address or an absolute file path is opened" };
108
+ const { command, args } = openerCommand(deps.platform, target);
109
+ try {
110
+ const child = deps.spawn(command, args, { shell: false, stdio: "ignore", detached: true });
111
+ child.on("error", (err) => deps.onError(`${command} could not start: ${err.message}`));
112
+ child.unref();
113
+ return { ok: true };
114
+ }
115
+ catch (err) {
116
+ return { ok: false, why: `${command} could not start: ${err instanceof Error ? err.message : String(err)}` };
117
+ }
118
+ }
@@ -1,4 +1,15 @@
1
1
  import { redactSecrets } from "./memory.js";
2
+ /** Every kind of violation the oracles record. One list, so the report's plain wording can be held equal to it. */
3
+ export const ORACLE_KINDS = [
4
+ "console_error",
5
+ "page_error",
6
+ "request_failed",
7
+ "http_error",
8
+ "dom_injection",
9
+ "refused_empty",
10
+ "false_success",
11
+ "postmessage_token",
12
+ ];
2
13
  /** How an HTTP error violation states its request. One formatter, so a reader of the detail (the check) cannot drift from its writer. */
3
14
  export function httpErrorDetail(method, url, status) {
4
15
  return `${method} ${url.slice(0, 200)} → HTTP ${status}`;
@@ -21,6 +32,19 @@ export function redactViolation(v) {
21
32
  }
22
33
  /** How long after a write-policy block a generic "fetch failed" error is still attributed to it. */
23
34
  export const POLICY_BLOCK_WINDOW_MS = 2000;
35
+ /**
36
+ * How a block surfaces in an error's wording: the browser's own lines for a
37
+ * dropped request or the stand-in 403, the status as HTTP clients word their
38
+ * rejection ("Request failed with status code 403", "403 Forbidden"), and the
39
+ * stand-in body's own sentence echoed into an error. A 403 alone with neither
40
+ * word is left out: "Error 403 at line 12" is not the policy.
41
+ */
42
+ const POLICY_INDUCED_RE = [
43
+ /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i,
44
+ /\bstatus(?: code)?:? 403\b/i,
45
+ /\b403\b[^\n]{0,40}\b(?:forbidden|refused)\b|\b(?:forbidden|refused)\b[^\n]{0,40}\b403\b/i,
46
+ /refused by the tester's [a-z-]+ write policy/i,
47
+ ];
24
48
  /**
25
49
  * Is this console or page error a consequence of the tester's OWN write-policy
26
50
  * block rather than something the app did?
@@ -42,7 +66,7 @@ export function isPolicyInduced(v, msSincePolicyBlock) {
42
66
  return false;
43
67
  if (v.kind !== "page_error" && v.kind !== "console_error")
44
68
  return false;
45
- return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i.test(v.detail);
69
+ return POLICY_INDUCED_RE.some((re) => re.test(v.detail));
46
70
  }
47
71
  /**
48
72
  * The request a console message is the browser's own echo of, or null when it
@@ -424,6 +448,22 @@ export class OracleMonitor {
424
448
  return out;
425
449
  }
426
450
  }
451
+ /**
452
+ * Violations a plan run with onViolation "continue" goes on past: an error
453
+ * status, and the console's echo of one. They say a step's page answered
454
+ * badly, which in a sweep of independent steps (tabs, filters, pages) is a
455
+ * result to list, not a reason the next step cannot run. Anything else (an
456
+ * uncaught exception, a failed request, typed markup coming back, a page
457
+ * contradicting the server, a token posted to any origin) stops a plan either way.
458
+ */
459
+ export const CONTINUABLE_KINDS = new Set(["http_error", "console_error"]);
460
+ /** Whether these violations, drained after one plan step, end the plan. Repeats of already-reported violations never do. */
461
+ export function planStopsAt(violations, rule) {
462
+ const fresh = violations.filter((v) => !v.repeat);
463
+ if (fresh.length === 0)
464
+ return false;
465
+ return rule === "stop" || fresh.some((v) => !CONTINUABLE_KINDS.has(v.kind));
466
+ }
427
467
  export function formatViolations(violations) {
428
468
  if (violations.length === 0)
429
469
  return "";
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The report in plain words, for a reader who does not read request
3
+ * signatures and route patterns: a QA tester, a product owner, anyone who
4
+ * needs to act on a finding without decoding it first.
5
+ *
6
+ * It is a view over the same findings the technical report prints, never a
7
+ * replacement: each item keeps its technical detail one click away, and the
8
+ * `report` setting chooses which parts a document carries. Everything here is
9
+ * pure string work, so the wording and the step translation are table-tested.
10
+ */
11
+ import { seenOnOf } from "./memory.js";
12
+ /** Which parts a report carries: the plain view and the technical one ("both"), or one of them. */
13
+ export const REPORT_AUDIENCES = ["both", "qa", "dev"];
14
+ /** Both, so nobody loses the part they rely on unless they ask to. */
15
+ export const DEFAULT_REPORT_AUDIENCE = "both";
16
+ /**
17
+ * The plain wording for every finding category and every oracle kind, in one
18
+ * table. contract-test holds its keys equal to FINDING_CATEGORIES and
19
+ * ORACLE_KINDS, so a new category or oracle cannot ship without its words.
20
+ */
21
+ export const PLAIN_WORDING = {
22
+ // Finding categories.
23
+ "console-error": { problem: "The page reported an error behind the scenes", expected: "The page works without reporting errors." },
24
+ "page-error": { problem: "Something on the page broke while it was being used", expected: "The page keeps working while it is used." },
25
+ "http-error": { problem: "The server refused or failed a request", expected: "The action completes, or the page says clearly why it could not." },
26
+ network: { problem: "A request to the server did not get through", expected: "The page loads what it needs, or says what is missing." },
27
+ "dead-end": { problem: "A path led nowhere", expected: "Every link and button leads somewhere useful, with a way back." },
28
+ "ux-confusing": { problem: "Something is confusing to use", expected: "It is clear what to do and what happened." },
29
+ "ux-polish": { problem: "Something feels unfinished", expected: "The page looks finished and consistent." },
30
+ visual: { problem: "Something looks wrong on screen", expected: "Everything is visible, readable and in its place." },
31
+ a11y: { problem: "Some people cannot use this part", expected: "Everyone can use it, including with a keyboard or a screen reader." },
32
+ "permission-leak": { problem: "Someone can see or do something they should not", expected: "Each person sees and does only what their role allows." },
33
+ "data-inconsistency": { problem: "The information shown does not add up", expected: "The same information agrees everywhere it is shown." },
34
+ "stale-state": { problem: "The page shows out-of-date information", expected: "The page shows the current state after every change." },
35
+ "data-loss": { problem: "Work or information was lost", expected: "What a person enters is kept." },
36
+ performance: { problem: "Something is too slow", expected: "The page responds without a noticeable wait." },
37
+ security: { problem: "A security weakness", expected: "The app keeps people's data and accounts safe." },
38
+ "missing-testid": { problem: "A control automated tests cannot find reliably", expected: "Every control can be found reliably by automated tests." },
39
+ other: { problem: "Something else is wrong", expected: "The app behaves the way a person using it would expect." },
40
+ // Oracle kinds: what the automatic checks notice on every page.
41
+ console_error: { problem: "The page reported an error behind the scenes", expected: "The page works without reporting errors." },
42
+ page_error: { problem: "Something on the page broke while it was being used", expected: "The page keeps working while it is used." },
43
+ request_failed: { problem: "A request to the server did not get through", expected: "The page loads what it needs, or says what is missing." },
44
+ http_error: { problem: "The server refused or failed a request", expected: "The action completes, or the page says clearly why it could not." },
45
+ dom_injection: { problem: "Text typed into a field came back as part of the page itself", expected: "What a person types is always shown as plain text." },
46
+ refused_empty: {
47
+ problem: "The server refused a request and the page showed nothing instead of an error",
48
+ expected: "When something fails, the page says so.",
49
+ },
50
+ false_success: { problem: "The page said something worked when the server had refused it", expected: "The page reports only what really happened." },
51
+ postmessage_token: { problem: "The page handed a sign-in token to any site that asked", expected: "Sign-in details go only to the app's own pages." },
52
+ };
53
+ /** The words for a category or oracle kind; one the table does not know (an older finding's) reads as "other". */
54
+ export function plainWording(kind) {
55
+ return Object.prototype.hasOwnProperty.call(PLAIN_WORDING, kind) ? PLAIN_WORDING[kind] : PLAIN_WORDING.other;
56
+ }
57
+ /** Severity as its effect on the people using the app. */
58
+ export const IMPACT = { high: "Blocks users", medium: "Annoying", low: "Cosmetic" };
59
+ /** What a control's role is called in a sentence. */
60
+ const ROLE_WORD = {
61
+ button: "button",
62
+ link: "link",
63
+ textbox: "field",
64
+ searchbox: "search box",
65
+ combobox: "list",
66
+ listbox: "list",
67
+ checkbox: "checkbox",
68
+ radio: "option",
69
+ switch: "switch",
70
+ tab: "tab",
71
+ menuitem: "menu item",
72
+ option: "option",
73
+ spinbutton: "field",
74
+ slider: "slider",
75
+ row: "row",
76
+ cell: "cell",
77
+ };
78
+ /** `role "name"` as "the "name" button"; a target that is not that shape is quoted as it is. */
79
+ function control(target) {
80
+ const m = /^(\w+) "(.*)"$/.exec(target.trim());
81
+ if (!m)
82
+ return target.trim();
83
+ const word = ROLE_WORD[m[1]] ?? m[1];
84
+ return m[2] ? `the "${m[2]}" ${word}` : `the unnamed ${word}`;
85
+ }
86
+ /** One action-log step, split into what was done, to what, and on which page. */
87
+ function splitStep(line) {
88
+ const at = line.lastIndexOf(" @ ");
89
+ const head = at >= 0 ? line.slice(0, at) : line;
90
+ const url = at >= 0 ? line.slice(at + 3) : "";
91
+ const space = head.indexOf(" ");
92
+ return space < 0 ? { action: head, target: "", url } : { action: head.slice(0, space), target: head.slice(space + 1), url };
93
+ }
94
+ /** Actions that move to a page, and so start the steps that matter. */
95
+ const OPENS = new Set(["navigate", "crawl"]);
96
+ /**
97
+ * One repro step as a person would do it, or null for a step only the tester
98
+ * takes (a snapshot, a screenshot, an audit, the write policy's own notes).
99
+ */
100
+ export function plainStep(line) {
101
+ const { action: raw, target, url } = splitStep(line);
102
+ // A plan's step is the same action, run from a list.
103
+ const action = raw.replace(/^plan:/, "");
104
+ if (OPENS.has(action))
105
+ return `Go to ${url || target}`;
106
+ let m;
107
+ if ((m = /^click(?:×(\d+))?$/.exec(action))) {
108
+ const times = Number(m[1] ?? 1);
109
+ const verb = times === 2 ? "Double-click" : times > 2 ? `Click ${times} times on` : "Click";
110
+ return `${verb} ${control(target)}`;
111
+ }
112
+ if (action === "type") {
113
+ const t = /^(\w+ ".*") ← ("(?:[^"\\]|\\.)*")( \+ Enter)?/.exec(target);
114
+ if (!t)
115
+ return `Type into ${control(target)}`;
116
+ return `Type ${t[2]} into ${control(t[1])}${t[3] ? " and press Enter" : ""}`;
117
+ }
118
+ if (action === "select") {
119
+ const s = /^(\w+ ".*") = (.*)$/.exec(target);
120
+ return s ? `Choose "${s[2]}" in ${control(s[1])}` : `Choose an option in ${control(target)}`;
121
+ }
122
+ if (action === "upload") {
123
+ const u = / ← (.+?) \(/.exec(target);
124
+ return u ? `Attach the file "${u[1]}"` : "Attach a file";
125
+ }
126
+ if (action === "hover")
127
+ return `Point at ${control(target)}`;
128
+ if (action === "press")
129
+ return `Press ${target}`;
130
+ if (action === "scroll")
131
+ return target ? `Scroll ${target}` : "Scroll the page";
132
+ if (action === "back")
133
+ return "Go back to the previous page";
134
+ return null;
135
+ }
136
+ /** The page a step was on, for the first step when the trace has no navigation of its own. */
137
+ function pageOf(line) {
138
+ return line ? splitStep(line).url : "";
139
+ }
140
+ /**
141
+ * A finding's repro trace as numbered steps a person can follow. It starts at
142
+ * the last time the run went to a page, since what came before that was on
143
+ * another page; when it never did, it starts by going to the page the trace
144
+ * began on. Steps only the tester takes are left out.
145
+ */
146
+ export function plainSteps(repro) {
147
+ let start = -1;
148
+ for (let i = repro.length - 1; i >= 0; i--) {
149
+ if (OPENS.has(splitStep(repro[i]).action.replace(/^plan:/, ""))) {
150
+ start = i;
151
+ break;
152
+ }
153
+ }
154
+ const steps = (start >= 0 ? repro.slice(start) : repro).map(plainStep).filter((s) => s !== null);
155
+ if (start < 0) {
156
+ const first = pageOf(repro[0]);
157
+ if (first)
158
+ steps.unshift(`Go to ${first}`);
159
+ }
160
+ return steps;
161
+ }
162
+ /** A relative path inside the run's folder, safe to put in a document: no scheme, no `..`, nothing absolute. */
163
+ export function isSafeRelativePath(p) {
164
+ return /^[A-Za-z0-9._-][A-Za-z0-9._/-]*$/.test(p) && !p.split("/").includes("..") && !p.includes("//");
165
+ }
166
+ /**
167
+ * The picture of a finding, as a path relative to the run's folder, or
168
+ * undefined. A finding's own picture comes first: `picture`, a path relative
169
+ * to the run's folder, is the slot a capture taken when a finding is filed
170
+ * fills, and no finding carries one yet. On a recorded run the last frame on
171
+ * screen before the finding was filed stands in. A value that is not a safe relative path is ignored, since the file is
172
+ * read back without a schema and the path goes into a page.
173
+ */
174
+ export function pictureOf(f, lastFrame) {
175
+ const own = f.picture;
176
+ for (const p of [own, lastFrame])
177
+ if (typeof p === "string" && isSafeRelativePath(p))
178
+ return p;
179
+ return undefined;
180
+ }
181
+ const count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
182
+ /** "a, b and c". */
183
+ function joinAnd(parts) {
184
+ return parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
185
+ }
186
+ /** The short summary that opens the plain section. */
187
+ export function plainSummary(input) {
188
+ const lines = [];
189
+ const { current } = input;
190
+ const by = (s) => current.filter((f) => f.severity === s).length;
191
+ const where = input.routes && input.routes.total > 0 ? ` It went to ${input.routes.visited} of the ${input.routes.total} pages it knew about.` : "";
192
+ if (current.length === 0) {
193
+ lines.push(`This run found no problems.${where}`);
194
+ }
195
+ else {
196
+ const split = [
197
+ ["high", "blocks users", "block users"],
198
+ ["medium", "is annoying", "are annoying"],
199
+ ["low", "is cosmetic", "are cosmetic"],
200
+ ]
201
+ .filter(([s]) => by(s) > 0)
202
+ .map(([s, one, many]) => `${by(s)} ${by(s) === 1 ? one : many}`);
203
+ lines.push(`This run found ${count(current.length, "problem")}: ${joinAnd(split)}.${where}`);
204
+ }
205
+ const elsewhere = input.audience === "qa" ? "" : " They are listed in the technical detail below.";
206
+ if (input.historical > 0) {
207
+ lines.push(``, `${count(input.historical, "problem")} found by earlier runs ${input.historical === 1 ? "was" : "were"} not checked again this time.${elsewhere}`);
208
+ }
209
+ if (input.worthALook > 0) {
210
+ lines.push(``, `${count(input.worthALook, "observation")} ${input.worthALook === 1 ? "is a problem" : "are problems"} only if your project follows a particular convention, so ${input.worthALook === 1 ? "it is" : "they are"} not counted here.${elsewhere}`);
211
+ }
212
+ const kinds = new Map();
213
+ for (const v of input.violations)
214
+ kinds.set(v.kind, (kinds.get(v.kind) ?? 0) + 1);
215
+ if (kinds.size > 0) {
216
+ lines.push(``, `While it worked, the automatic checks noticed:`, ``);
217
+ for (const [kind, n] of [...kinds.entries()].sort((a, b) => b[1] - a[1]))
218
+ lines.push(`- ${plainWording(kind).problem} (${n === 1 ? "once" : `${n} times`})`);
219
+ }
220
+ if (input.gaps.length > 0) {
221
+ lines.push(``, `The run left ${count(input.gaps.length, "thing")} unchecked.${input.audience === "qa" ? "" : " The gap ledger below says which."}`);
222
+ if (input.audience === "qa") {
223
+ lines.push(``);
224
+ for (const g of input.gaps)
225
+ lines.push(`- ${g}`);
226
+ }
227
+ }
228
+ return lines;
229
+ }
230
+ /** One finding in plain words, with its technical detail folded beneath it. */
231
+ export function plainFinding(f, index, input) {
232
+ const words = plainWording(f.category);
233
+ // One cause in a shared part of the app is seen on every page that shows it.
234
+ const also = seenOnOf(f);
235
+ const lines = [
236
+ `### ${index}. ${f.title}`,
237
+ ``,
238
+ `**${IMPACT[f.severity]}** · ${words.problem} · on ${f.url}${also.length > 0 ? `, and also seen on ${also.join(", ")}` : ""}`,
239
+ ``,
240
+ ];
241
+ const picture = pictureOf(f, input.lastFrames?.get(f.id));
242
+ if (picture)
243
+ lines.push(`![What the page showed](${picture})`, ``);
244
+ const steps = plainSteps(f.repro);
245
+ if (steps.length > 0) {
246
+ lines.push(`**What was done:**`, ``);
247
+ steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
248
+ lines.push(``);
249
+ }
250
+ lines.push(`**What was expected:** ${words.expected}`, ``, `**What happened:** ${f.detail.replace(/\r?\n+/g, " ")}`, ``);
251
+ lines.push(`<details><summary>Technical detail</summary>`, ``);
252
+ // "Finding id", not "Id": the HTML and the live view hang a finding's recorded
253
+ // frames under the list that names its "Id", and that is the full entry's.
254
+ lines.push(`- **Finding id:** \`${f.id}\` · **Category:** ${f.category} · **Severity:** ${f.severity}`);
255
+ if (f.evidence)
256
+ lines.push(`- **Evidence:** \`${f.evidence}\``);
257
+ lines.push(`- **Route:** \`${f.state}\``);
258
+ if (input.audience !== "qa")
259
+ lines.push(`- The full entry, with the repro trace and a test skeleton, is under the same id in the technical detail below.`);
260
+ lines.push(``, `</details>`, ``);
261
+ return lines;
262
+ }
263
+ /** The whole plain section: the summary, then each problem this run found, worst first. */
264
+ export function formatPlainSection(input) {
265
+ const lines = [`## In plain words`, ``, ...plainSummary(input), ``, ...(input.tickets ?? [])];
266
+ input.current.forEach((f, i) => lines.push(...plainFinding(f, i + 1, input)));
267
+ return lines;
268
+ }
@@ -236,3 +236,130 @@ export function diffImages(before, after, threshold = DIFF_THRESHOLD) {
236
236
  image: { width, height, data },
237
237
  };
238
238
  }
239
+ /**
240
+ * Shrinks a picture to `width` × `height` by averaging the source pixels each
241
+ * output pixel covers, so text and thin lines fade rather than vanish the way
242
+ * picking one pixel in every few would make them. Never enlarges: a size
243
+ * larger than the source in either direction is the source's in it.
244
+ */
245
+ export function shrinkImage(img, width, height) {
246
+ const w = Math.max(1, Math.min(img.width, Math.floor(width)));
247
+ const h = Math.max(1, Math.min(img.height, Math.floor(height)));
248
+ if (w === img.width && h === img.height)
249
+ return img;
250
+ const data = new Uint8Array(w * h * 4);
251
+ const sx = img.width / w;
252
+ const sy = img.height / h;
253
+ for (let y = 0; y < h; y++) {
254
+ const y0 = Math.floor(y * sy);
255
+ const y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
256
+ for (let x = 0; x < w; x++) {
257
+ const x0 = Math.floor(x * sx);
258
+ const x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
259
+ let r = 0;
260
+ let g = 0;
261
+ let b = 0;
262
+ let a = 0;
263
+ for (let yy = y0; yy < y1; yy++) {
264
+ for (let xx = x0; xx < x1; xx++) {
265
+ const i = (yy * img.width + xx) * 4;
266
+ r += img.data[i];
267
+ g += img.data[i + 1];
268
+ b += img.data[i + 2];
269
+ a += img.data[i + 3];
270
+ }
271
+ }
272
+ const n = (y1 - y0) * (x1 - x0);
273
+ const o = (y * w + x) * 4;
274
+ data[o] = Math.round(r / n);
275
+ data[o + 1] = Math.round(g / n);
276
+ data[o + 2] = Math.round(b / n);
277
+ data[o + 3] = Math.round(a / n);
278
+ }
279
+ }
280
+ return { width: w, height: h, data };
281
+ }
282
+ /**
283
+ * Writes a picture as a PNG as small as this encoder makes one: three
284
+ * channels when every pixel is opaque (a screenshot always is), and each row
285
+ * filtered by whichever of the five PNG filters leaves the smallest sum, the
286
+ * usual heuristic. encodePng stays as it is, since the diff is written by it.
287
+ */
288
+ export function encodePngCompact(img) {
289
+ if (img.data.length !== img.width * img.height * 4)
290
+ throw new Error("the pixel data is not width × height × 4 bytes");
291
+ let opaque = true;
292
+ for (let i = 3; i < img.data.length; i += 4)
293
+ if (img.data[i] !== 255) {
294
+ opaque = false;
295
+ break;
296
+ }
297
+ const channels = opaque ? 3 : 4;
298
+ const stride = img.width * channels;
299
+ const rows = new Uint8Array(img.height * stride);
300
+ if (opaque) {
301
+ for (let i = 0, j = 0; i < img.data.length; i += 4, j += 3) {
302
+ rows[j] = img.data[i];
303
+ rows[j + 1] = img.data[i + 1];
304
+ rows[j + 2] = img.data[i + 2];
305
+ }
306
+ }
307
+ else
308
+ rows.set(img.data);
309
+ const raw = Buffer.alloc(img.height * (stride + 1));
310
+ const trial = new Uint8Array(stride);
311
+ for (let y = 0; y < img.height; y++) {
312
+ const row = y * stride;
313
+ let best = Infinity;
314
+ const out = y * (stride + 1);
315
+ for (let filter = 0; filter <= 4; filter++) {
316
+ let sum = 0;
317
+ for (let x = 0; x < stride; x++) {
318
+ const left = x >= channels ? rows[row + x - channels] : 0;
319
+ const up = y > 0 ? rows[row - stride + x] : 0;
320
+ const upLeft = y > 0 && x >= channels ? rows[row - stride + x - channels] : 0;
321
+ const v = rows[row + x];
322
+ const p = filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? up : filter === 3 ? (left + up) >> 1 : paeth(left, up, upLeft);
323
+ const f = (v - p) & 0xff;
324
+ trial[x] = f;
325
+ sum += f < 128 ? f : 256 - f;
326
+ }
327
+ if (sum < best) {
328
+ best = sum;
329
+ raw[out] = filter;
330
+ raw.set(trial, out + 1);
331
+ }
332
+ }
333
+ }
334
+ const header = Buffer.alloc(13);
335
+ header.writeUInt32BE(img.width, 0);
336
+ header.writeUInt32BE(img.height, 4);
337
+ header[8] = 8;
338
+ header[9] = opaque ? 2 : 6;
339
+ return Buffer.concat([SIGNATURE, chunk("IHDR", header), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", new Uint8Array(0))]);
340
+ }
341
+ /** The most a picture is shrunk to fit its bytes: below this on its longer side it is no longer worth showing. */
342
+ export const FIT_MIN_SIDE = 120;
343
+ /**
344
+ * A picture within `maxSide` pixels on its longer side and `maxBytes` as a
345
+ * PNG: shrunk to the side first, then, while the file is still too large,
346
+ * shrunk again in proportion to how far over it is. Null when even
347
+ * FIT_MIN_SIDE on its longer side is over the bytes: nothing worth looking at
348
+ * fits, and the caller says so rather than keeping a smudge.
349
+ */
350
+ export function fitPicture(img, maxSide, maxBytes) {
351
+ const longer = Math.max(img.width, img.height);
352
+ let scale = Math.min(1, maxSide / longer);
353
+ for (let attempt = 0; attempt < 8; attempt++) {
354
+ const width = Math.max(1, Math.round(img.width * scale));
355
+ const height = Math.max(1, Math.round(img.height * scale));
356
+ const png = encodePngCompact(shrinkImage(img, width, height));
357
+ if (png.length <= maxBytes)
358
+ return { png, width, height, shrunk: width !== img.width || height !== img.height };
359
+ if (Math.max(width, height) <= FIT_MIN_SIDE)
360
+ return null;
361
+ // Bytes go roughly with area, so the side goes with the square root; a little under, so one more pass usually fits.
362
+ scale = Math.max(FIT_MIN_SIDE / longer, scale * Math.sqrt(maxBytes / png.length) * 0.9);
363
+ }
364
+ return null;
365
+ }