kaloko 0.4.8 → 0.4.10
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 +92 -5
- package/package.json +1 -1
- package/schemas/config.v1.json +33 -0
- package/schemas/scenario.v1.json +39 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +24 -3
- package/skills/kaloko/reference/acceptance.md +50 -2
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/evaluation.md +46 -3
- package/skills/kaloko/reference/execution.md +74 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +4 -1
- package/src/cli.mjs +428 -99
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- package/src/lib/design-cli.mjs +7 -3
- package/src/lib/devspec.mjs +821 -0
- package/src/lib/doctor.mjs +7 -0
- package/src/lib/drift.mjs +16 -8
- package/src/lib/evaluate.mjs +98 -14
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/onboarding.mjs +15 -8
- package/src/lib/pack-help.mjs +7 -0
- package/src/lib/packs.mjs +9 -2
- package/src/lib/png.mjs +61 -0
- package/src/lib/pr.mjs +24 -2
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- package/src/lib/spec-cli.mjs +90 -0
- package/src/lib/stability-core.mjs +114 -0
- package/src/lib/stability.mjs +249 -0
- package/src/lib/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -0
- package/src/lib/vision-core.mjs +132 -0
- package/src/lib/vision.mjs +127 -0
- package/templates/kaloko.config.yml +6 -0
- package/viewer/index.html +500 -44
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Keyboard and screen-reader checks of the a11y pack (#19), measured in the live page after the capture:
|
|
2
|
+
// a Tab walk (focus order, visible focus with contrast, traps, skip link) and accessible names from Chrome's
|
|
3
|
+
// accessibility tree (CDP). The page is left as it was found apart from focus: nothing is clicked or typed.
|
|
4
|
+
import { decodePng } from './png.mjs'
|
|
5
|
+
|
|
6
|
+
export const KEYBOARD_ASSERTS = new Set(['a11y_focus_order', 'a11y_focus_visible', 'a11y_keyboard_trap', 'a11y_skip_link', 'a11y_ax_names'])
|
|
7
|
+
export const needsKeyboard = (node) => (node?.criteria || []).some((c) => c.check?.type === 'deterministic' && KEYBOARD_ASSERTS.has(c.check.assert))
|
|
8
|
+
|
|
9
|
+
// In the page: the focused element (through shadow roots), where it is and what it is called.
|
|
10
|
+
function describeFocus () {
|
|
11
|
+
let el = document.activeElement
|
|
12
|
+
while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement
|
|
13
|
+
if (!el || el === document.body || el === document.documentElement) return null
|
|
14
|
+
const path = (e) => { const p = []; for (; e && e.nodeType === 1; e = e.parentElement || e.getRootNode()?.host) { let i = 1; for (let s = e.previousElementSibling; s; s = s.previousElementSibling) i++; p.unshift(`${e.tagName.toLowerCase()}:${i}`) } return p.join('/') }
|
|
15
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim()
|
|
16
|
+
const name = clean(el.getAttribute('aria-label') || (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean).map((i) => document.getElementById(i)?.innerText || '').join(' ') || el.innerText || el.getAttribute('alt') || el.getAttribute('title') || el.value || el.getAttribute('placeholder') || [...el.querySelectorAll('img[alt]')].map((i) => i.alt).join(' '))
|
|
17
|
+
const r = el.getBoundingClientRect(); const cs = getComputedStyle(el)
|
|
18
|
+
let fixed = false
|
|
19
|
+
for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const p = getComputedStyle(e).position; if (p === 'fixed' || p === 'sticky') { fixed = true; break } }
|
|
20
|
+
const visible = r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && Number(cs.opacity) > 0.05 && r.right + scrollX > 0 && r.bottom + scrollY > 0
|
|
21
|
+
const inView = r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth
|
|
22
|
+
let obscured = false
|
|
23
|
+
if (visible && inView) {
|
|
24
|
+
const hit = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, r.left + r.width / 2)), Math.min(innerHeight - 1, Math.max(0, r.top + r.height / 2)))
|
|
25
|
+
obscured = !!hit && !(hit === el || el.contains(hit) || hit.contains(el) || (el.labels && [...el.labels].some((l) => l.contains(hit))))
|
|
26
|
+
}
|
|
27
|
+
const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
|
|
28
|
+
return {
|
|
29
|
+
key: path(el), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', name: name.slice(0, 60), href: el.getAttribute('href'),
|
|
30
|
+
desc: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${cls}`,
|
|
31
|
+
x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height),
|
|
32
|
+
visible, fixed, obscured, dialog: !!el.closest('dialog[open], [aria-modal=true]'), main: !!el.closest('main, [role=main]'),
|
|
33
|
+
tabindex: el.getAttribute('tabindex')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// In the page: focuses the element at the path describeFocus produced, centred in the viewport; its viewport box.
|
|
38
|
+
function focusByPath (key) {
|
|
39
|
+
let el = document.documentElement
|
|
40
|
+
for (const part of key.split('/').slice(1)) {
|
|
41
|
+
const i = Number(part.split(':')[1])
|
|
42
|
+
const kids = el.shadowRoot && !el.children.length ? el.shadowRoot.children : el.children
|
|
43
|
+
el = kids[i - 1]
|
|
44
|
+
if (!el) return null
|
|
45
|
+
}
|
|
46
|
+
el.scrollIntoView({ block: 'center', inline: 'nearest' }); el.focus({ preventScroll: true })
|
|
47
|
+
const r = el.getBoundingClientRect()
|
|
48
|
+
return { x: r.left, y: r.top, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// In the page: static facts for the order check (positive tabindex, controls taken out of the tab order, click-only).
|
|
52
|
+
function orderFacts () {
|
|
53
|
+
const visible = (e) => { const r = e.getBoundingClientRect(); const cs = getComputedStyle(e); return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' }
|
|
54
|
+
const d = (e) => `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''}${typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''}`
|
|
55
|
+
const pos = [...document.querySelectorAll('[tabindex]')].filter((e) => Number(e.getAttribute('tabindex')) > 0 && visible(e))
|
|
56
|
+
const removed = [...document.querySelectorAll('a[href][tabindex^="-"], button[tabindex^="-"], input[tabindex^="-"]:not([type=hidden]), select[tabindex^="-"], textarea[tabindex^="-"]')].filter((e) => visible(e) && !e.closest('[aria-hidden=true], [inert]'))
|
|
57
|
+
const clickOnly = [...document.querySelectorAll('[role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [onclick]')].filter((e) => visible(e) && !e.matches('a[href], button, input, select, textarea, summary, [tabindex], [contenteditable=true]') && !e.closest('a[href], button'))
|
|
58
|
+
const box = (e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } }
|
|
59
|
+
return { positive: pos.slice(0, 5).map(d), positiveCount: pos.length, removed: removed.slice(0, 5).map(d), removedCount: removed.length, clickOnly: clickOnly.slice(0, 5).map(d), clickOnlyCount: clickOnly.length, boxes: [...removed, ...clickOnly].slice(0, 5).map(box), innerHeight }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// In the page: does an in-page link lead to the main content?
|
|
63
|
+
function skipTarget (href) {
|
|
64
|
+
const id = decodeURIComponent(String(href || '').slice(1))
|
|
65
|
+
if (!id) return null
|
|
66
|
+
const t = document.getElementById(id) || document.querySelector(`[name="${CSS.escape(id)}"]`)
|
|
67
|
+
if (!t) return { exists: false }
|
|
68
|
+
return { exists: true, main: t.matches('main, [role=main]') || !!t.closest('main, [role=main]') || !!t.querySelector('main, [role=main]') }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lum = (r, g, b) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How visible the focus indicator is: pixels that change between the unfocused and the focused screenshot of the
|
|
75
|
+
* same area, and how many of them change with at least 3:1 contrast (WCAG 2.4.7 / 2.4.13).
|
|
76
|
+
* @returns {{ changed: number, strong: number, contrast: number }} contrast: median of the changed pixels
|
|
77
|
+
*/
|
|
78
|
+
export function focusDelta (focusedPng, plainPng) {
|
|
79
|
+
const a = decodePng(focusedPng); const b = decodePng(plainPng)
|
|
80
|
+
const w = Math.min(a.width, b.width); const h = Math.min(a.height, b.height)
|
|
81
|
+
let changed = 0; let strong = 0
|
|
82
|
+
const ratios = []
|
|
83
|
+
for (let y = 0; y < h; y++) {
|
|
84
|
+
for (let x = 0; x < w; x++) {
|
|
85
|
+
const i = (y * a.width + x) * 4; const j = (y * b.width + x) * 4
|
|
86
|
+
const d = Math.abs(a.data[i] - b.data[j]) + Math.abs(a.data[i + 1] - b.data[j + 1]) + Math.abs(a.data[i + 2] - b.data[j + 2])
|
|
87
|
+
if (d <= 48) continue
|
|
88
|
+
changed++
|
|
89
|
+
const l1 = lum(a.data[i], a.data[i + 1], a.data[i + 2]); const l2 = lum(b.data[j], b.data[j + 1], b.data[j + 2])
|
|
90
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
|
|
91
|
+
if (ratio >= 3) strong++
|
|
92
|
+
if (ratios.length < 4000) ratios.push(ratio)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
ratios.sort((p, q) => p - q)
|
|
96
|
+
return { changed, strong, contrast: ratios.length ? Math.round(ratios[ratios.length >> 1] * 10) / 10 : 1 }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Verdict of one focus stop from its delta and size: ok, weak (below 3:1), none (no visible change). */
|
|
100
|
+
export function focusVerdict (delta, w, h) {
|
|
101
|
+
const perimeter = 2 * (Math.max(1, w) + Math.max(1, h))
|
|
102
|
+
if (delta.strong >= perimeter * 0.75) return 'ok'
|
|
103
|
+
if (delta.changed >= perimeter * 0.5) return 'weak'
|
|
104
|
+
return 'none'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const label = (s) => `${s.desc}${s.name ? ` "${s.name.slice(0, 30)}"` : ''}`
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Walks the page with Tab and fills the keyboard criteria of `criteria` ({ id, check }).
|
|
111
|
+
* opts: maxStops (60), focusChecks (stops checked for a visible indicator, 15), shots (focus screenshots, 5),
|
|
112
|
+
* saveShot(n, pngBuffer) → relative file path or null.
|
|
113
|
+
* @returns {{ checks: Record<string, {ok, evidence}>, boxes: Record<string, Array>, keyboard: object }}
|
|
114
|
+
*/
|
|
115
|
+
export async function keyboardChecks (page, criteria, { maxStops = 60, focusChecks = 15, shots = 5, saveShot = null } = {}) {
|
|
116
|
+
const want = new Map(criteria.filter((c) => c.check.type === 'deterministic' && KEYBOARD_ASSERTS.has(c.check.assert)).map((c) => [c.check.assert, c.id]))
|
|
117
|
+
const checks = {}; const boxes = {}
|
|
118
|
+
if (!want.size) return { checks, boxes, keyboard: null }
|
|
119
|
+
const set = (assert, ok, evidence, list) => { const id = want.get(assert); if (!id) return; checks[id] = { ok, evidence }; if (list?.length) boxes[id] = list.slice(0, 5) }
|
|
120
|
+
const keyboard = { stops: [], complete: false, total: 0, shots: [] }
|
|
121
|
+
const tabbing = [...want.keys()].some((a) => a !== 'a11y_ax_names')
|
|
122
|
+
try {
|
|
123
|
+
if (tabbing) {
|
|
124
|
+
// start from the top of the document: a temporary, invisible focus target before everything else
|
|
125
|
+
await page.evaluate(() => {
|
|
126
|
+
const a = document.activeElement; if (a && a !== document.body) a.blur()
|
|
127
|
+
scrollTo(0, 0)
|
|
128
|
+
const s = document.createElement('span'); s.id = '__kaloko_start'; s.tabIndex = -1; s.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;overflow:hidden'
|
|
129
|
+
document.body.prepend(s); s.focus({ preventScroll: true })
|
|
130
|
+
})
|
|
131
|
+
const stops = []; const seen = new Map()
|
|
132
|
+
let trap = null; let iframeRun = 0
|
|
133
|
+
for (let i = 0; i < maxStops; i++) {
|
|
134
|
+
await page.keyboard.press('Tab')
|
|
135
|
+
if (i === 0) await page.evaluate(() => document.getElementById('__kaloko_start')?.remove()).catch(() => {})
|
|
136
|
+
const f = await page.evaluate(describeFocus).catch(() => null)
|
|
137
|
+
if (!f) { keyboard.complete = true; break } // focus went back to the browser: the whole page was walked
|
|
138
|
+
if (stops.length && f.key === stops[0].key) { keyboard.complete = true; break }
|
|
139
|
+
if (f.tag === 'iframe') { if (stops.at(-1)?.key === f.key) { if (++iframeRun > 30) break; continue } } else iframeRun = 0
|
|
140
|
+
if (seen.has(f.key)) { const from = seen.get(f.key); const cycle = stops.slice(from); trap = { at: from, size: cycle.length, dialog: cycle.every((s) => s.dialog), first: cycle[0] }; break }
|
|
141
|
+
seen.set(f.key, stops.length)
|
|
142
|
+
stops.push(f)
|
|
143
|
+
}
|
|
144
|
+
keyboard.total = stops.length
|
|
145
|
+
|
|
146
|
+
// order: hidden stops, big jumps back up the page, positive tabindex, controls left out of the tab order
|
|
147
|
+
if (want.has('a11y_focus_order')) {
|
|
148
|
+
const facts = await page.evaluate(orderFacts)
|
|
149
|
+
const hidden = stops.filter((s) => !s.visible)
|
|
150
|
+
const jumps = []
|
|
151
|
+
for (let i = 1; i < stops.length; i++) {
|
|
152
|
+
const a = stops[i - 1]; const b = stops[i]
|
|
153
|
+
if (a.visible && b.visible && !a.fixed && !b.fixed && b.y + b.h < a.y - facts.innerHeight) jumps.push(`${i}→${i + 1} ${label(b)} jumps ${a.y - b.y} px up`)
|
|
154
|
+
}
|
|
155
|
+
const problems = [
|
|
156
|
+
...(hidden.length ? [`focus lands on ${hidden.length} invisible element(s): ${hidden.slice(0, 3).map(label).join(', ')}`] : []),
|
|
157
|
+
...(jumps.length ? [`order jumps back: ${jumps.slice(0, 2).join('; ')}`] : []),
|
|
158
|
+
...(facts.positiveCount ? [`positive tabindex reorders the page: ${facts.positive.join(', ')}`] : []),
|
|
159
|
+
...(facts.removedCount ? [`${facts.removedCount} visible control(s) taken out of the tab order: ${facts.removed.join(', ')}`] : []),
|
|
160
|
+
...(facts.clickOnlyCount ? [`${facts.clickOnlyCount} control(s) reachable only by mouse: ${facts.clickOnly.join(', ')}`] : [])
|
|
161
|
+
]
|
|
162
|
+
const seq = stops.slice(0, 8).map((s, i) => `${i + 1} ${label(s)}`).join(' → ')
|
|
163
|
+
set('a11y_focus_order', problems.length === 0, problems.length ? problems.join('; ') : `${stops.length} tab stop(s)${keyboard.complete ? '' : ` (first ${maxStops} checked)`} in reading order: ${seq || 'no focusable elements'}`, [...hidden.filter((s) => s.w && s.h), ...facts.boxes])
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (want.has('a11y_keyboard_trap')) {
|
|
167
|
+
if (trap && !trap.dialog) set('a11y_keyboard_trap', false, `Tab keeps cycling through ${trap.size} element(s) from ${label(trap.first)}; the rest of the page cannot be reached`, stops.slice(trap.at, trap.at + 5))
|
|
168
|
+
else if (trap) set('a11y_keyboard_trap', true, `an open modal dialog keeps focus inside (${trap.size} element(s)); that is expected while it is open`)
|
|
169
|
+
else set('a11y_keyboard_trap', true, keyboard.complete ? `Tab goes through all ${stops.length} stop(s) and leaves the page` : `no trap in the first ${stops.length} tab stops`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (want.has('a11y_skip_link')) {
|
|
173
|
+
let ok = null; let evidence = 'nothing on the page takes keyboard focus'
|
|
174
|
+
if (stops.length) {
|
|
175
|
+
const first = stops.slice(0, 3)
|
|
176
|
+
let skip = null
|
|
177
|
+
for (const s of first) {
|
|
178
|
+
if (!s.href || !s.href.startsWith('#') || s.href.length < 2) continue
|
|
179
|
+
const t = await page.evaluate(skipTarget, s.href).catch(() => null)
|
|
180
|
+
if (t?.exists && (t.main || /skip|jump|přeskoč|preskoč|obsah|content|main|hlavní/i.test(s.name))) { skip = { s, t }; break }
|
|
181
|
+
if (t && !t.exists && /skip|přeskoč|obsah|content|main/i.test(s.name)) { skip = { s, t, broken: true }; break }
|
|
182
|
+
}
|
|
183
|
+
const mainAt = stops.findIndex((s) => s.main)
|
|
184
|
+
if (skip && !skip.broken) { ok = true; evidence = `skip link ${label(skip.s)} → ${skip.s.href}` }
|
|
185
|
+
else if (skip?.broken) { ok = false; evidence = `skip link ${label(skip.s)} points to ${skip.s.href}, which does not exist` }
|
|
186
|
+
else if (mainAt >= 0 && mainAt < 3) { ok = true; evidence = `main content is reached at tab stop ${mainAt + 1}; no skip link needed` }
|
|
187
|
+
else if (mainAt < 0 && !stops.some((s) => s.main)) {
|
|
188
|
+
const hasMain = await page.evaluate(() => !!document.querySelector('main, [role=main]'))
|
|
189
|
+
ok = !hasMain && stops.length <= 3 ? true : false
|
|
190
|
+
evidence = ok ? `only ${stops.length} tab stop(s) on the page` : hasMain ? `no skip link; main content has no focusable element in the first ${stops.length} stops` : 'no skip link and no main landmark to skip to'
|
|
191
|
+
} else { ok = false; evidence = `no skip link; main content is first reached at tab stop ${mainAt + 1}, after ${mainAt} stop(s) of header and navigation` }
|
|
192
|
+
}
|
|
193
|
+
set('a11y_skip_link', ok, evidence)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// visible focus: each stop focused and unfocused, the same area compared (keyboard modality stays on)
|
|
197
|
+
const verdicts = new Map()
|
|
198
|
+
if (want.has('a11y_focus_visible') || shots > 0) {
|
|
199
|
+
const candidates = stops.map((s, i) => ({ s, n: i + 1 })).filter(({ s }) => s.visible && s.tag !== 'iframe').slice(0, Math.max(want.has('a11y_focus_visible') ? focusChecks : 0, shots))
|
|
200
|
+
for (const { s, n } of candidates) {
|
|
201
|
+
const pos = await page.evaluate(focusByPath, s.key).catch(() => null)
|
|
202
|
+
if (!pos || !pos.w || !pos.h) continue
|
|
203
|
+
const clipOf = (pad) => { const x = Math.max(0, Math.floor(pos.x - pad)); const y = Math.max(0, Math.floor(pos.y - pad)); return { x, y, width: Math.max(1, Math.min(pos.vw, Math.ceil(pos.x + pos.w + pad)) - x), height: Math.max(1, Math.min(pos.vh, Math.ceil(pos.y + pos.h + pad)) - y) } }
|
|
204
|
+
const clip = clipOf(6)
|
|
205
|
+
if (clip.width < 2 || clip.height < 2) continue
|
|
206
|
+
const focused = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
|
|
207
|
+
if (keyboard.shots.length < shots && saveShot) {
|
|
208
|
+
const file = await saveShot(n, await page.screenshot({ clip: clipOf(48), scale: 'css', animations: 'disabled', caret: 'hide' })).catch(() => null)
|
|
209
|
+
if (file) keyboard.shots.push({ n, file, name: s.name || s.desc })
|
|
210
|
+
}
|
|
211
|
+
if (!want.has('a11y_focus_visible') || verdicts.size >= focusChecks) continue
|
|
212
|
+
await page.evaluate(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; a?.blur?.() })
|
|
213
|
+
const plain = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
|
|
214
|
+
const delta = focusDelta(focused, plain)
|
|
215
|
+
verdicts.set(n, { v: s.obscured ? 'obscured' : focusVerdict(delta, pos.w, pos.h), contrast: delta.contrast })
|
|
216
|
+
}
|
|
217
|
+
if (want.has('a11y_focus_visible')) {
|
|
218
|
+
const bad = [...verdicts].filter(([, x]) => x.v !== 'ok')
|
|
219
|
+
const say = ([n, x]) => { const s = stops[n - 1]; return x.v === 'none' ? `${n} ${label(s)}: no visible change on focus` : x.v === 'weak' ? `${n} ${label(s)}: indicator contrast ${x.contrast}:1 (needs 3:1)` : `${n} ${label(s)}: covered by another element when focused` }
|
|
220
|
+
set('a11y_focus_visible', verdicts.size ? bad.length === 0 : null, !verdicts.size ? 'no visible focus stop to check' : bad.length ? `${bad.length} of ${verdicts.size} focus stop(s) without a clear indicator: ${bad.slice(0, 4).map(say).join('; ')}` : `all ${verdicts.size} checked focus stop(s) show an indicator with at least 3:1 contrast`, bad.map(([n]) => stops[n - 1]))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
keyboard.stops = stops.slice(0, 40).map((s, i) => ({ n: i + 1, x: s.x, y: s.y, w: s.w, h: s.h, name: s.name, el: s.desc, ...(s.visible ? {} : { hidden: true }), ...(verdicts.has(i + 1) ? { focus: verdicts.get(i + 1).v, contrast: verdicts.get(i + 1).contrast } : {}) }))
|
|
224
|
+
// leave the page as found: nothing focused, at the top
|
|
225
|
+
await page.evaluate(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; a?.blur?.(); scrollTo(0, 0) }).catch(() => {})
|
|
226
|
+
}
|
|
227
|
+
if (want.has('a11y_ax_names')) {
|
|
228
|
+
const r = await axNames(page)
|
|
229
|
+
set('a11y_ax_names', r.ok, r.evidence, r.boxes)
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
for (const id of want.values()) if (!checks[id]) checks[id] = { ok: null, evidence: `keyboard check error: ${String(error.message).split('\n')[0]}` }
|
|
233
|
+
}
|
|
234
|
+
return { checks, boxes, keyboard: tabbing ? keyboard : null }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const INTERACTIVE = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'slider', 'spinbutton', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab', 'listbox', 'treeitem'])
|
|
238
|
+
|
|
239
|
+
/** Interactive nodes of Chrome's accessibility tree without a name (what a screen reader announces). */
|
|
240
|
+
export async function axNames (page) {
|
|
241
|
+
let cdp
|
|
242
|
+
try { cdp = await page.context().newCDPSession(page) } catch { return { ok: null, evidence: 'the accessibility tree needs Chrome (CDP)' } }
|
|
243
|
+
try {
|
|
244
|
+
const { nodes } = await cdp.send('Accessibility.getFullAXTree')
|
|
245
|
+
const controls = nodes.filter((n) => !n.ignored && INTERACTIVE.has(n.role?.value))
|
|
246
|
+
const bad = controls.filter((n) => !String(n.name?.value || '').trim())
|
|
247
|
+
const boxes = []; const names = []
|
|
248
|
+
for (const n of bad.slice(0, 5)) {
|
|
249
|
+
try {
|
|
250
|
+
const { object } = await cdp.send('DOM.resolveNode', { backendNodeId: n.backendDOMNodeId })
|
|
251
|
+
const { result } = await cdp.send('Runtime.callFunctionOn', { objectId: object.objectId, returnByValue: true, functionDeclaration: 'function () { const r = this.getBoundingClientRect(); const c = typeof this.className === "string" && this.className.trim() ? "." + this.className.trim().split(/\\s+/).slice(0, 2).join(".") : ""; return { d: this.tagName.toLowerCase() + (this.id ? "#" + this.id : "") + c, x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } }' })
|
|
252
|
+
const v = result.value
|
|
253
|
+
names.push(`${v.d} (${n.role.value})`)
|
|
254
|
+
if (v.w && v.h) boxes.push({ x: v.x, y: v.y, w: v.w, h: v.h })
|
|
255
|
+
} catch { names.push(n.role.value) }
|
|
256
|
+
}
|
|
257
|
+
return bad.length
|
|
258
|
+
? { ok: false, evidence: `${bad.length} of ${controls.length} control(s) have no name in the accessibility tree: ${names.join(', ')}${bad.length > 5 ? ` (+${bad.length - 5})` : ''}`, boxes }
|
|
259
|
+
: { ok: true, evidence: `all ${controls.length} control(s) in the accessibility tree have a name`, boxes }
|
|
260
|
+
} finally { await cdp.detach().catch(() => {}) }
|
|
261
|
+
}
|
|
@@ -38,10 +38,14 @@ export function slimCapture (cap) {
|
|
|
38
38
|
overflow: cap.overflow || [], meta: cap.meta, mail: cap.mail || null, metadata: cap.metadata || null, ...(cap.view ? { view: cap.view } : {}),
|
|
39
39
|
...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.platform ? { platform: cap.platform, device: cap.device || null } : {}),
|
|
40
40
|
...(cap.design ? { design: cap.design } : {}), ...(cap.capture ? { capture: cap.capture } : {}),
|
|
41
|
-
...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {})
|
|
41
|
+
...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {}),
|
|
42
|
+
...(cap.ignore?.length ? { ignore: cap.ignore } : {}), ...(cap.unstable ? { unstable: cap.unstable } : {}), ...(cap.keyboard ? { keyboard: cap.keyboard } : {})
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** The keyboard walk of a capture as the canvas draws it: tab stops (numbered boxes) and the focus screenshots. */
|
|
47
|
+
export const keyboardOf = (k) => k ? { complete: !!k.complete, total: k.total ?? (k.stops || []).length, stops: (k.stops || []).slice(0, 40), shots: (k.shots || []).slice(0, 10) } : null
|
|
48
|
+
|
|
45
49
|
/**
|
|
46
50
|
* One manifest node from its scenario node and captures.
|
|
47
51
|
* captures: [{ locale, viewport, cap (capture record or slimCapture), ev (valid evaluation or null),
|
|
@@ -65,11 +69,15 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
|
|
|
65
69
|
...(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],
|
|
66
70
|
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),
|
|
67
71
|
blocked: (cap.blocked_requests || []).length, overflow: (cap.overflow || []).length, meta: cap.meta,
|
|
68
|
-
mail: cap.mail || null, eval: ev ? c.files.eval : null,
|
|
72
|
+
mail: cap.mail || null, eval: ev ? c.files.eval : null, ...(c.files.spec ? { spec: c.files.spec } : {}),
|
|
69
73
|
// quick signals from the evaluator (probabilities: purpose met, expected language, looks broken)
|
|
70
74
|
...(ev?.signals ? { signals: ev.signals } : {}),
|
|
71
75
|
...(c.live ? { live: c.live } : {}),
|
|
72
|
-
...(cap.view?.revision ? { view: cap.view } : {})
|
|
76
|
+
...(cap.view?.revision ? { view: cap.view } : {}),
|
|
77
|
+
// regions left out of pixel diffs (#20), regions that moved between repeated shots, the keyboard walk (#19)
|
|
78
|
+
...(cap.ignore?.length ? { ignore: cap.ignore.slice(0, 50) } : {}),
|
|
79
|
+
...(cap.unstable?.regions?.length ? { unstable: cap.unstable.regions.slice(0, 50) } : {}),
|
|
80
|
+
...(cap.keyboard ? { keyboard: keyboardOf(cap.keyboard) } : {})
|
|
73
81
|
}
|
|
74
82
|
if (ev?.semantic || ev?.jev) semanticByKey[`${c.locale}|${c.viewport}`] = ev.semantic || ev.jev
|
|
75
83
|
if (cap.metadata && !metadata[c.locale]) metadata[c.locale] = cap.metadata
|
|
@@ -80,11 +88,14 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
|
|
|
80
88
|
const loc = (agg.by_locale[c.locale] ||= {})
|
|
81
89
|
const score = r.score ?? r.jev?.noul ?? r.noul ?? null
|
|
82
90
|
// semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
|
|
83
|
-
|
|
91
|
+
// semantic answers carry their one-sentence reason (#17) and whether the evaluator saw the screenshot
|
|
92
|
+
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 } : {}) } : {}) }
|
|
84
94
|
if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
|
|
95
|
+
if (r.reason && (!agg.reason || (r.verdict !== 'pass' && agg.reason_verdict === 'pass'))) { agg.reason = r.reason; agg.reason_verdict = r.verdict }
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
|
-
const criteria = [...perCriterion.values()].map((agg) => {
|
|
98
|
+
const criteria = [...perCriterion.values()].map(({ reason_verdict: _rv, ...agg }) => {
|
|
88
99
|
for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
|
|
89
100
|
const verdicts = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp].verdict))
|
|
90
101
|
return { ...agg, verdict: aggregate(verdicts) }
|
package/src/lib/onboarding.mjs
CHANGED
|
@@ -10,16 +10,23 @@ export const realOrg = (org) => (typeof org === 'string' && /^[a-z0-9][a-z0-9-]{
|
|
|
10
10
|
/** Address of the organization's sample run on the service. */
|
|
11
11
|
export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${org}/sample`
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Lines printed at the end of `kaloko init`. `devServer` ({ command, url }) when init found the app's dev server but it
|
|
15
|
+
* is not answering yet; `scenario` the path of the first scenario init drafted, which then is the first real flow.
|
|
16
|
+
*/
|
|
17
|
+
export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null }) {
|
|
15
18
|
const o = realOrg(org)
|
|
16
|
-
|
|
17
|
-
'
|
|
18
|
-
loggedIn ? ' 1. Signed in already (KALOKO_TOKEN found).' : ' 1. npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)',
|
|
19
|
-
o ? ` 2. Try a review on the sample run: ${sampleUrl(base, o)}` : ' 2. Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.',
|
|
20
|
-
' 3. Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:',
|
|
21
|
-
` "${FIRST_FLOW_PROMPT}"`
|
|
19
|
+
const steps = [
|
|
20
|
+
loggedIn ? 'Signed in already (KALOKO_TOKEN found).' : 'npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)'
|
|
22
21
|
]
|
|
22
|
+
if (devServer) steps.push(`${devServer.command || 'start your app'} (the dev server is not answering on ${devServer.url} yet)`)
|
|
23
|
+
steps.push(o ? `Try a review on the sample run: ${sampleUrl(base, o)}` : 'Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.')
|
|
24
|
+
if (scenario) steps.push(`Your first real flow: npx kaloko start --scenario ${scenario} --env local && npx kaloko walk`)
|
|
25
|
+
else steps.push('Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:', ` "${FIRST_FLOW_PROMPT}"`)
|
|
26
|
+
const lines = ['Next:']
|
|
27
|
+
let n = 0
|
|
28
|
+
for (const s of steps) lines.push(s.startsWith(' "') ? ` ${s}` : ` ${++n}. ${s}`)
|
|
29
|
+
return lines
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/** Lines printed after a successful `kaloko login`. */
|
package/src/lib/pack-help.mjs
CHANGED
|
@@ -25,6 +25,13 @@ export const PACK_HELP = {
|
|
|
25
25
|
AC305: { cs: 'Text splňuje kontrast WCAG AA', why: ['Low contrast text is hard to read in sunlight and for people with weaker sight.', 'Text s nízkým kontrastem se špatně čte na slunci i lidem se slabším zrakem.'], fix: ['Darken the text or lighten the background to at least 4.5 : 1 (3 : 1 for large text).', 'Ztmavte text nebo zesvětlete pozadí aspoň na 4,5 : 1 (u velkého textu 3 : 1).'] },
|
|
26
26
|
AC306: { cs: 'Stránka má hlavní oblast (main)', why: ['Screen reader users jump straight to <main> to skip the navigation.', 'Uživatelé čteček skáčou rovnou na <main>, aby přeskočili navigaci.'], fix: ['Wrap the main content in <main> (once per page).', 'Hlavní obsah obalte do <main> (jednou na stránce).'] },
|
|
27
27
|
AC307: { cs: 'Id prvků jsou jedinečná', why: ['Duplicate ids break labels, anchors and scripts that look elements up by id.', 'Duplicitní id rozbijí popisky, kotvy i skripty, které prvky hledají podle id.'], fix: ['Rename the repeated ids listed in the evidence (often from a copied component).', 'Přejmenujte opakovaná id z důkazu (často z kopírované komponenty).'] },
|
|
28
|
+
AC308: { cs: 'Pořadí tabulátoru odpovídá stránce a dojde ke každému prvku', why: ['People who use a keyboard move with Tab; focus that jumps around or skips a button leaves them lost or stuck.', 'Kdo ovládá web klávesnicí, posouvá se tabulátorem; když fokus skáče nebo tlačítko vynechá, ztratí se nebo nemůže dál.'], fix: ['Keep the DOM in reading order, remove positive tabindex, and use real buttons and links instead of clickable divs.', 'Držte DOM v pořadí čtení, odstraňte kladný tabindex a místo klikacích divů použijte skutečná tlačítka a odkazy.'] },
|
|
29
|
+
AC309: { cs: 'Fokus klávesnice je dobře vidět (kontrast aspoň 3 : 1)', why: ['Without a visible focus ring, keyboard users do not know where they are on the page.', 'Bez viditelného rámečku fokusu uživatel klávesnice neví, kde na stránce je.'], fix: ['Do not remove outline; style :focus-visible with an outline of at least 2 px that contrasts 3 : 1 with its surroundings.', 'Neodstraňujte outline; nastavte :focus-visible s obrysem aspoň 2 px a kontrastem 3 : 1 vůči okolí.'] },
|
|
30
|
+
AC310: { cs: 'Klávesnice nikde neuvízne', why: ['A widget that keeps Tab inside (an embed, a custom menu) traps keyboard users for good.', 'Prvek, který tabulátor nepustí ven (vložený přehrávač, vlastní menu), uživatele klávesnice uvězní.'], fix: ['Let Tab leave the widget; trap focus only in an open modal dialog that closes with Esc.', 'Nechte tabulátor z prvku odejít; fokus držte jen v otevřeném modálním okně, které zavře Esc.'] },
|
|
31
|
+
AC311: { cs: 'Odkaz pro přeskočení na obsah nebo rychlá cesta k němu', why: ['Without a skip link, keyboard and screen reader users tab through the whole menu on every page.', 'Bez odkazu „Přeskočit na obsah“ musí uživatelé klávesnice a čteček na každé stránce protabovat celé menu.'], fix: ['Add <a href="#main" class="skip-link">Skip to content</a> as the first element and show it on focus.', 'Jako první prvek přidejte <a href="#main" class="skip-link">Přeskočit na obsah</a> a při fokusu ho zobrazte.'] },
|
|
32
|
+
AC312: { cs: 'Ovládací prvky mají název ve stromu přístupnosti', why: ['The accessibility tree is what screen readers read; a control without a name there is announced as just "button".', 'Strom přístupnosti je to, co čtečky skutečně čtou; prvek bez názvu ohlásí jen jako „tlačítko“.'], fix: ['Give the control visible text, aria-label or aria-labelledby; check that the label is not hidden with aria-hidden.', 'Dejte prvku viditelný text, aria-label nebo aria-labelledby; ověřte, že popisek neskrývá aria-hidden.'] },
|
|
33
|
+
AC313: { cs: 'Orientační oblasti jsou úplné a pojmenované', why: ['Screen reader users jump between header, navigation, main and footer; duplicates without names all sound the same.', 'Uživatelé čteček skáčou mezi hlavičkou, navigací, obsahem a patičkou; neoznačené duplicity znějí všechny stejně.'], fix: ['Use one <main>, one page <header> and <footer>, and give repeated <nav> or <aside> an aria-label.', 'Použijte jeden <main>, jednu hlavičku a patičku stránky a opakovaným <nav> či <aside> dejte aria-label.'] },
|
|
34
|
+
AC314: { cs: 'Role a atributy ARIA jsou platné', why: ['Wrong ARIA is worse than none: screen readers trust it and announce things that are not there.', 'Chybná ARIA je horší než žádná: čtečky jí věří a ohlašují, co na stránce není.'], fix: ['Fix the listed role or attribute, point aria-labelledby to an existing id, and never put aria-hidden on focusable content.', 'Opravte uvedenou roli nebo atribut, aria-labelledby nasměrujte na existující id a aria-hidden nikdy nedávejte na prvky s fokusem.'] },
|
|
28
35
|
AC401: { cs: 'Největší obsah se vykreslí do 2,5 s', why: ['LCP is how long people wait before the page looks ready; Google uses it for ranking.', 'LCP je doba, než stránka působí hotově; Google ho používá při řazení.'], fix: ['Make the hero image smaller (WebP/AVIF, right size), preload it, and cut render-blocking scripts.', 'Zmenšete hlavní obrázek (WebP/AVIF, správná velikost), přednačtěte ho a omezte blokující skripty.'] },
|
|
29
36
|
AC402: { cs: 'Rozvržení se při načítání nehýbe (CLS pod 0,1)', why: ['Content that jumps while loading makes people tap the wrong thing.', 'Obsah, který při načítání poskakuje, svádí k ťuknutí vedle.'], fix: ['Give images and embeds width and height, and reserve space for banners and fonts.', 'Dejte obrázkům a vloženým prvkům šířku a výšku a rezervujte místo pro bannery a písma.'] },
|
|
30
37
|
AC403: { cs: 'Server odpoví do 800 ms', why: ['A slow first byte delays everything else on the page.', 'Pomalý první bajt zdrží všechno ostatní na stránce.'], fix: ['Cache the page (CDN, full-page cache) or speed up the backend work behind it.', 'Stránku kešujte (CDN, cache celé stránky) nebo zrychlete práci backendu za ní.'] },
|
package/src/lib/packs.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export const PACKS = {
|
|
|
43
43
|
},
|
|
44
44
|
a11y: {
|
|
45
45
|
base: 300,
|
|
46
|
-
title: 'Accessibility
|
|
46
|
+
title: 'Accessibility (WCAG 2.2 AA subset): alt texts, names, labels, headings, contrast, landmarks, unique ids, plus a keyboard walk (tab order, visible focus, traps, skip link) and the accessibility tree (names, landmarks, ARIA)',
|
|
47
47
|
criteria: () => [
|
|
48
48
|
{ id: 'AC301', text: 'Images have alternative text', check: det('a11y_images_alt') },
|
|
49
49
|
{ id: 'AC302', text: 'Links and buttons have an accessible name', check: det('a11y_names') },
|
|
@@ -51,7 +51,14 @@ export const PACKS = {
|
|
|
51
51
|
{ id: 'AC304', text: 'Heading levels are not skipped', check: det('a11y_headings') },
|
|
52
52
|
{ id: 'AC305', text: 'Text meets WCAG AA contrast', check: det('a11y_contrast') },
|
|
53
53
|
{ id: 'AC306', text: 'The page has a main landmark', check: det('a11y_main') },
|
|
54
|
-
{ id: 'AC307', text: 'Element ids are unique', check: det('a11y_unique_ids') }
|
|
54
|
+
{ id: 'AC307', text: 'Element ids are unique', check: det('a11y_unique_ids') },
|
|
55
|
+
{ id: 'AC308', text: 'Tab order follows the page and reaches every control', check: det('a11y_focus_order') },
|
|
56
|
+
{ id: 'AC309', text: 'Keyboard focus is clearly visible (at least 3:1 contrast)', check: det('a11y_focus_visible') },
|
|
57
|
+
{ id: 'AC310', text: 'No keyboard trap', check: det('a11y_keyboard_trap') },
|
|
58
|
+
{ id: 'AC311', text: 'A skip link or an early way into the main content', check: det('a11y_skip_link') },
|
|
59
|
+
{ id: 'AC312', text: 'Controls have a name in the accessibility tree', check: det('a11y_ax_names') },
|
|
60
|
+
{ id: 'AC313', text: 'Landmarks are complete and labelled', check: det('a11y_landmarks') },
|
|
61
|
+
{ id: 'AC314', text: 'ARIA roles and attributes are valid', check: det('a11y_aria') }
|
|
55
62
|
]
|
|
56
63
|
},
|
|
57
64
|
perf: {
|
package/src/lib/png.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Minimal PNG codec (no native dependency): decodes the 8-bit, non-interlaced PNGs that Chrome screenshots are
|
|
2
|
+
// (truecolour, truecolour with alpha, greyscale, palette) into RGBA, and encodes RGBA for tests and crops.
|
|
3
|
+
import { inflateSync, deflateSync, crc32 } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
6
|
+
|
|
7
|
+
/** @returns {{ width: number, height: number, data: Uint8ClampedArray }} RGBA pixels */
|
|
8
|
+
export function decodePng (buf) {
|
|
9
|
+
buf = Buffer.from(buf)
|
|
10
|
+
if (!buf.subarray(0, 8).equals(SIG)) throw new Error('not a PNG')
|
|
11
|
+
let width = 0; let height = 0; let depth = 0; let type = 0; let interlace = 0; let palette = null; let trns = null
|
|
12
|
+
const idat = []
|
|
13
|
+
for (let at = 8; at < buf.length;) {
|
|
14
|
+
const len = buf.readUInt32BE(at); const kind = buf.toString('latin1', at + 4, at + 8); const data = buf.subarray(at + 8, at + 8 + len)
|
|
15
|
+
if (kind === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); depth = data[8]; type = data[9]; interlace = data[12] }
|
|
16
|
+
else if (kind === 'PLTE') palette = data
|
|
17
|
+
else if (kind === 'tRNS') trns = data
|
|
18
|
+
else if (kind === 'IDAT') idat.push(data)
|
|
19
|
+
else if (kind === 'IEND') break
|
|
20
|
+
at += 12 + len
|
|
21
|
+
}
|
|
22
|
+
if (depth !== 8 || interlace) throw new Error(`unsupported PNG (bit depth ${depth}, interlace ${interlace})`)
|
|
23
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[type]
|
|
24
|
+
if (!channels) throw new Error(`unsupported PNG colour type ${type}`)
|
|
25
|
+
const raw = inflateSync(Buffer.concat(idat))
|
|
26
|
+
const stride = width * channels
|
|
27
|
+
const px = Buffer.alloc(stride * height)
|
|
28
|
+
for (let y = 0; y < height; y++) {
|
|
29
|
+
const f = raw[y * (stride + 1)]; const src = y * (stride + 1) + 1; const dst = y * stride
|
|
30
|
+
for (let x = 0; x < stride; x++) {
|
|
31
|
+
const a = x >= channels ? px[dst + x - channels] : 0
|
|
32
|
+
const b = y ? px[dst - stride + x] : 0
|
|
33
|
+
const c = y && x >= channels ? px[dst - stride + x - channels] : 0
|
|
34
|
+
let v = raw[src + x]
|
|
35
|
+
if (f === 1) v += a
|
|
36
|
+
else if (f === 2) v += b
|
|
37
|
+
else if (f === 3) v += (a + b) >> 1
|
|
38
|
+
else if (f === 4) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c }
|
|
39
|
+
px[dst + x] = v & 255
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const data = new Uint8ClampedArray(width * height * 4)
|
|
43
|
+
for (let i = 0, j = 0; i < width * height; i++, j += channels) {
|
|
44
|
+
const o = i * 4
|
|
45
|
+
if (type === 6) { data[o] = px[j]; data[o + 1] = px[j + 1]; data[o + 2] = px[j + 2]; data[o + 3] = px[j + 3] }
|
|
46
|
+
else if (type === 2) { data[o] = px[j]; data[o + 1] = px[j + 1]; data[o + 2] = px[j + 2]; data[o + 3] = 255 }
|
|
47
|
+
else if (type === 0 || type === 4) { data[o] = data[o + 1] = data[o + 2] = px[j]; data[o + 3] = type === 4 ? px[j + 1] : 255 }
|
|
48
|
+
else { const k = px[j] * 3; data[o] = palette[k]; data[o + 1] = palette[k + 1]; data[o + 2] = palette[k + 2]; data[o + 3] = trns && px[j] < trns.length ? trns[px[j]] : 255 }
|
|
49
|
+
}
|
|
50
|
+
return { width, height, data }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** RGBA → PNG (filter 0, colour type 6). */
|
|
54
|
+
export function encodePng ({ width, height, data }) {
|
|
55
|
+
const stride = width * 4
|
|
56
|
+
const raw = Buffer.alloc((stride + 1) * height)
|
|
57
|
+
for (let y = 0; y < height; y++) Buffer.from(data.buffer, data.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1)
|
|
58
|
+
const chunk = (kind, body) => { const len = Buffer.alloc(4); len.writeUInt32BE(body.length); const td = Buffer.concat([Buffer.from(kind, 'latin1'), body]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td) >>> 0); return Buffer.concat([len, td, crc]) }
|
|
59
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6
|
|
60
|
+
return Buffer.concat([SIG, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))])
|
|
61
|
+
}
|
package/src/lib/pr.mjs
CHANGED
|
@@ -52,8 +52,11 @@ export function prCommentBody ({ manifest, url, at = new Date() }) {
|
|
|
52
52
|
* Creates or updates the scenario's comment on the current branch's PR.
|
|
53
53
|
* @returns {{ ok: boolean, action?: 'created'|'updated', url?: string, reason?: string }}
|
|
54
54
|
*/
|
|
55
|
-
export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun }) {
|
|
56
|
-
|
|
55
|
+
export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun, vars = process.env, fetchImpl = fetch }) {
|
|
56
|
+
// GitLab merge request pipelines: a note on the merge request through the API
|
|
57
|
+
if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) return upsertMrNote({ manifest, url, vars, fetchImpl })
|
|
58
|
+
// in CI the checkout is often a detached commit: KALOKO_PR (kaloko ci preview-url sets it) names the pull request
|
|
59
|
+
const view = await run('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,headRepository,headRepositoryOwner'], cwd)
|
|
57
60
|
if (view == null) return { ok: false, reason: 'gh is not installed, not signed in, or this branch has no pull request' }
|
|
58
61
|
let pr
|
|
59
62
|
try { pr = JSON.parse(view) } catch { return { ok: false, reason: 'gh pr view returned no JSON' } }
|
|
@@ -73,6 +76,25 @@ export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun })
|
|
|
73
76
|
return r == null ? { ok: false, reason: 'could not create the comment' } : { ok: true, action: 'created', url: r.trim() || pr.url }
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/** One note per scenario on a GitLab merge request (KALOKO_GITLAB_TOKEN: a project access token with the api scope). */
|
|
80
|
+
export async function upsertMrNote ({ manifest, url, vars = process.env, fetchImpl = fetch }) {
|
|
81
|
+
const token = vars.KALOKO_GITLAB_TOKEN || vars.GITLAB_TOKEN
|
|
82
|
+
if (!token) return { ok: false, reason: 'set KALOKO_GITLAB_TOKEN (a project access token with the api scope) to comment on the merge request' }
|
|
83
|
+
const notes = `${vars.CI_API_V4_URL}/projects/${encodeURIComponent(vars.CI_PROJECT_ID)}/merge_requests/${vars.CI_MERGE_REQUEST_IID}/notes`
|
|
84
|
+
const headers = { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' }
|
|
85
|
+
const body = prCommentBody({ manifest, url })
|
|
86
|
+
const link = (id) => `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}#note_${id}`
|
|
87
|
+
try {
|
|
88
|
+
const list = await fetchImpl(`${notes}?per_page=100&sort=desc`, { headers })
|
|
89
|
+
if (!list.ok) return { ok: false, reason: `GitLab answered ${list.status} listing notes` }
|
|
90
|
+
const mine = (await list.json()).find((n) => String(n.body || '').includes(marker(manifest.scenario.id)))
|
|
91
|
+
const res = await fetchImpl(mine ? `${notes}/${mine.id}` : notes, { method: mine ? 'PUT' : 'POST', headers, body: JSON.stringify({ body }) })
|
|
92
|
+
if (!res.ok) return { ok: false, reason: `GitLab answered ${res.status}` }
|
|
93
|
+
const note = await res.json()
|
|
94
|
+
return { ok: true, action: mine ? 'updated' : 'created', url: link(note.id || mine?.id) }
|
|
95
|
+
} catch (e) { return { ok: false, reason: e.message } }
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
async function defaultRun (cmd, args, cwd) {
|
|
77
99
|
try { const { stdout } = await exec(cmd, args, { cwd, timeout: 30000, maxBuffer: 4 * 1024 * 1024 }); return stdout } catch { return null }
|
|
78
100
|
}
|
package/src/lib/probe.mjs
CHANGED
|
@@ -195,6 +195,63 @@ export function probe ({ checks = [], highlights = [], scopes = [], maxChars = 9
|
|
|
195
195
|
}
|
|
196
196
|
return { ok: bad.length === 0, evidence: bad.length ? `${bad.length} text(s) below WCAG AA contrast: ${bad.slice(0, 4).join('; ')}` : `${checked} texts meet WCAG AA contrast` }
|
|
197
197
|
}
|
|
198
|
+
if (kind === 'landmarks') {
|
|
199
|
+
// landmark structure as a screen reader lists it: one main, one top-level banner and contentinfo, repeated
|
|
200
|
+
// landmarks told apart by a label
|
|
201
|
+
const roleOf = (e) => {
|
|
202
|
+
const r = e.getAttribute('role'); if (r) return ['banner', 'navigation', 'main', 'contentinfo', 'complementary', 'search', 'form', 'region'].includes(r) ? r : null
|
|
203
|
+
const t = e.tagName
|
|
204
|
+
if (t === 'MAIN') return 'main'; if (t === 'NAV') return 'navigation'; if (t === 'ASIDE') return 'complementary'; if (t === 'SEARCH') return 'search'
|
|
205
|
+
// header/footer are banner/contentinfo only outside article, aside, main, nav and section
|
|
206
|
+
if (t === 'HEADER' || t === 'FOOTER') return e.parentElement?.closest('article, aside, main, nav, section') ? null : t === 'HEADER' ? 'banner' : 'contentinfo'
|
|
207
|
+
if ((t === 'SECTION' || t === 'FORM') && (e.getAttribute('aria-label') || e.getAttribute('aria-labelledby'))) return t === 'FORM' ? 'form' : 'region'
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
const all = [...document.querySelectorAll('main, nav, aside, header, footer, search, section, form, [role]')].filter((e) => !e.closest('[aria-hidden=true]') && visible(e)).map((e) => ({ e, role: roleOf(e) })).filter((x) => x.role)
|
|
211
|
+
const by = {}; for (const x of all) (by[x.role] ||= []).push(x.e)
|
|
212
|
+
const bad = []
|
|
213
|
+
if (!by.main) bad.push('no main landmark')
|
|
214
|
+
else if (by.main.length > 1) bad.push(`${by.main.length} main landmarks`)
|
|
215
|
+
for (const r of ['banner', 'contentinfo']) if ((by[r] || []).length > 1) bad.push(`${by[r].length} ${r} landmarks`)
|
|
216
|
+
for (const [r, list] of Object.entries(by)) {
|
|
217
|
+
if (list.length < 2 || r === 'main') continue
|
|
218
|
+
const labels = list.map((e) => clean(e.getAttribute('aria-label') || (e.getAttribute('aria-labelledby') || '').split(/\s+/).map((i) => document.getElementById(i)?.innerText || '').join(' ')))
|
|
219
|
+
if (labels.some((l) => !l) || new Set(labels).size < labels.length) bad.push(`${list.length} ${r} landmarks without distinct labels (aria-label)`)
|
|
220
|
+
}
|
|
221
|
+
const summary = Object.entries(by).map(([r, list]) => `${r}${list.length > 1 ? ` ×${list.length}` : ''}`).join(', ')
|
|
222
|
+
return { ok: bad.length === 0, evidence: bad.length ? `${bad.join('; ')} (landmarks: ${summary || 'none'})` : `landmarks: ${summary}` }
|
|
223
|
+
}
|
|
224
|
+
if (kind === 'aria') {
|
|
225
|
+
// ARIA that misleads a screen reader: unknown roles, misspelt attributes, references to missing ids, hidden
|
|
226
|
+
// focusable content, boolean attributes with other values
|
|
227
|
+
const ROLES = 'alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading img insertion link list listbox listitem log main marquee math meter menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem doc-abstract doc-acknowledgments doc-afterword doc-appendix doc-backlink doc-biblioentry doc-bibliography doc-biblioref doc-chapter doc-colophon doc-conclusion doc-cover doc-credit doc-credits doc-dedication doc-endnote doc-endnotes doc-epigraph doc-epilogue doc-errata doc-example doc-footnote doc-foreword doc-glossary doc-glossref doc-index doc-introduction doc-noteref doc-notice doc-pagebreak doc-pagelist doc-part doc-preface doc-prologue doc-pullquote doc-qna doc-subtitle doc-tip doc-toc graphics-document graphics-object graphics-symbol'.split(' ')
|
|
228
|
+
const ATTRS = 'activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount colindex colindextext colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(' ')
|
|
229
|
+
const BOOL = ['atomic', 'busy', 'disabled', 'hidden', 'modal', 'multiline', 'multiselectable', 'readonly', 'required']
|
|
230
|
+
const TRI = { expanded: ['true', 'false', 'undefined'], selected: ['true', 'false', 'undefined'], checked: ['true', 'false', 'mixed', 'undefined'], pressed: ['true', 'false', 'mixed', 'undefined'] }
|
|
231
|
+
// rendered on screen, whatever aria-hidden says (visible() treats aria-hidden content as gone)
|
|
232
|
+
const rendered = (e) => typeof e.checkVisibility === 'function' ? e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) : getComputedStyle(e).display !== 'none'
|
|
233
|
+
const REFS = ['labelledby', 'describedby', 'controls', 'owns', 'activedescendant', 'errormessage', 'flowto', 'details']
|
|
234
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex^="-"]), [contenteditable=true]'
|
|
235
|
+
const issues = []
|
|
236
|
+
const add = (e, text) => { if (issues.length < 40) issues.push(`${describe(e)}: ${text}`) }
|
|
237
|
+
for (const e of document.querySelectorAll('body [role], body *')) {
|
|
238
|
+
if (issues.length >= 40) break
|
|
239
|
+
const role = e.getAttribute('role')
|
|
240
|
+
if (role != null && role.trim() && !role.trim().split(/\s+/).some((r) => ROLES.includes(r))) add(e, `unknown role "${role}"`)
|
|
241
|
+
for (const a of e.attributes) {
|
|
242
|
+
if (!a.name.startsWith('aria-')) continue
|
|
243
|
+
const n = a.name.slice(5)
|
|
244
|
+
if (!ATTRS.includes(n)) { add(e, `unknown attribute ${a.name}`); continue }
|
|
245
|
+
if (BOOL.includes(n) && !['true', 'false', ''].includes(a.value.trim())) add(e, `${a.name}="${a.value}" (true or false)`)
|
|
246
|
+
if (TRI[n] && a.value.trim() && !TRI[n].includes(a.value.trim())) add(e, `${a.name}="${a.value}" (${TRI[n].join(', ')})`)
|
|
247
|
+
if (REFS.includes(n)) { const missing = a.value.split(/\s+/).filter(Boolean).filter((i) => !document.getElementById(i)); if (missing.length) add(e, `${a.name} points to missing id ${missing.slice(0, 2).map((i) => '#' + i).join(', ')}`) }
|
|
248
|
+
}
|
|
249
|
+
if (e.getAttribute('aria-hidden') === 'true' && rendered(e)) { const f = e.matches(FOCUSABLE) ? e : e.querySelector(FOCUSABLE); if (f && rendered(f) && !f.closest('[inert]')) add(e, `aria-hidden="true" hides focusable ${describe(f)} from screen readers`) }
|
|
250
|
+
if (/^(presentation|none)$/.test(role || '') && e.matches(FOCUSABLE)) add(e, `role="${role}" on a focusable element`)
|
|
251
|
+
}
|
|
252
|
+
const shown = [...new Set(issues)]
|
|
253
|
+
return { ok: shown.length === 0, evidence: shown.length ? `${shown.length} ARIA problem(s): ${shown.slice(0, 5).join('; ')}` : 'roles and aria-* attributes are valid' }
|
|
254
|
+
}
|
|
198
255
|
return { ok: null, evidence: `unknown a11y check ${kind}` }
|
|
199
256
|
}
|
|
200
257
|
const buffered = (type) => { try { const po = new PerformanceObserver(() => {}); po.observe({ type, buffered: true }); const r = po.takeRecords(); po.disconnect(); return r } catch { return null } }
|
package/src/lib/recheck.mjs
CHANGED
|
@@ -12,8 +12,10 @@ import { probeArgs } from './capture.mjs'
|
|
|
12
12
|
import { contextOptions, prepareContext } from './access.mjs'
|
|
13
13
|
import { launchEphemeral } from './browser.mjs'
|
|
14
14
|
import { treeChecks } from './apps/tree.mjs'
|
|
15
|
+
import { KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// keyboard walk and accessibility tree (#19) need the live page too
|
|
18
|
+
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)
|
|
17
19
|
|
|
18
20
|
const withBase = (html, url) => {
|
|
19
21
|
if (!url || /<base\s/i.test(html)) return html
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -31,7 +31,8 @@ export function validateScenario (data) {
|
|
|
31
31
|
if (node.kind === 'stack' && node.items && ['urls', 'file', 'sitemap', 'crawl', 'script'].filter((k) => node.items[k]).length !== 1) problems.push(`node "${node.id}": items needs exactly one source of urls, file, sitemap, crawl or script`)
|
|
32
32
|
if (node.kind !== 'stack' && (node.items || node.fields)) problems.push(`node "${node.id}": items and fields belong to stack steps`)
|
|
33
33
|
// app screens are reached by a script or a person as often as by a deep link
|
|
34
|
-
|
|
34
|
+
// a manual screen may have no path: a person (or an imported walkthrough) shows how to get there
|
|
35
|
+
if (node.kind === 'screen' && !node.path && (!data.platform || data.platform === 'web') && node.execution?.strategy !== 'manual') problems.push(`node "${node.id}": screen steps need a path`)
|
|
35
36
|
if (node.kind === 'stack' && data.platform && data.platform !== 'web') problems.push(`node "${node.id}": stack steps list web pages; they are not available on platform ${data.platform}`)
|
|
36
37
|
if (node.kind === 'email' && !node.mail) problems.push(`node "${node.id}": email steps need mail.subject`)
|
|
37
38
|
}
|