kaloko 0.4.8 → 0.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -5
- package/package.json +1 -1
- package/schemas/config.v1.json +33 -0
- package/schemas/scenario.v1.json +39 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +24 -3
- package/skills/kaloko/reference/acceptance.md +50 -2
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/evaluation.md +46 -3
- package/skills/kaloko/reference/execution.md +74 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +4 -1
- package/src/cli.mjs +428 -99
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- package/src/lib/design-cli.mjs +7 -3
- package/src/lib/devspec.mjs +821 -0
- package/src/lib/doctor.mjs +7 -0
- package/src/lib/drift.mjs +16 -8
- package/src/lib/evaluate.mjs +98 -14
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/onboarding.mjs +15 -8
- package/src/lib/pack-help.mjs +7 -0
- package/src/lib/packs.mjs +9 -2
- package/src/lib/png.mjs +61 -0
- package/src/lib/pr.mjs +24 -2
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- package/src/lib/spec-cli.mjs +90 -0
- package/src/lib/stability-core.mjs +114 -0
- package/src/lib/stability.mjs +249 -0
- package/src/lib/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -0
- package/src/lib/vision-core.mjs +132 -0
- package/src/lib/vision.mjs +127 -0
- package/templates/kaloko.config.yml +6 -0
- package/viewer/index.html +500 -44
package/src/lib/doctor.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/lib/heal.mjs
ADDED
|
@@ -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
|
+
}
|