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 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, and `npx kaloko share`
424
- refuses a run that still holds an unmasked e-mail or phone number, naming the
425
- step and element (`--allow-pii` overrides). Checks always read the real page.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -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": 1024 },
94
- "tile_height": { "type": "integer", "minimum": 400, "maximum": 4000, "default": 1400 },
95
- "max_tiles": { "type": "integer", "minimum": 1, "maximum": 4, "default": 4 },
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 unmasked e-mail or phone number and
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] An unmasked e-mail, phone number, birth number or IBAN in the run stops the upload
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 ? ` Stopped early: ${report.triage.skipped}` : ''}` : `Triage (experimental): not run: ${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
- const findings = await piiFindings({ dir: run.dir, files: [...files, ...views], manifest, allow: guardAllow({ config, env, scenario, run }) })
1816
- if (findings.length && !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.`)
1817
- if (findings.length) say(`Sharing ${findings.length} unmasked personal data value(s), as --allow-pii says:\n${formatFindings(findings, 5)}`)
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
  /**
@@ -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 = 320, thumbQuality = 0.7, marks = [], scale = 1, masks = [] } = {}) {
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 t = document.createElement('canvas'); t.width = Math.round(img.width * k); t.height = Math.round(img.height * k)
37
- t.getContext('2d').drawImage(full, 0, 0, t.width, t.height)
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
- // One mask for every output (#64): from here to the end of the capture the page shows placeholders or fake
165
- // values instead of personal data, so the screenshot, HTML, outline, spec, keyboard shots and HTML view agree
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 page.evaluate((known) => window.__kalokoMask?.update({ known }), knownPii()).catch(() => {})
170
- // the outline the evaluators read comes from the masked page; checks and boxes keep the first probe
171
- if (masking.rects.length || masking.pairs.length || Object.keys(masking.counts).length) {
172
- const again = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)).catch(() => null)
173
- if (again) for (const k of ['state', 'scoped', 'overflow', 'meta', 'design']) if (again[k] !== undefined) result[k] = again[k]
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
- for (const c of node.criteria || []) {
235
- if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
236
- result.checks[c.id] = collected === null
237
- ? { ok: null, evidence: 'Console was not observed.' }
238
- : { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
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) => { const f = paths.step(stepId, locale, viewport, `focus-${n}.png`); await writeFile(f, png); return relative(run.dir, f) }
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}`, file: s.file, at: new Date().toISOString() })
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: masking?.counts || {}, rects: (masking?.rects || []).slice(0, 200).map(({ x, y, w, h, kind }) => ({ x, y, w, h, kind })) } } : {}),
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, redactDeep(record))
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(record)
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 (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {})
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 (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
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
 
@@ -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
- const own = process.env.CLOUDFLARE_API_TOKEN && process.env.CLOUDFLARE_ACCOUNT_ID
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, 4).map((i) => `data:${i.media_type};base64,${i.data}`)
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
- if (video) {
358
- 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.max_width })
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 <= 0.15 || p >= 0.85).map(([id]) => id))
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
  }
@@ -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')
@@ -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
- const file = await saveShot(n, await page.screenshot({ clip: clipOf(48), scale: 'css', animations: 'disabled', caret: 'hide' })).catch(() => null)
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