kaloko 0.11.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 +16 -0
- package/package.json +1 -1
- package/schemas/config.v1.json +33 -0
- package/schemas/run.v1.json +11 -0
- package/schemas/scenario.v1.json +9 -5
- package/skills/kaloko/SKILL.md +3 -1
- package/skills/kaloko/reference/acceptance.md +8 -2
- package/skills/kaloko/reference/evaluation.md +12 -0
- package/src/cli.mjs +29 -10
- package/src/lib/build.mjs +3 -1
- package/src/lib/capture.mjs +135 -56
- package/src/lib/evaluate.mjs +115 -12
- package/src/lib/htmlview.mjs +3 -1
- package/src/lib/keyboard.mjs +4 -2
- package/src/lib/mail.mjs +6 -4
- package/src/lib/manifest-core.mjs +17 -2
- package/src/lib/pii.mjs +924 -0
- package/src/lib/recording.mjs +12 -2
- package/src/lib/run.mjs +3 -1
- package/src/lib/secrets.mjs +5 -3
- package/src/lib/triage-core.mjs +167 -0
- package/src/lib/triage.mjs +135 -0
- package/templates/scenario.yml +1 -0
- package/viewer/fonts/OFL-IBMPlexMono.txt +93 -0
- package/viewer/fonts/OFL-InstrumentSans.txt +93 -0
- package/viewer/fonts/ibm-plex-mono-400-latin-ext.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-400-latin.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-500-latin-ext.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-500-latin.woff2 +0 -0
- package/viewer/fonts/instrument-sans-latin-ext.woff2 +0 -0
- package/viewer/fonts/instrument-sans-latin.woff2 +0 -0
- package/viewer/index.html +132 -22
package/src/lib/capture.mjs
CHANGED
|
@@ -5,6 +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, maskHtml, scrubDeep, scrubText, updateMasks, loadKnownPii, saveKnownPii, RECORD_PLAIN_KEYS } from './pii.mjs'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Encodes a PNG screenshot inside the page (no native dependency): the image as WebP (or PNG, lossless) and a
|
|
@@ -16,7 +17,14 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
|
|
|
16
17
|
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
|
|
17
18
|
const full = document.createElement('canvas'); full.width = img.width; full.height = img.height
|
|
18
19
|
const g = full.getContext('2d'); g.drawImage(img, 0, 0)
|
|
19
|
-
|
|
20
|
+
// masked personal data: a dark box with diagonal stripes, so a reader sees "hidden" and not a broken page (#64)
|
|
21
|
+
for (const r of masks) {
|
|
22
|
+
const x = Math.floor(r.x * scale); const y = Math.floor(r.y * scale); const w = Math.ceil(r.w * scale); const h = Math.ceil(r.h * scale)
|
|
23
|
+
g.fillStyle = '#1f1f1d'; g.fillRect(x, y, w, h)
|
|
24
|
+
g.save(); g.beginPath(); g.rect(x, y, w, h); g.clip(); g.strokeStyle = '#3d3d38'; g.lineWidth = 3 * scale
|
|
25
|
+
for (let i = -h; i < w; i += 12 * scale) { g.beginPath(); g.moveTo(x + i, y + h); g.lineTo(x + i + h, y); g.stroke() }
|
|
26
|
+
g.restore()
|
|
27
|
+
}
|
|
20
28
|
for (const m of marks) {
|
|
21
29
|
const r = 13 * scale; const x = Math.max(r + 2, m.x * scale); const y = Math.max(r + 2, m.y * scale)
|
|
22
30
|
g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fillStyle = '#ffe452'; g.fill(); g.lineWidth = 2 * scale; g.strokeStyle = '#151514'; g.stroke()
|
|
@@ -50,12 +58,15 @@ export async function stepSpec (page, tokens = null, mode = null) {
|
|
|
50
58
|
|
|
51
59
|
export function captureOptions (scenario, node) {
|
|
52
60
|
const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
|
|
61
|
+
// masks add up: a step's own selectors never drop the scenario's (personal data stays hidden on every step)
|
|
62
|
+
const masks = [maskList(scenario?.capture?.mask), maskList(node?.capture?.mask)]
|
|
53
63
|
const q = Number(o.quality)
|
|
54
64
|
return {
|
|
55
65
|
format: o.format === 'png' ? 'png' : 'webp',
|
|
56
66
|
scale: Number(o.scale) === 2 ? 2 : 1,
|
|
57
67
|
quality: Number.isFinite(q) && q > 0 ? Math.min(1, Math.max(0.3, q)) : 0.82,
|
|
58
|
-
mask:
|
|
68
|
+
mask: [...new Set(masks.flatMap((m) => m.selectors))],
|
|
69
|
+
maskAuto: masks.some((m) => m.auto),
|
|
59
70
|
annotate: o.annotate === true,
|
|
60
71
|
// stack pages default to the first screen: hundreds of full-page shots would swamp storage
|
|
61
72
|
fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack'
|
|
@@ -121,6 +132,11 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
121
132
|
const original = page.viewportSize()
|
|
122
133
|
const results = []
|
|
123
134
|
let netResults = null
|
|
135
|
+
// personal data (#64): what this capture masks; recipients of a captured e-mail are masked before the body
|
|
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)
|
|
139
|
+
if (mask.active && extra.mail) extra = { ...extra, mail: maskMailHeaders(extra.mail, mask) }
|
|
124
140
|
// batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
|
|
125
141
|
const engine = engineOf(page)
|
|
126
142
|
const scope = run.meta?.scope
|
|
@@ -128,14 +144,58 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
128
144
|
const now = page.viewportSize()
|
|
129
145
|
if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
|
|
130
146
|
const undoVariant = await applyVariant(page, scope?.variants?.[viewport], scope?.devices?.[scope?.variants?.[viewport]?.device] || null)
|
|
147
|
+
let masking = null
|
|
131
148
|
try {
|
|
132
149
|
await settle(page)
|
|
133
150
|
const html = paths.step(stepId, locale, viewport, 'html')
|
|
134
151
|
await rm(paths.step(stepId, locale, viewport, 'eval.json'), { force: true })
|
|
135
152
|
await mkdir(dirname(html), { recursive: true })
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
153
|
+
// the checks read the page as it is: masks change what is stored, never a verdict
|
|
154
|
+
let result
|
|
155
|
+
for (let attempt = 0; ; attempt++) {
|
|
156
|
+
try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
|
|
157
|
+
// the page navigated under us (redirect, Turbo); wait once and probe the new document
|
|
158
|
+
if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
|
|
159
|
+
throw error
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
163
|
+
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
164
|
+
// URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the mask and the keyboard walk
|
|
165
|
+
await liveValueChecks({ page, node, result, url: page.url(), tokens })
|
|
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
|
|
184
|
+
if (mask.active) {
|
|
185
|
+
masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() }))
|
|
186
|
+
rememberPii(masking.pairs)
|
|
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
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const maskRects = (masking?.rects || []).filter((r) => r.box)
|
|
139
199
|
let shot
|
|
140
200
|
// a device preset's pixel ratio (3 on an iPhone) shapes the layout, not the image: captures stay in CSS pixels
|
|
141
201
|
// (or 2× for documentation), so the canvas, boxes and diffs work the same for every device
|
|
@@ -152,22 +212,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
152
212
|
// WebKit and Firefox have no 2× capture of their own: 2× when the context renders at 2× or more, else 1×
|
|
153
213
|
// (exactly 2×: a 3× or 2.625× device would give an image the masks and marks are not drawn for)
|
|
154
214
|
if (opts.scale === 2 && dpr !== 2) shotScale = 1
|
|
155
|
-
shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css'
|
|
215
|
+
shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css' })
|
|
156
216
|
}
|
|
157
217
|
// secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
|
|
158
|
-
await writeFile(html, redact(await page.content()))
|
|
159
|
-
let result
|
|
160
|
-
for (let attempt = 0; ; attempt++) {
|
|
161
|
-
try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
|
|
162
|
-
// the page navigated under us (redirect, Turbo); wait once and probe the new document
|
|
163
|
-
if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
|
|
164
|
-
throw error
|
|
165
|
-
}
|
|
166
|
-
}
|
|
218
|
+
await writeFile(html, redact(maskHtml(await page.content(), masking?.attrs)))
|
|
167
219
|
// the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
|
|
168
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)
|
|
169
|
-
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
170
|
-
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
171
221
|
// what moves while the page sits still (walk --stability N): repeated screenshots, compared here
|
|
172
222
|
const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
|
|
173
223
|
// numbered marks on the highlighted elements, in criteria order (first box of each)
|
|
@@ -176,7 +226,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
176
226
|
let thumb = paths.step(stepId, locale, viewport, 'thumb.webp')
|
|
177
227
|
let thumbWritten = true
|
|
178
228
|
try {
|
|
179
|
-
|
|
229
|
+
// mask boxes are drawn while encoding, at the image's own scale (Playwright's mask only works at the page's pixel ratio)
|
|
230
|
+
const enc = await encodeImage(page, shot, { format: opts.format, quality: opts.quality, marks: annotations, scale: shotScale, masks: maskRects })
|
|
180
231
|
// the extension follows the bytes (WebKit encodes PNG), so the service accepts the file
|
|
181
232
|
if (enc.imageType === 'image/png') image = paths.step(stepId, locale, viewport, 'png')
|
|
182
233
|
if (enc.thumbType === 'image/png') thumb = paths.step(stepId, locale, viewport, 'thumb.png')
|
|
@@ -184,33 +235,31 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
184
235
|
await writeFile(thumb, enc.thumb)
|
|
185
236
|
} catch (error) {
|
|
186
237
|
// never store an unmasked image when masks were asked for
|
|
187
|
-
if (
|
|
238
|
+
if (maskRects.length) throw new Error(`capture: could not draw the masks on ${node.id} (${error.message}); the image was not stored`)
|
|
188
239
|
image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
|
|
189
240
|
await writeFile(image, shot)
|
|
190
241
|
}
|
|
191
|
-
const url = page.url()
|
|
192
|
-
// URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the keyboard walk moves focus
|
|
193
|
-
await liveValueChecks({ page, node, result, url, tokens })
|
|
194
|
-
const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
|
|
195
|
-
const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
|
|
196
|
-
// requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
|
|
197
|
-
// an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
|
|
198
|
-
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)
|
|
199
|
-
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)
|
|
200
|
-
const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
|
|
201
242
|
// network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
|
|
202
243
|
const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
|
|
203
244
|
if (net.length) {
|
|
204
245
|
netResults ||= await networkChecks(page, net, url, { run, locale })
|
|
205
246
|
Object.assign(result.checks, netResults)
|
|
206
247
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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) } }
|
|
213
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 }
|
|
214
263
|
// keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
|
|
215
264
|
// the console was read (focus handlers of the page must not count as its errors)
|
|
216
265
|
let keyboard = null
|
|
@@ -225,22 +274,16 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
225
274
|
}
|
|
226
275
|
if (needsKeyboard(kbNode) && kbSampled) {
|
|
227
276
|
const kb = await keyboardChecks(page, kbNode.criteria, {
|
|
228
|
-
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
|
+
}
|
|
229
282
|
})
|
|
230
283
|
Object.assign(result.checks, kb.checks)
|
|
231
284
|
result.boxes = { ...result.boxes, ...kb.boxes }
|
|
232
285
|
keyboard = kb.keyboard
|
|
233
|
-
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}
|
|
234
|
-
}
|
|
235
|
-
// experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
|
|
236
|
-
let view = null
|
|
237
|
-
if (htmlView) {
|
|
238
|
-
try {
|
|
239
|
-
// the same PII masks as the screenshot (#27), applied to the text of the copy
|
|
240
|
-
const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])] })
|
|
241
|
-
await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
|
|
242
|
-
view = { revision: rev.id, entry: rev.entry }
|
|
243
|
-
} 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() })
|
|
244
287
|
}
|
|
245
288
|
const record = {
|
|
246
289
|
schema: 'qawalk.capture.v1',
|
|
@@ -254,6 +297,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
254
297
|
...extra,
|
|
255
298
|
http_status: extra.http_status ?? result.http_status ?? null,
|
|
256
299
|
capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
|
|
300
|
+
// what was hidden and where (#64): the canvas marks these places, so a blank is not read as a bug
|
|
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 })) } } : {}),
|
|
257
302
|
...(ignore.length ? { ignore } : {}),
|
|
258
303
|
...(unstable ? { unstable } : {}),
|
|
259
304
|
...(keyboard ? { keyboard } : {}),
|
|
@@ -261,31 +306,65 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
261
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])) : {}) } } : {}),
|
|
262
307
|
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
|
|
263
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)
|
|
264
313
|
const file = paths.step(stepId, locale, viewport, 'capture.json')
|
|
265
|
-
await writeJson(file,
|
|
314
|
+
await writeJson(file, stored)
|
|
266
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 })
|
|
267
|
-
results.push(
|
|
268
|
-
} finally {
|
|
316
|
+
results.push(stored)
|
|
317
|
+
} finally {
|
|
318
|
+
// the page gets its own text back, also when the capture failed half-way
|
|
319
|
+
if (mask.active) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {})
|
|
320
|
+
await undoVariant()
|
|
321
|
+
}
|
|
269
322
|
}
|
|
270
323
|
const last = page.viewportSize()
|
|
271
324
|
if (original && (!last || last.width !== original.width || last.height !== original.height)) await page.setViewportSize(original)
|
|
272
325
|
return results
|
|
273
326
|
}
|
|
274
327
|
|
|
275
|
-
|
|
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
|
+
|
|
343
|
+
export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0, options = null }) {
|
|
276
344
|
const paths = runPaths(run.dir)
|
|
277
345
|
const el = page.locator(selector).first()
|
|
278
346
|
const name = `${node.id}-${locale}-${Date.now().toString(36)}.png`
|
|
279
347
|
const file = paths.attachment(name)
|
|
280
348
|
await mkdir(dirname(file), { recursive: true })
|
|
281
|
-
|
|
282
|
-
|
|
349
|
+
// the same mask as the step's capture (#64): masked text in the page, boxes over masked elements
|
|
350
|
+
const opts = options || captureOptions(null, node)
|
|
351
|
+
const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
|
|
352
|
+
let masking = null
|
|
353
|
+
try {
|
|
354
|
+
if (mask.active) { await loadKnownPii(run.dir); masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() })); rememberPii(masking.pairs); await saveKnownPii(run.dir) }
|
|
355
|
+
const boxes = (masking?.rects || []).filter((r) => r.box)
|
|
356
|
+
const doc = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, x: scrollX, y: scrollY }))
|
|
283
357
|
const box = await el.boundingBox()
|
|
284
358
|
if (!box) throw new Error(`crop: ${selector} is not visible`)
|
|
285
|
-
|
|
359
|
+
// element plus a margin of surrounding page (documentation crops), clipped from a full-page shot
|
|
286
360
|
const x = Math.max(0, box.x + doc.x - padding); const y = Math.max(0, box.y + doc.y - padding)
|
|
287
|
-
|
|
288
|
-
|
|
361
|
+
const clip = { x, y, width: Math.min(doc.w - x, box.width + padding * 2), height: Math.min(doc.h - y, box.height + padding * 2) }
|
|
362
|
+
const png = padding > 0 || boxes.length ? await page.screenshot({ fullPage: true, clip }) : await el.screenshot()
|
|
363
|
+
if (boxes.length) {
|
|
364
|
+
const enc = await encodeImage(page, png, { format: 'png', masks: boxes.map((r) => ({ ...r, x: r.x - clip.x, y: r.y - clip.y })) })
|
|
365
|
+
await writeFile(file, enc.image)
|
|
366
|
+
} else await writeFile(file, png)
|
|
367
|
+
} finally { if (mask.active) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
|
|
289
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() }
|
|
290
369
|
await addCapture(run.dir, entry)
|
|
291
370
|
return entry
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { readCaptures, runPaths } from './run.mjs'
|
|
|
4
4
|
import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES, purposeOf } from './jev.mjs'
|
|
5
5
|
import { visionSettings, visionCriteria, templateReason, VISION_PROMPT_VERSION } from './vision-core.mjs'
|
|
6
6
|
import { prepareImages, visionPlan, askVision, hostedVisionSender } from './vision.mjs'
|
|
7
|
+
import { triageSettings, probeFindings, TRIAGE_PROMPT_VERSION } from './triage-core.mjs'
|
|
8
|
+
import { askTriage, hostedTriageSender, chooseFrames, frameBatches, mergeFindings } from './triage.mjs'
|
|
7
9
|
import { KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
8
10
|
import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
|
|
9
11
|
import { packHelp } from './pack-help.mjs'
|
|
@@ -31,7 +33,7 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
|
|
|
31
33
|
* asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
|
|
32
34
|
* model's own, or a sentence saying what the text evaluator read (it returns a probability only).
|
|
33
35
|
*/
|
|
34
|
-
export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
|
|
36
|
+
export function combine ({ node, capture, jev, vision = null, triage = null, cascade = false, thresholds, environment = null }) {
|
|
35
37
|
return (node.criteria || []).map((c) => {
|
|
36
38
|
if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
|
|
37
39
|
if (!appliesToState(c, capture?.step_state)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies to the state${c.states.length > 1 ? 's' : ''} ${c.states.join(', ')} only; this capture shows ${capture?.step_state || 'default'}.`, na: true, not_applicable: 'state' }
|
|
@@ -48,15 +50,19 @@ export function combine ({ node, capture, jev, vision = null, thresholds, enviro
|
|
|
48
50
|
const answer = jev?.response?.answers?.[c.id]
|
|
49
51
|
const text = answer?.noul ?? null
|
|
50
52
|
const seen = vision?.answers?.[c.id] || null
|
|
51
|
-
|
|
53
|
+
// Clef's answer on the screenshot (#71): recorded beside the others; with cascade on it decides when it is sure
|
|
54
|
+
const clef = triage?.criteria?.[c.id] ?? null
|
|
55
|
+
const clefDecides = cascade && !seen && clef != null && (clef <= thresholds.fail || clef >= thresholds.pass)
|
|
56
|
+
const score = seen ? seen.probability : clefDecides ? clef : text
|
|
52
57
|
const verdict = verdictFor(score, thresholds)
|
|
53
|
-
const reason = seen?.reason || templateReason({ score, scope: c.check.scope, about: c.check.about, verdict })
|
|
58
|
+
const reason = seen?.reason || (clefDecides ? `Judged from the screenshot by Clef: ${Math.round(clef * 100)} % yes, clear enough for a verdict.` : templateReason({ score, scope: c.check.scope, about: c.check.about, verdict }))
|
|
54
59
|
return {
|
|
55
60
|
id: c.id, type: 'semantic', verdict, evidence: null, score,
|
|
56
|
-
provider: seen ? `${vision.provider || 'anthropic'}-vision` : answer ? 'typesafe-jev' : null,
|
|
61
|
+
provider: seen ? `${vision.provider || 'anthropic'}-vision` : clefDecides ? 'cloudflare-clef' : answer ? 'typesafe-jev' : null,
|
|
57
62
|
note: score == null ? (jev?.skipped || jev?.error || vision?.skipped || vision?.error || null) : null,
|
|
58
63
|
...(reason ? { reason, reason_source: seen?.reason ? 'model' : 'template' } : {}),
|
|
59
|
-
...(seen && text != null ? { text_score: text } : {}),
|
|
64
|
+
...((seen || clefDecides) && text != null ? { text_score: text } : {}),
|
|
65
|
+
...(clef != null ? { clef_score: clef } : {}),
|
|
60
66
|
...(!seen && (vision?.skipped || vision?.error) && vision.ids?.includes(c.id) ? { vision_note: vision.skipped || vision.error } : {})
|
|
61
67
|
}
|
|
62
68
|
}
|
|
@@ -80,12 +86,17 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
80
86
|
// with an own text-evaluator key the vision evaluator uses an own key too, unless hosted: true says otherwise
|
|
81
87
|
if (vset && process.env[settings.api_key_env] && config.evaluators?.vision?.hosted !== true) vset.hosted = false
|
|
82
88
|
const vision = vset ? visionContext({ config, root, settings: vset, report, fetchImpl }) : null
|
|
89
|
+
// triage (#65, experimental): the kind of problem on each screen and in recording frames, advisory only
|
|
90
|
+
const tset = triageSettings(config)
|
|
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, thresholds: settings.thresholds }) : null
|
|
83
93
|
// checks against the design reference and the token set (pack design, #42) need the service and a browser
|
|
84
94
|
const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
|
|
85
95
|
// style checks against a token (style_equals token: color.brand.primary) use the project's token set
|
|
86
96
|
const styleTokens = scenario.nodes.some((n) => tokenCriteria(n).length) ? (drift ? { tokens: drift.tokens, mode: drift.mode } : await runTokens({ config, root })) : null
|
|
87
97
|
try {
|
|
88
|
-
const
|
|
98
|
+
const entries = await readCaptures(run.dir)
|
|
99
|
+
const latest = latestCaptures(entries)
|
|
89
100
|
// stack steps: semantic criteria only on the first N pages (items.semantic_sample, default 20); cost stays bounded
|
|
90
101
|
const sampled = new Map()
|
|
91
102
|
for (const e of latest) {
|
|
@@ -129,13 +140,15 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
129
140
|
if ((w.jev.skipped || w.jev.error) && w.asks) report.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.jev.skipped || w.jev.error}`)
|
|
130
141
|
}
|
|
131
142
|
}))
|
|
132
|
-
//
|
|
143
|
+
// what kind of problem each screen and its recording shows; with cascade on, Clef may settle uncertain criteria
|
|
144
|
+
if (triage) await triage.ask(work.filter((w) => w.current && !w.outOfSample), run.dir, entries)
|
|
145
|
+
// second opinion with the screenshot for flagged and uncertain criteria (those Clef did not settle)
|
|
133
146
|
if (vision) await vision.ask(work.filter((w) => w.current && !w.outOfSample && w.request), run.dir)
|
|
134
|
-
for (const { entry, node, capture, current, outOfSample, request, jev, vision: seen } of work) {
|
|
147
|
+
for (const { entry, node, capture, current, outOfSample, request, jev, vision: seen, triage: tri } of work) {
|
|
135
148
|
const source = evaluationSource(node, capture)
|
|
136
149
|
if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
|
|
137
150
|
if (styleTokens?.tokens && current && tokenCriteria(node).length) capture.checks = { ...capture.checks, ...storedValueChecks(node, capture, { ...styleTokens, recorded: !!capture.rechecked_at, only: (c) => !!c.check.token && STYLE_ASSERTS.has(c.check.assert) }) }
|
|
138
|
-
let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds, environment })
|
|
151
|
+
let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, triage: tri, cascade: !!tset?.cascade, thresholds: settings.thresholds, environment })
|
|
139
152
|
// pages outside the semantic sample carry only the deterministic results
|
|
140
153
|
if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
|
|
141
154
|
const evaluation = {
|
|
@@ -156,6 +169,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
156
169
|
thresholds: settings.thresholds,
|
|
157
170
|
state_chars: request ? JSON.stringify(request.state).length : null,
|
|
158
171
|
...(signalsOf(jev) ? { signals: signalsOf(jev) } : {}),
|
|
172
|
+
// advisory findings (#65, #66): the kind of problem, never a verdict
|
|
173
|
+
...(tri ? { triage: tri.findings ? { provider: 'cloudflare-clef', via: tri.via, model: tri.model, prompt_version: TRIAGE_PROMPT_VERSION, requests: tri.requests, cached: tri.cached, ms: tri.ms } : { provider: 'cloudflare-clef', skipped: tri.skipped || null, error: tri.error || null }, ...(tri.findings?.length ? { findings: tri.findings } : {}) } : {}),
|
|
159
174
|
...(seen ? { vision: seen.answers ? { provider: seen.provider, via: seen.via, model: seen.model, prompt_version: VISION_PROMPT_VERSION, cached: seen.cached, usage: seen.usage || null, answers: seen.answers } : { provider: seen.provider, skipped: seen.skipped || null, error: seen.error || null } } : {}),
|
|
160
175
|
criteria
|
|
161
176
|
}
|
|
@@ -165,8 +180,11 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
165
180
|
for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes${c.reason_source === 'model' ? `: ${c.reason}` : ''}` : c.note) || null, ...(c.reason ? { reason: c.reason } : {}), ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
|
-
} finally { if (drift?.browser) await drift.browser.close(); await vision?.close() }
|
|
169
|
-
|
|
183
|
+
} finally { if (drift?.browser) await drift.browser.close(); await vision?.close(); await triage?.close() }
|
|
184
|
+
// triage is on by default but stays silent where nobody asked for it and it did not run (experiment off, no service)
|
|
185
|
+
if (report.triage && !report.triage.screens && config.evaluators?.triage == null) delete report.triage
|
|
186
|
+
if (report.triage) { const ms = report.triage.ms.filter((x) => x != null).sort((a, b) => a - b); report.triage.median_ms = ms.length ? ms[Math.floor(ms.length / 2)] : null; delete report.triage.ms }
|
|
187
|
+
const { hostedStop, visionStop, triageStop, ...out } = report
|
|
170
188
|
return { ...out, outdated: [...report.outdated] }
|
|
171
189
|
}
|
|
172
190
|
|
|
@@ -249,7 +267,7 @@ function visionContext ({ config, root, settings, report, fetchImpl }) {
|
|
|
249
267
|
const queue = []
|
|
250
268
|
for (const w of work) {
|
|
251
269
|
const semantic = (w.node.criteria || []).filter((c) => c.check.type === 'semantic' && w.request.questions[c.id])
|
|
252
|
-
const crit = visionCriteria({ criteria: semantic, answers: w.jev?.response?.answers, settings })
|
|
270
|
+
const crit = visionCriteria({ criteria: semantic, answers: w.jev?.response?.answers, settings }).filter((c) => !w.triageDecided?.has(c.id))
|
|
253
271
|
if (crit.length) queue.push({ w, crit })
|
|
254
272
|
}
|
|
255
273
|
await Promise.all(Array.from({ length: Math.max(1, Math.min(settings.concurrency || 3, queue.length)) }, async () => {
|
|
@@ -281,6 +299,91 @@ function visionContext ({ config, root, settings, report, fetchImpl }) {
|
|
|
281
299
|
}
|
|
282
300
|
}
|
|
283
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The triage step of an evaluation (#65): one Clef request per screen (page tiles), and for a step with a recording
|
|
304
|
+
* up to `frames_per_step` chosen frames in requests of four. Uncertain semantic criteria ride along as extra
|
|
305
|
+
* questions (#71). Fills `w.triage` with merged findings; a stopped service (experiment off, allowance) ends triage
|
|
306
|
+
* for this evaluation with one note in the report.
|
|
307
|
+
*/
|
|
308
|
+
function triageContext ({ config, root, settings, report, fetchImpl, uncertain = [0.15, 0.85], thresholds = { pass: 0.85, fail: 0.15 } }) {
|
|
309
|
+
const cacheDir = join(resolve(root, config.output), '.cache', 'triage')
|
|
310
|
+
const hosted = hostedTriageSender(config, report)
|
|
311
|
+
let browser = null; let pagePromise = null
|
|
312
|
+
const page = () => (pagePromise ||= (async () => {
|
|
313
|
+
const { launchEphemeral } = await import('./browser.mjs')
|
|
314
|
+
browser = await launchEphemeral(config)
|
|
315
|
+
const p = await browser.newPage()
|
|
316
|
+
await p.setContent('<!doctype html><title>triage</title>')
|
|
317
|
+
return p
|
|
318
|
+
})())
|
|
319
|
+
const allowCall = () => { if (report.triage.calls >= (settings.max_calls ?? 200)) return false; report.triage.calls++; return true }
|
|
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
|
|
323
|
+
const unavailable = !own && !process.env[config.share?.token_env || 'KALOKO_TOKEN'] ? 'no service token (KALOKO_TOKEN)' : null
|
|
324
|
+
let framesLeft = settings.frames_per_run
|
|
325
|
+
const ask = async (w, payload, where) => {
|
|
326
|
+
const r = await askTriage({ payload, settings, cacheDir, where, fetchImpl, hosted, allowCall })
|
|
327
|
+
if (r.findings) { if (r.cached) report.triage.cached++; report.triage.via[r.via] = (report.triage.via[r.via] || 0) + 1; report.triage.ms.push(r.ms) }
|
|
328
|
+
else if (!report.triage.skipped) report.triage.skipped = r.skipped || r.error
|
|
329
|
+
return r
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
async ask (work, runDir, entries) {
|
|
333
|
+
if (unavailable) { report.triage.skipped = unavailable; return }
|
|
334
|
+
if (!own && !(await hosted.ready())) { report.triage.skipped = report.triageStop; return }
|
|
335
|
+
const queue = [...work]
|
|
336
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(3, queue.length)) }, async () => {
|
|
337
|
+
for (let w = queue.shift(); w; w = queue.shift()) {
|
|
338
|
+
if (report.triageStop) break
|
|
339
|
+
const file = w.capture.files?.image || w.capture.files?.png
|
|
340
|
+
if (!file) continue
|
|
341
|
+
try {
|
|
342
|
+
const [lo, hi] = uncertain
|
|
343
|
+
const criteria = (w.node.criteria || []).filter((c) => c.check.type === 'semantic' && c.check.about !== 'console' && w.request?.questions?.[c.id])
|
|
344
|
+
.filter((c) => { const s = w.jev?.response?.answers?.[c.id]?.noul; return s != null && s > lo && s < hi })
|
|
345
|
+
.map((c) => ({ id: c.id, question: c.check.question }))
|
|
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 } }
|
|
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 } })
|
|
348
|
+
const tiles = images.slice(0, settings.max_tiles || 2).map((i) => `data:${i.media_type};base64,${i.data}`)
|
|
349
|
+
report.triage.screens++
|
|
350
|
+
const lists = []; const results = []
|
|
351
|
+
const screen = await ask(w, { kind: 'screen', context, criteria, images: tiles }, {})
|
|
352
|
+
results.push(screen)
|
|
353
|
+
if (screen.findings) lists.push(screen.findings)
|
|
354
|
+
// the recording of this capture (same walk), as frames
|
|
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()
|
|
356
|
+
if (rec && framesLeft > 0 && settings.frames_per_step > 0 && !report.triageStop) {
|
|
357
|
+
const side = await readJson(join(runDir, rec.file), null)
|
|
358
|
+
const video = side?.files?.webm || side?.files?.mp4
|
|
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 })
|
|
362
|
+
framesLeft -= frames.length; report.triage.frames += frames.length
|
|
363
|
+
for (const batch of frameBatches(frames)) {
|
|
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) })
|
|
365
|
+
results.push(r)
|
|
366
|
+
if (r.findings) lists.push(r.findings)
|
|
367
|
+
}
|
|
368
|
+
} catch (error) { report.triage.frameErrors = (report.triage.frameErrors || 0) + 1 }
|
|
369
|
+
}
|
|
370
|
+
const done = results.filter((r) => r.findings)
|
|
371
|
+
if (!done.length) { w.triage = { skipped: results[0]?.skipped || null, error: results[0]?.error || null }; continue }
|
|
372
|
+
const findings = mergeFindings([...lists, probeFindings(w.capture)])
|
|
373
|
+
report.triage.findings += findings.length; report.triage.shown += findings.filter((f) => f.shown).length
|
|
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 }
|
|
375
|
+
// cascade (#71): criteria Clef is sure about skip the vision evaluator
|
|
376
|
+
if (settings.cascade) w.triageDecided = new Set(Object.entries(screen.criteria || {}).filter(([, p]) => p <= thresholds.fail || p >= thresholds.pass).map(([id]) => id))
|
|
377
|
+
} catch (error) {
|
|
378
|
+
w.triage = { error: String(error.message || error).slice(0, 300) }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}))
|
|
382
|
+
},
|
|
383
|
+
async close () { if (browser) await browser.close().catch(() => {}) }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
284
387
|
/**
|
|
285
388
|
* Sends a JEV request through the organization's service (hosted evaluation) when a service token is set.
|
|
286
389
|
* An allowance used up or a service without hosting stops further hosted calls for this run with one message.
|
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
|
package/src/lib/mail.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// E-mails of a flow, from wherever the project's stack puts them. Every source returns the same
|
|
2
|
-
// message shape: { id, subject, from, to: [address], date, html, text }.
|
|
2
|
+
// message shape: { id, subject, from, to: [address], cc: [address], date, html, text }.
|
|
3
3
|
// mailpit — built in (REST API), optional gate auth;
|
|
4
4
|
// script — a project module for anything else (letter_opener, Mailosaur, IMAP, the app's own API);
|
|
5
5
|
// file — `kaloko mail --file`: a message the agent obtained itself (.eml, .html, .json, .txt).
|
|
@@ -40,7 +40,7 @@ export function mailpit (mail) {
|
|
|
40
40
|
hitId = hit.ID
|
|
41
41
|
}
|
|
42
42
|
const m = await get(`/api/v1/message/${encodeURIComponent(hitId)}`)
|
|
43
|
-
return { id: m.ID, subject: m.Subject || '', from: address(m.From), to: (m.To || []).map((t) => t.Address), date: m.Date, html: m.HTML || null, text: m.Text || null }
|
|
43
|
+
return { id: m.ID, subject: m.Subject || '', from: address(m.From), to: (m.To || []).map((t) => t.Address), cc: (m.Cc || []).map((t) => t.Address), date: m.Date, html: m.HTML || null, text: m.Text || null }
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -95,6 +95,7 @@ export function normalize (m) {
|
|
|
95
95
|
subject: m.subject ?? m.Subject ?? '',
|
|
96
96
|
from: typeof from === 'object' && from ? address(from) : from || '',
|
|
97
97
|
to: list(m.to ?? (m.To || []).map?.((t) => t.Address ?? t)),
|
|
98
|
+
cc: list(m.cc ?? (m.Cc || []).map?.((t) => t.Address ?? t)),
|
|
98
99
|
date: m.date ?? m.Date ?? null,
|
|
99
100
|
html: m.html ?? m.HTML ?? null,
|
|
100
101
|
text: m.text ?? m.Text ?? null
|
|
@@ -109,8 +110,8 @@ export async function readMessageFile (file) {
|
|
|
109
110
|
if (ext === '.eml' || ext === '.msg' || ext === '.mbox') return parseEml(raw)
|
|
110
111
|
if (ext === '.json') return normalize(JSON.parse(raw.toString('utf8')))
|
|
111
112
|
const body = raw.toString('utf8')
|
|
112
|
-
if (ext === '.html' || ext === '.htm') return { id: '', subject: (body.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim(), from: '', to: [], date: null, html: body, text: null }
|
|
113
|
-
return { id: '', subject: '', from: '', to: [], date: null, html: null, text: body }
|
|
113
|
+
if (ext === '.html' || ext === '.htm') return { id: '', subject: (body.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim(), from: '', to: [], cc: [], date: null, html: body, text: null }
|
|
114
|
+
return { id: '', subject: '', from: '', to: [], cc: [], date: null, html: null, text: body }
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
// --- RFC 5322 / MIME, enough for what mailers send: multipart, base64, quoted-printable, charsets ---
|
|
@@ -175,6 +176,7 @@ export function parseEml (raw) {
|
|
|
175
176
|
subject: h('subject'),
|
|
176
177
|
from: h('from'),
|
|
177
178
|
to: list(h('to')),
|
|
179
|
+
cc: list(h('cc')),
|
|
178
180
|
date: top.headers.date ? new Date(top.headers.date).toISOString() : null,
|
|
179
181
|
html: found.html ?? null,
|
|
180
182
|
text: found.text ?? null
|