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
|
@@ -244,8 +244,12 @@ export const MAX_UNFILED_NAMED = 10;
|
|
|
244
244
|
* report, while the lane's session is still open.
|
|
245
245
|
*
|
|
246
246
|
* The check exists to catch unfiled defects, so a false "filed" costs more
|
|
247
|
-
* than a false alarm, which only asks someone to look. A decision
|
|
248
|
-
* filed
|
|
247
|
+
* than a false alarm, which only asks someone to look. A decision that names
|
|
248
|
+
* the finding it was filed as (`finding`, the id scout_finding returned) is
|
|
249
|
+
* filed when the project holds a defect with that id: the lane's own link,
|
|
250
|
+
* exact, where matching evidence the lane may have reworded is not. An id the
|
|
251
|
+
* project does not hold is named in the line and the evidence is tried.
|
|
252
|
+
* Otherwise a decision counts as filed by one of three rules, tried in order:
|
|
249
253
|
*
|
|
250
254
|
* 1. Failing signatures. When the decision's evidence names one or more
|
|
251
255
|
* failing requests (the store's `failingSignatures`), it is filed only when
|
|
@@ -269,14 +273,17 @@ export const MAX_UNFILED_NAMED = 10;
|
|
|
269
273
|
* request does, and sends the decision to rule 1's verdict.
|
|
270
274
|
* 3. Text. Evidence naming no failing request is matched on its text: the
|
|
271
275
|
* same evidence, a restatement of part of it, or shared identifiers and
|
|
272
|
-
* near-identical wording
|
|
273
|
-
* because there a miss is
|
|
276
|
+
* near-identical wording, with the ids in paths templated on both sides.
|
|
277
|
+
* The calibration join refuses a text match, because there a miss is
|
|
278
|
+
* scored against the lane.
|
|
274
279
|
*/
|
|
275
280
|
export function unfiledDefects(decisions, findings) {
|
|
276
281
|
const keys = [];
|
|
277
282
|
const filed = [];
|
|
278
283
|
// A defect filed only as worth a look is not in the report's findings, so it is still unfiled.
|
|
279
|
-
|
|
284
|
+
const defects = findings.filter((x) => !isWorthALook(x));
|
|
285
|
+
const ids = new Set(defects.map((f) => f.id));
|
|
286
|
+
for (const f of defects) {
|
|
280
287
|
const text = `${f.evidence ?? ""} ${f.title}`;
|
|
281
288
|
const requests = statedRequests(f.evidence ?? "");
|
|
282
289
|
// The store's signatures, and the finding's own failing requests as
|
|
@@ -291,6 +298,7 @@ export function unfiledDefects(decisions, findings) {
|
|
|
291
298
|
words: words(text),
|
|
292
299
|
evidenceWords: words(f.evidence ?? ""),
|
|
293
300
|
evidence: squash(f.evidence ?? ""),
|
|
301
|
+
templated: squash(templateIds(f.evidence ?? "")),
|
|
294
302
|
raw: f.evidence ?? "",
|
|
295
303
|
requests,
|
|
296
304
|
});
|
|
@@ -299,31 +307,52 @@ export function unfiledDefects(decisions, findings) {
|
|
|
299
307
|
for (const d of decisions) {
|
|
300
308
|
if (d.verdict !== "defect")
|
|
301
309
|
continue;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// the decision has one and no finding covers it, nothing else is a match.
|
|
312
|
-
if (joined.length > 0) {
|
|
313
|
-
out.push(`${d.observation} — ${d.evidence}`);
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
if (filed.some((f) => matchesText(evidence, f)))
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
out.push(d.evidence ? `${d.observation} — ${d.evidence}` : d.observation);
|
|
310
|
+
// The lane's own link to its filing: exact, so tried first. An id the
|
|
311
|
+
// project does not hold falls through to the evidence, and is named.
|
|
312
|
+
if (d.finding && ids.has(d.finding))
|
|
313
|
+
continue;
|
|
314
|
+
if (d.evidence && coveredByEvidence(d.evidence, keys, filed))
|
|
315
|
+
continue;
|
|
316
|
+
const look = d.finding ? findings.some((f) => f.id === d.finding) : false;
|
|
317
|
+
const unknown = !d.finding ? "" : look ? ` (finding ${d.finding} is filed only as worth a look)` : ` (finding ${d.finding} is not in this project)`;
|
|
318
|
+
out.push(`${d.evidence ? `${d.observation} — ${d.evidence}` : d.observation}${unknown}`);
|
|
320
319
|
}
|
|
321
320
|
return out;
|
|
322
321
|
}
|
|
323
|
-
/**
|
|
322
|
+
/** Rules 1 to 3: whether a finding's evidence covers the decision's. */
|
|
323
|
+
function coveredByEvidence(evidence, keys, filed) {
|
|
324
|
+
const joined = [...joinKeys(evidence)];
|
|
325
|
+
const failing = joined.flatMap((sig) => statedRequests(sig));
|
|
326
|
+
if (failing.length > 0 && failing.every((sig) => keys.some((k) => requestsPair(sig, k))))
|
|
327
|
+
return true;
|
|
328
|
+
if (filed.some((f) => restatesRequest(evidence, failing, f)))
|
|
329
|
+
return true;
|
|
330
|
+
// A failing-endpoint signature is the store's own identity for a bug. When
|
|
331
|
+
// the decision has one and no finding covers it, nothing else is a match.
|
|
332
|
+
if (joined.length > 0)
|
|
333
|
+
return false;
|
|
334
|
+
return filed.some((f) => matchesText(evidence, f));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Rule 3: the same evidence, a restatement of part of it, or shared
|
|
338
|
+
* identifiers and wording — each with the ids in its paths templated, so a
|
|
339
|
+
* lane that reported the pages it visited (`/things/5,/1,/2`) matches the
|
|
340
|
+
* finding it filed against the route (`/things/:id`).
|
|
341
|
+
*/
|
|
324
342
|
function matchesText(reported, f) {
|
|
325
|
-
const text = squash(reported);
|
|
326
|
-
return (text !== "" && f.
|
|
343
|
+
const text = squash(templateIds(reported));
|
|
344
|
+
return (text !== "" && f.templated === text) || restates(text, f.templated) || covers(identifiers(reported), words(reported), f);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Text with every id segment of a path written `:id`: a number, a list of
|
|
348
|
+
* numbers (`/5,/1,/2`, `/5,1`), or a template (`{thingId}`, `:id`, `*`). Only
|
|
349
|
+
* after a slash that follows a path segment holding a letter, so a ratio
|
|
350
|
+
* (`3/4`, `3.1:1`) or a count in the text is left alone.
|
|
351
|
+
*/
|
|
352
|
+
export function templateIds(text) {
|
|
353
|
+
return text
|
|
354
|
+
.replace(/(?<=[A-Za-z_}][\w.}-]*)\/\d+(?:\s*,\s*\/?\d+)*(?=$|[/\s,;:)?#\]])/g, "/:id")
|
|
355
|
+
.replace(/(?<=[A-Za-z_}][\w.}-]*)\/(?:\{[^}/\s]+\}|:[A-Za-z_][A-Za-z0-9_]*|\*)(?=$|[/\s,;)?#\]])/g, "/:id");
|
|
327
356
|
}
|
|
328
357
|
/**
|
|
329
358
|
* Rule 2 of unfiledDefects: the decision names a request that did not fail,
|
|
@@ -344,7 +373,7 @@ function restatesRequest(evidence, failing, f) {
|
|
|
344
373
|
if (!failing.every((sig) => sig.method === r.method && templatedPathsMatch(sig.segments, r.segments)))
|
|
345
374
|
continue;
|
|
346
375
|
const aligned = squash(evidence.slice(0, r.start) + f.raw.slice(paired.start, paired.end) + evidence.slice(r.end));
|
|
347
|
-
if (aligned !== "" && (f.evidence === aligned || restates(aligned, f) || addsOnlyExpectedStatus(aligned, f.evidence, r.status)))
|
|
376
|
+
if (aligned !== "" && (f.evidence === aligned || restates(aligned, f.evidence) || addsOnlyExpectedStatus(aligned, f.evidence, r.status)))
|
|
348
377
|
return true;
|
|
349
378
|
}
|
|
350
379
|
return false;
|
|
@@ -373,7 +402,9 @@ function addsOnlyExpectedStatus(reported, filed, status) {
|
|
|
373
402
|
const MIN_RESTATED = 24;
|
|
374
403
|
/** A test id, captured without its attribute; and a kebab-case id of three or more parts. Lowercase text only. */
|
|
375
404
|
const TESTID_RE = /testid=["']?([a-z0-9_-]+)/g;
|
|
376
|
-
|
|
405
|
+
// Underscores inside a part too: `row-stage-pending_review` is one id, where
|
|
406
|
+
// without them it ended mid-word and yielded none.
|
|
407
|
+
const KEBAB_ID_RE = /\b[a-z][a-z0-9_]*(?:-[a-z0-9_]+){2,}\b/g;
|
|
377
408
|
/**
|
|
378
409
|
* Whether the reported evidence appears word for word inside a finding's
|
|
379
410
|
* evidence: a lane that filed "testid=row-1..6 non-interactive; GET /api/x 200
|
|
@@ -384,8 +415,8 @@ const KEBAB_ID_RE = /\b[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}\b/g;
|
|
|
384
415
|
* refuses. One direction only: a short FILED evidence found inside a longer
|
|
385
416
|
* report proves nothing about the rest of the report.
|
|
386
417
|
*/
|
|
387
|
-
function restates(reported,
|
|
388
|
-
if (reported.length < MIN_RESTATED || !
|
|
418
|
+
function restates(reported, filed) {
|
|
419
|
+
if (reported.length < MIN_RESTATED || !filed.includes(reported))
|
|
389
420
|
return false;
|
|
390
421
|
const rest = reported.replace(TESTID_RE, " ").replace(KEBAB_ID_RE, " ");
|
|
391
422
|
return words(rest).size >= 2;
|
package/dist/engine/capture.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* comment runs (docs/ci.md).
|
|
8
8
|
*/
|
|
9
9
|
/** CSS pixels kept around the element, so its edges and shadow are in the picture. */
|
|
10
|
+
import { plainSegment } from "./replay.js";
|
|
10
11
|
export const CAPTURE_MARGIN = 8;
|
|
11
12
|
export const MAX_CAPTURE_MARGIN = 64;
|
|
12
13
|
/**
|
|
@@ -26,6 +27,18 @@ export function captureClip(box, margin, viewport) {
|
|
|
26
27
|
return null;
|
|
27
28
|
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether an element itself (its margin aside) reaches outside the viewport
|
|
32
|
+
* once scrolled into view, so only the part inside is pictured: then a phrase
|
|
33
|
+
* saying so, for the report; null when all of it is in the picture. A pixel of
|
|
34
|
+
* slack, for boxes at fractional positions.
|
|
35
|
+
*/
|
|
36
|
+
export function cutByViewport(box, viewport) {
|
|
37
|
+
const inside = box.x >= -1 && box.y >= -1 && box.x + box.width <= viewport.width + 1 && box.y + box.height <= viewport.height + 1;
|
|
38
|
+
if (inside)
|
|
39
|
+
return null;
|
|
40
|
+
return `only the part inside the ${viewport.width}×${viewport.height} window is pictured: the element is ${Math.round(box.width)}×${Math.round(box.height)}`;
|
|
41
|
+
}
|
|
29
42
|
/** A capture's file name: lower-case letters, digits and dashes, then .png. Nothing else reaches the path. */
|
|
30
43
|
export function captureFileName(name) {
|
|
31
44
|
const base = String(name ?? "")
|
|
@@ -119,3 +132,154 @@ export function rebaseUrl(page, fromRoot, toRoot) {
|
|
|
119
132
|
/** The files a capture run writes, under shots/ in the run's output. Fixed names: nothing a model says becomes a path. */
|
|
120
133
|
export const SHOT_FILES = { preview: "preview.png", base: "base.png", diff: "diff.png" };
|
|
121
134
|
export const SHOTS_DIRNAME = "shots";
|
|
135
|
+
// ── a finding's picture ─────────────────────────────────────────────────────
|
|
136
|
+
//
|
|
137
|
+
// Every finding is filed with a picture of what it is about: the element it
|
|
138
|
+
// names plus a margin, or the viewport when it names none. scout_finding
|
|
139
|
+
// takes the picture (browser.ts), and these rules decide whether one is taken,
|
|
140
|
+
// what it frames, how big it may be, whether the tool result carries it, and
|
|
141
|
+
// where it is kept, so each is table-tested (ci-test) without a browser.
|
|
142
|
+
/**
|
|
143
|
+
* What happens to a finding's picture. "inline": it is kept under the run's
|
|
144
|
+
* recordings/ folder, shown in report.html, and returned in the scout_finding
|
|
145
|
+
* result, so a chat client shows it as the finding is filed. "file": kept and
|
|
146
|
+
* shown in the report, not returned. "off": none is taken.
|
|
147
|
+
*/
|
|
148
|
+
export const EVIDENCE_MODES = ["inline", "file", "off"];
|
|
149
|
+
export const EVIDENCE_ENV = "SCENESCOUT_EVIDENCE";
|
|
150
|
+
export const EVIDENCE_MAX_PX_ENV = "SCENESCOUT_EVIDENCE_MAX_PX";
|
|
151
|
+
export const EVIDENCE_MAX_KB_ENV = "SCENESCOUT_EVIDENCE_MAX_KB";
|
|
152
|
+
export const EVIDENCE_INLINE_ENV = "SCENESCOUT_EVIDENCE_INLINE";
|
|
153
|
+
export const RECORD_ENV = "SCENESCOUT_RECORD";
|
|
154
|
+
/**
|
|
155
|
+
* The bounds a finding's picture is held to, and their defaults. The longer
|
|
156
|
+
* side keeps a picture readable while a chat client's cost for it stays small;
|
|
157
|
+
* the bytes bound what each picture adds to the conversation and the folder;
|
|
158
|
+
* the inline count stops a run that files many findings from filling the
|
|
159
|
+
* conversation with pictures (later ones are still kept and in the report).
|
|
160
|
+
*/
|
|
161
|
+
export const EVIDENCE_LIMITS = {
|
|
162
|
+
maxPx: { env: EVIDENCE_MAX_PX_ENV, min: 160, max: 2000, default: 800, unit: "pixels on the picture's longer side" },
|
|
163
|
+
maxKb: { env: EVIDENCE_MAX_KB_ENV, min: 16, max: 2048, default: 200, unit: "kilobytes a picture may take" },
|
|
164
|
+
inline: { env: EVIDENCE_INLINE_ENV, min: 0, max: 500, default: 10, unit: "pictures one session returns in its scout_finding results" },
|
|
165
|
+
};
|
|
166
|
+
/** CSS pixels kept around an element a finding names: more than scout_capture's, so what is next to it is in view. */
|
|
167
|
+
export const EVIDENCE_MARGIN = 24;
|
|
168
|
+
/** Whether this process runs in a CI job: the CI variable every hosted runner sets, read the way they set it. */
|
|
169
|
+
export function isCiEnv(env) {
|
|
170
|
+
const ci = (env.CI ?? "").trim().toLowerCase();
|
|
171
|
+
return (ci !== "" && ci !== "0" && ci !== "false") || (env.GITHUB_ACTIONS ?? "").trim().toLowerCase() === "true";
|
|
172
|
+
}
|
|
173
|
+
function readLimit(kind, env) {
|
|
174
|
+
const spec = EVIDENCE_LIMITS[kind];
|
|
175
|
+
const raw = env[spec.env];
|
|
176
|
+
if (raw === undefined || raw.trim() === "")
|
|
177
|
+
return spec.default;
|
|
178
|
+
const text = raw.trim();
|
|
179
|
+
const value = Number(text);
|
|
180
|
+
if (!/^\d+$/.test(text) || value < spec.min || value > spec.max)
|
|
181
|
+
throw new Error(`${spec.env} must be a whole number of ${spec.unit}, from ${spec.min} to ${spec.max} (got "${raw}").`);
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* How this session treats a finding's picture: the attach option, else the
|
|
186
|
+
* environment, else the default for the kind of run. Interactive use returns
|
|
187
|
+
* the picture inline, which is what a person following the conversation
|
|
188
|
+
* wants. A CI job keeps it on file only: nobody reads the conversation, and
|
|
189
|
+
* `scenescout ci`'s model loop is text-only, so an image there is bytes no one
|
|
190
|
+
* sees. A value outside the known ones is refused, never guessed at.
|
|
191
|
+
*/
|
|
192
|
+
export function evidenceSettings(asked, env) {
|
|
193
|
+
const limits = { maxPx: readLimit("maxPx", env), maxBytes: readLimit("maxKb", env) * 1024, inlineMax: readLimit("inline", env) };
|
|
194
|
+
if (asked)
|
|
195
|
+
return { mode: asked, source: "option", ...limits };
|
|
196
|
+
const raw = (env[EVIDENCE_ENV] ?? "").trim().toLowerCase();
|
|
197
|
+
if (raw) {
|
|
198
|
+
if (!EVIDENCE_MODES.includes(raw))
|
|
199
|
+
throw new Error(`${EVIDENCE_ENV} must be one of ${EVIDENCE_MODES.join(", ")} (got "${env[EVIDENCE_ENV]}").`);
|
|
200
|
+
return { mode: raw, source: "environment", ...limits };
|
|
201
|
+
}
|
|
202
|
+
return isCiEnv(env) ? { mode: "file", source: "default (CI)", ...limits } : { mode: "inline", source: "default (interactive)", ...limits };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Whether a session keeps a frame after every action (scout_attach `record`):
|
|
206
|
+
* the option, else SCENESCOUT_RECORD, else off. Off in every kind of run by
|
|
207
|
+
* default: a recording is a picture per step of the app under test, and a
|
|
208
|
+
* team that wants every QA run recorded says so once, in the server's
|
|
209
|
+
* environment, rather than relying on each attach to ask.
|
|
210
|
+
*/
|
|
211
|
+
export function recordChoice(asked, env) {
|
|
212
|
+
if (asked !== undefined)
|
|
213
|
+
return asked;
|
|
214
|
+
const raw = (env[RECORD_ENV] ?? "").trim().toLowerCase();
|
|
215
|
+
if (!raw)
|
|
216
|
+
return false;
|
|
217
|
+
if (["on", "true", "1"].includes(raw))
|
|
218
|
+
return true;
|
|
219
|
+
if (["off", "false", "0"].includes(raw))
|
|
220
|
+
return false;
|
|
221
|
+
throw new Error(`${RECORD_ENV} must be on or off (got "${env[RECORD_ENV]}").`);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Whether a picture is taken for a filing, and of what. None when pictures
|
|
225
|
+
* are off, when the session has no page open, or when the filing was merged
|
|
226
|
+
* into a finding that already has its picture (one picture a finding, from
|
|
227
|
+
* when it was first seen) — unless the merge reopened it as a regression,
|
|
228
|
+
* when the new picture shows it is back. Otherwise the element the filing
|
|
229
|
+
* names, else the viewport.
|
|
230
|
+
*/
|
|
231
|
+
export function evidenceFrame(o) {
|
|
232
|
+
if (o.mode === "off")
|
|
233
|
+
return { take: false, why: "pictures are off for this session" };
|
|
234
|
+
if (!o.pageOpen)
|
|
235
|
+
return { take: false, why: "the session has no page open" };
|
|
236
|
+
if (!o.isNew && o.hasPicture && !o.regressed)
|
|
237
|
+
return { take: false, why: "the finding it merged into already has its picture" };
|
|
238
|
+
const ref = (o.ref ?? "").trim();
|
|
239
|
+
return ref ? { take: true, frame: "element", ref } : { take: true, frame: "viewport" };
|
|
240
|
+
}
|
|
241
|
+
/** Whether this picture goes into the tool result: inline mode, and the session's count not yet reached. */
|
|
242
|
+
export function returnsInline(mode, shownSoFar, inlineMax) {
|
|
243
|
+
return mode === "inline" && shownSoFar < inlineMax;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Where a finding's picture is kept, relative to the project's .scenescout/
|
|
247
|
+
* folder, with forward slashes: beside the session's recorded frames, so the
|
|
248
|
+
* report and the live view reach it the way they reach a frame. The session
|
|
249
|
+
* name is the agent's, so it is reduced to a plain name; the id is the
|
|
250
|
+
* finding's, which is hex.
|
|
251
|
+
*/
|
|
252
|
+
export function findingPicturePath(session, id) {
|
|
253
|
+
// The same folder name as the session's frames (replay.ts framePath), so the two sit together.
|
|
254
|
+
const plain = plainSegment(session, "session");
|
|
255
|
+
const safeId = id.replace(/[^0-9a-f]/gi, "").slice(0, 40) || "finding";
|
|
256
|
+
return `recordings/${plain}/finding-${safeId}.png`;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* A finding's picture read back from disk, or null when its path is not one
|
|
260
|
+
* of the engine's picture paths: the report links only what it can trust.
|
|
261
|
+
* A shot that does not read is left out, and the path still stands.
|
|
262
|
+
*/
|
|
263
|
+
export function readFindingPicture(f) {
|
|
264
|
+
const file = f.picture;
|
|
265
|
+
if (typeof file !== "string" || !/^recordings\/[a-z0-9._-]+\/finding-[0-9a-f]+\.png$/i.test(file))
|
|
266
|
+
return null;
|
|
267
|
+
const o = (f.pictureShot && typeof f.pictureShot === "object" ? f.pictureShot : {});
|
|
268
|
+
if (!Number.isInteger(o.width) || !Number.isInteger(o.height) || (o.frame !== "element" && o.frame !== "viewport"))
|
|
269
|
+
return { file };
|
|
270
|
+
return {
|
|
271
|
+
file,
|
|
272
|
+
width: o.width,
|
|
273
|
+
height: o.height,
|
|
274
|
+
frame: o.frame,
|
|
275
|
+
...(typeof o.label === "string" && o.label ? { label: o.label } : {}),
|
|
276
|
+
at: typeof o.at === "string" ? o.at : "",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** What a picture frames, in words for the report and the tool result. */
|
|
280
|
+
export function describePicture(p) {
|
|
281
|
+
if (!p.frame || !p.width || !p.height)
|
|
282
|
+
return "what the page showed";
|
|
283
|
+
const what = p.frame === "element" ? (p.label ? `"${p.label.replace(/\s+/g, " ").slice(0, 80)}" and around it` : "the element and around it") : "the page as it was";
|
|
284
|
+
return `${p.width}×${p.height}, ${what}`;
|
|
285
|
+
}
|