kaloko 0.4.10 → 0.5.0

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.
Files changed (52) hide show
  1. package/README.md +64 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +70 -2
  4. package/schemas/run.v1.json +33 -2
  5. package/schemas/scenario.v1.json +130 -17
  6. package/skills/kaloko/SKILL.md +18 -5
  7. package/skills/kaloko/reference/acceptance.md +70 -1
  8. package/skills/kaloko/reference/apps.md +103 -8
  9. package/skills/kaloko/reference/ci.md +3 -1
  10. package/skills/kaloko/reference/design.md +39 -0
  11. package/skills/kaloko/reference/devices.md +109 -0
  12. package/skills/kaloko/reference/environments.md +6 -0
  13. package/skills/kaloko/reference/execution.md +6 -0
  14. package/skills/kaloko/reference/mcp.md +1 -0
  15. package/skills/kaloko/reference/recording.md +95 -0
  16. package/src/cli.mjs +381 -167
  17. package/src/lib/access.mjs +5 -2
  18. package/src/lib/anim.mjs +125 -0
  19. package/src/lib/apps/devices.mjs +187 -0
  20. package/src/lib/apps/drivers.mjs +11 -3
  21. package/src/lib/apps/ios-device.mjs +78 -0
  22. package/src/lib/apps/recording-native.mjs +212 -0
  23. package/src/lib/apps/tree.mjs +9 -1
  24. package/src/lib/apps/webview.mjs +54 -0
  25. package/src/lib/apps/windows.mjs +108 -0
  26. package/src/lib/apps/windows.ps1 +78 -0
  27. package/src/lib/assertions.mjs +300 -0
  28. package/src/lib/build.mjs +63 -4
  29. package/src/lib/capture.mjs +146 -119
  30. package/src/lib/ci.mjs +8 -4
  31. package/src/lib/design-cli.mjs +35 -6
  32. package/src/lib/design.mjs +3 -1
  33. package/src/lib/devices.mjs +85 -0
  34. package/src/lib/devspec.mjs +3 -2
  35. package/src/lib/dimensions.mjs +326 -0
  36. package/src/lib/doctor.mjs +35 -6
  37. package/src/lib/drift.mjs +8 -4
  38. package/src/lib/evaluate.mjs +25 -7
  39. package/src/lib/import.mjs +3 -2
  40. package/src/lib/jev.mjs +3 -1
  41. package/src/lib/manifest-core.mjs +12 -3
  42. package/src/lib/probe.mjs +60 -0
  43. package/src/lib/recheck.mjs +6 -1
  44. package/src/lib/recording.mjs +373 -0
  45. package/src/lib/scenario.mjs +3 -0
  46. package/src/lib/stability-core.mjs +4 -2
  47. package/src/lib/trace.mjs +109 -0
  48. package/src/lib/video-cli.mjs +209 -0
  49. package/src/lib/video.mjs +427 -0
  50. package/src/lib/walk-dimensions.mjs +83 -0
  51. package/src/lib/webm.mjs +144 -0
  52. package/viewer/index.html +467 -25
@@ -0,0 +1,326 @@
1
+ // Run dimensions beyond locale and viewport (batch E): browser engine, colour scheme, reduced motion, forced colours
2
+ // and display mode (installed PWA), plus device presets in `viewports:`.
3
+ //
4
+ // Every combination is one capture key, so the run layout and the service stay as they are
5
+ // (steps/<node>/<locale>/<key>.*): the key is the viewport name followed by the values that differ from the
6
+ // default, joined by dots — `mobile`, `mobile.webkit`, `iphone-15.webkit.dark.reduce`. A run without dimensions has
7
+ // plain viewport keys, and a capture without a browser in its key is Chromium (every run before batch E).
8
+ // `scope.variants` names the dimensions of each key; `scope.dimensions` lists the values for the canvas switchers.
9
+ import { UserError } from './util.mjs'
10
+ import { devicePreset, emulationOf, presetSummary, presetNames } from './devices.mjs'
11
+
12
+ export const BROWSERS = ['chromium', 'webkit', 'firefox']
13
+ export const COLOR_SCHEMES = ['light', 'dark']
14
+ export const REDUCED_MOTION = ['no-preference', 'reduce']
15
+ export const FORCED_COLORS = ['none', 'active']
16
+ export const DISPLAY_MODES = ['browser', 'standalone']
17
+
18
+ /** The dimensions, their default value (left out of keys) and the key suffix of the other values. */
19
+ export const DIMENSIONS = [
20
+ { name: 'browser', list: 'browsers', values: BROWSERS, default: 'chromium', suffix: (v) => v },
21
+ { name: 'color_scheme', list: 'color_schemes', values: COLOR_SCHEMES, default: 'light', suffix: (v) => v },
22
+ { name: 'reduced_motion', list: 'reduced_motion', values: REDUCED_MOTION, default: 'no-preference', suffix: () => 'reduce' },
23
+ { name: 'forced_colors', list: 'forced_colors', values: FORCED_COLORS, default: 'none', suffix: () => 'forced' },
24
+ { name: 'display_mode', list: 'display_modes', values: DISPLAY_MODES, default: 'browser', suffix: (v) => v }
25
+ ]
26
+ const ALIASES = { chrome: 'chromium', safari: 'webkit', gecko: 'firefox', 'no-motion': 'reduce', reduced: 'reduce', 'no_preference': 'no-preference', high_contrast: 'active', 'high-contrast': 'active', forced: 'active', pwa: 'standalone', installed: 'standalone' }
27
+
28
+ const asList = (v) => v == null ? null : Array.isArray(v) ? v.map(String) : String(v).split(',').map((s) => s.trim()).filter(Boolean)
29
+
30
+ /** Normalises one dimension's list (CLI string or YAML array); unknown values are a UserError naming the valid ones. */
31
+ export function dimensionValues (dim, raw) {
32
+ const d = DIMENSIONS.find((x) => x.name === dim || x.list === dim)
33
+ const list = asList(raw)
34
+ if (!list?.length) return null
35
+ const out = []
36
+ for (const v0 of list) {
37
+ const v = ALIASES[v0.toLowerCase()] || v0.toLowerCase()
38
+ if (!d.values.includes(v)) throw new UserError(`Unknown ${d.list.replace(/_/g, ' ')} value "${v0}". Use: ${d.values.join(', ')}.`)
39
+ if (!out.includes(v)) out.push(v)
40
+ }
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * The run's dimensions from the CLI (--browsers, --color-schemes, --reduced-motion, --forced-colors,
46
+ * --display-modes), the scenario and the config's `walk:` block, in that order. Lists that are not set stay null:
47
+ * nothing is emulated for them.
48
+ */
49
+ export function dimensionsFrom ({ args = {}, scenario = {}, config = {} } = {}) {
50
+ const walk = config?.walk || {}
51
+ const out = {}
52
+ for (const d of DIMENSIONS) {
53
+ const flag = d.list.replace(/_/g, '-')
54
+ const raw = args[flag] ?? args[d.list] ?? scenario?.[d.list] ?? walk[d.list]
55
+ out[d.list] = dimensionValues(d.name, raw)
56
+ }
57
+ return out
58
+ }
59
+
60
+ /**
61
+ * Viewports of a scenario: { name: [w, h] } as before, or names of device presets — as an array
62
+ * (`[iphone-15, ipad-landscape, desktop]`) or as values (`phone: iphone-15`, `tablet: { device: ipad-portrait }`,
63
+ * `tv: { device: tv-1080p, width: 1280, height: 720 }`). Returns { viewports: { name: [w, h] }, devices: { name: preset } }.
64
+ * `fullScreen` (Design environments) sizes presets to the whole screen.
65
+ */
66
+ export function resolveViewports (spec, { fullScreen = false, fallback = { desktop: [1280, 800], mobile: [390, 844] } } = {}) {
67
+ const viewports = {}; const devices = {}
68
+ const entries = spec == null ? Object.entries(fallback) : Array.isArray(spec) ? spec.map((s) => (typeof s === 'string' ? [s, s] : Object.entries(s)[0])) : Object.entries(spec)
69
+ for (const [name, value] of entries) {
70
+ if (Array.isArray(value)) { viewports[name] = [Number(value[0]), Number(value[1])]; continue }
71
+ const ref = typeof value === 'string' ? { device: value } : value || {}
72
+ const preset = devicePreset(ref.device, { fullScreen: ref.full_screen ?? fullScreen })
73
+ if (!preset) {
74
+ // a bare name that is no preset, e.g. `viewports: [desktop]`: the default size of that name
75
+ if (typeof value === 'string' && fallback[value]) { viewports[name] = fallback[value]; continue }
76
+ throw new UserError(`Viewport "${name}": unknown device "${ref.device}". Presets: ${presetNames().join(', ')} (and <preset>-landscape).`)
77
+ }
78
+ viewports[name] = [Number(ref.width) || preset.size[0], Number(ref.height) || preset.size[1]]
79
+ devices[name] = presetSummary({ ...preset, size: viewports[name] })
80
+ devices[name].emulation = emulationOf(preset)
81
+ }
82
+ return { viewports, devices }
83
+ }
84
+
85
+ /**
86
+ * Devices of an app scenario (`devices: [pixel-8-api-34, iphone-15-ios-18]`) as capture keys: the size and frame
87
+ * come from the longest preset the name starts with (pixel-8, iphone-15), else a common phone of the platform.
88
+ */
89
+ export function appDevices (ids, platform) {
90
+ const viewports = {}; const devices = {}
91
+ for (const id of ids || []) {
92
+ const parts = String(id).toLowerCase().split('-')
93
+ let preset = null
94
+ for (let n = parts.length; n > 0 && !preset; n--) preset = devicePreset(parts.slice(0, n).join('-'), { fullScreen: true })
95
+ preset ||= devicePreset(platform === 'ios' ? 'iphone-15' : platform === 'android' ? 'pixel-8' : 'laptop', { fullScreen: true })
96
+ viewports[id] = preset.size
97
+ devices[id] = { ...presetSummary(preset), name: id, title: preset.name === id ? preset.title : id, app: true }
98
+ }
99
+ return { viewports, devices }
100
+ }
101
+
102
+ /** The capture key of a viewport and dimension values: defaults are left out. */
103
+ export function variantKey (viewport, dims = {}) {
104
+ const parts = [viewport]
105
+ for (const d of DIMENSIONS) { const v = dims[d.name]; if (v && v !== d.default) parts.push(d.suffix(v)) }
106
+ return parts.join('.')
107
+ }
108
+
109
+ /**
110
+ * Scope of a run: every combination of viewport and dimension values as a capture key with its size, and the
111
+ * dimensions of each key. Without dimensions and presets it is the plain { locales, viewports } of before.
112
+ */
113
+ export function expandScope ({ locales, viewports, devices = {}, dims = {} }) {
114
+ const lists = DIMENSIONS.map((d) => [d, dims[d.list] || null])
115
+ const used = lists.filter(([, l]) => l)
116
+ const loc = locales ? { locales } : {}
117
+ if (!used.length && !Object.keys(devices).length) return { ...loc, viewports }
118
+ const out = {}; const variants = {}
119
+ let combos = [{}]
120
+ for (const [d, l] of used) combos = combos.flatMap((c) => l.map((v) => ({ ...c, [d.name]: v })))
121
+ for (const [name, size] of Object.entries(viewports)) {
122
+ const device = devices[name] || null
123
+ for (const c of combos) {
124
+ const key = variantKey(name, c)
125
+ let s = size
126
+ // installed PWA: the whole screen of a preset without the browser's bars (the status bar stays)
127
+ if (c.display_mode === 'standalone' && device?.screen) s = [size[0], Math.max(size[1], device.screen[1] - (device.safe?.top || 0))]
128
+ out[key] = s
129
+ variants[key] = { viewport: name, browser: c.browser || 'chromium', ...c, ...(device ? { device: name } : {}) }
130
+ }
131
+ }
132
+ if (Object.keys(out).length > 400) throw new UserError(`This scope has ${Object.keys(out).length} combinations of viewport, browser, colour scheme and motion; keep it under 400 (pick fewer viewports or values).`)
133
+ const dimensions = Object.fromEntries(used.map(([d, l]) => [d.list, l]))
134
+ return { ...loc, viewports: out, variants, ...(used.length ? { dimensions } : {}), ...(Object.keys(devices).length ? { devices } : {}) }
135
+ }
136
+
137
+ /** Dimensions of a capture key: from the scope, or parsed from the key for runs that only have the key. */
138
+ export function variantOf (scope, key) {
139
+ const v = scope?.variants?.[key]
140
+ if (v) return { browser: 'chromium', ...v }
141
+ const [viewport, ...rest] = String(key).split('.')
142
+ const out = { viewport, browser: 'chromium' }
143
+ for (const part of rest) {
144
+ if (BROWSERS.includes(part)) out.browser = part
145
+ else if (part === 'dark' || part === 'light') out.color_scheme = part
146
+ else if (part === 'reduce') out.reduced_motion = 'reduce'
147
+ else if (part === 'forced') out.forced_colors = 'active'
148
+ else if (part === 'standalone') out.display_mode = 'standalone'
149
+ }
150
+ return out
151
+ }
152
+
153
+ /** The fields a capture record and a manifest capture carry for its dimensions (only those that were set). */
154
+ export function variantFields (scope, key) {
155
+ const v = scope?.variants?.[key]
156
+ if (!v) return {}
157
+ const out = { browser: v.browser || 'chromium' }
158
+ for (const d of DIMENSIONS.slice(1)) if (v[d.name]) out[d.name] = v[d.name]
159
+ if (v.device) out.device_preset = v.device
160
+ return out
161
+ }
162
+
163
+ /**
164
+ * Walk passes of a web run: one browser context per engine and device profile (pixel ratio, mobile, touch, user
165
+ * agent), each capturing only its own keys. The first pass (Chromium, default profile) is the walk of before.
166
+ * `only` limits the engines (walk --browsers).
167
+ */
168
+ export function passesOf (scope, { only = null } = {}) {
169
+ const keys = Object.keys(scope?.viewports || {})
170
+ const passes = new Map()
171
+ for (const key of keys) {
172
+ const v = variantOf(scope, key)
173
+ if (only && !only.includes(v.browser)) continue
174
+ const dev = v.device ? scope.devices?.[v.device] : null
175
+ const emu = dev?.emulation || null
176
+ const id = `${v.browser}|${emu ? emu.key : ''}`
177
+ if (!passes.has(id)) passes.set(id, { engine: v.browser, emulation: emu, keys: [], label: [v.browser !== 'chromium' ? v.browser : null, dev ? dev.title : null].filter(Boolean).join(' · ') })
178
+ passes.get(id).keys.push(key)
179
+ }
180
+ // the plain Chromium pass first: it may use the shared browser and its signed-in context
181
+ return [...passes.values()].sort((a, b) => (rank(a) - rank(b)))
182
+ }
183
+ const rank = (p) => (p.engine === 'chromium' ? 0 : BROWSERS.indexOf(p.engine) * 10) + (p.emulation ? 1 : 0)
184
+
185
+ /** Playwright context options of a pass (device profile); Firefox has no mobile viewport mode. */
186
+ export function contextOptionsOf (pass) {
187
+ const e = pass?.emulation
188
+ if (!e) return {}
189
+ return { deviceScaleFactor: e.dpr, hasTouch: e.touch, ...(e.ua ? { userAgent: e.ua } : {}), ...(pass.engine !== 'firefox' ? { isMobile: e.mobile } : {}) }
190
+ }
191
+
192
+ /**
193
+ * Keys a script or stack step asks for (`capture({ viewports: ['mobile'] })`) within a pass: a base viewport name
194
+ * selects its variants of the pass; a full key selects itself.
195
+ */
196
+ export function pickKeys (scope, passKeys, names) {
197
+ if (!names) return passKeys
198
+ return passKeys.filter((k) => names.includes(k) || names.includes(variantOf(scope, k).viewport))
199
+ }
200
+
201
+ /**
202
+ * Emulates the key's colour scheme, motion, forced colours and display mode on the page; returns an undo function.
203
+ * A full-screen device (Design, installed PWA) also gets its safe areas as CSS variables --kaloko-safe-top/right/
204
+ * bottom/left: browsers on a computer report env(safe-area-inset-*) as 0, so app screens use
205
+ * max(env(safe-area-inset-top), var(--kaloko-safe-top, 0px)) and look right in Kaloko and on the phone.
206
+ */
207
+ export async function applyVariant (page, variant, device = null) {
208
+ if (!variant) return async () => {}
209
+ const safe = device?.safe && (device.full_screen || variant.display_mode === 'standalone') ? device.safe : null
210
+ if (safe) await page.evaluate((s) => { for (const k of ['top', 'right', 'bottom', 'left']) document.documentElement.style.setProperty(`--kaloko-safe-${k}`, `${s[k] || 0}px`) }, safe).catch(() => {})
211
+ const media = {}
212
+ if (variant.color_scheme) media.colorScheme = variant.color_scheme
213
+ if (variant.reduced_motion) media.reducedMotion = variant.reduced_motion
214
+ if (variant.forced_colors) media.forcedColors = variant.forced_colors
215
+ if (Object.keys(media).length) await page.emulateMedia(media).catch(() => {})
216
+ let standalone = false
217
+ if (variant.display_mode === 'standalone') standalone = await page.evaluate(displayMode, 'standalone').catch(() => false)
218
+ return async () => {
219
+ if (safe) await page.evaluate(() => { for (const k of ['top', 'right', 'bottom', 'left']) document.documentElement.style.removeProperty(`--kaloko-safe-${k}`) }).catch(() => {})
220
+ if (Object.keys(media).length) await page.emulateMedia(Object.fromEntries(Object.keys(media).map((k) => [k, null]))).catch(() => {})
221
+ if (standalone) await page.evaluate(displayMode, null).catch(() => {})
222
+ }
223
+ }
224
+
225
+ /**
226
+ * In the page: makes `(display-mode: standalone)` rules apply and `browser` rules not, and answers matchMedia the
227
+ * same way (no engine emulates the display mode). Same-origin style sheets only; `null` restores the page.
228
+ */
229
+ function displayMode (mode) {
230
+ const w = window
231
+ if (!mode) {
232
+ for (const [rule, text] of w.__kalokoDisplayMode || []) { try { rule.media.mediaText = text } catch {} }
233
+ w.__kalokoDisplayMode = null
234
+ if (w.__kalokoMatchMedia) { w.matchMedia = w.__kalokoMatchMedia; w.__kalokoMatchMedia = null }
235
+ return true
236
+ }
237
+ const saved = []
238
+ const visit = (rules) => {
239
+ for (const r of rules) {
240
+ if (r.media && /display-mode/.test(r.media.mediaText)) {
241
+ saved.push([r, r.media.mediaText])
242
+ // per query of the list: another display mode drops the query, this one becomes an always-true feature
243
+ const queries = r.media.mediaText.split(',').map((q) => q.trim()).filter((q) => {
244
+ const m = q.match(/\(\s*display-mode\s*:\s*([a-z-]+)\s*\)/)
245
+ return !m || m[1] === mode
246
+ }).map((q) => q.replace(/\(\s*display-mode\s*:\s*[a-z-]+\s*\)/g, '(min-width: 0px)'))
247
+ try { r.media.mediaText = queries.length ? queries.join(', ') : 'not all' } catch {}
248
+ }
249
+ if (r.cssRules) visit(r.cssRules)
250
+ }
251
+ }
252
+ for (const s of document.styleSheets) { try { visit(s.cssRules) } catch {} }
253
+ w.__kalokoDisplayMode = saved
254
+ if (!w.__kalokoMatchMedia) {
255
+ w.__kalokoMatchMedia = w.matchMedia
256
+ w.matchMedia = (q) => (/display-mode/.test(q) ? { matches: new RegExp(`display-mode\\s*:\\s*${mode}`).test(q), media: q, addListener () {}, removeListener () {}, addEventListener () {}, removeEventListener () {}, onchange: null } : w.__kalokoMatchMedia.call(w, q))
257
+ }
258
+ return true
259
+ }
260
+
261
+ /** Engine name of a Playwright page (Electron and pages over CDP are Chromium). */
262
+ export function engineOf (page) {
263
+ try { return page.context().browser()?.browserType().name() || 'chromium' } catch { return 'chromium' }
264
+ }
265
+
266
+ /**
267
+ * Checks other engines cannot evaluate like Chromium does, with the reason the canvas shows ("not evaluated").
268
+ * WebKit moves Tab focus between form controls only (Safari's default), so its Tab walk would report false gaps.
269
+ */
270
+ export const CHROMIUM_ONLY = {
271
+ a11y_ax_names: 'needs Chromium: the accessibility tree is read over the DevTools protocol',
272
+ a11y_focus_visible: 'needs Chromium: focus rings are compared on Chromium screenshots',
273
+ perf_cls: 'needs Chromium: only Chromium reports layout shifts'
274
+ }
275
+ const WEBKIT_TAB = 'not on WebKit: Tab moves between form controls only (Safari\'s default), so the walk would miss links'
276
+ export function engineGap (engine, assert) {
277
+ if (!engine || engine === 'chromium') return null
278
+ if (CHROMIUM_ONLY[assert]) return CHROMIUM_ONLY[assert]
279
+ if (engine === 'webkit' && ['a11y_focus_order', 'a11y_keyboard_trap', 'a11y_skip_link'].includes(assert)) return WEBKIT_TAB
280
+ return null
281
+ }
282
+
283
+ /** Marks a capture's checks that the engine cannot evaluate; returns the criteria left for the keyboard walk. */
284
+ export function markEngineGaps (engine, criteria, checks) {
285
+ const left = []
286
+ for (const c of criteria || []) {
287
+ const why = c.check?.type === 'deterministic' ? engineGap(engine, c.check.assert) : null
288
+ if (why) checks[c.id] = { ok: null, na: true, evidence: `${why} (this capture: ${engine})` }
289
+ else left.push(c)
290
+ }
291
+ return left
292
+ }
293
+
294
+ /**
295
+ * The key of a reference capture to compare a capture with (design drift, compare across runs): the same key, else
296
+ * the same viewport and scheme in Chromium (a WebKit capture is checked against the Chromium design), else null.
297
+ */
298
+ export function referenceKey (key, available, scope = null) {
299
+ const have = new Set(available || [])
300
+ if (have.has(key)) return key
301
+ const v = variantOf(scope, key)
302
+ const chromium = variantKey(v.viewport, { ...v, browser: 'chromium' })
303
+ if (have.has(chromium)) return chromium
304
+ // a reference without a colour scheme dimension (light by default) answers for light captures
305
+ if (!v.color_scheme || v.color_scheme === 'light') { const plain = variantKey(v.viewport, { ...v, browser: 'chromium', color_scheme: null }); if (have.has(plain)) return plain }
306
+ return null
307
+ }
308
+
309
+ /** Token mode of a capture: its colour scheme when the token set has that mode, else the environment's mode. */
310
+ export const tokenModeOf = (variant, modes = [], fallback = null) => (variant?.color_scheme && modes.includes(variant.color_scheme) ? variant.color_scheme : fallback)
311
+
312
+ /** Engines a scope needs (for `kaloko browser install` hints and doctor). */
313
+ export const enginesOf = (scope) => [...new Set(Object.keys(scope?.viewports || {}).map((k) => variantOf(scope, k).browser))]
314
+
315
+ /** A Playwright browser type by engine name; a UserError with the install command when it is missing. */
316
+ export async function launchEngine (engine, { channel, headless = true } = {}) {
317
+ const pw = await import('playwright')
318
+ const type = pw[engine]
319
+ if (!type) throw new UserError(`Unknown browser engine ${engine}. Use: ${BROWSERS.join(', ')}.`)
320
+ try {
321
+ return await type.launch({ headless, ...(engine === 'chromium' && channel ? { channel } : {}) })
322
+ } catch (error) {
323
+ if (/Executable doesn't exist|install/i.test(error.message)) throw new UserError(`${engine} is not installed for Playwright. Run: kaloko browser install --browsers ${engine}`)
324
+ throw error
325
+ }
326
+ }
@@ -11,6 +11,7 @@ import { portFor } from './browser.mjs'
11
11
  import { DEFAULTS as JEV } from './jev.mjs'
12
12
  import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
13
13
  import { serviceUrl } from './service.mjs'
14
+ import { videoTools, recordingChecks } from './video.mjs'
14
15
 
15
16
  const portFree = (port) => new Promise((done) => {
16
17
  const s = createServer().once('error', () => done(false)).once('listening', () => s.close(() => done(true)))
@@ -32,8 +33,11 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
32
33
  * @param {Function} [o.fetchImpl]
33
34
  * @param {Function} [o.launchChrome] async () => { version } — throws when Chrome is missing
34
35
  * @param {object} [o.env] process.env
36
+ * @param {string[]} [o.engines] browser engines the scenarios and config.walk ask for (webkit, firefox)
37
+ * @param {Function} [o.launchEngine] async (engine) => { version } — throws when the engine is not installed
38
+ * @param {object} [o.tools] app tools found (default: looked up on PATH)
35
39
  */
36
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false }) {
40
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null }) {
37
41
  const checks = []
38
42
  const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
39
43
 
@@ -44,6 +48,10 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
44
48
  if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the organization slug from kaloko.app).')
45
49
 
46
50
  try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) { add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (npx playwright install chromium).' : 'Run: npx playwright install chromium') }
51
+ // cross-browser runs (batch E): the Playwright engines the scenarios and walk.browsers name
52
+ for (const engine of [...new Set([...(engines || []), ...(config.walk?.browsers || [])])].filter((e) => e !== 'chromium')) {
53
+ try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch { add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run: kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`) }
54
+ }
47
55
 
48
56
  for (const name of Object.keys(config.environments || {})) {
49
57
  let e
@@ -52,13 +60,23 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
52
60
  const free = await portFree(port)
53
61
  add(`port ${name}`, true, `${port} ${free ? 'free' : 'in use (shared browser running, or another project)'}`)
54
62
  const base = typeof e.base_url === 'string' ? e.base_url : Object.values(e.base_url || {})[0]
55
- // apps: the tools their driver needs
63
+ // apps: the tools their driver needs on this operating system, with what to install
56
64
  for (const p of Object.keys(e.app || {})) {
57
- const t = await appTools()
58
- if (p === 'android') add(`app ${name} android`, t.adb, t.adb ? `adb found · ${e.app.android.package || 'no package'}` : 'adb not found', 'Install Android platform-tools and start an emulator or connect a device (adb devices).')
59
- if (p === 'ios') add(`app ${name} ios`, t.simctl, t.simctl ? `simctl · ${t.idb ? 'idb for the UI tree and taps' : 'no idb: screen text by OCR, deep links only'}` : 'Xcode simctl not found', 'Install Xcode with an iOS Simulator runtime; optional idb (brew install facebook/fb/idb-companion, pip install fb-idb) for the UI tree and taps.')
65
+ const t = tools || await appTools()
66
+ if (p === 'android') {
67
+ const a = e.app.android
68
+ const needEmu = !!(a.avd || Object.values(a.devices || {}).some((d) => d.avd))
69
+ add(`app ${name} android`, t.adb && (!needEmu || t.emulator), [t.adb ? 'adb found' : 'adb not found', needEmu ? (t.emulator ? 'emulator found' : 'emulator not found') : null, a.connect ? `Wi-Fi device ${a.connect}` : null, a.package || 'no package', a.webview ? 'WebView pages over CDP' : null].filter(Boolean).join(' · '), !t.adb ? `Install Android platform-tools (${os === 'darwin' ? 'brew install --cask android-platform-tools' : os === 'win32' ? 'winget install Google.PlatformTools' : 'apt install android-tools-adb, or the SDK'}) and start an emulator or connect a device (adb devices).` : needEmu && !t.emulator ? 'Install the Android emulator (Android Studio → SDK Manager) or set app.android.emulator / ANDROID_HOME.' : '')
70
+ }
71
+ if (p === 'ios') {
72
+ const i = e.app.ios
73
+ if (os !== 'darwin') { add(`app ${name} ios`, false, 'the iOS Simulator and devicectl need macOS', 'Walk iOS on a Mac (or a macos CI runner); elsewhere import screens with kaloko capture --image.'); continue }
74
+ if (i.real || Object.values(i.devices || {}).some((d) => d.real)) add(`app ${name} ios device`, t.devicectl && (t.idevicescreenshot || t.pymobiledevice3), `${t.devicectl ? 'devicectl' : 'no devicectl'} · ${t.idevicescreenshot ? 'idevicescreenshot' : t.pymobiledevice3 ? 'pymobiledevice3' : 'no screenshot tool'} · no taps or UI tree (OCR)`, t.devicectl ? 'Install libimobiledevice (brew install libimobiledevice) or pymobiledevice3 (pipx install pymobiledevice3; iOS 17+: sudo pymobiledevice3 remote tunneld).' : 'Install Xcode 15 or newer (xcrun devicectl).')
75
+ if (!i.real) add(`app ${name} ios`, t.simctl, t.simctl ? `simctl · ${t.idb ? 'idb for the UI tree and taps' : 'no idb: screen text by OCR, deep links only'}` : 'Xcode simctl not found', 'Install Xcode with an iOS Simulator runtime; optional idb (brew install facebook/fb/idb-companion, pip install fb-idb) for the UI tree and taps.')
76
+ }
77
+ if (p === 'windows') add(`app ${name} windows`, os === 'win32' ? t.powershell : !!e.app.windows.powershell, os === 'win32' ? (t.powershell ? 'PowerShell · UI Automation tree, clicks and typing' : 'PowerShell not found') : 'Windows apps are walked on Windows', os === 'win32' ? (t.powershell ? '' : 'Windows PowerShell is part of Windows; set app.windows.powershell to pwsh if it was removed.') : 'Walk on a Windows machine or a windows-latest CI runner, or capture with kaloko capture --image --platform windows.')
60
78
  if (p === 'desktop') {
61
- if (process.platform !== 'darwin') { add(`app ${name} desktop`, false, 'built-in desktop capture needs macOS', 'On Windows or Linux capture with kaloko capture --image --tree.'); continue }
79
+ if (os !== 'darwin') { add(`app ${name} desktop`, false, 'built-in desktop capture needs macOS', os === 'win32' ? 'For a Windows app use platform: windows with app.windows (UI Automation).' : 'On Linux capture with kaloko capture --image --tree.'); continue }
62
80
  const perm = await macPermissions(false).catch(() => null)
63
81
  add(`app ${name} desktop`, !!perm?.screen, perm ? `Screen Recording ${perm.screen ? 'allowed' : 'not allowed'} · Accessibility ${perm.accessibility ? 'allowed (UI tree)' : 'not allowed (text by OCR)'}` : 'the macOS helper could not run', perm?.screen ? '' : 'Run kaloko doctor --fix to ask macOS, then turn this terminal on in System Settings → Privacy & Security → Screen Recording (and Accessibility for the UI tree) and restart it.')
64
82
  if (fix && perm && (!perm.screen || !perm.accessibility)) await macPermissions(true).catch(() => {})
@@ -144,9 +162,20 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
144
162
  else if (token && vision.hosted !== false) add('vision evaluator', true, `via Kaloko · model ${vision.model} · one vision question counts as ${VISION_WEIGHT}`)
145
163
  else add('vision evaluator', true, `${vision.api_key_env} not set (optional)`, `Criteria with vision: true and unsure answers stay with a person until ${vision.api_key_env} is set or ${tokenEnv} lets the service ask.`)
146
164
 
165
+ // recordings (batch D): ffmpeg and the device recorders, with install hints per OS
166
+ checks.push(...recordingChecks({ config, tools: await (videoToolsImpl || videoTools)() }))
167
+
147
168
  return { ok: checks.every((c) => c.ok), checks }
148
169
  }
149
170
 
171
+ async function defaultEngine (engine) {
172
+ const pw = await import('playwright')
173
+ const browser = await pw[engine].launch({ headless: true })
174
+ const version = browser.version()
175
+ await browser.close()
176
+ return { version }
177
+ }
178
+
150
179
  async function defaultLaunch (config) {
151
180
  const { chromium } = await import('playwright')
152
181
  const browser = await chromium.launch({ headless: true, channel: config.browser?.channel || undefined })
package/src/lib/drift.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // in-page checks, so combine() turns them into verdicts the usual way.
5
5
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
6
6
  import { join, resolve } from 'node:path'
7
- import { flattenTokens, tokenIndex, checkDeclaration } from './tokens.mjs'
7
+ import { flattenTokens, tokenIndex, checkDeclaration, modesOf } from './tokens.mjs'
8
+ import { referenceKey, tokenModeOf } from './dimensions.mjs'
8
9
  import { sha256 } from './util.mjs'
9
10
 
10
11
  export const DRIFT_ASSERTS = new Set(['design_pixels', 'design_structure', 'uses_tokens'])
@@ -81,8 +82,10 @@ export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
81
82
  const a = c.check.assert
82
83
  if (a === 'uses_tokens') {
83
84
  if (!ctx.tokens) { results[c.id] = { ok: null, evidence: 'No token set: push one with kaloko tokens push, or keep tokens.json in the design folder.' }; continue }
84
- const refStyles = !ctx.isDesign ? (await referenceStep(ctx, node.id, entry.locale))?.captures?.[entry.viewport]?.design?.styles : null
85
- const off = tokenOffenders(capture.design?.styles, ctx.tokens, ctx.mode, refStyles)
85
+ const refCaps = !ctx.isDesign ? (await referenceStep(ctx, node.id, entry.locale))?.captures : null
86
+ const refStyles = refCaps ? refCaps[referenceKey(entry.viewport, Object.keys(refCaps))]?.design?.styles : null
87
+ // a dark capture is checked against the dark mode of the token set (DTCG modes light/dark)
88
+ const off = tokenOffenders(capture.design?.styles, ctx.tokens, tokenModeOf({ color_scheme: capture.color_scheme }, modesOf(ctx.tokens), ctx.mode), refStyles)
86
89
  if (off == null) { results[c.id] = { ok: null, evidence: 'Capture has no style summary; capture this step again.' }; continue }
87
90
  results[c.id] = off.length
88
91
  ? { ok: false, evidence: `${off.length} value(s) outside the token set: ${off.slice(0, 5).map((o) => `${o.property} ${o.value}${o.suggest ? ` → ${o.suggest}` : ''}`).join('; ')}` }
@@ -91,7 +94,8 @@ export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
91
94
  }
92
95
  if (ctx.isDesign) { results[c.id] = { ok: null, evidence: 'This is the design itself; the check applies to implementation runs.' }; continue }
93
96
  if (!ref) { results[c.id] = { ok: null, evidence: ctx.referenceError || 'No design reference for this scenario yet: accept a version and set it as the design reference.' }; continue }
94
- const rc = ref.captures?.[entry.viewport]
97
+ // the same key, else the Chromium capture of that viewport and scheme: a WebKit page is checked against the design
98
+ const rc = ref.captures?.[referenceKey(entry.viewport, Object.keys(ref.captures || {}))]
95
99
  if (!rc) { results[c.id] = { ok: null, evidence: `The design reference has no ${entry.viewport} capture for ${entry.locale}.` }; continue }
96
100
  if (a === 'design_structure') {
97
101
  const diff = structureDiff(capture.design?.structure, rc.design?.structure)
@@ -7,6 +7,7 @@ import { prepareImages, visionPlan, askVision, hostedVisionSender } from './visi
7
7
  import { KEYBOARD_ASSERTS } from './keyboard.mjs'
8
8
  import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
9
9
  import { packHelp } from './pack-help.mjs'
10
+ import { tokenCriteria, storedValueChecks, STYLE_ASSERTS } from './assertions.mjs'
10
11
 
11
12
  /** Latest capture per node/locale/viewport. */
12
13
  export function latestCaptures (entries) {
@@ -31,7 +32,7 @@ export function combine ({ node, capture, jev, vision = null, thresholds }) {
31
32
  const r = capture.checks?.[c.id]
32
33
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
33
34
  const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '') || KEYBOARD_ASSERTS.has(c.check.assert)
34
- return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
35
+ return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(r?.na ? { na: true } : {}), ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
35
36
  }
36
37
  if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
37
38
  return { id: c.id, type: 'semantic', verdict: 'pass', evidence: 'no console errors', score: null, provider: null, note: null, reason: 'No console errors on this page, so there is nothing to triage.', reason_source: 'rule' }
@@ -72,6 +73,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
72
73
  const vision = vset ? visionContext({ config, root, settings: vset, report, fetchImpl }) : null
73
74
  // checks against the design reference and the token set (pack design, #42) need the service and a browser
74
75
  const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
76
+ // style checks against a token (style_equals token: color.brand.primary) use the project's token set
77
+ const styleTokens = scenario.nodes.some((n) => tokenCriteria(n).length) ? (drift ? { tokens: drift.tokens, mode: drift.mode } : await runTokens({ config, root })) : null
75
78
  try {
76
79
  const latest = latestCaptures(await readCaptures(run.dir))
77
80
  // stack steps: semantic criteria only on the first N pages (items.semantic_sample, default 20); cost stays bounded
@@ -122,6 +125,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
122
125
  for (const { entry, node, capture, current, outOfSample, request, jev, vision: seen } of work) {
123
126
  const source = evaluationSource(node, capture)
124
127
  if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
128
+ 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) }) }
125
129
  let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds })
126
130
  // pages outside the semantic sample carry only the deterministic results
127
131
  if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
@@ -285,17 +289,31 @@ function hostedSender (config, report) {
285
289
  }
286
290
  }
287
291
 
292
+ /** The design environment's folder and token mode, when the config has one. */
293
+ async function designEnv ({ config, root }) {
294
+ try {
295
+ const { environment } = await import('./config.mjs')
296
+ const d = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
297
+ if (d) return { designDir: resolve(root, d.serve), mode: d.tokens_mode || null }
298
+ } catch {}
299
+ return { designDir: null, mode: null }
300
+ }
301
+
302
+ /** The token set of the run without the design checks: the project's on the service, else the design folder's. */
303
+ async function runTokens ({ config, root }) {
304
+ let client = null
305
+ try { const { service } = await import('./service.mjs'); client = service(config) } catch {}
306
+ const { designDir, mode } = await designEnv({ config, root })
307
+ return { tokens: await loadTokens({ client, config, root, designDir }), mode }
308
+ }
309
+
288
310
  /** Context of the design checks: service client, token set, reference cache and a browser for pixel diffs. */
289
311
  async function driftContext ({ run, scenario, config, root }) {
290
312
  const isDesign = run.meta.environment.kind === 'design'
291
313
  const ctx = { org: config.org, scenarioId: scenario.id, isDesign, git: run.meta.git?.branch || null, references: new Map(), cacheDir: driftCacheDir(root, config.output), mode: null, tokens: null, client: null, page: null, browser: null }
292
314
  try { const { service } = await import('./service.mjs'); ctx.client = service(config) } catch (e) { ctx.referenceError = `Design checks need the service: ${e.message}` }
293
- let designDir = null
294
- try {
295
- const { environment } = await import('./config.mjs')
296
- const d = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
297
- if (d) { designDir = resolve(root, d.serve); ctx.mode = d.tokens_mode || null }
298
- } catch {}
315
+ const { designDir, mode } = await designEnv({ config, root })
316
+ ctx.mode = mode
299
317
  ctx.tokens = await loadTokens({ client: ctx.client, config, root, designDir })
300
318
  if (!isDesign && scenario.nodes.some((n) => (n.criteria || []).some((c) => c.check.assert === 'design_pixels'))) {
301
319
  const { launchEphemeral } = await import('./browser.mjs')
@@ -272,7 +272,8 @@ export function stepFor (item, nodes, map = {}) {
272
272
  /** The run viewport closest to the image's width (screens taken at 2× or 3× count by their CSS width). */
273
273
  export function viewportFor (size, viewports, wanted = null) {
274
274
  if (wanted && viewports[wanted]) return wanted
275
- const names = Object.keys(viewports)
275
+ // plain keys first: an imported screenshot is Chromium, light, unless --viewport names a key like mobile.dark
276
+ const names = Object.keys(viewports).sort((a, b) => a.includes('.') - b.includes('.'))
276
277
  if (!size || names.length === 1) return names[0]
277
278
  let best = names[0]; let bestD = Infinity
278
279
  for (const n of names) for (const k of [1, 2, 3]) { const d = Math.abs(size[0] / k - viewports[n][0]); if (d < bestD) { bestD = d; best = n } }
@@ -363,7 +364,7 @@ export async function attachImported ({ run, node, locale, item }) {
363
364
  return entry
364
365
  }
365
366
 
366
- /** Recordings are kept in the run folder (imported/), not uploaded: the canvas does not play them yet. */
367
+ /** Imported recordings are kept in the run folder (imported/), not attached to a step and not uploaded. */
367
368
  export async function keepVideos (run, videos) {
368
369
  const kept = []
369
370
  for (const v of [...new Set(videos)]) {
package/src/lib/jev.mjs CHANGED
@@ -42,7 +42,9 @@ export function buildRequest ({ node, capture, criteria, model, signals = false
42
42
  screen_purpose: purposeOf(node, capture),
43
43
  url: capture.url,
44
44
  locale: capture.locale,
45
- viewport: capture.viewport,
45
+ // the viewport name without browser and scheme (mobile.webkit.dark → mobile): the same page text in another
46
+ // engine or scheme is the same question, answered once from the cache
47
+ viewport: capture.viewport == null ? capture.viewport : String(capture.viewport).split('.')[0],
46
48
  page: capture.state,
47
49
  ...(Object.keys(scopes).length ? { scopes } : {}),
48
50
  ...(criteria.some((c) => c.check.about === 'console') ? { console_errors: (capture.console_errors || []).slice(0, 30) } : {})
@@ -21,6 +21,12 @@ export function targetOf (c) {
21
21
  return null
22
22
  }
23
23
 
24
+ /**
25
+ * Verdicts that count for a criterion: a check another browser engine cannot run (`na`, e.g. the accessibility tree
26
+ * outside Chromium) does not make a step partial when the engines that can run it gave a verdict.
27
+ */
28
+ export const counted = (entries) => { const own = entries.filter((e) => !e?.na); return (own.length ? own : entries).map((e) => e?.verdict ?? 'not_reached') }
29
+
24
30
  /** Node status from criterion verdicts. */
25
31
  export function statusOf (verdicts, captured) {
26
32
  if (!captured) return 'skip'
@@ -39,6 +45,7 @@ export function slimCapture (cap) {
39
45
  ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.platform ? { platform: cap.platform, device: cap.device || null } : {}),
40
46
  ...(cap.design ? { design: cap.design } : {}), ...(cap.capture ? { capture: cap.capture } : {}),
41
47
  ...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {}),
48
+ ...Object.fromEntries(['browser', 'color_scheme', 'reduced_motion', 'forced_colors', 'display_mode', 'device_preset'].filter((k) => cap[k]).map((k) => [k, cap[k]])),
42
49
  ...(cap.ignore?.length ? { ignore: cap.ignore } : {}), ...(cap.unstable ? { unstable: cap.unstable } : {}), ...(cap.keyboard ? { keyboard: cap.keyboard } : {})
43
50
  }
44
51
  }
@@ -65,6 +72,8 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
65
72
  ;(capturesOut[c.locale] ||= {})[c.viewport] = {
66
73
  png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url ?? null,
67
74
  ...(cap.platform ? { platform: cap.platform, device: cap.device || null, tree: c.files.tree || null } : {}),
75
+ // run dimensions (batch E): engine, colour scheme, motion, forced colours, display mode, device preset
76
+ ...Object.fromEntries(['browser', 'color_scheme', 'reduced_motion', 'forced_colors', 'display_mode', 'device_preset'].filter((k) => cap[k]).map((k) => [k, cap[k]])),
68
77
  ...(cap.http_status ? { http_status: cap.http_status } : {}),
69
78
  ...(cap.capture && (cap.capture.scale !== 1 || cap.capture.format === 'png' || cap.capture.masked || cap.capture.annotations?.length) ? { quality: cap.capture } : {}), size: [cap.size.width, cap.size.height],
70
79
  boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 20), blocked_list: (cap.blocked_requests || []).slice(0, 20),
@@ -90,20 +99,20 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
90
99
  // semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
91
100
  // semantic answers carry their one-sentence reason (#17) and whether the evaluator saw the screenshot
92
101
  const seen = r.provider && /-vision$/.test(r.provider)
93
- loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
102
+ loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
94
103
  if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
95
104
  if (r.reason && (!agg.reason || (r.verdict !== 'pass' && agg.reason_verdict === 'pass'))) { agg.reason = r.reason; agg.reason_verdict = r.verdict }
96
105
  }
97
106
  }
98
107
  const criteria = [...perCriterion.values()].map(({ reason_verdict: _rv, ...agg }) => {
99
108
  for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
100
- const verdicts = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp].verdict))
109
+ const verdicts = counted(applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp])))
101
110
  return { ...agg, verdict: aggregate(verdicts) }
102
111
  })
103
112
  const byLocale = {}
104
113
  for (const l of locales) {
105
114
  const captured = !!capturesOut[l]
106
- const vs = criteria.map((c) => aggregate(Object.values(c.by_locale[l] || {}).map((v) => v.verdict)))
115
+ const vs = criteria.map((c) => aggregate(counted(Object.values(c.by_locale[l] || {}))))
107
116
  if (applicable.includes(l) && viewportNames.some((vp) => !capturesOut[l]?.[vp])) vs.push('not_reached')
108
117
  byLocale[l] = statusOf(vs, captured)
109
118
  }