kaloko 0.4.8 → 0.4.9

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.
@@ -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/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
@@ -0,0 +1,90 @@
1
+ // `kaloko spec` and `kaloko drift` (#42 Dev mode for agents): what to build for a step as it was approved, and how
2
+ // an implementation run differs from it, in the same vocabulary (elements by role and name, styles → DTCG tokens,
3
+ // interaction states). The spec comes from the service (design reference, draft, a version or a shared run) or from
4
+ // a local run's captures; drift compares the local implementation run with the reference from the service.
5
+ import { join } from 'node:path'
6
+ import { readJson, UserError } from './util.mjs'
7
+ import { readCaptures } from './run.mjs'
8
+ import { latestCaptures } from './evaluate.mjs'
9
+ import { specText, specDrift, driftText } from './devspec.mjs'
10
+
11
+ const enc = encodeURIComponent
12
+
13
+ /** The local run's specs: [{ node, locale, viewport, spec }] (newest capture of each). */
14
+ export async function localSpecs (run, { step, locale, viewport } = {}) {
15
+ const out = []
16
+ for (const c of latestCaptures(await readCaptures(run.dir))) {
17
+ if (c.item || (step && c.node !== step) || (locale && c.locale !== locale) || (viewport && c.viewport !== viewport)) continue
18
+ const rec = await readJson(join(run.dir, c.file), null)
19
+ out.push({ node: c.node, locale: c.locale, viewport: c.viewport, spec: rec?.spec || null })
20
+ }
21
+ return out
22
+ }
23
+
24
+ const pickOne = (list, viewport) => list.find((x) => x.viewport === viewport) || list.find((x) => x.viewport === 'desktop') || list[0]
25
+
26
+ export async function specCmd ({ config, client, run, scenario, scenarioId, args, say }) {
27
+ const step = typeof args._[0] === 'string' ? args._[0] : typeof args.step === 'string' ? args.step : null
28
+ const locale = typeof args.locale === 'string' ? args.locale : null
29
+ const viewport = typeof args.viewport === 'string' ? args.viewport : null
30
+ const steps = step ? [step] : (scenario?.nodes || []).filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id)
31
+ if (!steps.length) throw new UserError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]')
32
+ const out = []
33
+ for (const s of steps) {
34
+ if (args.local || !client) {
35
+ if (!run) throw new UserError('--local needs a run: pass --run <id> or start one.')
36
+ const list = (await localSpecs(run, { step: s, locale, viewport })).filter((x) => locale || x.locale === (run.meta.scope?.locales?.[0] || x.locale))
37
+ const one = pickOne(list, viewport)
38
+ if (!one) continue
39
+ out.push({ node: s, locale: one.locale, viewport: one.viewport, ref: 'local', spec: one.spec })
40
+ } else {
41
+ const ref = typeof args.ref === 'string' ? args.ref : typeof args.version === 'string' || typeof args.version === 'number' ? `v${args.version}` : 'design'
42
+ const q = new URLSearchParams(Object.entries({ ref, locale, viewport }).filter(([, v]) => v))
43
+ try {
44
+ const d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(s)}/spec?${q}`)
45
+ out.push({ node: s, locale: d.locale, viewport: d.viewport, ref: d.ref, spec: d.spec })
46
+ } catch (e) { if (step) throw e; out.push({ node: s, error: e.message.split('\n')[0] }) }
47
+ }
48
+ }
49
+ if (args.json) { say(JSON.stringify(step ? out[0] || null : out, null, 2)); return }
50
+ if (!out.length) throw new UserError(`No capture of ${step || 'any step'} in this run.`)
51
+ say(out.map((o) => o.error ? `${o.node}: ${o.error}` : specText(o.spec, { title: `${o.node} · ${o.locale} · ${o.viewport} · ${o.ref}` })).join('\n\n'))
52
+ }
53
+
54
+ /**
55
+ * Drift of the local implementation run (or, with --remote, a shared run) from the approved design. Exit code 1
56
+ * when anything differs, so an agent can loop: implement → walk → drift → fix.
57
+ */
58
+ export async function driftCmd ({ config, client, run, scenarioId, args, say }) {
59
+ const step = typeof args._[0] === 'string' ? args._[0] : typeof args.step === 'string' ? args.step : null
60
+ const ref = typeof args.ref === 'string' ? args.ref : typeof args.version === 'string' || typeof args.version === 'number' ? `v${args.version}` : 'design'
61
+ const locale = typeof args.locale === 'string' ? args.locale : null
62
+ const viewport = typeof args.viewport === 'string' ? args.viewport : null
63
+ if (!client) throw new UserError('kaloko drift compares with the approved design on the service: set org and a token (kaloko login).')
64
+ const results = []
65
+ if (args.remote) {
66
+ if (!step) throw new UserError('Usage: kaloko drift <step> --remote <run id|latest>')
67
+ const q = new URLSearchParams(Object.entries({ run: typeof args.remote === 'string' ? args.remote : 'latest', ref, locale, viewport }).filter(([, v]) => v))
68
+ const d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(step)}/drift?${q}`)
69
+ results.push({ node: step, locale: d.locale, viewport: d.viewport, ref: d.ref, findings: d.findings, text: d.text })
70
+ } else {
71
+ if (!run) throw new UserError('No implementation run here: walk the scenario first (packs: [design] on the steps), or pass --remote <run id>.')
72
+ if (run.meta.environment?.kind === 'design') throw new UserError('This is a design run; drift compares an implementation run with the design. Pass --run <implementation run>.')
73
+ const git = run.meta.git?.branch
74
+ const mine = (await localSpecs(run, { step, locale, viewport }))
75
+ if (!mine.length) throw new UserError(`No capture${step ? ` of ${step}` : ''} in run ${run.meta.id}.`)
76
+ for (const m of mine) {
77
+ if (!m.spec) { results.push({ node: m.node, locale: m.locale, viewport: m.viewport, note: 'no spec in this capture: add packs: [design] to the step and walk again' }); continue }
78
+ const q = new URLSearchParams(Object.entries({ ref, locale: m.locale, viewport: m.viewport, git }).filter(([, v]) => v))
79
+ let d
80
+ try { d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(m.node)}/spec?${q}`) } catch (e) { results.push({ node: m.node, locale: m.locale, viewport: m.viewport, note: e.message.split('\n')[0] }); continue }
81
+ if (!d.spec) { results.push({ node: m.node, locale: m.locale, viewport: m.viewport, note: 'the design has no spec for this step yet (walk and sync the design with the current CLI)' }); continue }
82
+ const findings = specDrift(m.spec, d.spec)
83
+ results.push({ node: m.node, locale: m.locale, viewport: m.viewport, ref: d.ref, findings, text: driftText(findings, { title: `${m.node} · ${m.locale} · ${m.viewport} vs ${d.ref}` }) })
84
+ }
85
+ }
86
+ const differing = results.filter((r) => r.findings?.length).length
87
+ if (args.json) say(JSON.stringify(results.map(({ text, ...r }) => r), null, 2))
88
+ else say(results.map((r) => r.text || `${r.node} · ${r.locale} · ${r.viewport}: ${r.note}`).join('\n\n') + `\n\n${differing ? `${differing} of ${results.length} capture(s) differ from the approved design.` : `${results.filter((r) => r.findings).length} capture(s) match the approved design.`}`)
89
+ if (differing) process.exitCode = 1
90
+ }
@@ -0,0 +1,114 @@
1
+ // Unstable regions (#20): parts of a screen that change between captures of the same state (clocks, ads,
2
+ // carousels, avatars, "3 minutes ago"). Pure functions without Node APIs: `unstableRegions` also runs inside a
3
+ // browser page (the CLI injects its source to compare WebP captures there), so it must stay self-contained.
4
+
5
+ /**
6
+ * Regions that differ in most consecutive pairs of `images` (RGBA arrays of the same width × height).
7
+ * The images are split into cells; a cell changes in a pair when at least `cellFill` of its pixels differ by more
8
+ * than `threshold` (sum of RGB differences, the canvas diff's rule). A cell is unstable when it changed in at least
9
+ * `minShare` of the pairs. Neighbouring unstable cells (gap up to `gap` cells) become one rectangle.
10
+ * When more than `maxArea` of the screen is unstable the page itself changed: no regions are suggested.
11
+ * @returns {{ regions: Array<{x:number,y:number,w:number,h:number,share:number}>, changed: number, pairs: number, too_much?: boolean }}
12
+ */
13
+ export function unstableRegions ({ images, width, height, threshold = 48, cell = 8, cellFill = 0.02, minShare = 0.66, gap = 2, pad = 4, maxArea = 0.35, minCells = 2 }) {
14
+ const pairs = images.length - 1
15
+ if (pairs < 1 || !width || !height) return { regions: [], changed: 0, pairs: Math.max(0, pairs) }
16
+ const cw = Math.ceil(width / cell); const ch = Math.ceil(height / cell)
17
+ const hits = new Uint16Array(cw * ch)
18
+ const count = new Uint32Array(cw * ch)
19
+ for (let p = 0; p < pairs; p++) {
20
+ const a = images[p]; const b = images[p + 1]
21
+ count.fill(0)
22
+ for (let y = 0; y < height; y++) {
23
+ const row = (y / cell | 0) * cw
24
+ for (let x = 0; x < width; x++) {
25
+ const i = (y * width + x) * 4
26
+ if (Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]) > threshold) count[row + (x / cell | 0)]++
27
+ }
28
+ }
29
+ for (let c = 0; c < count.length; c++) {
30
+ const cx = c % cw; const cy = (c / cw) | 0
31
+ const area = (Math.min(cell, width - cx * cell)) * (Math.min(cell, height - cy * cell))
32
+ if (count[c] >= Math.max(1, area * cellFill)) hits[c]++
33
+ }
34
+ }
35
+ const flagged = new Uint8Array(cw * ch)
36
+ let total = 0
37
+ for (let c = 0; c < hits.length; c++) if (hits[c] / pairs >= minShare) { flagged[c] = 1; total++ }
38
+ const changed = total / (cw * ch)
39
+ if (changed > maxArea) return { regions: [], changed, pairs, too_much: true }
40
+ // connected components over flagged cells, bridging gaps of up to `gap` cells
41
+ const label = new Int32Array(cw * ch).fill(-1)
42
+ const boxes = []
43
+ for (let c = 0; c < flagged.length; c++) {
44
+ if (!flagged[c] || label[c] >= 0) continue
45
+ const box = { x0: c % cw, y0: (c / cw) | 0, x1: c % cw, y1: (c / cw) | 0, cells: 0, hits: 0 }
46
+ const stack = [c]; label[c] = boxes.length
47
+ while (stack.length) {
48
+ const k = stack.pop(); const kx = k % cw; const ky = (k / cw) | 0
49
+ box.cells++; box.hits += hits[k]
50
+ if (kx < box.x0) box.x0 = kx; if (kx > box.x1) box.x1 = kx; if (ky < box.y0) box.y0 = ky; if (ky > box.y1) box.y1 = ky
51
+ for (let dy = -gap - 1; dy <= gap + 1; dy++) {
52
+ for (let dx = -gap - 1; dx <= gap + 1; dx++) {
53
+ const nx = kx + dx; const ny = ky + dy
54
+ if (nx < 0 || ny < 0 || nx >= cw || ny >= ch) continue
55
+ const n = ny * cw + nx
56
+ if (flagged[n] && label[n] < 0) { label[n] = boxes.length; stack.push(n) }
57
+ }
58
+ }
59
+ }
60
+ boxes.push(box)
61
+ }
62
+ const regions = boxes.filter((b) => b.cells >= minCells).map((b) => {
63
+ const x = Math.max(0, b.x0 * cell - pad); const y = Math.max(0, b.y0 * cell - pad)
64
+ return { x, y, w: Math.min(width, (b.x1 + 1) * cell + pad) - x, h: Math.min(height, (b.y1 + 1) * cell + pad) - y, share: Math.round((b.hits / b.cells / pairs) * 100) / 100 }
65
+ })
66
+ return { regions: mergeRects(regions), changed, pairs }
67
+ }
68
+
69
+ /** Merges overlapping rectangles ({x,y,w,h}); keeps the highest share. */
70
+ export function mergeRects (rects, slack = 0) {
71
+ const out = rects.map((r) => ({ ...r }))
72
+ for (let merged = true; merged;) {
73
+ merged = false
74
+ for (let i = 0; i < out.length && !merged; i++) {
75
+ for (let j = i + 1; j < out.length; j++) {
76
+ const a = out[i]; const b = out[j]
77
+ if (a.x - slack <= b.x + b.w && b.x - slack <= a.x + a.w && a.y - slack <= b.y + b.h && b.y - slack <= a.y + a.h) {
78
+ const x = Math.min(a.x, b.x); const y = Math.min(a.y, b.y)
79
+ out[i] = { ...a, x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y, ...(a.share != null || b.share != null ? { share: Math.max(a.share ?? 0, b.share ?? 0) } : {}) }
80
+ out.splice(j, 1); merged = true; break
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return out
86
+ }
87
+
88
+ /**
89
+ * The ignore entries of a step that apply to one locale and viewport. Scenario form:
90
+ * ignore: [{ selector: '.clock' }, { rect: [x, y, w, h], viewport: desktop }, { selector: '#ad', locales: [cs] }]
91
+ */
92
+ export function ignoreFor (node, locale, viewport) {
93
+ return (Array.isArray(node?.ignore) ? node.ignore : []).filter((e) => {
94
+ if (!e || typeof e !== 'object') return false
95
+ if (e.viewport && e.viewport !== viewport) return false
96
+ if (Array.isArray(e.viewports) && e.viewports.length && !e.viewports.includes(viewport)) return false
97
+ if (Array.isArray(e.locales) && e.locales.length && !e.locales.includes(locale)) return false
98
+ return !!(e.selector || (Array.isArray(e.rect) && e.rect.length === 4))
99
+ })
100
+ }
101
+
102
+ /** Is pixel (x, y) (CSS px of the page) inside one of the rectangles? For diff loops, prefer `ignoreMask`. */
103
+ export const inRects = (rects, x, y) => rects.some((r) => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h)
104
+
105
+ /** A W × H mask (1 = ignored) from rectangles in CSS px scaled by k (diff width / page width). */
106
+ export function ignoreMask (rects, W, H, k = 1) {
107
+ const m = new Uint8Array(W * H)
108
+ for (const r of rects || []) {
109
+ const x0 = Math.max(0, Math.floor(r.x * k)); const y0 = Math.max(0, Math.floor(r.y * k))
110
+ const x1 = Math.min(W, Math.ceil((r.x + r.w) * k)); const y1 = Math.min(H, Math.ceil((r.y + r.h) * k))
111
+ for (let y = y0; y < y1; y++) m.fill(1, y * W + x0, y * W + x1)
112
+ }
113
+ return m
114
+ }
@@ -0,0 +1,249 @@
1
+ // Auto-detected unstable regions (#20). Two sources:
2
+ // - repeated screenshots of one capture (`kaloko walk --stability N`): what moves while nobody touches the page
3
+ // (clocks, carousels, animations) lands in the capture record as `unstable`;
4
+ // - earlier local runs of the same scenario and environment (`kaloko stability`): what differs on every load
5
+ // (ads, avatars, "3 minutes ago").
6
+ // `kaloko stability` turns both into ignore suggestions per step (`ignore: [{ selector } | { rect, viewport }]`),
7
+ // written next to the run and, with --apply, into the scenario. Pixel diffs on the canvas and the design pixel
8
+ // check skip ignored regions.
9
+ import { readFile, writeFile, readdir } from 'node:fs/promises'
10
+ import { join, resolve } from 'node:path'
11
+ import yaml from 'js-yaml'
12
+ import { readJson, writeJson, UserError } from './util.mjs'
13
+ import { readCaptures } from './run.mjs'
14
+ import { latestCaptures } from './evaluate.mjs'
15
+ import { decodePng } from './png.mjs'
16
+ import { unstableRegions, mergeRects, ignoreFor } from './stability-core.mjs'
17
+
18
+ const MAX_HEIGHT = 12000
19
+
20
+ /** Copies the top-left width × height of an RGBA image. */
21
+ function cropRgba (img, width, height) {
22
+ if (img.width === width) return img.data.subarray(0, width * height * 4)
23
+ const out = new Uint8ClampedArray(width * height * 4)
24
+ for (let y = 0; y < height; y++) out.set(img.data.subarray(y * img.width * 4, y * img.width * 4 + width * 4), y * width * 4)
25
+ return out
26
+ }
27
+
28
+ /** Regions that change while the page sits still: `n` screenshots `delay` ms apart (CSS pixels). */
29
+ export async function repeatRegions (page, { n = 3, delay = 800, fullPage = true } = {}) {
30
+ const shots = []
31
+ for (let i = 0; i < n; i++) {
32
+ if (i) await page.waitForTimeout(delay)
33
+ shots.push(decodePng(await page.screenshot({ fullPage, scale: 'css', animations: 'allow' })))
34
+ }
35
+ const width = Math.min(...shots.map((s) => s.width)); const height = Math.min(MAX_HEIGHT, ...shots.map((s) => s.height))
36
+ const r = unstableRegions({ images: shots.map((s) => cropRgba(s, width, height)), width, height })
37
+ return { shots: n, regions: r.regions, ...(r.too_much ? { too_much: Math.round(r.changed * 100) / 100 } : {}) }
38
+ }
39
+
40
+ /** Ignore regions of one capture as page rectangles (CSS px): selectors resolved in the live page, rects as given. */
41
+ export async function resolveIgnore (page, node, locale, viewport) {
42
+ const entries = ignoreFor(node, locale, viewport)
43
+ if (!entries.length) return []
44
+ const sels = entries.filter((e) => e.selector).map((e) => e.selector)
45
+ const found = sels.length ? await page.evaluate((list) => list.flatMap((sel) => {
46
+ let els = []
47
+ try { els = [...document.querySelectorAll(sel)] } catch { return [] }
48
+ return els.map((el) => { const r = el.getBoundingClientRect(); return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height), selector: sel } }).filter((r) => r.w > 0 && r.h > 0).slice(0, 20)
49
+ }), sels).catch(() => []) : []
50
+ return [...found, ...entries.filter((e) => e.rect).map((e) => ({ x: e.rect[0], y: e.rect[1], w: e.rect[2], h: e.rect[3] }))]
51
+ }
52
+
53
+ /** Local runs of the scenario and environment of `run`, newest first (meta and directory). */
54
+ export async function siblingRuns ({ root, config, run, sameCommit = false }) {
55
+ const base = resolve(root, config.output)
56
+ let names = []
57
+ try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name) } catch { return [] }
58
+ const out = []
59
+ for (const name of names) {
60
+ const meta = await readJson(join(base, name, 'meta.json'), null)
61
+ if (!meta || meta.id === run.meta.id || meta.scenario?.id !== run.meta.scenario?.id || meta.environment?.name !== run.meta.environment?.name) continue
62
+ if (sameCommit && (meta.git?.commit || null) !== (run.meta.git?.commit || null)) continue
63
+ out.push({ dir: join(base, name), meta })
64
+ }
65
+ return out.sort((a, b) => String(b.meta.started_at).localeCompare(String(a.meta.started_at)))
66
+ }
67
+
68
+ const key = (e) => `${e.node}|${e.locale}|${e.viewport}`
69
+
70
+ // the self-contained detector, injected into a blank page to compare WebP captures there
71
+ const DETECTOR = `window.__mergeRects = ${mergeRects.toString()}; const mergeRects = window.__mergeRects; window.__unstableRegions = ${unstableRegions.toString()};`
72
+
73
+ /** Cross-run regions of one step, locale and viewport: images drawn at the page's CSS width. */
74
+ async function crossRegions (page, sources, cssWidth) {
75
+ return page.evaluate(async ({ sources, W, MAX }) => {
76
+ const load = (src) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = src })
77
+ const imgs = await Promise.all(sources.map(load))
78
+ const H = Math.min(MAX, ...imgs.map((i) => Math.round(i.naturalHeight * W / i.naturalWidth)))
79
+ const data = imgs.map((img) => { const c = document.createElement('canvas'); c.width = W; c.height = H; const g = c.getContext('2d', { willReadFrequently: true }); g.drawImage(img, 0, 0, W, Math.round(img.naturalHeight * W / img.naturalWidth)); return g.getImageData(0, 0, W, H).data })
80
+ return window.__unstableRegions({ images: data, width: W, height: H })
81
+ }, { sources, W: cssWidth, MAX: MAX_HEIGHT })
82
+ }
83
+
84
+ const dataUrl = async (file) => `data:image/${file.endsWith('.png') ? 'png' : 'webp'};base64,${(await readFile(file)).toString('base64')}`
85
+ const withBase = (html, url) => {
86
+ if (!url || /<base\s/i.test(html)) return html
87
+ const tag = `<base href="${String(url).replace(/"/g, '&quot;')}">`
88
+ return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + tag) : tag + html
89
+ }
90
+
91
+ /** A stable CSS selector for the element that covers a region in the stored HTML, or null (runs in the page). */
92
+ function selectorAt (r) {
93
+ const cx = r.x + r.w / 2; const cy = r.y + r.h / 2
94
+ window.scrollTo(0, Math.max(0, cy - innerHeight / 2))
95
+ const hit = document.elementFromPoint(cx - scrollX, cy - scrollY)
96
+ const fit = (e) => {
97
+ const b = e.getBoundingClientRect(); const x = b.left + scrollX; const y = b.top + scrollY
98
+ const ix = Math.max(0, Math.min(x + b.width, r.x + r.w) - Math.max(x, r.x)); const iy = Math.max(0, Math.min(y + b.height, r.y + r.h) - Math.max(y, r.y))
99
+ return { covers: (ix * iy) / (r.w * r.h), ratio: (b.width * b.height) / (r.w * r.h) }
100
+ }
101
+ let best = null
102
+ for (let e = hit; e && e !== document.body && e !== document.documentElement; e = e.parentElement) {
103
+ const f = fit(e)
104
+ if (f.ratio > 6) break
105
+ if (f.covers >= 0.7) { best = e; break }
106
+ }
107
+ if (!best) return null
108
+ const esc = (s) => CSS.escape(s)
109
+ const unique = (s) => { try { return document.querySelectorAll(s).length === 1 } catch { return false } }
110
+ // generated ids and state classes change between builds; leave them out
111
+ const stableId = (e) => e.id && !/\d{4,}|[a-f0-9]{8,}|^:r/i.test(e.id)
112
+ const part = (e) => e.tagName.toLowerCase() + [...e.classList].filter((c) => !/\d{4,}|[a-f0-9]{6,}|^(is|has)-|active|current|visible|show|open|selected|swiper-slide-/i.test(c)).slice(0, 2).map((c) => '.' + esc(c)).join('')
113
+ if (stableId(best) && unique('#' + esc(best.id))) return '#' + esc(best.id)
114
+ let sel = part(best)
115
+ if (sel.includes('.') && unique(sel)) return sel
116
+ for (let e = best.parentElement, d = 0; e && e !== document.body && d < 4; e = e.parentElement, d++) {
117
+ if (stableId(e) && unique(`#${esc(e.id)} ${sel}`)) return `#${esc(e.id)} ${sel}`
118
+ sel = `${part(e)} > ${sel}`
119
+ if (unique(sel)) return sel
120
+ }
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * Ignore suggestions for the steps of `run` from its own repeated shots and from earlier runs.
126
+ * @returns {{ runs: string[], steps: Array<{ node, ignore: object[], found: object[] }>, skipped: string[] }}
127
+ */
128
+ export async function detectUnstable ({ root, config, run, scenario, last = 4, runs = null, sameCommit = false, launch = null }) {
129
+ const others = runs ? (await Promise.all(runs.map(async (id) => {
130
+ const dir = id.includes('/') ? resolve(id) : join(resolve(root, config.output), id)
131
+ const meta = await readJson(join(dir, 'meta.json'), null)
132
+ if (!meta) throw new UserError(`Run ${id} not found in ${config.output}.`)
133
+ return { dir, meta }
134
+ }))) : (await siblingRuns({ root, config, run, sameCommit })).slice(0, Math.max(0, last - 1))
135
+ const all = [{ dir: run.dir, meta: run.meta }, ...others]
136
+ const maps = await Promise.all(all.map(async (r) => new Map(latestCaptures(await readCaptures(r.dir)).filter((e) => !e.item).map((e) => [key(e), { ...e, dir: r.dir }]))))
137
+ const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
138
+ const found = new Map() // node → [{ locale, viewport, x, y, w, h, share, source }]
139
+ const skipped = []
140
+ const browser = await (launch ? launch() : (await import('./browser.mjs')).launchEphemeral(config))
141
+ try {
142
+ const page = await browser.newPage()
143
+ await page.setContent('<!doctype html><title>stability</title>')
144
+ await page.addScriptTag({ content: DETECTOR })
145
+ for (const [k, entry] of maps[0]) {
146
+ if (!nodes.has(entry.node)) continue
147
+ const caps = []
148
+ for (const m of maps) { const e = m.get(k); if (e) caps.push({ e, cap: await readJson(join(e.dir, e.file), null) }) }
149
+ const mine = caps[0].cap
150
+ if (!mine?.files) continue
151
+ const regions = []
152
+ for (const { cap } of caps) for (const r of cap?.unstable?.regions || []) regions.push({ ...r, source: 'repeat' })
153
+ const usable = caps.filter(({ cap }) => cap?.files?.image || cap?.files?.png)
154
+ if (usable.length >= 2) {
155
+ try {
156
+ const cssWidth = mine.size?.width || mine.size?.viewport?.[0] || 1280
157
+ const r = await crossRegions(page, await Promise.all(usable.map(({ e, cap }) => dataUrl(join(e.dir, cap.files.image || cap.files.png)))), cssWidth)
158
+ if (r.too_much) skipped.push(`${entry.node} ${entry.locale}/${entry.viewport}: ${Math.round(r.changed * 100)} % of the screen differs between runs; that is a changed page, not a noisy region`)
159
+ for (const x of r.regions) regions.push({ ...x, source: 'runs' })
160
+ } catch (error) { skipped.push(`${entry.node} ${entry.locale}/${entry.viewport}: ${error.message}`) }
161
+ }
162
+ if (!regions.length) continue
163
+ const merged = mergeRects(regions.map(({ x, y, w, h, share }) => ({ x, y, w, h, share })), 8)
164
+ const list = found.get(entry.node) || []
165
+ for (const r of merged) list.push({ ...r, locale: entry.locale, viewport: entry.viewport, html: mine.files.html ? join(entry.dir, mine.files.html) : null, url: mine.url, sources: [...new Set(regions.filter((x) => x.x < r.x + r.w && r.x < x.x + x.w && x.y < r.y + r.h && r.y < x.y + x.h).map((x) => x.source))] })
166
+ found.set(entry.node, list)
167
+ }
168
+ // regions → selectors in the stored HTML (scripts off); rectangles when no element fits
169
+ const steps = []
170
+ for (const [node, list] of found) {
171
+ const ignore = []
172
+ for (const r of list) {
173
+ let selector = null
174
+ if (r.html) {
175
+ const vp = run.meta.scope.viewports?.[r.viewport] || [1280, 800]
176
+ const ctx = await browser.newContext({ viewport: { width: vp[0], height: vp[1] }, javaScriptEnabled: false })
177
+ try {
178
+ const p = await ctx.newPage()
179
+ await p.setContent(withBase(await readFile(r.html, 'utf8'), r.url), { waitUntil: 'load', timeout: 20000 }).catch(() => {})
180
+ selector = await p.evaluate(selectorAt, { x: r.x, y: r.y, w: r.w, h: r.h }).catch(() => null)
181
+ } finally { await ctx.close() }
182
+ }
183
+ const note = `${r.sources.includes('repeat') ? 'moves while the page is idle' : 'changes between runs'} (${r.locale}/${r.viewport})`
184
+ const known = selector && ignore.find((x) => x.selector === selector)
185
+ if (known) { known.viewports = [...new Set([...(known.viewports || []), r.viewport])]; continue }
186
+ ignore.push(selector ? { selector, viewports: [r.viewport], note } : { rect: [r.x, r.y, r.w, r.h], viewport: r.viewport, note })
187
+ }
188
+ // a selector seen in every viewport of the run needs no viewport list
189
+ const vps = Object.keys(run.meta.scope.viewports || {})
190
+ for (const x of ignore) if (x.viewports && vps.every((v) => x.viewports.includes(v))) delete x.viewports
191
+ steps.push({ node, ignore, found: list.map(({ html, url, ...r }) => r) })
192
+ }
193
+ return { runs: all.map((r) => r.meta.id), steps, skipped }
194
+ } finally { await browser.close() }
195
+ }
196
+
197
+ /** The suggestions as a YAML fragment per step, ready to paste under each node. */
198
+ export function suggestionsYaml (report) {
199
+ return report.steps.map((s) => `# ${s.node}\nignore:\n${s.ignore.map((x) => ` - ${yaml.dump(x, { flowLevel: 0, lineWidth: 200 }).trim()}`).join('\n')}`).join('\n\n')
200
+ }
201
+
202
+ /**
203
+ * Inserts the suggestions into the scenario file under each node that has no `ignore` yet (block-style nodes only;
204
+ * comments and formatting stay). Returns { applied: [node], skipped: [reason] }.
205
+ */
206
+ export async function applySuggestions (file, report) {
207
+ let text = await readFile(file, 'utf8')
208
+ const applied = []; const skipped = []
209
+ const parsed = yaml.load(text)
210
+ for (const s of report.steps) {
211
+ const node = (parsed?.nodes || []).find((n) => n.id === s.node)
212
+ if (!node) { skipped.push(`${s.node}: not in ${file}`); continue }
213
+ if (node.ignore) { skipped.push(`${s.node}: already has ignore; merge by hand`); continue }
214
+ const lines = text.split('\n')
215
+ const at = lines.findIndex((l) => new RegExp(`^(\\s*)- id: ['"]?${s.node.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?\\s*(#.*)?$`).test(l))
216
+ if (at < 0) { skipped.push(`${s.node}: node is not in block style; paste the suggestion by hand`); continue }
217
+ const indent = lines[at].match(/^(\s*)-/)[1] + ' '
218
+ const block = [`${indent}ignore:`, ...s.ignore.map((x) => `${indent} - ${yaml.dump(x, { flowLevel: 0, lineWidth: 400 }).trim()}`)]
219
+ lines.splice(at + 1, 0, ...block)
220
+ text = lines.join('\n')
221
+ applied.push(s.node)
222
+ }
223
+ if (applied.length) {
224
+ yaml.load(text) // must still parse
225
+ await writeFile(file, text)
226
+ }
227
+ return { applied, skipped }
228
+ }
229
+
230
+ /** `kaloko stability`: detect, write stability.json and stability.yml in the run, print, optionally apply. */
231
+ export async function stabilityCmd ({ root, config, run, scenario, args, say }) {
232
+ const report = await detectUnstable({ root, config, run, scenario, last: Number(args.last) || 4, runs: typeof args.runs === 'string' ? args.runs.split(',').map((s) => s.trim()).filter(Boolean) : null, sameCommit: !!args['same-commit'] })
233
+ await writeJson(join(run.dir, 'stability.json'), { schema: 'qawalk.stability.v1', generated_at: new Date().toISOString(), ...report })
234
+ if (args.json) { say(JSON.stringify(report, null, 2)); return report }
235
+ const repeats = report.steps.some((s) => s.found.some((r) => r.sources.includes('repeat')))
236
+ say(`Compared ${report.runs.length} run(s)${report.runs.length < 2 && !repeats ? ' (one run and no repeated shots: walk again, or walk with --stability 3, to find regions that move)' : ''}: ${report.steps.length ? `${report.steps.reduce((n, s) => n + s.ignore.length, 0)} unstable region(s) in ${report.steps.length} step(s).` : 'no unstable regions.'}`)
237
+ for (const s of report.skipped) say(` ! ${s}`)
238
+ if (!report.steps.length) return report
239
+ const text = suggestionsYaml(report)
240
+ await writeFile(join(run.dir, 'stability.yml'), text + '\n')
241
+ say(`\n${text}\n\nSuggestions: ${join(run.dir, 'stability.yml')}`)
242
+ if (args.apply) {
243
+ const file = resolve(root, run.meta.scenario_file)
244
+ const r = await applySuggestions(file, report)
245
+ if (r.applied.length) say(`Added ignore to ${r.applied.join(', ')} in ${run.meta.scenario_file}. Capture those steps again so the canvas and diffs use them.`)
246
+ for (const x of r.skipped) say(` skipped ${x}`)
247
+ } else say('Check them on the screenshots, then run kaloko stability --apply (or paste them into the scenario).')
248
+ return report
249
+ }