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
package/src/lib/probe.mjs
CHANGED
|
@@ -268,6 +268,21 @@ export function probe ({ checks = [], highlights = [], scopes = [], maxChars = 9
|
|
|
268
268
|
// HTTP status of the document (Chrome 109+); null for pages set from stored HTML
|
|
269
269
|
const httpStatus = performance.getEntriesByType('navigation')[0]?.responseStatus || null
|
|
270
270
|
const results = {}
|
|
271
|
+
// computed values for style_equals / style_matches, decided outside the page (src/lib/assertions.mjs) and kept
|
|
272
|
+
// with the capture so a recheck can reuse them
|
|
273
|
+
const values = {}
|
|
274
|
+
let paint = null
|
|
275
|
+
// canonical colour through the canvas: names, hsl(), hex and rgb() all come out as #rrggbb or rgba(…)
|
|
276
|
+
const canvasColor = (v) => {
|
|
277
|
+
try {
|
|
278
|
+
paint ||= document.createElement('canvas').getContext('2d')
|
|
279
|
+
paint.fillStyle = '#010203'; paint.fillStyle = String(v)
|
|
280
|
+
const out = paint.fillStyle
|
|
281
|
+
return out === '#010203' && !/^#010203$/i.test(String(v).trim()) ? null : out
|
|
282
|
+
} catch { return null }
|
|
283
|
+
}
|
|
284
|
+
const isColor = (p) => /(^|-)color$|^fill$|^stroke$/.test(String(p || '').trim().toLowerCase())
|
|
285
|
+
const squash = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
271
286
|
for (const { id, check } of checks) {
|
|
272
287
|
try {
|
|
273
288
|
// optional flags (i, m, s); page text is innerText, which follows CSS text-transform (#54)
|
|
@@ -303,6 +318,50 @@ export function probe ({ checks = [], highlights = [], scopes = [], maxChars = 9
|
|
|
303
318
|
evidence = `${check.attr}=${v}`
|
|
304
319
|
break
|
|
305
320
|
}
|
|
321
|
+
case 'text_equals': {
|
|
322
|
+
// the element's whole text, whitespace collapsed; flags: i ignores case
|
|
323
|
+
const els = check.selector ? visibleAll(check.selector) : [document.body]
|
|
324
|
+
if (!els.length) { evidence = `no visible ${check.selector}`; break }
|
|
325
|
+
const ci = /i/.test(String(check.flags || ''))
|
|
326
|
+
const want = squash(check.value)
|
|
327
|
+
const same = (el) => ci ? squash(el.innerText).toLowerCase() === want.toLowerCase() : squash(el.innerText) === want
|
|
328
|
+
const hits = els.filter(same)
|
|
329
|
+
ok = check.all === true ? hits.length === els.length : hits.length > 0
|
|
330
|
+
const shown = check.all === true ? (els.find((el) => !same(el)) || els[0]) : (hits[0] || els[0])
|
|
331
|
+
const text = clean(shown.innerText).slice(0, 160)
|
|
332
|
+
evidence = `${els.length > 1 ? `${hits.length} of ${els.length} × ${check.selector}; ` : ''}"${text}" ${same(shown) ? '=' : '≠'} "${clean(want).slice(0, 160)}"`
|
|
333
|
+
break
|
|
334
|
+
}
|
|
335
|
+
case 'attr_matches': {
|
|
336
|
+
const els = [...document.querySelectorAll(check.selector)]
|
|
337
|
+
if (!els.length) { evidence = `no ${check.selector}`; break }
|
|
338
|
+
const test = (el) => { const v = el.getAttribute(check.attr); return v != null && re.test(v) }
|
|
339
|
+
const hits = els.filter(test)
|
|
340
|
+
ok = check.all === true ? hits.length === els.length : hits.length > 0
|
|
341
|
+
const shown = check.all === true ? (els.find((el) => !test(el)) || els[0]) : (hits[0] || els[0])
|
|
342
|
+
const v = shown.getAttribute(check.attr)
|
|
343
|
+
evidence = `${els.length > 1 ? `${hits.length} of ${els.length} × ${check.selector}; ` : ''}${check.attr}${v == null ? ' missing' : `="${clean(v).slice(0, 160)}"`} ${test(shown) ? 'matches' : 'does not match'} /${check.pattern}/`
|
|
344
|
+
break
|
|
345
|
+
}
|
|
346
|
+
case 'style_equals':
|
|
347
|
+
case 'style_matches': {
|
|
348
|
+
// visible matches; when none is visible (a check for display: none), every match
|
|
349
|
+
let els = visibleAll(check.selector)
|
|
350
|
+
if (!els.length) els = [...document.querySelectorAll(check.selector)]
|
|
351
|
+
const prop = String(check.property || '').trim().toLowerCase()
|
|
352
|
+
const color = isColor(prop)
|
|
353
|
+
const rec = { selector: check.selector, property: check.property, values: els.slice(0, 50).map((el) => { const v = squash(getComputedStyle(el).getPropertyValue(prop)); return { el: describe(el), value: v, ...(color ? { color: canvasColor(v) } : {}) } }), root_px: parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 }
|
|
354
|
+
if (check.token) {
|
|
355
|
+
// a token published as a CSS custom property (color.brand.primary → --color-brand-primary)
|
|
356
|
+
const name = `--${String(check.token).replace(/\./g, '-')}`
|
|
357
|
+
const v = squash(getComputedStyle(document.documentElement).getPropertyValue(name))
|
|
358
|
+
rec.token_var = v ? { name, value: v, ...(color ? { color: canvasColor(v) } : {}) } : null
|
|
359
|
+
}
|
|
360
|
+
if (check.value != null && color) rec.expected_color = canvasColor(check.value)
|
|
361
|
+
values[id] = rec
|
|
362
|
+
ok = null; evidence = 'decided outside the page'
|
|
363
|
+
break
|
|
364
|
+
}
|
|
306
365
|
case 'no_overflow': { const list = check.selector ? [...document.querySelectorAll(check.selector)].flatMap(overflowIn) : overflow; ok = list.length === 0; evidence = ok ? 'no horizontal overflow' : list.slice(0, 3).map((o) => `${o.el} right=${o.right}px > ${o.width}px`).join('; '); break }
|
|
307
366
|
case 'title_matches': ok = re.test(document.title); evidence = document.title; break
|
|
308
367
|
case 'meta_matches': { const el = document.querySelector(check.selector); const v = el?.getAttribute('content'); ok = v != null && re.test(v); evidence = v ?? `no ${check.selector}`; break }
|
|
@@ -426,6 +485,7 @@ export function probe ({ checks = [], highlights = [], scopes = [], maxChars = 9
|
|
|
426
485
|
http_status: httpStatus,
|
|
427
486
|
scoped,
|
|
428
487
|
checks: results,
|
|
488
|
+
...(Object.keys(values).length ? { values } : {}),
|
|
429
489
|
boxes,
|
|
430
490
|
overflow,
|
|
431
491
|
meta,
|
package/src/lib/recheck.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `kaloko evaluate --recheck` (#55): after a scenario fix, re-run the deterministic checks against the stored HTML of
|
|
2
2
|
// each capture instead of walking again (some steps cannot be repeated: data created, e-mails sent once, states that
|
|
3
3
|
// moved on). Page scripts stay off, so nothing in the stored page runs; checks that only the live page can answer
|
|
4
|
-
// (console errors, overflow, performance) keep their result from the capture.
|
|
4
|
+
// (console errors, overflow, performance) keep their result from the capture. Computed styles and js_equals
|
|
5
|
+
// expressions are decided again from the values recorded at capture, when the criterion reads the same thing.
|
|
5
6
|
import { readFile } from 'node:fs/promises'
|
|
6
7
|
import { join } from 'node:path'
|
|
7
8
|
import { readJson, writeJson } from './util.mjs'
|
|
@@ -13,6 +14,7 @@ import { contextOptions, prepareContext } from './access.mjs'
|
|
|
13
14
|
import { launchEphemeral } from './browser.mjs'
|
|
14
15
|
import { treeChecks } from './apps/tree.mjs'
|
|
15
16
|
import { KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
17
|
+
import { VALUE_ASSERTS, storedValueChecks } from './assertions.mjs'
|
|
16
18
|
|
|
17
19
|
// keyboard walk and accessibility tree (#19) need the live page too
|
|
18
20
|
export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || assert === 'links_ok' || assert === 'hreflang_pairs' || /^perf_/.test(assert) || KEYBOARD_ASSERTS.has(assert)
|
|
@@ -59,8 +61,11 @@ export async function recheckCaptures ({ run, scenario, config, root, env, only
|
|
|
59
61
|
await page.setContent(withBase(html, cap.url), { waitUntil: 'load', timeout: 20000 }).catch(() => {})
|
|
60
62
|
const result = await page.evaluate(probe, probeArgs(node, config.evaluators?.jev?.max_state_chars))
|
|
61
63
|
const checks = {}
|
|
64
|
+
// URL from the capture; styles and expressions from the values recorded at capture (src/lib/assertions.mjs)
|
|
65
|
+
const stored = storedValueChecks(node, cap)
|
|
62
66
|
for (const c of node.criteria || []) {
|
|
63
67
|
if (c.check.type !== 'deterministic') continue
|
|
68
|
+
if (VALUE_ASSERTS.has(c.check.assert)) { checks[c.id] = stored[c.id]; continue }
|
|
64
69
|
checks[c.id] = LIVE_ONLY(c.check.assert) ? (cap.checks?.[c.id] ?? { ok: null, evidence: 'live-page check; capture the step again to evaluate it' }) : result.checks[c.id]
|
|
65
70
|
}
|
|
66
71
|
await writeJson(file, { ...cap, checks, boxes: result.boxes, criteria_hash: criteriaHash(node), rechecked_at: new Date().toISOString() })
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import yaml from 'js-yaml'
|
|
|
3
3
|
import { schemaErrors } from './schema.mjs'
|
|
4
4
|
import { UserError } from './util.mjs'
|
|
5
5
|
import { expandPacks } from './packs.mjs'
|
|
6
|
+
import { jsGuard } from './assertions.mjs'
|
|
6
7
|
|
|
7
8
|
export async function loadScenario (file) {
|
|
8
9
|
const data = yaml.load(await readFile(file, 'utf8'))
|
|
@@ -25,6 +26,8 @@ export function validateScenario (data) {
|
|
|
25
26
|
for (const c of node.criteria || []) {
|
|
26
27
|
if (acIds.has(c.id)) problems.push(`node "${node.id}": criterion ${c.id} is duplicated`)
|
|
27
28
|
acIds.add(c.id)
|
|
29
|
+
// js_equals reads the page; an expression that could write or act is refused before any walk
|
|
30
|
+
if (c.check?.assert === 'js_equals') { const why = jsGuard(c.check.expression); if (why) problems.push(`node "${node.id}": ${c.id} js_equals expression: ${why}`) }
|
|
28
31
|
}
|
|
29
32
|
if (!['decision', 'stack'].includes(node.kind) && !node.execution) problems.push(`node "${node.id}": execution is required for ${node.kind} steps`)
|
|
30
33
|
if (node.kind === 'stack' && !node.items) problems.push(`node "${node.id}": stack steps need items (urls, file, sitemap, crawl or script)`)
|
|
@@ -90,10 +90,12 @@ export function mergeRects (rects, slack = 0) {
|
|
|
90
90
|
* ignore: [{ selector: '.clock' }, { rect: [x, y, w, h], viewport: desktop }, { selector: '#ad', locales: [cs] }]
|
|
91
91
|
*/
|
|
92
92
|
export function ignoreFor (node, locale, viewport) {
|
|
93
|
+
// a viewport name also covers its capture keys in other browsers and schemes (mobile → mobile.webkit.dark)
|
|
94
|
+
const hit = (v) => v === viewport || v === String(viewport).split('.')[0]
|
|
93
95
|
return (Array.isArray(node?.ignore) ? node.ignore : []).filter((e) => {
|
|
94
96
|
if (!e || typeof e !== 'object') return false
|
|
95
|
-
if (e.viewport && e.viewport
|
|
96
|
-
if (Array.isArray(e.viewports) && e.viewports.length && !e.viewports.
|
|
97
|
+
if (e.viewport && !hit(e.viewport)) return false
|
|
98
|
+
if (Array.isArray(e.viewports) && e.viewports.length && !e.viewports.some(hit)) return false
|
|
97
99
|
if (Array.isArray(e.locales) && e.locales.length && !e.locales.includes(locale)) return false
|
|
98
100
|
return !!(e.selector || (Array.isArray(e.rect) && e.rect.length === 4))
|
|
99
101
|
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// CLI side of run dimensions (batch E): the scope `kaloko start` writes, the capture keys a walk pass or a manual
|
|
2
|
+
// capture takes, and the lines the CLI prints. The rules themselves live in dimensions.mjs.
|
|
3
|
+
import { UserError } from './util.mjs'
|
|
4
|
+
import { isApp } from './apps/drivers.mjs'
|
|
5
|
+
import { resolveViewports, expandScope, dimensionsFrom, appDevices, variantOf, pickKeys, DIMENSIONS } from './dimensions.mjs'
|
|
6
|
+
|
|
7
|
+
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : Array.isArray(v) ? v : null)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The scope of a new run: viewports (sizes and device presets), app devices and the dimensions from the CLI,
|
|
11
|
+
* the scenario and the config. Web runs take every dimension; app runs take devices, colour scheme and motion;
|
|
12
|
+
* Design environments render in Chromium with presets at their whole screen.
|
|
13
|
+
*/
|
|
14
|
+
export function startScope ({ args = {}, scenario, config, env }) {
|
|
15
|
+
const platform = scenario.platform || 'web'
|
|
16
|
+
const dims = dimensionsFrom({ args, scenario, config })
|
|
17
|
+
const design = env?.kind === 'design'
|
|
18
|
+
if (isApp(platform) || design) {
|
|
19
|
+
for (const k of ['browsers', 'display_modes']) if (dims[k] && (args[k.replace(/_/g, '-')] || scenario[k])) throw new UserError(`${k.replace(/_/g, ' ')} apply to web runs; ${design ? 'design steps render in Chromium' : `a ${platform} app walks on its devices`}.`)
|
|
20
|
+
dims.browsers = null; dims.display_modes = null
|
|
21
|
+
if (isApp(platform)) dims.forced_colors = null
|
|
22
|
+
}
|
|
23
|
+
if (isApp(platform)) {
|
|
24
|
+
const ids = list(args.devices) || scenario.devices
|
|
25
|
+
if (ids?.length) {
|
|
26
|
+
const { viewports, devices } = appDevices(ids, platform)
|
|
27
|
+
return expandScope({ viewports, devices, dims })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const fullScreen = design
|
|
31
|
+
const { viewports: all, devices } = resolveViewports(scenario.viewports, { fullScreen })
|
|
32
|
+
const names = list(args.viewports) || Object.keys(all)
|
|
33
|
+
const vps = {}; const devs = {}
|
|
34
|
+
for (const n of names) {
|
|
35
|
+
if (all[n]) { vps[n] = all[n]; if (devices[n]) devs[n] = devices[n]; continue }
|
|
36
|
+
// --viewports may name a preset the scenario does not list (kaloko start --viewports iphone-se,fold-open)
|
|
37
|
+
const r = resolveViewports([n], { fullScreen, fallback: {} })
|
|
38
|
+
Object.assign(vps, r.viewports); Object.assign(devs, r.devices)
|
|
39
|
+
}
|
|
40
|
+
return expandScope({ viewports: vps, devices: devs, dims })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Viewport names of a scope without the dimension suffixes, in order. */
|
|
44
|
+
export const baseViewports = (scope) => [...new Set(Object.keys(scope?.viewports || {}).map((k) => variantOf(scope, k).viewport))]
|
|
45
|
+
|
|
46
|
+
/** " browsers: chromium, webkit · color schemes: light, dark" for `kaloko start` (empty without dimensions). */
|
|
47
|
+
export function dimensionLine (scope) {
|
|
48
|
+
const parts = DIMENSIONS.filter((d) => scope?.dimensions?.[d.list]).map((d) => `${d.list.replace(/_/g, ' ')}: ${scope.dimensions[d.list].join(', ')}`)
|
|
49
|
+
const devices = Object.values(scope?.devices || {}).map((d) => d.title)
|
|
50
|
+
if (devices.length) parts.push(`devices: ${devices.join(', ')}`)
|
|
51
|
+
const keys = Object.keys(scope?.viewports || {}).length
|
|
52
|
+
return parts.length ? `\n ${parts.join(' · ')}${keys > 1 ? ` (${keys} captures per step and locale)` : ''}` : ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* { key: [w, h] } of the keys a capture takes: `names` (viewport names or full keys, from --viewports or
|
|
57
|
+
* capture({ viewports })) within `keys` (a walk pass, or every key of an engine). An unknown name is an error.
|
|
58
|
+
*/
|
|
59
|
+
export function scopeKeys (scope, keys, names = null) {
|
|
60
|
+
const all = scope.viewports
|
|
61
|
+
if (names) for (const n of names) if (!all[n] && !Object.keys(all).some((k) => variantOf(scope, k).viewport === n)) throw new UserError(`Unknown viewport "${n}".`)
|
|
62
|
+
return Object.fromEntries(pickKeys(scope, keys, names).map((k) => [k, all[k]]))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walks of an app run: one per device of `devices:` (its keys: colour scheme and motion variants), or one walk on
|
|
67
|
+
* the environment's device with the keys of the first viewport. `only` limits the devices (walk --devices).
|
|
68
|
+
*/
|
|
69
|
+
export function appDeviceGroups (scope, only = null) {
|
|
70
|
+
const keys = Object.keys(scope?.viewports || {})
|
|
71
|
+
const bases = baseViewports(scope)
|
|
72
|
+
const devices = bases.filter((b) => scope.devices?.[b]?.app)
|
|
73
|
+
const keysOf = (base) => keys.filter((k) => variantOf(scope, k).viewport === base)
|
|
74
|
+
if (devices.length) {
|
|
75
|
+
const picked = devices.filter((id) => !only || only.includes(id))
|
|
76
|
+
if (!picked.length) throw new UserError(`This run has no device ${only.join(', ')} (its devices: ${devices.join(', ')}).`)
|
|
77
|
+
return picked.map((id) => ({ device: id, title: scope.devices[id].title || id, keys: keysOf(id) }))
|
|
78
|
+
}
|
|
79
|
+
return [{ device: null, title: null, keys: bases.length ? keysOf(bases[0]) : [] }]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Keys of one engine (a capture on the shared Chromium takes the Chromium keys). */
|
|
83
|
+
export const engineKeys = (scope, engine = 'chromium') => Object.keys(scope?.viewports || {}).filter((k) => variantOf(scope, k).browser === engine)
|