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.
Files changed (43) hide show
  1. package/README.md +45 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +42 -2
  4. package/schemas/run.v1.json +33 -2
  5. package/schemas/scenario.v1.json +107 -16
  6. package/skills/kaloko/SKILL.md +8 -2
  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/src/cli.mjs +326 -190
  15. package/src/lib/access.mjs +5 -2
  16. package/src/lib/apps/devices.mjs +187 -0
  17. package/src/lib/apps/drivers.mjs +11 -3
  18. package/src/lib/apps/ios-device.mjs +78 -0
  19. package/src/lib/apps/tree.mjs +9 -1
  20. package/src/lib/apps/webview.mjs +54 -0
  21. package/src/lib/apps/windows.mjs +108 -0
  22. package/src/lib/apps/windows.ps1 +78 -0
  23. package/src/lib/assertions.mjs +300 -0
  24. package/src/lib/build.mjs +4 -1
  25. package/src/lib/capture.mjs +146 -119
  26. package/src/lib/ci.mjs +8 -4
  27. package/src/lib/design-cli.mjs +35 -6
  28. package/src/lib/design.mjs +3 -1
  29. package/src/lib/devices.mjs +85 -0
  30. package/src/lib/devspec.mjs +3 -2
  31. package/src/lib/dimensions.mjs +326 -0
  32. package/src/lib/doctor.mjs +31 -6
  33. package/src/lib/drift.mjs +8 -4
  34. package/src/lib/evaluate.mjs +25 -7
  35. package/src/lib/import.mjs +2 -1
  36. package/src/lib/jev.mjs +3 -1
  37. package/src/lib/manifest-core.mjs +12 -3
  38. package/src/lib/probe.mjs +60 -0
  39. package/src/lib/recheck.mjs +6 -1
  40. package/src/lib/scenario.mjs +3 -0
  41. package/src/lib/stability-core.mjs +4 -2
  42. package/src/lib/walk-dimensions.mjs +83 -0
  43. 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,
@@ -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() })
@@ -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 !== viewport) return false
96
- if (Array.isArray(e.viewports) && e.viewports.length && !e.viewports.includes(viewport)) return false
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)