kaloko 0.12.0 → 0.12.1

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.1",
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])
@@ -5,7 +5,7 @@ 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
@@ -134,6 +134,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
134
134
  let netResults = null
135
135
  // personal data (#64): what this capture masks; recipients of a captured e-mail are masked before the body
136
136
  const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
137
+ // values masked by an earlier process of this run (kaloko walk, then kaloko capture) are masked here too
138
+ if (mask.active) await loadKnownPii(run.dir)
137
139
  if (mask.active && extra.mail) extra = { ...extra, mail: maskMailHeaders(extra.mail, mask) }
138
140
  // batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
139
141
  const engine = engineOf(page)
@@ -161,16 +163,36 @@ export async function captureStep ({ page, node, locale, viewports, run, console
161
163
  const ignore = await resolveIgnore(page, node, locale, viewport)
162
164
  // URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the mask and the keyboard walk
163
165
  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
166
+ // the console is read before the mask: the page's own reaction to masked text never counts as its error
167
+ const url = page.url()
168
+ const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
169
+ const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
170
+ // requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
171
+ // an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
172
+ 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)
173
+ 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)
174
+ const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
175
+ for (const c of node.criteria || []) {
176
+ if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
177
+ result.checks[c.id] = collected === null
178
+ ? { ok: null, evidence: 'Console was not observed.' }
179
+ : { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
180
+ }
181
+ }
182
+ // One mask for every output (#64): from here until the HTML view the page shows placeholders or fake values
183
+ // instead of personal data, so the screenshot, HTML, outline, spec and HTML view agree
166
184
  if (mask.active) {
167
185
  masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() }))
168
186
  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]
187
+ await saveKnownPii(run.dir)
188
+ await updateMasks(page)
189
+ // the outline the evaluators read comes from the masked page; checks and boxes keep the first probe. When the
190
+ // masked page cannot be read, nothing is stored: the first probe holds the real data
191
+ if (masking.changed) {
192
+ let again = null
193
+ try { again = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)) } catch {}
194
+ 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`)
195
+ for (const [k, v] of Object.entries(again)) if (k !== 'checks' && k !== 'boxes' && k !== 'http_status') result[k] = v
174
196
  }
175
197
  }
176
198
  const maskRects = (masking?.rects || []).filter((r) => r.box)
@@ -193,7 +215,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
193
215
  shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css' })
194
216
  }
195
217
  // 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()))
218
+ await writeFile(html, redact(maskHtml(await page.content(), masking?.attrs)))
197
219
  // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
198
220
  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
221
  // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
@@ -217,27 +239,27 @@ export async function captureStep ({ page, node, locale, viewports, run, console
217
239
  image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
218
240
  await writeFile(image, shot)
219
241
  }
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
242
  // network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
229
243
  const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
230
244
  if (net.length) {
231
245
  netResults ||= await networkChecks(page, net, url, { run, locale })
232
246
  Object.assign(result.checks, netResults)
233
247
  }
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
- }
248
+ // experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
249
+ let view = null
250
+ if (htmlView) {
251
+ try {
252
+ // the page is still masked here (#64); the selectors are covered in the copy as well (#27)
253
+ const attrs = masking?.attrs
254
+ 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)) })
255
+ await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
256
+ view = { revision: rev.id, entry: rev.entry }
257
+ } catch (error) { view = { error: String(error.message).slice(0, 200) } }
240
258
  }
259
+ // the page gets its own text back before the keyboard walk: Tab and the accessibility tree see the real page,
260
+ // and only the focus shots are taken masked
261
+ const maskedPage = masking
262
+ if (masking) { await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}); masking = null }
241
263
  // keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
242
264
  // the console was read (focus handlers of the page must not count as its errors)
243
265
  let keyboard = null
@@ -252,22 +274,16 @@ export async function captureStep ({ page, node, locale, viewports, run, console
252
274
  }
253
275
  if (needsKeyboard(kbNode) && kbSampled) {
254
276
  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) }
277
+ saveShot: async (n, png, clip) => {
278
+ const f = paths.step(stepId, locale, viewport, `focus-${n}.png`)
279
+ await writeFile(f, mask.active ? await maskedShot(page, mask, clip) : png)
280
+ return relative(run.dir, f)
281
+ }
256
282
  })
257
283
  Object.assign(result.checks, kb.checks)
258
284
  result.boxes = { ...result.boxes, ...kb.boxes }
259
285
  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) } }
286
+ 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
287
  }
272
288
  const record = {
273
289
  schema: 'qawalk.capture.v1',
@@ -282,7 +298,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
282
298
  http_status: extra.http_status ?? result.http_status ?? null,
283
299
  capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
284
300
  // 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 })) } } : {}),
301
+ ...(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
302
  ...(ignore.length ? { ignore } : {}),
287
303
  ...(unstable ? { unstable } : {}),
288
304
  ...(keyboard ? { keyboard } : {}),
@@ -290,13 +306,17 @@ export async function captureStep ({ page, node, locale, viewports, run, console
290
306
  ...(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
307
  files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
292
308
  }
309
+ // the whole record goes through the mask: remembered values, and with detection anything that looks like
310
+ // personal data (console lines, blocked requests, the URL, evidence, metadata)
311
+ const stored = mask.auto ? scrubDeep(redactDeep(record), mask, RECORD_PLAIN_KEYS) : redactDeep(record)
312
+ if (mask.active) await saveKnownPii(run.dir)
293
313
  const file = paths.step(stepId, locale, viewport, 'capture.json')
294
- await writeJson(file, redactDeep(record))
314
+ await writeJson(file, stored)
295
315
  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)
316
+ results.push(stored)
297
317
  } finally {
298
318
  // the page gets its own text back, also when the capture failed half-way
299
- if (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {})
319
+ if (mask.active) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {})
300
320
  await undoVariant()
301
321
  }
302
322
  }
@@ -305,6 +325,21 @@ export async function captureStep ({ page, node, locale, viewports, run, console
305
325
  return results
306
326
  }
307
327
 
328
+ /**
329
+ * A focus shot of the keyboard walk with the mask on: the page masked for the moment of the shot, boxes drawn over
330
+ * the masked places in the clip, the page restored (the focus stays where the walk put it).
331
+ */
332
+ async function maskedShot (page, mask, clip) {
333
+ try {
334
+ const masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() }))
335
+ rememberPii(masking.pairs)
336
+ const png = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
337
+ const doc = await page.evaluate(() => ({ x: scrollX, y: scrollY }))
338
+ 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 }))
339
+ return boxes.length ? (await encodeImage(page, png, { format: 'png', masks: boxes })).image : png
340
+ } finally { await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
341
+ }
342
+
308
343
  export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0, options = null }) {
309
344
  const paths = runPaths(run.dir)
310
345
  const el = page.locator(selector).first()
@@ -316,7 +351,7 @@ export async function captureCrop ({ page, node, locale, selector, title, run, p
316
351
  const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
317
352
  let masking = null
318
353
  try {
319
- if (mask.active) { masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() })); rememberPii(masking.pairs) }
354
+ if (mask.active) { await loadKnownPii(run.dir); masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() })); rememberPii(masking.pairs); await saveKnownPii(run.dir) }
320
355
  const boxes = (masking?.rects || []).filter((r) => r.box)
321
356
  const doc = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, x: scrollX, y: scrollY }))
322
357
  const box = await el.boundingBox()
@@ -329,7 +364,7 @@ export async function captureCrop ({ page, node, locale, selector, title, run, p
329
364
  const enc = await encodeImage(page, png, { format: 'png', masks: boxes.map((r) => ({ ...r, x: r.x - clip.x, y: r.y - clip.y })) })
330
365
  await writeFile(file, enc.image)
331
366
  } else await writeFile(file, png)
332
- } finally { if (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
367
+ } finally { if (mask.active) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
333
368
  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
369
  await addCapture(run.dir, entry)
335
370
  return entry
@@ -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