kaloko 0.4.11 → 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.
- package/README.md +45 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +42 -2
- package/schemas/run.v1.json +33 -2
- package/schemas/scenario.v1.json +107 -16
- package/skills/kaloko/SKILL.md +8 -2
- package/skills/kaloko/reference/acceptance.md +70 -1
- package/skills/kaloko/reference/apps.md +103 -8
- package/skills/kaloko/reference/ci.md +3 -1
- package/skills/kaloko/reference/design.md +39 -0
- package/skills/kaloko/reference/devices.md +109 -0
- package/skills/kaloko/reference/environments.md +6 -0
- package/skills/kaloko/reference/execution.md +6 -0
- package/src/cli.mjs +326 -190
- package/src/lib/access.mjs +5 -2
- package/src/lib/apps/devices.mjs +187 -0
- package/src/lib/apps/drivers.mjs +11 -3
- package/src/lib/apps/ios-device.mjs +78 -0
- package/src/lib/apps/tree.mjs +9 -1
- package/src/lib/apps/webview.mjs +54 -0
- package/src/lib/apps/windows.mjs +108 -0
- package/src/lib/apps/windows.ps1 +78 -0
- package/src/lib/assertions.mjs +300 -0
- package/src/lib/build.mjs +4 -1
- package/src/lib/capture.mjs +146 -119
- package/src/lib/ci.mjs +8 -4
- package/src/lib/design-cli.mjs +35 -6
- package/src/lib/design.mjs +3 -1
- package/src/lib/devices.mjs +85 -0
- package/src/lib/devspec.mjs +3 -2
- package/src/lib/dimensions.mjs +326 -0
- package/src/lib/doctor.mjs +31 -6
- package/src/lib/drift.mjs +8 -4
- package/src/lib/evaluate.mjs +25 -7
- package/src/lib/import.mjs +2 -1
- package/src/lib/jev.mjs +3 -1
- package/src/lib/manifest-core.mjs +12 -3
- package/src/lib/probe.mjs +60 -0
- package/src/lib/recheck.mjs +6 -1
- package/src/lib/scenario.mjs +3 -0
- package/src/lib/stability-core.mjs +4 -2
- package/src/lib/walk-dimensions.mjs +83 -0
- package/viewer/index.html +282 -19
|
@@ -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
|
+
}
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -33,8 +33,11 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
|
|
|
33
33
|
* @param {Function} [o.fetchImpl]
|
|
34
34
|
* @param {Function} [o.launchChrome] async () => { version } — throws when Chrome is missing
|
|
35
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)
|
|
36
39
|
*/
|
|
37
|
-
export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, videoToolsImpl = null }) {
|
|
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 }) {
|
|
38
41
|
const checks = []
|
|
39
42
|
const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
|
|
40
43
|
|
|
@@ -45,6 +48,10 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
45
48
|
if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the organization slug from kaloko.app).')
|
|
46
49
|
|
|
47
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
|
+
}
|
|
48
55
|
|
|
49
56
|
for (const name of Object.keys(config.environments || {})) {
|
|
50
57
|
let e
|
|
@@ -53,13 +60,23 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
53
60
|
const free = await portFree(port)
|
|
54
61
|
add(`port ${name}`, true, `${port} ${free ? 'free' : 'in use (shared browser running, or another project)'}`)
|
|
55
62
|
const base = typeof e.base_url === 'string' ? e.base_url : Object.values(e.base_url || {})[0]
|
|
56
|
-
// apps: the tools their driver needs
|
|
63
|
+
// apps: the tools their driver needs on this operating system, with what to install
|
|
57
64
|
for (const p of Object.keys(e.app || {})) {
|
|
58
|
-
const t = await appTools()
|
|
59
|
-
if (p === 'android')
|
|
60
|
-
|
|
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.')
|
|
61
78
|
if (p === 'desktop') {
|
|
62
|
-
if (
|
|
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 }
|
|
63
80
|
const perm = await macPermissions(false).catch(() => null)
|
|
64
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.')
|
|
65
82
|
if (fix && perm && (!perm.screen || !perm.accessibility)) await macPermissions(true).catch(() => {})
|
|
@@ -151,6 +168,14 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
151
168
|
return { ok: checks.every((c) => c.ok), checks }
|
|
152
169
|
}
|
|
153
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
|
+
|
|
154
179
|
async function defaultLaunch (config) {
|
|
155
180
|
const { chromium } = await import('playwright')
|
|
156
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
|
|
85
|
-
const
|
|
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
|
-
|
|
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)
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -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
|
-
|
|
294
|
-
|
|
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')
|
package/src/lib/import.mjs
CHANGED
|
@@ -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
|
-
|
|
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 } }
|
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:
|
|
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]
|
|
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] || {})
|
|
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
|
}
|