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.
- package/CHANGELOG.md +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- 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
|
+
}
|
package/dist/engine/oracles.js
CHANGED
|
@@ -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
|
|
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(``, ``);
|
|
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
|
+
}
|
package/dist/engine/png.js
CHANGED
|
@@ -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
|
+
}
|