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.
Files changed (51) hide show
  1. package/README.md +92 -5
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +33 -0
  4. package/schemas/scenario.v1.json +39 -0
  5. package/schemas/spec.v1.json +60 -0
  6. package/skills/kaloko/SKILL.md +24 -3
  7. package/skills/kaloko/reference/acceptance.md +50 -2
  8. package/skills/kaloko/reference/ci.md +70 -0
  9. package/skills/kaloko/reference/collaboration.md +9 -1
  10. package/skills/kaloko/reference/design.md +35 -2
  11. package/skills/kaloko/reference/environments.md +5 -0
  12. package/skills/kaloko/reference/evaluation.md +46 -3
  13. package/skills/kaloko/reference/execution.md +74 -0
  14. package/skills/kaloko/reference/import.md +66 -0
  15. package/skills/kaloko/reference/mcp.md +4 -1
  16. package/src/cli.mjs +428 -99
  17. package/src/lib/affected-core.mjs +242 -0
  18. package/src/lib/affected.mjs +233 -0
  19. package/src/lib/build.mjs +30 -5
  20. package/src/lib/capture.mjs +43 -1
  21. package/src/lib/ci.mjs +207 -0
  22. package/src/lib/config.mjs +2 -0
  23. package/src/lib/design-cli.mjs +7 -3
  24. package/src/lib/devspec.mjs +821 -0
  25. package/src/lib/doctor.mjs +7 -0
  26. package/src/lib/drift.mjs +16 -8
  27. package/src/lib/evaluate.mjs +98 -14
  28. package/src/lib/git.mjs +6 -2
  29. package/src/lib/heal.mjs +207 -0
  30. package/src/lib/import.mjs +374 -0
  31. package/src/lib/init-detect.mjs +219 -0
  32. package/src/lib/keyboard.mjs +261 -0
  33. package/src/lib/manifest-core.mjs +16 -5
  34. package/src/lib/onboarding.mjs +15 -8
  35. package/src/lib/pack-help.mjs +7 -0
  36. package/src/lib/packs.mjs +9 -2
  37. package/src/lib/png.mjs +61 -0
  38. package/src/lib/pr.mjs +24 -2
  39. package/src/lib/probe.mjs +57 -0
  40. package/src/lib/recheck.mjs +3 -1
  41. package/src/lib/scenario.mjs +2 -1
  42. package/src/lib/service.mjs +7 -1
  43. package/src/lib/spec-cli.mjs +90 -0
  44. package/src/lib/stability-core.mjs +114 -0
  45. package/src/lib/stability.mjs +249 -0
  46. package/src/lib/unzip.mjs +31 -0
  47. package/src/lib/upload.mjs +114 -0
  48. package/src/lib/vision-core.mjs +132 -0
  49. package/src/lib/vision.mjs +127 -0
  50. package/templates/kaloko.config.yml +6 -0
  51. 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
- 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' && ev.semantic?.via ? { via: ev.semantic.via } : {}) }
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) }
@@ -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
- /** Lines printed at the end of `kaloko init`. */
14
- export function initNextSteps ({ base, org, loggedIn = false }) {
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
- return [
17
- 'Next:',
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`. */
@@ -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 basics (WCAG 2.2 AA subset): alt texts, names, labels, headings, contrast, landmarks, unique ids',
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: {
@@ -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
- const view = await run('gh', ['pr', 'view', '--json', 'number,url,headRepository,headRepositoryOwner'], cwd)
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 } }
@@ -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
- export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || assert === 'links_ok' || assert === 'hreflang_pairs' || /^perf_/.test(assert)
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
@@ -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
- if (node.kind === 'screen' && !node.path && (!data.platform || data.platform === 'web')) problems.push(`node "${node.id}": screen steps need a path`)
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
  }