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 +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/capture.mjs +76 -41
- 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 +2 -1
- package/src/lib/triage-core.mjs +3 -1
- package/src/lib/triage.mjs +17 -5
- package/viewer/index.html +23 -12
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/capture.mjs
CHANGED
|
@@ -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
|
-
//
|
|
165
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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) => {
|
|
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}
|
|
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:
|
|
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,
|
|
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(
|
|
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 (
|
|
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 (
|
|
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
|
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
|