kaloko 0.12.0 → 0.12.2
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/README.md +5 -3
- package/package.json +1 -1
- package/schemas/config.v1.json +6 -4
- package/skills/kaloko/reference/acceptance.md +2 -2
- package/src/cli.mjs +11 -7
- package/src/lib/build.mjs +1 -1
- package/src/lib/capture.mjs +88 -44
- package/src/lib/changelog-core.mjs +3 -1
- package/src/lib/evaluate.mjs +12 -9
- package/src/lib/htmlview.mjs +3 -1
- package/src/lib/keyboard.mjs +4 -2
- package/src/lib/pii.mjs +529 -212
- package/src/lib/recording.mjs +12 -3
- package/src/lib/triage-core.mjs +3 -1
- package/src/lib/triage.mjs +17 -5
- package/src/lib/video.mjs +2 -0
- package/viewer/index.html +82 -19
package/README.md
CHANGED
|
@@ -420,9 +420,11 @@ same length in the HTML and the page outline, and the same places in traces,
|
|
|
420
420
|
recordings and captured e-mails. `pii: redact` on a scenario finds e-mails,
|
|
421
421
|
phone numbers, birth numbers, IBANs and addresses by itself; `pii:
|
|
422
422
|
pseudonymize` puts consistent fake values in their place instead. An
|
|
423
|
-
environment with `pii: required` gets no run without it
|
|
424
|
-
|
|
425
|
-
step and element (`--allow-pii` overrides)
|
|
423
|
+
environment with `pii: required` gets no run without it. When such a run still
|
|
424
|
+
holds an unmasked e-mail or phone number, `npx kaloko share` refuses it and
|
|
425
|
+
names the step and element (`--allow-pii` overrides); other runs are shared
|
|
426
|
+
with a warning. The site's own contacts (its domain, the footer, Organization
|
|
427
|
+
JSON-LD) do not count. Checks always read the real page.
|
|
426
428
|
|
|
427
429
|
Set `browser.channel: chrome` to use an installed Chrome, or run
|
|
428
430
|
`npx kaloko browser install` for the Chromium of kaloko's Playwright
|
package/package.json
CHANGED
package/schemas/config.v1.json
CHANGED
|
@@ -90,9 +90,10 @@
|
|
|
90
90
|
"model": { "enum": ["clef", "clef-flash"], "default": "clef", "description": "clef found more on the first bench; clef-flash answers about twice as fast." },
|
|
91
91
|
"keep": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.7, "description": "Findings at or above this probability show on the canvas; lower ones fold under Less likely." },
|
|
92
92
|
"maybe": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5, "description": "Findings below this probability are dropped." },
|
|
93
|
-
"max_width": { "type": "integer", "minimum": 320, "maximum": 2048, "default":
|
|
94
|
-
"tile_height": { "type": "integer", "minimum": 400, "maximum": 4000, "default":
|
|
95
|
-
"max_tiles": { "type": "integer", "minimum": 1, "maximum": 4, "default":
|
|
93
|
+
"max_width": { "type": "integer", "minimum": 320, "maximum": 2048, "default": 896 },
|
|
94
|
+
"tile_height": { "type": "integer", "minimum": 400, "maximum": 4000, "default": 1024, "description": "Clef reads about one token per 40 pixels within 65,536 tokens: keep max_tiles × max_width × tile_height near 1.8 million." },
|
|
95
|
+
"max_tiles": { "type": "integer", "minimum": 1, "maximum": 4, "default": 2 },
|
|
96
|
+
"frame_width": { "type": "integer", "minimum": 240, "maximum": 1280, "default": 640, "description": "Width of recording frames sent to Clef (four per request)." },
|
|
96
97
|
"frames_per_step": { "type": "integer", "minimum": 0, "maximum": 12, "default": 6, "description": "Frames chosen from a step's recording (around taps, sharp changes and the end); 0 skips recordings." },
|
|
97
98
|
"frames_per_run": { "type": "integer", "minimum": 0, "default": 60 },
|
|
98
99
|
"max_calls": { "type": "integer", "minimum": 0, "default": 200, "description": "Uncached triage requests per evaluation." },
|
|
@@ -129,7 +130,8 @@
|
|
|
129
130
|
"additionalProperties": false,
|
|
130
131
|
"description": "Personal data (#64).",
|
|
131
132
|
"properties": {
|
|
132
|
-
"allow": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Values that look like personal data but are not, such as the company's contact e-mail or phone number. * matches any text (*@acme.example). Neither masked nor stopped by kaloko share." }
|
|
133
|
+
"allow": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Values that look like personal data but are not, such as the company's contact e-mail or phone number. * matches any text (*@acme.example). Neither masked nor stopped by kaloko share." },
|
|
134
|
+
"site_contacts": { "type": "boolean", "default": true, "description": "kaloko share takes the site's own contacts for not personal data: e-mails on the environment's domain, e-mails and phone numbers in the page footer, and those of an Organization or ContactPoint in JSON-LD. false checks them like any other value." }
|
|
133
135
|
}
|
|
134
136
|
},
|
|
135
137
|
"share": {
|
|
@@ -165,8 +165,8 @@ Masks cover every output of the step: the screenshot gets a striped box, the HTM
|
|
|
165
165
|
`[hidden]` of the same length, traces, recordings and captured e-mails are covered too; checks still read the real
|
|
166
166
|
page. On data copied from production add `pii: redact` to the scenario (automatic detection of e-mails, phones,
|
|
167
167
|
birth numbers, IBANs, addresses) or `pii: pseudonymize` (consistent fake values, readable screens). An environment
|
|
168
|
-
with `pii: required` refuses a run without it, and `kaloko share` stops on an
|
|
169
|
-
names the step and element: mask it and capture again, list a company contact under `pii.allow` in the config, and
|
|
168
|
+
with `pii: required` refuses a run without it, and in a run that hides personal data `kaloko share` stops on an
|
|
169
|
+
unmasked e-mail or phone number and names the step and element (other runs are shared with a warning): mask it and capture again, list a company contact under `pii.allow` in the config, and
|
|
170
170
|
use `--allow-pii` only when a person says the value may go up.
|
|
171
171
|
|
|
172
172
|
## Keeping plan and scenario aligned
|
package/src/cli.mjs
CHANGED
|
@@ -62,7 +62,7 @@ import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantF
|
|
|
62
62
|
import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
|
|
63
63
|
import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
|
|
64
64
|
import { attachAndroidWebView } from './lib/apps/webview.mjs'
|
|
65
|
-
import { piiSettings, piiFindings, guardAllow, formatFindings } from './lib/pii.mjs'
|
|
65
|
+
import { piiSettings, piiFindings, piiRequested, guardAllow, formatFindings } from './lib/pii.mjs'
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
|
|
@@ -201,8 +201,9 @@ Results
|
|
|
201
201
|
hash, so unchanged captures are not sent again and a broken-off share resumes;
|
|
202
202
|
--pr adds or updates one comment on the branch's pull request via gh (KALOKO_PR in
|
|
203
203
|
CI), or a note on the GitLab merge request (KALOKO_GITLAB_TOKEN)
|
|
204
|
-
[--allow-pii]
|
|
205
|
-
and names the step and element; --allow-pii uploads anyway
|
|
204
|
+
[--allow-pii] In a run that hides personal data, an unmasked e-mail, phone number, birth number or
|
|
205
|
+
IBAN stops the upload and names the step and element; --allow-pii uploads anyway
|
|
206
|
+
(other runs upload with a warning)
|
|
206
207
|
feedback [--run id] [--scenario id] [--json]
|
|
207
208
|
Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
|
|
208
209
|
returned steps first, with their notes
|
|
@@ -1744,7 +1745,7 @@ const commands = {
|
|
|
1744
1745
|
const via = report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
|
|
1745
1746
|
say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
|
|
1746
1747
|
if (report.vision?.asked) say(`Vision evaluator ${report.vision.model}: ${report.vision.asked} capture(s) with flagged or uncertain criteria, ${report.vision.calls} call(s), ${report.vision.cached} cached${report.vision.skipped.length ? `; not asked: ${[...new Set(report.vision.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}` : ''}.`)
|
|
1747
|
-
if (report.triage) say(report.triage.screens ? `Triage (experimental, ${report.triage.model}): ${report.triage.screens} screen(s)${report.triage.frames ? ` and ${report.triage.frames} recording frame(s)` : ''}, ${report.triage.shown} finding(s) to look at${report.triage.findings > report.triage.shown ? ` and ${report.triage.findings - report.triage.shown} less likely` : ''}; they never change a verdict.${report.triage.skipped ? `
|
|
1748
|
+
if (report.triage) say(report.triage.screens ? `Triage (experimental, ${report.triage.model}): ${report.triage.screens} screen(s)${report.triage.frames ? ` and ${report.triage.frames} recording frame(s)` : ''}, ${report.triage.shown} finding(s) to look at${report.triage.findings > report.triage.shown ? ` and ${report.triage.findings - report.triage.shown} less likely` : ''}; they never change a verdict.${report.triage.skipped ? ` Not every request was answered: ${report.triage.skipped}` : ''}` : `Triage (experimental): not run${report.triage.skipped ? `: ${report.triage.skipped}` : ' (no screenshot to look at)'}`)
|
|
1748
1749
|
if (report.skipped.length) {
|
|
1749
1750
|
// one line per reason: "12 captures: TYPESAFE_API_KEY is not set …"
|
|
1750
1751
|
const byReason = new Map()
|
|
@@ -1812,9 +1813,12 @@ const commands = {
|
|
|
1812
1813
|
}
|
|
1813
1814
|
// personal data (#64): nothing goes up with an unmasked e-mail, phone number, birth number or IBAN in it
|
|
1814
1815
|
const views = manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))).map((id) => `live/${id}/index.html`)
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1816
|
+
// a run that asked for hiding personal data is refused; any other run is shared with a warning
|
|
1817
|
+
const findings = await piiFindings({ dir: run.dir, files: [...files, ...views], manifest, allow: guardAllow({ config, env, scenario, run }), site: { url: run.meta.environment?.base_url || env.base_url, contacts: config.pii?.site_contacts !== false } })
|
|
1818
|
+
const strict = piiRequested({ run, env, scenario })
|
|
1819
|
+
if (findings.length && strict && !args['allow-pii']) throw new UserError(`Not shared: the run holds ${findings.length} unmasked personal data value(s):\n${formatFindings(findings)}\nMask them (capture.mask in the scenario, or pii: redact for automatic detection) and capture the steps again. Values that are not personal data, such as a company contact, go under pii.allow in kaloko.config.yml. To share anyway: kaloko share --allow-pii.`)
|
|
1820
|
+
if (findings.length && strict) say(`Sharing ${findings.length} unmasked personal data value(s), as --allow-pii says:\n${formatFindings(findings, 5)}`)
|
|
1821
|
+
else if (findings.length) say(`This run shows ${findings.length} value(s) that look like personal data:\n${formatFindings(findings, 5)}\nShared anyway, because the run does not hide personal data. To hide it, add pii: redact to the scenario or the environment; values that are not personal data go under pii.allow in kaloko.config.yml.`)
|
|
1818
1822
|
const days = Number(args.expires) || config.share?.expires_days || 30
|
|
1819
1823
|
// by content hash (#28): files the organization already stores (earlier runs, an interrupted share) are not sent
|
|
1820
1824
|
let entries = await hashFiles(run.dir, [...files])
|
package/src/lib/build.mjs
CHANGED
|
@@ -97,7 +97,7 @@ async function recEntry (run, e) {
|
|
|
97
97
|
if (!r?.files || !Object.values(r.files).some(Boolean)) return null
|
|
98
98
|
const files = Object.fromEntries(Object.entries(r.files).filter(([k, f]) => f && ['webm', 'mp4', 'preview', 'poster'].includes(k) && existsSync(join(run.dir, f))))
|
|
99
99
|
if (!files.webm && !files.mp4) return null
|
|
100
|
-
return { ...files, duration_ms: r.duration_ms, ...(r.width ? { size: [r.width, r.height] } : {}), codec: r.codec || null, mode: r.mode, source: r.source || null, bytes: Object.values(r.bytes || {}).reduce((n, b) => n + (b || 0), 0), ...(r.failed ? { failed: true } : {}), ...(r.error ? { error: r.error } : {}), ...(r.trimmed ? { trimmed: true } : {}), ...(r.offset_ms ? { offset_ms: r.offset_ms } : {}), ...(r.taps?.length ? { taps: r.taps.slice(0, 200) } : {}), ...(r.chapters ? { chapters: r.chapters } : {}) }
|
|
100
|
+
return { ...files, ...(r.still ? { still: true } : {}), duration_ms: r.duration_ms, ...(r.width ? { size: [r.width, r.height] } : {}), codec: r.codec || null, mode: r.mode, source: r.source || null, bytes: Object.values(r.bytes || {}).reduce((n, b) => n + (b || 0), 0), ...(r.failed ? { failed: true } : {}), ...(r.error ? { error: r.error } : {}), ...(r.trimmed ? { trimmed: true } : {}), ...(r.offset_ms ? { offset_ms: r.offset_ms } : {}), ...(r.taps?.length ? { taps: r.taps.slice(0, 200) } : {}), ...(r.chapters ? { chapters: r.chapters } : {}) }
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
package/src/lib/capture.mjs
CHANGED
|
@@ -5,13 +5,13 @@ import { probe } from './probe.mjs'
|
|
|
5
5
|
import { pageSpecScript } from './devspec.mjs'
|
|
6
6
|
import { writeJson, readJsonl } from './util.mjs'
|
|
7
7
|
import { addCapture, runPaths } from './run.mjs'
|
|
8
|
-
import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMailHeaders } from './pii.mjs'
|
|
8
|
+
import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMailHeaders, maskHtml, scrubDeep, scrubText, updateMasks, loadKnownPii, saveKnownPii, RECORD_PLAIN_KEYS } from './pii.mjs'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Encodes a PNG screenshot inside the page (no native dependency): the image as WebP (or PNG, lossless) and a
|
|
12
12
|
* WebP thumbnail for the canvas grid; `marks` are numbered circles drawn on the image (documentation captures).
|
|
13
13
|
*/
|
|
14
|
-
export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth =
|
|
14
|
+
export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 640, thumbQuality = 0.8, marks = [], scale = 1, masks = [] } = {}) {
|
|
15
15
|
const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks }) => {
|
|
16
16
|
const img = new Image()
|
|
17
17
|
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
|
|
@@ -32,9 +32,18 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
|
|
|
32
32
|
}
|
|
33
33
|
// the blob's own type: WebKit cannot write WebP and silently returns PNG
|
|
34
34
|
const blob = (c, type, q) => new Promise((res, rej) => c.toBlob((b) => (b ? b.arrayBuffer().then((buf) => res({ bytes: Array.from(new Uint8Array(buf)), type: b.type })) : rej(new Error('encode failed'))), type, q))
|
|
35
|
+
// the card thumbnail: halved step by step with high-quality smoothing (one 4× or 8× jump makes text fall apart),
|
|
36
|
+
// 640 wide so a card stays sharp on a 2× screen
|
|
35
37
|
const k = Math.min(1, thumbWidth / img.width)
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
+
const tw = Math.round(img.width * k); const th = Math.round(img.height * k)
|
|
39
|
+
let src = full
|
|
40
|
+
while (src.width / 2 >= tw * 1.4) {
|
|
41
|
+
const half = document.createElement('canvas'); half.width = Math.round(src.width / 2); half.height = Math.round(src.height / 2)
|
|
42
|
+
const hg = half.getContext('2d'); hg.imageSmoothingEnabled = true; hg.imageSmoothingQuality = 'high'; hg.drawImage(src, 0, 0, half.width, half.height)
|
|
43
|
+
src = half
|
|
44
|
+
}
|
|
45
|
+
const t = document.createElement('canvas'); t.width = tw; t.height = th
|
|
46
|
+
const tg = t.getContext('2d'); tg.imageSmoothingEnabled = true; tg.imageSmoothingQuality = 'high'; tg.drawImage(src, 0, 0, tw, th)
|
|
38
47
|
return { image: await blob(full, format === 'png' ? 'image/png' : 'image/webp', quality), thumb: await blob(t, 'image/webp', thumbQuality) }
|
|
39
48
|
}, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks })
|
|
40
49
|
return { image: Buffer.from(result.image.bytes), thumb: Buffer.from(result.thumb.bytes), imageType: result.image.type, thumbType: result.thumb.type }
|
|
@@ -134,6 +143,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
134
143
|
let netResults = null
|
|
135
144
|
// personal data (#64): what this capture masks; recipients of a captured e-mail are masked before the body
|
|
136
145
|
const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
|
|
146
|
+
// values masked by an earlier process of this run (kaloko walk, then kaloko capture) are masked here too
|
|
147
|
+
if (mask.active) await loadKnownPii(run.dir)
|
|
137
148
|
if (mask.active && extra.mail) extra = { ...extra, mail: maskMailHeaders(extra.mail, mask) }
|
|
138
149
|
// batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
|
|
139
150
|
const engine = engineOf(page)
|
|
@@ -161,16 +172,36 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
161
172
|
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
162
173
|
// URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the mask and the keyboard walk
|
|
163
174
|
await liveValueChecks({ page, node, result, url: page.url(), tokens })
|
|
164
|
-
//
|
|
165
|
-
|
|
175
|
+
// the console is read before the mask: the page's own reaction to masked text never counts as its error
|
|
176
|
+
const url = page.url()
|
|
177
|
+
const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
|
|
178
|
+
const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
|
|
179
|
+
// requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
|
|
180
|
+
// an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
|
|
181
|
+
const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
|
|
182
|
+
const errors = consoleEntries.filter((e) => e.type !== 'blocked' && sameDocument(e.url, url) && !e.text.includes('ERR_BLOCKED_BY_CLIENT') && !ownStatus(e)).map((e) => e.text)
|
|
183
|
+
const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
|
|
184
|
+
for (const c of node.criteria || []) {
|
|
185
|
+
if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
|
|
186
|
+
result.checks[c.id] = collected === null
|
|
187
|
+
? { ok: null, evidence: 'Console was not observed.' }
|
|
188
|
+
: { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// One mask for every output (#64): from here until the HTML view the page shows placeholders or fake values
|
|
192
|
+
// instead of personal data, so the screenshot, HTML, outline, spec and HTML view agree
|
|
166
193
|
if (mask.active) {
|
|
167
194
|
masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() }))
|
|
168
195
|
rememberPii(masking.pairs)
|
|
169
|
-
await
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
196
|
+
await saveKnownPii(run.dir)
|
|
197
|
+
await updateMasks(page)
|
|
198
|
+
// the outline the evaluators read comes from the masked page; checks and boxes keep the first probe. When the
|
|
199
|
+
// masked page cannot be read, nothing is stored: the first probe holds the real data
|
|
200
|
+
if (masking.changed) {
|
|
201
|
+
let again = null
|
|
202
|
+
try { again = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)) } catch {}
|
|
203
|
+
if (!again) throw new Error(`capture: the masked page of ${node.id} could not be read again; nothing was stored so no personal data leaves in the page outline`)
|
|
204
|
+
for (const [k, v] of Object.entries(again)) if (k !== 'checks' && k !== 'boxes' && k !== 'http_status') result[k] = v
|
|
174
205
|
}
|
|
175
206
|
}
|
|
176
207
|
const maskRects = (masking?.rects || []).filter((r) => r.box)
|
|
@@ -193,7 +224,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
193
224
|
shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css' })
|
|
194
225
|
}
|
|
195
226
|
// secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
|
|
196
|
-
await writeFile(html, redact(await page.content()))
|
|
227
|
+
await writeFile(html, redact(maskHtml(await page.content(), masking?.attrs)))
|
|
197
228
|
// the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
|
|
198
229
|
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.token_mode || scope?.variants?.[viewport]?.color_scheme || null)
|
|
199
230
|
// what moves while the page sits still (walk --stability N): repeated screenshots, compared here
|
|
@@ -217,27 +248,27 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
217
248
|
image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
|
|
218
249
|
await writeFile(image, shot)
|
|
219
250
|
}
|
|
220
|
-
const url = page.url()
|
|
221
|
-
const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
|
|
222
|
-
const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
|
|
223
|
-
// requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
|
|
224
|
-
// an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
|
|
225
|
-
const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
|
|
226
|
-
const errors = consoleEntries.filter((e) => e.type !== 'blocked' && sameDocument(e.url, url) && !e.text.includes('ERR_BLOCKED_BY_CLIENT') && !ownStatus(e)).map((e) => e.text)
|
|
227
|
-
const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
|
|
228
251
|
// network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
|
|
229
252
|
const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
|
|
230
253
|
if (net.length) {
|
|
231
254
|
netResults ||= await networkChecks(page, net, url, { run, locale })
|
|
232
255
|
Object.assign(result.checks, netResults)
|
|
233
256
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
257
|
+
// experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
|
|
258
|
+
let view = null
|
|
259
|
+
if (htmlView) {
|
|
260
|
+
try {
|
|
261
|
+
// the page is still masked here (#64); the selectors are covered in the copy as well (#27)
|
|
262
|
+
const attrs = masking?.attrs
|
|
263
|
+
const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])], scrub: (text) => redact(maskHtml(text, attrs)) })
|
|
264
|
+
await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
|
|
265
|
+
view = { revision: rev.id, entry: rev.entry }
|
|
266
|
+
} catch (error) { view = { error: String(error.message).slice(0, 200) } }
|
|
240
267
|
}
|
|
268
|
+
// the page gets its own text back before the keyboard walk: Tab and the accessibility tree see the real page,
|
|
269
|
+
// and only the focus shots are taken masked
|
|
270
|
+
const maskedPage = masking
|
|
271
|
+
if (masking) { await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}); masking = null }
|
|
241
272
|
// keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
|
|
242
273
|
// the console was read (focus handlers of the page must not count as its errors)
|
|
243
274
|
let keyboard = null
|
|
@@ -252,22 +283,16 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
252
283
|
}
|
|
253
284
|
if (needsKeyboard(kbNode) && kbSampled) {
|
|
254
285
|
const kb = await keyboardChecks(page, kbNode.criteria, {
|
|
255
|
-
saveShot: async (n, png) => {
|
|
286
|
+
saveShot: async (n, png, clip) => {
|
|
287
|
+
const f = paths.step(stepId, locale, viewport, `focus-${n}.png`)
|
|
288
|
+
await writeFile(f, mask.active ? await maskedShot(page, mask, clip) : png)
|
|
289
|
+
return relative(run.dir, f)
|
|
290
|
+
}
|
|
256
291
|
})
|
|
257
292
|
Object.assign(result.checks, kb.checks)
|
|
258
293
|
result.boxes = { ...result.boxes, ...kb.boxes }
|
|
259
294
|
keyboard = kb.keyboard
|
|
260
|
-
for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: `Focus ${s.n}: ${s.name}
|
|
261
|
-
}
|
|
262
|
-
// experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
|
|
263
|
-
let view = null
|
|
264
|
-
if (htmlView) {
|
|
265
|
-
try {
|
|
266
|
-
// the page is still masked here (#64); the selectors are covered in the copy as well (#27)
|
|
267
|
-
const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])] })
|
|
268
|
-
await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
|
|
269
|
-
view = { revision: rev.id, entry: rev.entry }
|
|
270
|
-
} catch (error) { view = { error: String(error.message).slice(0, 200) } }
|
|
295
|
+
for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: scrubText(redact(`Focus ${s.n}: ${s.name}`), mask.auto ? mask : null), file: s.file, at: new Date().toISOString() })
|
|
271
296
|
}
|
|
272
297
|
const record = {
|
|
273
298
|
schema: 'qawalk.capture.v1',
|
|
@@ -282,7 +307,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
282
307
|
http_status: extra.http_status ?? result.http_status ?? null,
|
|
283
308
|
capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
|
|
284
309
|
// what was hidden and where (#64): the canvas marks these places, so a blank is not read as a bug
|
|
285
|
-
...(mask.active ? { pii: { mode: mask.mode, auto: mask.auto, selectors: mask.selectors.length, kinds:
|
|
310
|
+
...(mask.active ? { pii: { mode: mask.mode, auto: mask.auto, selectors: mask.selectors.length, kinds: maskedPage?.counts || {}, rects: (maskedPage?.rects || []).slice(0, 200).map(({ x, y, w, h, kind }) => ({ x, y, w, h, kind })) } } : {}),
|
|
286
311
|
...(ignore.length ? { ignore } : {}),
|
|
287
312
|
...(unstable ? { unstable } : {}),
|
|
288
313
|
...(keyboard ? { keyboard } : {}),
|
|
@@ -290,13 +315,17 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
290
315
|
...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
|
|
291
316
|
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
|
|
292
317
|
}
|
|
318
|
+
// the whole record goes through the mask: remembered values, and with detection anything that looks like
|
|
319
|
+
// personal data (console lines, blocked requests, the URL, evidence, metadata)
|
|
320
|
+
const stored = mask.auto ? scrubDeep(redactDeep(record), mask, RECORD_PLAIN_KEYS) : redactDeep(record)
|
|
321
|
+
if (mask.active) await saveKnownPii(run.dir)
|
|
293
322
|
const file = paths.step(stepId, locale, viewport, 'capture.json')
|
|
294
|
-
await writeJson(file,
|
|
323
|
+
await writeJson(file, stored)
|
|
295
324
|
await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
|
|
296
|
-
results.push(
|
|
325
|
+
results.push(stored)
|
|
297
326
|
} finally {
|
|
298
327
|
// the page gets its own text back, also when the capture failed half-way
|
|
299
|
-
if (
|
|
328
|
+
if (mask.active) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {})
|
|
300
329
|
await undoVariant()
|
|
301
330
|
}
|
|
302
331
|
}
|
|
@@ -305,6 +334,21 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
305
334
|
return results
|
|
306
335
|
}
|
|
307
336
|
|
|
337
|
+
/**
|
|
338
|
+
* A focus shot of the keyboard walk with the mask on: the page masked for the moment of the shot, boxes drawn over
|
|
339
|
+
* the masked places in the clip, the page restored (the focus stays where the walk put it).
|
|
340
|
+
*/
|
|
341
|
+
async function maskedShot (page, mask, clip) {
|
|
342
|
+
try {
|
|
343
|
+
const masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() }))
|
|
344
|
+
rememberPii(masking.pairs)
|
|
345
|
+
const png = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
|
|
346
|
+
const doc = await page.evaluate(() => ({ x: scrollX, y: scrollY }))
|
|
347
|
+
const boxes = masking.rects.filter((r) => r.box).map((r) => ({ ...r, x: r.x - doc.x - clip.x, y: r.y - doc.y - clip.y }))
|
|
348
|
+
return boxes.length ? (await encodeImage(page, png, { format: 'png', masks: boxes })).image : png
|
|
349
|
+
} finally { await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
|
|
350
|
+
}
|
|
351
|
+
|
|
308
352
|
export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0, options = null }) {
|
|
309
353
|
const paths = runPaths(run.dir)
|
|
310
354
|
const el = page.locator(selector).first()
|
|
@@ -316,7 +360,7 @@ export async function captureCrop ({ page, node, locale, selector, title, run, p
|
|
|
316
360
|
const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
|
|
317
361
|
let masking = null
|
|
318
362
|
try {
|
|
319
|
-
if (mask.active) { masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() })); rememberPii(masking.pairs) }
|
|
363
|
+
if (mask.active) { await loadKnownPii(run.dir); masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() })); rememberPii(masking.pairs); await saveKnownPii(run.dir) }
|
|
320
364
|
const boxes = (masking?.rects || []).filter((r) => r.box)
|
|
321
365
|
const doc = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, x: scrollX, y: scrollY }))
|
|
322
366
|
const box = await el.boundingBox()
|
|
@@ -329,7 +373,7 @@ export async function captureCrop ({ page, node, locale, selector, title, run, p
|
|
|
329
373
|
const enc = await encodeImage(page, png, { format: 'png', masks: boxes.map((r) => ({ ...r, x: r.x - clip.x, y: r.y - clip.y })) })
|
|
330
374
|
await writeFile(file, enc.image)
|
|
331
375
|
} else await writeFile(file, png)
|
|
332
|
-
} finally { if (
|
|
376
|
+
} finally { if (mask.active) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
|
|
333
377
|
const entry = { type: 'attachment', kind: 'crop', node: node.id, locale, title: title || selector, selector, file: relative(run.dir, file), viewport: page.viewportSize(), at: new Date().toISOString() }
|
|
334
378
|
await addCapture(run.dir, entry)
|
|
335
379
|
return entry
|
|
@@ -86,7 +86,9 @@ export function versionOfTag (pattern, tag) {
|
|
|
86
86
|
// ---------- Keep a Changelog ----------
|
|
87
87
|
|
|
88
88
|
export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
|
|
89
|
-
const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed'
|
|
89
|
+
const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed',
|
|
90
|
+
// Czech Keep a Changelog headings (Squared, other Czech projects)
|
|
91
|
+
'přidáno': 'Added', 'nové': 'Added', 'změněno': 'Changed', 'zastaralé': 'Deprecated', 'odstraněno': 'Removed', 'opraveno': 'Fixed', 'bezpečnost': 'Security' }
|
|
90
92
|
/** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
|
|
91
93
|
export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
|
|
92
94
|
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -89,7 +89,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
89
89
|
// triage (#65, experimental): the kind of problem on each screen and in recording frames, advisory only
|
|
90
90
|
const tset = triageSettings(config)
|
|
91
91
|
if (tset) report.triage = { model: tset.model, screens: 0, frames: 0, calls: 0, cached: 0, findings: 0, shown: 0, skipped: null, via: {}, ms: [] }
|
|
92
|
-
const triage = tset ? triageContext({ config, root, settings: tset, report, fetchImpl, uncertain: (vset || {}).uncertain }) : null
|
|
92
|
+
const triage = tset ? triageContext({ config, root, settings: tset, report, fetchImpl, uncertain: (vset || {}).uncertain, thresholds: settings.thresholds }) : null
|
|
93
93
|
// checks against the design reference and the token set (pack design, #42) need the service and a browser
|
|
94
94
|
const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
|
|
95
95
|
// style checks against a token (style_equals token: color.brand.primary) use the project's token set
|
|
@@ -305,7 +305,7 @@ function visionContext ({ config, root, settings, report, fetchImpl }) {
|
|
|
305
305
|
* questions (#71). Fills `w.triage` with merged findings; a stopped service (experiment off, allowance) ends triage
|
|
306
306
|
* for this evaluation with one note in the report.
|
|
307
307
|
*/
|
|
308
|
-
function triageContext ({ config, root, settings, report, fetchImpl, uncertain = [0.15, 0.85] }) {
|
|
308
|
+
function triageContext ({ config, root, settings, report, fetchImpl, uncertain = [0.15, 0.85], thresholds = { pass: 0.85, fail: 0.15 } }) {
|
|
309
309
|
const cacheDir = join(resolve(root, config.output), '.cache', 'triage')
|
|
310
310
|
const hosted = hostedTriageSender(config, report)
|
|
311
311
|
let browser = null; let pagePromise = null
|
|
@@ -317,7 +317,9 @@ function triageContext ({ config, root, settings, report, fetchImpl, uncertain =
|
|
|
317
317
|
return p
|
|
318
318
|
})())
|
|
319
319
|
const allowCall = () => { if (report.triage.calls >= (settings.max_calls ?? 200)) return false; report.triage.calls++; return true }
|
|
320
|
-
|
|
320
|
+
// a Cloudflare key in the environment (a CI that deploys with wrangler) is used only when triage was asked for in
|
|
321
|
+
// the config: on by default means through the service, where the organization switched the experiment on
|
|
322
|
+
const own = config.evaluators?.triage != null && process.env.CLOUDFLARE_API_TOKEN && process.env.CLOUDFLARE_ACCOUNT_ID
|
|
321
323
|
const unavailable = !own && !process.env[config.share?.token_env || 'KALOKO_TOKEN'] ? 'no service token (KALOKO_TOKEN)' : null
|
|
322
324
|
let framesLeft = settings.frames_per_run
|
|
323
325
|
const ask = async (w, payload, where) => {
|
|
@@ -343,7 +345,7 @@ function triageContext ({ config, root, settings, report, fetchImpl, uncertain =
|
|
|
343
345
|
.map((c) => ({ id: c.id, question: c.check.question }))
|
|
344
346
|
const context = { purpose: purposeOf(w.node, w.capture), url: w.capture.url, locale: w.capture.locale, viewport: w.entry.viewport, signals: { overflow: (w.capture.overflow || []).length, console_errors: (w.capture.console_errors || []).length } }
|
|
345
347
|
const { images } = await prepareImages({ page: await page(), file: join(runDir, file), cssWidth: w.capture.size?.width, full: true, settings: { max_width: settings.max_width, tile_height: settings.tile_height, max_tiles: settings.max_tiles } })
|
|
346
|
-
const tiles = images.slice(0,
|
|
348
|
+
const tiles = images.slice(0, settings.max_tiles || 2).map((i) => `data:${i.media_type};base64,${i.data}`)
|
|
347
349
|
report.triage.screens++
|
|
348
350
|
const lists = []; const results = []
|
|
349
351
|
const screen = await ask(w, { kind: 'screen', context, criteria, images: tiles }, {})
|
|
@@ -351,18 +353,19 @@ function triageContext ({ config, root, settings, report, fetchImpl, uncertain =
|
|
|
351
353
|
if (screen.findings) lists.push(screen.findings)
|
|
352
354
|
// the recording of this capture (same walk), as frames
|
|
353
355
|
const rec = entries.filter((e) => e.type === 'recording' && e.node === w.entry.node && e.locale === w.entry.locale && e.viewport === w.entry.viewport && e.captured_at && e.captured_at === w.capture.captured_at).pop()
|
|
354
|
-
if (rec && framesLeft > 0 && !report.triageStop) {
|
|
356
|
+
if (rec && framesLeft > 0 && settings.frames_per_step > 0 && !report.triageStop) {
|
|
355
357
|
const side = await readJson(join(runDir, rec.file), null)
|
|
356
358
|
const video = side?.files?.webm || side?.files?.mp4
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
// a recording that cannot be read costs its frames only, never the screen's findings
|
|
360
|
+
if (video) try {
|
|
361
|
+
const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.frame_width || 640 })
|
|
359
362
|
framesLeft -= frames.length; report.triage.frames += frames.length
|
|
360
363
|
for (const batch of frameBatches(frames)) {
|
|
361
364
|
const r = await ask(w, { kind: 'motion', context: { ...context, frames: batch.map((f) => ({ t_ms: f.t, why: f.why })) }, images: batch.map((f) => f.data) }, { frames: batch.map((f) => f.t) })
|
|
362
365
|
results.push(r)
|
|
363
366
|
if (r.findings) lists.push(r.findings)
|
|
364
367
|
}
|
|
365
|
-
}
|
|
368
|
+
} catch (error) { report.triage.frameErrors = (report.triage.frameErrors || 0) + 1 }
|
|
366
369
|
}
|
|
367
370
|
const done = results.filter((r) => r.findings)
|
|
368
371
|
if (!done.length) { w.triage = { skipped: results[0]?.skipped || null, error: results[0]?.error || null }; continue }
|
|
@@ -370,7 +373,7 @@ function triageContext ({ config, root, settings, report, fetchImpl, uncertain =
|
|
|
370
373
|
report.triage.findings += findings.length; report.triage.shown += findings.filter((f) => f.shown).length
|
|
371
374
|
w.triage = { findings, criteria: screen.criteria || {}, model: done[0].model, via: done[0].via, cached: done.every((r) => r.cached), requests: done.length, ms: done.reduce((n, r) => n + (r.ms || 0), 0) || null }
|
|
372
375
|
// cascade (#71): criteria Clef is sure about skip the vision evaluator
|
|
373
|
-
if (settings.cascade) w.triageDecided = new Set(Object.entries(screen.criteria || {}).filter(([, p]) => p <=
|
|
376
|
+
if (settings.cascade) w.triageDecided = new Set(Object.entries(screen.criteria || {}).filter(([, p]) => p <= thresholds.fail || p >= thresholds.pass).map(([id]) => id))
|
|
374
377
|
} catch (error) {
|
|
375
378
|
w.triage = { error: String(error.message || error).slice(0, 300) }
|
|
376
379
|
}
|
package/src/lib/htmlview.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function viewInPage ({ mask = [] }) {
|
|
|
68
68
|
* cookies and gate headers as the capture), rewrites CSS url()s, writes the bundle into the run directory
|
|
69
69
|
* (live/<revision>/…) and returns the revision ({ id, entry, files, sizes }).
|
|
70
70
|
*/
|
|
71
|
-
export async function buildHtmlView ({ page, run, scenarioId, node, locale, viewport, mask = [] }) {
|
|
71
|
+
export async function buildHtmlView ({ page, run, scenarioId, node, locale, viewport, mask = [], scrub = null }) {
|
|
72
72
|
const { html, assets } = await page.evaluate(viewInPage, { mask })
|
|
73
73
|
const request = page.context().request
|
|
74
74
|
const files = {}, sizes = {}, bodies = new Map()
|
|
@@ -109,6 +109,8 @@ export async function buildHtmlView ({ page, run, scenarioId, node, locale, view
|
|
|
109
109
|
const name = await fetchAsset(url)
|
|
110
110
|
out = out.split(`__QW_ASSET_${i}__`).join(name || 'about:blank')
|
|
111
111
|
}
|
|
112
|
+
// personal data (#64): attributes masked only for storage, and the values remembered for the run
|
|
113
|
+
if (scrub) out = scrub(out)
|
|
112
114
|
const htmlBody = Buffer.from(out, 'utf8')
|
|
113
115
|
files['index.html'] = await sha256Hex(htmlBody); sizes['index.html'] = htmlBody.length; bodies.set('index.html', htmlBody)
|
|
114
116
|
const problems = bundleProblems(Object.keys(files).map((p) => ({ path: p, bytes: sizes[p] })), 'index.html')
|
package/src/lib/keyboard.mjs
CHANGED
|
@@ -135,7 +135,7 @@ const label = (s) => `${s.desc}${s.name ? ` "${s.name.slice(0, 30)}"` : ''}`
|
|
|
135
135
|
/**
|
|
136
136
|
* Walks the page with Tab and fills the keyboard criteria of `criteria` ({ id, check }).
|
|
137
137
|
* opts: maxStops (60), focusChecks (stops checked for a visible indicator, 15), shots (focus screenshots, 5),
|
|
138
|
-
* saveShot(n, pngBuffer) → relative file path or null.
|
|
138
|
+
* saveShot(n, pngBuffer, clip) → relative file path or null.
|
|
139
139
|
* @returns {{ checks: Record<string, {ok, evidence}>, boxes: Record<string, Array>, keyboard: object }}
|
|
140
140
|
*/
|
|
141
141
|
export async function keyboardChecks (page, criteria, { maxStops = 60, focusChecks = 15, shots = 5, saveShot = null } = {}) {
|
|
@@ -232,7 +232,9 @@ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChec
|
|
|
232
232
|
if (clip.width < 2 || clip.height < 2) continue
|
|
233
233
|
const focused = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
|
|
234
234
|
if (keyboard.shots.length < shots && saveShot) {
|
|
235
|
-
|
|
235
|
+
// the clip goes along: a masked capture takes its own shot of it with personal data covered (#64)
|
|
236
|
+
const shotClip = clipOf(48)
|
|
237
|
+
const file = await saveShot(n, await page.screenshot({ clip: shotClip, scale: 'css', animations: 'disabled', caret: 'hide' }), shotClip).catch(() => null)
|
|
236
238
|
if (file) keyboard.shots.push({ n, file, name: s.name || s.desc })
|
|
237
239
|
}
|
|
238
240
|
if (!want.has('a11y_focus_visible') || verdicts.size >= focusChecks) continue
|