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
@@ -9,6 +9,7 @@ import { gateOf, gateHeaders, accountFor } from './access.mjs'
9
9
  import { missing } from './secrets.mjs'
10
10
  import { portFor } from './browser.mjs'
11
11
  import { DEFAULTS as JEV } from './jev.mjs'
12
+ import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
12
13
  import { serviceUrl } from './service.mjs'
13
14
 
14
15
  const portFree = (port) => new Promise((done) => {
@@ -136,6 +137,12 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
136
137
  else add('evaluator', false, s ? 'hosted evaluation not included in the plan' : 'hosted evaluation unavailable', `Put ${keyEnv} into .env or ~/.config/kaloko/.env to evaluate with your own key${s ? ', or upgrade the plan' : ''}.`)
137
138
  } else add('evaluator', false, `${keyEnv} not set`, `Semantic criteria will be "not evaluated" until ${keyEnv} is in .env or ~/.config/kaloko/.env${jev?.hosted === false ? '' : `, or ${tokenEnv} lets the service evaluate`}.`)
138
139
  }
140
+ // the vision evaluator is optional: without it unsure answers simply stay with a person
141
+ const vision = visionSettings(config)
142
+ if (!vision) add('vision evaluator', true, 'off in config')
143
+ else if (env[vision.api_key_env]) add('vision evaluator', true, `${vision.api_key_env} set · your key · model ${vision.model} · ${vision.when}`)
144
+ else if (token && vision.hosted !== false) add('vision evaluator', true, `via Kaloko · model ${vision.model} · one vision question counts as ${VISION_WEIGHT}`)
145
+ else add('vision evaluator', true, `${vision.api_key_env} not set (optional)`, `Criteria with vision: true and unsure answers stay with a person until ${vision.api_key_env} is set or ${tokenEnv} lets the service ask.`)
139
146
 
140
147
  return { ok: checks.every((c) => c.ok), checks }
141
148
  }
package/src/lib/drift.mjs CHANGED
@@ -46,18 +46,24 @@ export function tokenOffenders (styles, tokens, mode = null, allowed = null) {
46
46
  return out.sort((a, b) => b.count - a.count)
47
47
  }
48
48
 
49
- /** Pixel difference ratio of two images in a browser page (the viewer's algorithm, so numbers match the canvas). */
50
- export async function pixelRatio (page, a, b, width) {
51
- return page.evaluate(async ({ a, b, W }) => {
49
+ /**
50
+ * Pixel difference ratio of two images in a browser page (the viewer's algorithm, so numbers match the canvas).
51
+ * ignore: { rects: [{x,y,w,h}] in CSS px, cssWidth } — regions left out of the count (#20, scenario `ignore`).
52
+ */
53
+ export async function pixelRatio (page, a, b, width, ignore = null) {
54
+ return page.evaluate(async ({ a, b, W, rects, cssWidth }) => {
52
55
  const load = (src) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = src })
53
56
  const [x, y] = await Promise.all([load(a), load(b)])
54
57
  const ha = Math.round(x.naturalHeight * W / x.naturalWidth), hb = Math.round(y.naturalHeight * W / y.naturalWidth), H = Math.max(ha, hb)
55
58
  const draw = (img, h) => { const c = document.createElement('canvas'); c.width = W; c.height = H; const g = c.getContext('2d', { willReadFrequently: true }); g.fillStyle = '#fff'; g.fillRect(0, 0, W, H); g.drawImage(img, 0, 0, W, h); return g.getImageData(0, 0, W, H).data }
56
59
  const da = draw(x, ha), db = draw(y, hb)
57
- let n = 0
58
- for (let i = 0; i < da.length; i += 4) if (Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]) > 48) n++
59
- return { ratio: n / (W * H), heights: [ha, hb] }
60
- }, { a, b, W: width })
60
+ const k = W / (cssWidth || W)
61
+ const skip = new Uint8Array(W * H)
62
+ for (const r of rects) { const x0 = Math.max(0, Math.floor(r.x * k)); const x1 = Math.min(W, Math.ceil((r.x + r.w) * k)); for (let yy = Math.max(0, Math.floor(r.y * k)); yy < Math.min(H, Math.ceil((r.y + r.h) * k)); yy++) skip.fill(1, yy * W + x0, yy * W + x1) }
63
+ let n = 0; let counted = 0
64
+ for (let i = 0, p = 0; i < da.length; i += 4, p++) { if (skip[p]) continue; counted++; if (Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]) > 48) n++ }
65
+ return { ratio: n / Math.max(1, counted), heights: [ha, hb], ignored: W * H - counted }
66
+ }, { a, b, W: width, rects: ignore?.rects || [], cssWidth: ignore?.cssWidth || null })
61
67
  }
62
68
 
63
69
  /**
@@ -96,7 +102,9 @@ export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
96
102
  const refPath = await cachedImage(ctx, rc.image)
97
103
  const mine = join(runDir, capture.files.image || capture.files.png)
98
104
  const toData = async (p) => `data:image/${p.endsWith('.png') ? 'png' : 'webp'};base64,${(await readFile(p)).toString('base64')}`
99
- const { ratio } = await pixelRatio(ctx.page, await toData(mine), await toData(refPath), Math.min(1000, capture.size?.viewport?.[0] || 1280))
105
+ // regions the scenario ignores (clocks, ads) count in neither image
106
+ const ignore = { rects: [...(capture.ignore || []), ...(rc.ignore || [])], cssWidth: capture.size?.width || capture.size?.viewport?.[0] || null }
107
+ const { ratio } = await pixelRatio(ctx.page, await toData(mine), await toData(refPath), Math.min(1000, capture.size?.viewport?.[0] || 1280), ignore)
100
108
  const max = c.check.ratio ?? 0.03
101
109
  results[c.id] = { ok: ratio <= max, evidence: `${(ratio * 100).toFixed(1)} % of pixels differ from design v${ctx.referenceVersion ?? '?'} (limit ${(max * 100).toFixed(1)} %)`, ratio }
102
110
  }
@@ -1,7 +1,10 @@
1
1
  import { join, resolve } from 'node:path'
2
2
  import { readJson, writeJson, sha256 } from './util.mjs'
3
3
  import { readCaptures, runPaths } from './run.mjs'
4
- import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES } from './jev.mjs'
4
+ import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES, purposeOf } from './jev.mjs'
5
+ import { visionSettings, visionCriteria, templateReason, VISION_PROMPT_VERSION } from './vision-core.mjs'
6
+ import { prepareImages, visionPlan, askVision, hostedVisionSender } from './vision.mjs'
7
+ import { KEYBOARD_ASSERTS } from './keyboard.mjs'
5
8
  import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
6
9
  import { packHelp } from './pack-help.mjs'
7
10
 
@@ -17,22 +20,37 @@ export const criteriaHash = (node) => sha256(node.criteria || [])
17
20
  export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
18
21
  export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
19
22
 
20
- /** Criterion verdicts for one capture, combining deterministic results and JEV answers. */
21
- export function combine ({ node, capture, jev, thresholds }) {
23
+ /**
24
+ * Criterion verdicts for one capture, combining deterministic results, the text evaluator (JEV) and, where it was
25
+ * asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
26
+ * model's own, or a sentence saying what the text evaluator read (it returns a probability only).
27
+ */
28
+ export function combine ({ node, capture, jev, vision = null, thresholds }) {
22
29
  return (node.criteria || []).map((c) => {
23
30
  if (c.check.type === 'deterministic') {
24
31
  const r = capture.checks?.[c.id]
25
32
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
26
- const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '')
33
+ const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '') || KEYBOARD_ASSERTS.has(c.check.assert)
27
34
  return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
28
35
  }
29
36
  if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
30
- return { id: c.id, type: 'semantic', verdict: 'pass', evidence: 'no console errors', score: null, provider: null, note: null }
37
+ return { id: c.id, type: 'semantic', verdict: 'pass', evidence: 'no console errors', score: null, provider: null, note: null, reason: 'No console errors on this page, so there is nothing to triage.', reason_source: 'rule' }
31
38
  }
32
39
  if (c.check.type === 'semantic') {
33
40
  const answer = jev?.response?.answers?.[c.id]
34
- const score = answer?.noul ?? null
35
- return { id: c.id, type: 'semantic', verdict: verdictFor(score, thresholds), evidence: null, score, provider: answer ? 'typesafe-jev' : null, note: jev?.skipped || jev?.error || null }
41
+ const text = answer?.noul ?? null
42
+ const seen = vision?.answers?.[c.id] || null
43
+ const score = seen ? seen.probability : text
44
+ const verdict = verdictFor(score, thresholds)
45
+ const reason = seen?.reason || templateReason({ score, scope: c.check.scope, about: c.check.about, verdict })
46
+ return {
47
+ id: c.id, type: 'semantic', verdict, evidence: null, score,
48
+ provider: seen ? `${vision.provider || 'anthropic'}-vision` : answer ? 'typesafe-jev' : null,
49
+ note: score == null ? (jev?.skipped || jev?.error || vision?.skipped || vision?.error || null) : null,
50
+ ...(reason ? { reason, reason_source: seen?.reason ? 'model' : 'template' } : {}),
51
+ ...(seen && text != null ? { text_score: text } : {}),
52
+ ...(!seen && (vision?.skipped || vision?.error) && vision.ids?.includes(c.id) ? { vision_note: vision.skipped || vision.error } : {})
53
+ }
36
54
  }
37
55
  return { id: c.id, type: 'manual', verdict: 'not_evaluated', evidence: c.check.note || null }
38
56
  })
@@ -46,6 +64,12 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
46
64
  const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
47
65
  const paths = runPaths(run.dir)
48
66
  const report = { evaluated: 0, semantic_calls: 0, semantic_cached: 0, skipped: [], model: settings.model, drift: 0, outdated: new Set(), via: {}, hosted: null, hostedStop: null, findings: [] }
67
+ // the vision evaluator (#17): flagged and uncertain criteria, a bounded number of calls per evaluation
68
+ const vset = visionSettings(config)
69
+ if (vset) report.vision = { model: vset.model, asked: 0, calls: 0, cached: 0, skipped: [], via: {}, hosted: null }
70
+ // with an own text-evaluator key the vision evaluator uses an own key too, unless hosted: true says otherwise
71
+ if (vset && process.env[settings.api_key_env] && config.evaluators?.vision?.hosted !== true) vset.hosted = false
72
+ const vision = vset ? visionContext({ config, root, settings: vset, report, fetchImpl }) : null
49
73
  // checks against the design reference and the token set (pack design, #42) need the service and a browser
50
74
  const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
51
75
  try {
@@ -77,8 +101,10 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
77
101
  const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
78
102
  // quick signals ride along on every captured page state (also steps without semantic criteria)
79
103
  const signals = settings.signals !== false && !outOfSample && !!capture.state
80
- const request = (semantic.length || signals) && current ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
81
- work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: current ? null : { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } })
104
+ // an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
105
+ const textless = capture.capture?.source === 'import' && !capture.state
106
+ const request = (semantic.length || signals) && current && !textless ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
107
+ work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: !current ? { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } : textless && semantic.length ? { skipped: 'imported screenshot without page text; a person decides on the canvas' } : null })
82
108
  if (!current) report.outdated.add(entry.node)
83
109
  }
84
110
 
@@ -91,10 +117,12 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
91
117
  if ((w.jev.skipped || w.jev.error) && w.asks) report.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.jev.skipped || w.jev.error}`)
92
118
  }
93
119
  }))
94
- for (const { entry, node, capture, current, outOfSample, request, jev } of work) {
120
+ // second opinion with the screenshot for flagged and uncertain criteria
121
+ if (vision) await vision.ask(work.filter((w) => w.current && !w.outOfSample && w.request), run.dir)
122
+ for (const { entry, node, capture, current, outOfSample, request, jev, vision: seen } of work) {
95
123
  const source = evaluationSource(node, capture)
96
124
  if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
97
- let criteria = combine({ node, capture: current ? capture : {}, jev, thresholds: settings.thresholds })
125
+ let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds })
98
126
  // pages outside the semantic sample carry only the deterministic results
99
127
  if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
100
128
  const evaluation = {
@@ -115,16 +143,17 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
115
143
  thresholds: settings.thresholds,
116
144
  state_chars: request ? JSON.stringify(request.state).length : null,
117
145
  ...(signalsOf(jev) ? { signals: signalsOf(jev) } : {}),
146
+ ...(seen ? { vision: seen.answers ? { provider: seen.provider, via: seen.via, model: seen.model, prompt_version: VISION_PROMPT_VERSION, cached: seen.cached, usage: seen.usage || null, answers: seen.answers } : { provider: seen.provider, skipped: seen.skipped || null, error: seen.error || null } } : {}),
118
147
  criteria
119
148
  }
120
149
  await writeJson(paths.step(stepIdOf(entry), entry.locale, entry.viewport, 'eval.json'), evaluation)
121
150
  report.evaluated++
122
151
  // what did not pass, for the walk summary and agents: step, locale, viewport, criterion and why
123
- for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes` : c.note) || null, ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
152
+ for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes${c.reason_source === 'model' ? `: ${c.reason}` : ''}` : c.note) || null, ...(c.reason ? { reason: c.reason } : {}), ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
124
153
  }
125
154
  }
126
- } finally { if (drift?.browser) await drift.browser.close() }
127
- const { hostedStop, ...out } = report
155
+ } finally { if (drift?.browser) await drift.browser.close(); await vision?.close() }
156
+ const { hostedStop, visionStop, ...out } = report
128
157
  return { ...out, outdated: [...report.outdated] }
129
158
  }
130
159
 
@@ -184,6 +213,61 @@ export function semanticPrefetch ({ scenario, config, root }) {
184
213
  }
185
214
  }
186
215
 
216
+ /**
217
+ * The vision step of an evaluation: picks the criteria per capture, cuts the images in a lazily started browser
218
+ * and asks, a few captures at a time, within `max_calls` uncached requests. Fills `w.vision` on each work item.
219
+ */
220
+ function visionContext ({ config, root, settings, report, fetchImpl }) {
221
+ const cacheDir = join(resolve(root, config.output), '.cache', 'vision')
222
+ const hosted = hostedVisionSender(config, report)
223
+ let browser = null; let pagePromise = null
224
+ const page = () => (pagePromise ||= (async () => {
225
+ const { launchEphemeral } = await import('./browser.mjs')
226
+ browser = await launchEphemeral(config)
227
+ const p = await browser.newPage()
228
+ await p.setContent('<!doctype html><title>vision</title>')
229
+ return p
230
+ })())
231
+ const allowCall = () => { if (report.vision.calls >= (settings.max_calls ?? 40)) return false; report.vision.calls++; return true }
232
+ // no key and no service token: nothing to ask, so no browser either
233
+ const unavailable = process.env[settings.api_key_env] ? null : settings.hosted === false ? `${settings.api_key_env} is not set` : !process.env[config.share?.token_env || 'KALOKO_TOKEN'] ? `${settings.api_key_env} is not set and the service is not configured (KALOKO_TOKEN)` : null
234
+ return {
235
+ async ask (work, runDir) {
236
+ const queue = []
237
+ for (const w of work) {
238
+ const semantic = (w.node.criteria || []).filter((c) => c.check.type === 'semantic' && w.request.questions[c.id])
239
+ const crit = visionCriteria({ criteria: semantic, answers: w.jev?.response?.answers, settings })
240
+ if (crit.length) queue.push({ w, crit })
241
+ }
242
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(settings.concurrency || 3, queue.length)) }, async () => {
243
+ for (let job = queue.shift(); job; job = queue.shift()) {
244
+ const { w, crit } = job
245
+ const ids = crit.map((c) => c.id)
246
+ const file = w.capture.files?.image || w.capture.files?.png
247
+ report.vision.asked++
248
+ if (unavailable) { w.vision = { provider: settings.provider, skipped: unavailable, ids }; report.vision.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${unavailable}`); continue }
249
+ if (!file) { w.vision = { provider: settings.provider, skipped: 'no screenshot', ids }; continue }
250
+ if (report.visionStop && !process.env[settings.api_key_env]) { w.vision = { provider: settings.provider, skipped: report.visionStop, ids }; continue }
251
+ try {
252
+ const plan = visionPlan({ criteria: crit, capture: w.capture })
253
+ const { images, failed } = await prepareImages({ page: await page(), file: join(runDir, file), cssWidth: w.capture.size?.width, crops: plan.crops, full: plan.full, settings })
254
+ for (const q of plan.questions) if (failed.includes(q.id)) delete q.image
255
+ const payload = { context: { purpose: purposeOf(w.node, w.capture), url: w.capture.url, locale: w.capture.locale, viewport: w.entry.viewport }, questions: plan.questions, images }
256
+ const r = await askVision({ payload, settings, cacheDir, fetchImpl, hosted, allowCall })
257
+ w.vision = { provider: settings.provider, ids, ...r }
258
+ if (r.answers) { if (r.cached) report.vision.cached++; report.vision.via[r.via] = (report.vision.via[r.via] || 0) + 1; if (r.hosted) report.vision.hosted = r.hosted }
259
+ else report.vision.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${r.skipped || r.error}`)
260
+ } catch (error) {
261
+ w.vision = { provider: settings.provider, error: String(error.message || error).slice(0, 300), ids }
262
+ report.vision.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.vision.error}`)
263
+ }
264
+ }
265
+ }))
266
+ },
267
+ async close () { if (browser) await browser.close().catch(() => {}) }
268
+ }
269
+ }
270
+
187
271
  /**
188
272
  * Sends a JEV request through the organization's service (hosted evaluation) when a service token is set.
189
273
  * An allowance used up or a service without hosting stops further hosted calls for this run with one message.
package/src/lib/git.mjs CHANGED
@@ -59,12 +59,16 @@ export async function gitMeta (cwd) {
59
59
  out('git', ['rev-parse', 'HEAD'], cwd)
60
60
  ])
61
61
  let pr = null
62
- const json = await out('gh', ['pr', 'view', '--json', 'number,url,state'], cwd)
62
+ const vars = process.env
63
+ // CI checks out a detached commit: the branch and pull request come from the environment
64
+ const ciBranch = vars.KALOKO_BRANCH || vars.GITHUB_HEAD_REF || vars.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || vars.CI_COMMIT_REF_NAME || null
65
+ if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) pr = { number: Number(vars.CI_MERGE_REQUEST_IID), url: `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}`, state: 'open' }
66
+ const json = pr ? null : await out('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,state'], cwd)
63
67
  if (json) {
64
68
  try {
65
69
  const p = JSON.parse(json)
66
70
  pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase() }
67
71
  } catch {}
68
72
  }
69
- return { branch, commit, sha, message, author, pr }
73
+ return { branch: branch === 'HEAD' && ciBranch ? ciBranch : branch, commit, sha, message, author, pr }
70
74
  }
@@ -0,0 +1,207 @@
1
+ // Self-healing selectors (#13): when a step script waits in vain for a selector, propose a replacement from the page
2
+ // as it is now (role and accessible name, test ids, text), preferring the element the old selector matched in the
3
+ // step's previous capture. The proposal is printed as a patch; `walk --heal` applies it to the script only after the
4
+ // step passed with it. Nothing is ever changed silently.
5
+ import { readFile, writeFile, rm, readdir } from 'node:fs/promises'
6
+ import { join, dirname, basename, relative, resolve } from 'node:path'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { readJson } from './util.mjs'
9
+
10
+ /** The selector a Playwright timeout waited for, or null (only plain locator strings can be patched). */
11
+ export function failedSelector (message) {
12
+ const text = String(message || '')
13
+ if (!/Timeout \d+ms exceeded|waiting for/i.test(text) || /strict mode violation/i.test(text)) return null
14
+ const unq = (s, q) => s.replace(new RegExp(`\\\\${q}`, 'g'), q)
15
+ let m = text.match(/waiting for locator\('((?:\\'|[^'])+)'\)/)
16
+ if (m) return unq(m[1], "'")
17
+ m = text.match(/waiting for locator\("((?:\\"|[^"])+)"\)/)
18
+ if (m) return unq(m[1], '"')
19
+ m = text.match(/waiting for selector "((?:\\"|[^"])+)"/)
20
+ if (m) return unq(m[1], '"')
21
+ return null
22
+ }
23
+
24
+ /** Words a selector is made of: ids, classes, attribute values and text, split on punctuation and camelCase. */
25
+ export function tokensOf (selector) {
26
+ return [...new Set(String(selector)
27
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
28
+ .toLowerCase()
29
+ .split(/[^a-z0-9áčďéěíňóřšťúůýž]+/i)
30
+ .filter((w) => w.length > 1 && !['text', 'has', 'nth', 'first', 'last', 'visible', 'true', 'role', 'name', 'internal', 'data', 'testid', 'test', 'id', 'qa', 'cy', 'div', 'span'].includes(w)))]
31
+ }
32
+
33
+ /**
34
+ * In the page (self-contained, passed to evaluate): interactive and labelled elements with role, accessible name,
35
+ * test id, id and classes; given an element, that element only.
36
+ */
37
+ function describeAll (limit) {
38
+ const TEST = ['data-testid', 'data-test', 'data-qa', 'data-cy', 'data-test-id']
39
+ const roleOf = (el) => {
40
+ const r = el.getAttribute('role'); if (r) return r
41
+ const t = el.tagName.toLowerCase()
42
+ if (t === 'a' && el.hasAttribute('href')) return 'link'
43
+ if (t === 'button' || (t === 'input' && ['button', 'submit', 'reset'].includes(el.type))) return 'button'
44
+ if (t === 'input') return { checkbox: 'checkbox', radio: 'radio', range: 'slider', search: 'searchbox' }[el.type] || 'textbox'
45
+ if (t === 'textarea') return 'textbox'
46
+ if (t === 'select') return 'combobox'
47
+ if (/^h[1-6]$/.test(t)) return 'heading'
48
+ if (t === 'img') return 'img'
49
+ return null
50
+ }
51
+ const text = (s) => String(s || '').replace(/\s+/g, ' ').trim()
52
+ const nameOf = (el) => {
53
+ const l = el.getAttribute('aria-label'); if (l) return text(l)
54
+ const by = el.getAttribute('aria-labelledby'); if (by) { const t = by.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' '); if (text(t)) return text(t) }
55
+ if (el.id) { const lab = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (lab) return text(lab.textContent) }
56
+ const wrap = el.closest('label'); if (wrap && wrap !== el && /^(input|select|textarea)$/i.test(el.tagName)) return text(wrap.textContent)
57
+ if (/^(input|textarea)$/i.test(el.tagName)) return text(el.getAttribute('placeholder') || el.getAttribute('title') || (['button', 'submit'].includes(el.type) ? el.value : ''))
58
+ if (el.tagName === 'IMG') return text(el.getAttribute('alt'))
59
+ return text(el.innerText || el.textContent).slice(0, 80)
60
+ }
61
+ const describe = (el) => {
62
+ const testid = TEST.map((a) => el.getAttribute(a) ? [a, el.getAttribute(a)] : null).find(Boolean) || null
63
+ const r = el.getBoundingClientRect(); const cs = getComputedStyle(el)
64
+ return { tag: el.tagName.toLowerCase(), role: roleOf(el), name: nameOf(el), testid, id: el.id || null, classes: [...el.classList].slice(0, 8), visible: r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' }
65
+ }
66
+ if (typeof limit === 'object' && limit?.nodeType === 1) return describe(limit)
67
+ const sel = 'a[href], button, input, select, textarea, summary, [role], [aria-label], [data-testid], [data-test], [data-qa], [data-cy], [data-test-id], h1, h2, h3, label'
68
+ return [...document.querySelectorAll(sel)].slice(0, limit).map(describe).filter((d) => d.visible && (d.name || d.testid || d.id))
69
+ }
70
+
71
+ /** The element the old selector matched in the previous capture of this step (its HTML), described; null if none. */
72
+ async function previousFingerprint ({ page, selector, root, output, run, node, locale }) {
73
+ const base = resolve(root, output)
74
+ let names = []
75
+ try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && e.name !== run.meta.id && !e.name.startsWith('.')).map((e) => e.name).sort().reverse() } catch { return null }
76
+ for (const name of names.slice(0, 20)) {
77
+ const meta = await readJson(join(base, name, 'meta.json'), null)
78
+ if (meta?.scenario?.id !== run.meta.scenario.id) continue
79
+ let html = null
80
+ for (const vp of Object.keys(run.meta.scope.viewports || {})) { html = await readFile(join(base, name, 'steps', node.id, locale, `${vp}.html`), 'utf8').catch(() => null); if (html) break }
81
+ if (!html) continue
82
+ let scratch = null
83
+ try {
84
+ const browser = page.context().browser()
85
+ scratch = browser ? await browser.newContext({ javaScriptEnabled: false }) : null
86
+ if (!scratch) return null
87
+ const p = await scratch.newPage()
88
+ await p.route('**/*', (r) => r.abort())
89
+ await p.setContent(html.replace(/<script[\s\S]*?<\/script>/gi, ''), { waitUntil: 'domcontentloaded', timeout: 5000 })
90
+ const el = p.locator(selector).first()
91
+ if (!(await el.count().catch(() => 0))) continue
92
+ const fp = await el.evaluate(describeAll)
93
+ return { ...fp, run: name }
94
+ } catch { continue } finally { await scratch?.close().catch(() => {}) }
95
+ }
96
+ return null
97
+ }
98
+
99
+ const q = (s) => JSON.stringify(String(s))
100
+
101
+ /** Scores a candidate against the fingerprint of the old element and the words of the old selector. */
102
+ export function score (c, { fp, tokens }) {
103
+ let s = 0; const why = []
104
+ if (fp) {
105
+ if (fp.testid && c.testid && fp.testid[1] === c.testid[1]) { s += 6; why.push(`same ${c.testid[0]} "${c.testid[1]}"`) }
106
+ if (fp.name && c.name && fp.name.toLowerCase() === c.name.toLowerCase()) { s += 4; why.push(`same name "${c.name}"`) }
107
+ if (fp.role && fp.role === c.role) { s += 2; why.push(`role ${c.role}`) }
108
+ if (fp.tag === c.tag) s += 1
109
+ if (fp.id && fp.id === c.id) { s += 3; why.push(`same id ${c.id}`) }
110
+ const shared = (fp.classes || []).filter((k) => c.classes.includes(k)).length
111
+ s += Math.min(2, shared)
112
+ }
113
+ const hay = [c.id, ...(c.classes || []), c.testid?.[1], c.name, c.role, c.tag].filter(Boolean).join(' ').toLowerCase()
114
+ const words = tokens.filter((t) => hay.includes(t))
115
+ if (words.length) { s += words.length * 1.5; why.push(`words ${words.map((w) => `"${w}"`).join(', ')} of the old selector`) }
116
+ return { score: s, why }
117
+ }
118
+
119
+ /** Selectors for an element, most stable first: test id, role + name, id, text. */
120
+ export function selectorsFor (c) {
121
+ const out = []
122
+ if (c.testid) out.push(`[${c.testid[0]}=${q(c.testid[1])}]`)
123
+ if (c.role && c.name) out.push(`role=${c.role}[name=${q(c.name)}]`)
124
+ if (c.id && !/\d{3,}|^[a-f0-9-]{16,}$|^(radix|headlessui|mui|react-aria|:r)/i.test(c.id)) out.push(`#${c.id}`)
125
+ if (c.name && c.name.length <= 60) out.push(`text=${q(c.name)}`)
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * A replacement for `selector` on the page as it is now: { replacement, why, from } or null.
131
+ * The replacement matches exactly one element.
132
+ */
133
+ export async function proposeSelector ({ page, selector, root, output, run, node, locale }) {
134
+ const fp = await previousFingerprint({ page, selector, root, output, run, node, locale }).catch(() => null)
135
+ const tokens = tokensOf(selector)
136
+ let candidates
137
+ try { candidates = await page.evaluate(describeAll, 1500) } catch { return null }
138
+ const ranked = candidates.map((c) => ({ c, ...score(c, { fp, tokens }) })).filter((x) => x.score >= (fp ? 4 : 3)).sort((a, b) => b.score - a.score)
139
+ for (const { c, why } of ranked.slice(0, 8)) {
140
+ for (const sel of selectorsFor(c)) {
141
+ if (sel === selector) continue
142
+ const n = await page.locator(sel).count().catch(() => 0)
143
+ if (n === 1) return { replacement: sel, why: why.join(', ') || 'closest element', from: fp?.run || null, element: { role: c.role, name: c.name, tag: c.tag } }
144
+ }
145
+ }
146
+ return null
147
+ }
148
+
149
+ /** Replaces the selector's string literal in a script; { text, diff, count } or null when it is not written literally. */
150
+ export function patchScript (source, selector, replacement, file = 'script.mjs') {
151
+ const lines = source.split('\n')
152
+ const out = []; const diff = []; let count = 0
153
+ for (const [i, line] of lines.entries()) {
154
+ let next = line
155
+ for (const quote of ["'", '"', '`']) {
156
+ const lit = quote + selector.replaceAll('\\', '\\\\').replaceAll(quote, '\\' + quote) + quote
157
+ if (next.includes(lit)) {
158
+ const rep = quote + replacement.replaceAll('\\', '\\\\').replaceAll(quote, '\\' + quote) + quote
159
+ count += next.split(lit).length - 1
160
+ next = next.replaceAll(lit, rep)
161
+ }
162
+ }
163
+ if (next !== line) diff.push(`@@ -${i + 1} +${i + 1} @@`, `-${line}`, `+${next}`)
164
+ out.push(next)
165
+ }
166
+ if (!count) return null
167
+ return { text: out.join('\n'), count, diff: [`--- a/${file}`, `+++ b/${file}`, ...diff].join('\n') }
168
+ }
169
+
170
+ /**
171
+ * After a failed step: the proposal and, with `apply`, a re-run of the step with the patched script. `rerun(module)`
172
+ * runs the step with a script module and throws when it fails. Returns { selector, proposal, patch, applied, note }.
173
+ */
174
+ export async function healStep ({ error, page, scriptFile, root, output, run, node, locale, apply = false, rerun }) {
175
+ const selector = failedSelector(error?.message)
176
+ if (!selector) return null
177
+ const proposal = await proposeSelector({ page, selector, root, output, run, node, locale })
178
+ if (!proposal) return { selector, proposal: null, note: `no element on the page now resembles ${selector}` }
179
+ const source = await readFile(scriptFile, 'utf8').catch(() => null)
180
+ const rel = relative(root, scriptFile)
181
+ const patch = source ? patchScript(source, selector, proposal.replacement, rel) : null
182
+ if (!patch) return { selector, proposal, patch: null, note: `${selector} is not written literally in ${rel}; change it by hand` }
183
+ if (!apply) return { selector, proposal, patch, applied: false }
184
+ // the patched script runs from a sibling file (its relative imports resolve the same) and replaces the original
185
+ // only when the step passed with it
186
+ const trial = join(dirname(scriptFile), `.${basename(scriptFile, '.mjs')}.kaloko-heal-${process.pid}.mjs`)
187
+ try {
188
+ await writeFile(trial, patch.text)
189
+ await rerun(await import(`${pathToFileURL(trial).href}?t=${Date.now()}`))
190
+ await writeFile(scriptFile, patch.text)
191
+ return { selector, proposal, patch, applied: true }
192
+ } catch (e) {
193
+ return { selector, proposal, patch, applied: false, note: `the step still fails with the proposal (${String(e.message).split('\n')[0]}); ${rel} is unchanged` }
194
+ } finally { await rm(trial, { force: true }) }
195
+ }
196
+
197
+ /** Lines for the terminal: what failed, what is proposed and why, and the patch. */
198
+ export function formatHeal (h, { stepArg }) {
199
+ if (!h) return ''
200
+ if (!h.proposal) return ` ↳ selector ${h.selector}: ${h.note}`
201
+ const lines = [` ↳ selector ${h.selector} not found; proposed ${h.proposal.replacement} (${h.proposal.why}${h.proposal.from ? `; the old selector matched it in run ${h.proposal.from}` : ''})`]
202
+ if (h.applied) lines.push(` ↳ healed: the step passed with it, ${h.patch.count} occurrence(s) changed:`)
203
+ else if (h.note) lines.push(` ↳ ${h.note}`)
204
+ if (h.patch) lines.push(h.patch.diff.split('\n').map((l) => ` ${l}`).join('\n'))
205
+ if (h.patch && !h.applied && !h.note) lines.push(` ↳ apply it after the step passes: kaloko walk --steps ${stepArg} --heal`)
206
+ return lines.join('\n')
207
+ }