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.
- package/README.md +23 -1
- package/package.json +1 -1
- package/schemas/config.v1.json +26 -0
- package/schemas/scenario.v1.json +32 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +12 -2
- package/skills/kaloko/reference/acceptance.md +34 -2
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/evaluation.md +46 -3
- package/skills/kaloko/reference/execution.md +18 -0
- package/skills/kaloko/reference/mcp.md +3 -1
- package/src/cli.mjs +44 -4
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- 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 +94 -12
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- 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/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -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/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/build.mjs
CHANGED
|
@@ -29,9 +29,14 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
29
29
|
const cap = await readJson(join(run.dir, c.file))
|
|
30
30
|
const stored = await readJson(runPaths(run.dir).step(c.node, c.locale, c.viewport, 'eval.json'), null)
|
|
31
31
|
const ev = stored?.source === evaluationSource(node, cap) ? stored : null
|
|
32
|
-
|
|
32
|
+
// the step spec travels as its own file (Dev mode on screenshots, `kaloko spec`, drift on the service)
|
|
33
|
+
const spec = cap.spec ? `steps/${c.node}/${c.locale}/${c.viewport}.spec.json` : null
|
|
34
|
+
if (spec) await writeJson(join(run.dir, spec), cap.spec)
|
|
35
|
+
caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json`, spec }, ...(cap.live ? { live: cap.live } : {}) })
|
|
33
36
|
}
|
|
34
|
-
|
|
37
|
+
// focus screenshots of the keyboard walk (#19): only those of the current captures, not of earlier walks
|
|
38
|
+
const shots = new Set(caps.flatMap((c) => (c.cap.keyboard?.shots || []).map((s) => s.file)))
|
|
39
|
+
nodes.push(assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id && (a.kind !== 'focus' || shots.has(a.file))) }))
|
|
35
40
|
}
|
|
36
41
|
const summary = summarize(nodes, scenario.sessions)
|
|
37
42
|
|
|
@@ -126,19 +131,39 @@ async function buildStack ({ run, node, base, captures, attachments }) {
|
|
|
126
131
|
return {
|
|
127
132
|
...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
|
|
128
133
|
execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
|
|
129
|
-
attachments: attachments.filter((a) => a.node === node.id).map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
|
|
134
|
+
attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
|
|
130
135
|
stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Lines for `kaloko build`: semantic criteria that failed or need a person, per step, locale and viewport, with the
|
|
141
|
+
* score and the one-sentence reason (#17). At most `max` lines.
|
|
142
|
+
*/
|
|
143
|
+
export function attentionLines (manifest, max = 20) {
|
|
144
|
+
const out = []
|
|
145
|
+
for (const n of manifest.nodes || []) {
|
|
146
|
+
for (const c of n.criteria || []) {
|
|
147
|
+
if (c.type !== 'semantic' || !['fail', 'needs_review'].includes(c.verdict)) continue
|
|
148
|
+
for (const [l, vps] of Object.entries(c.by_locale || {})) {
|
|
149
|
+
for (const [vp, r] of Object.entries(vps)) {
|
|
150
|
+
if (!['fail', 'needs_review'].includes(r.verdict)) continue
|
|
151
|
+
out.push(` ${r.verdict === 'fail' ? '✕' : '?'} ${n.id} ${l}/${vp} ${c.id}${r.score != null ? ` ${Math.round(r.score * 100)} %${r.vision ? ' (with screenshot)' : ''}` : ''}${r.reason ? `: ${r.reason}` : r.note ? `: ${r.note}` : ''}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out.length > max ? [...out.slice(0, max), ` … ${out.length - max} more on the canvas`] : out
|
|
157
|
+
}
|
|
158
|
+
|
|
134
159
|
export async function writePreview ({ run, manifest }) {
|
|
135
160
|
const template = await readFile(join(packageRoot, 'viewer', 'index.html'), 'utf8')
|
|
136
161
|
// the local preview opens from file://, where fetch cannot read files: stack indexes go inline
|
|
137
162
|
// file:// cannot fetch: stack indexes and app UI trees travel inside the preview
|
|
138
163
|
const withTrees = async (n) => {
|
|
139
|
-
if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree))) return n
|
|
164
|
+
if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree || c?.spec))) return n
|
|
140
165
|
const captures = {}
|
|
141
|
-
for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree ? { ...c, tree_inline: await readJson(join(run.dir, c.tree), null) } : c }
|
|
166
|
+
for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree || c?.spec ? { ...c, ...(c.tree ? { tree_inline: await readJson(join(run.dir, c.tree), null) } : {}), ...(c.spec ? { spec_inline: await readJson(join(run.dir, c.spec), null) } : {}) } : c }
|
|
142
167
|
return { ...n, captures }
|
|
143
168
|
}
|
|
144
169
|
// design runs: live steps open from the merged tree, where links between steps resolve (design.mjs copyLive)
|
package/src/lib/capture.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFile, mkdir, rm } from 'node:fs/promises'
|
|
|
2
2
|
import { dirname, relative, join } from 'node:path'
|
|
3
3
|
import { buildHtmlView } from './htmlview.mjs'
|
|
4
4
|
import { probe } from './probe.mjs'
|
|
5
|
+
import { pageSpecScript } from './devspec.mjs'
|
|
5
6
|
import { writeJson, readJsonl } from './util.mjs'
|
|
6
7
|
import { addCapture, runPaths } from './run.mjs'
|
|
7
8
|
|
|
@@ -37,6 +38,15 @@ export const toWebp = (page, png, opts = {}) => encodeImage(page, png, { ...opts
|
|
|
37
38
|
* Capture options: scenario-wide `capture:` merged with the node's. Documentation runs use
|
|
38
39
|
* { format: png, scale: 2, mask: [selectors], annotate: true }; daily acceptance keeps the WebP default.
|
|
39
40
|
*/
|
|
41
|
+
/** The page spec (src/lib/devspec.mjs), kept under ~250 kB so it fits a design revision's capture record. */
|
|
42
|
+
export async function stepSpec (page, tokens = null) {
|
|
43
|
+
try {
|
|
44
|
+
const spec = await page.evaluate(pageSpecScript(tokens))
|
|
45
|
+
while (spec.elements.length > 20 && JSON.stringify(spec).length > 250000) spec.elements = spec.elements.slice(0, Math.floor(spec.elements.length * 0.8))
|
|
46
|
+
return spec
|
|
47
|
+
} catch { return null }
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export function captureOptions (scenario, node) {
|
|
41
51
|
const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
|
|
42
52
|
const q = Number(o.quality)
|
|
@@ -56,6 +66,8 @@ import { consoleEntriesFor } from './console.mjs'
|
|
|
56
66
|
import { redact, redactDeep } from './secrets.mjs'
|
|
57
67
|
import { readFields } from './stack.mjs'
|
|
58
68
|
import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
|
|
69
|
+
import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
70
|
+
import { repeatRegions, resolveIgnore } from './stability.mjs'
|
|
59
71
|
|
|
60
72
|
/** Probe arguments derived from the node's criteria. */
|
|
61
73
|
export function probeArgs (node, maxChars, design = false) {
|
|
@@ -74,6 +86,9 @@ export function probeArgs (node, maxChars, design = false) {
|
|
|
74
86
|
// md-menu, listboxes, tooltips). A step that leaves one open wants it on the capture.
|
|
75
87
|
const OVERLAY = '[role=menu], [role=listbox], [role=tooltip], .MuiPopover-root, .MuiPopper-root, .MuiMenu-root, md-menu-content, .md-open-menu-container.md-active, .md-panel-outer-wrapper.md-panel-is-showing'
|
|
76
88
|
|
|
89
|
+
const STACK_KEYBOARD = new Map()
|
|
90
|
+
const STACK_KEYBOARD_PAGES = 5
|
|
91
|
+
|
|
77
92
|
export async function settle (page) {
|
|
78
93
|
try { await page.waitForLoadState('networkidle', { timeout: 5000 }) } catch {}
|
|
79
94
|
try { await page.evaluate(() => document.fonts?.ready) } catch {}
|
|
@@ -95,7 +110,7 @@ export async function settle (page) {
|
|
|
95
110
|
* Captures the current state of `page` for one step and locale in every
|
|
96
111
|
* requested viewport: full-page PNG, rendered HTML, probe results.
|
|
97
112
|
*/
|
|
98
|
-
export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null }) {
|
|
113
|
+
export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null, tokens = null, stability = 0 }) {
|
|
99
114
|
const opts = { ...(options || captureOptions(null, node)), ...(format ? { format } : {}) }
|
|
100
115
|
// a page of a stack step lives under steps/<node>/items/<item>/…; its capture record names node and item
|
|
101
116
|
const stepId = item ? `${node.id}/items/${item.id}` : node.id
|
|
@@ -132,6 +147,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
132
147
|
throw error
|
|
133
148
|
}
|
|
134
149
|
}
|
|
150
|
+
// the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
|
|
151
|
+
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens)
|
|
152
|
+
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
153
|
+
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
154
|
+
// what moves while the page sits still (walk --stability N): repeated screenshots, compared here
|
|
155
|
+
const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
|
|
135
156
|
// numbered marks on the highlighted elements, in criteria order (first box of each)
|
|
136
157
|
const annotations = opts.annotate ? Object.entries(result.boxes || {}).filter(([, list]) => list?.length).map(([criterion, list], i) => ({ n: i + 1, criterion, x: list[0].x, y: list[0].y })) : []
|
|
137
158
|
let image = paths.step(stepId, locale, viewport, opts.format)
|
|
@@ -168,6 +189,24 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
168
189
|
: { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
|
|
169
190
|
}
|
|
170
191
|
}
|
|
192
|
+
// keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
|
|
193
|
+
// the console was read (focus handlers of the page must not count as its errors)
|
|
194
|
+
let keyboard = null
|
|
195
|
+
// stack steps walk the keyboard on their first few pages only (one template, hundreds of pages)
|
|
196
|
+
const kbKey = `${run.dir}|${node.id}|${viewport}`
|
|
197
|
+
const kbSampled = !item || (STACK_KEYBOARD.set(kbKey, (STACK_KEYBOARD.get(kbKey) || 0) + 1).get(kbKey) <= STACK_KEYBOARD_PAGES)
|
|
198
|
+
if (needsKeyboard(node) && !kbSampled) {
|
|
199
|
+
for (const c of node.criteria) if (KEYBOARD_ASSERTS.has(c.check.assert)) result.checks[c.id] = { ok: null, evidence: `keyboard walk runs on the first ${STACK_KEYBOARD_PAGES} pages of a stack step` }
|
|
200
|
+
}
|
|
201
|
+
if (needsKeyboard(node) && kbSampled) {
|
|
202
|
+
const kb = await keyboardChecks(page, node.criteria, {
|
|
203
|
+
saveShot: async (n, png) => { const f = paths.step(stepId, locale, viewport, `focus-${n}.png`); await writeFile(f, png); return relative(run.dir, f) }
|
|
204
|
+
})
|
|
205
|
+
Object.assign(result.checks, kb.checks)
|
|
206
|
+
result.boxes = { ...result.boxes, ...kb.boxes }
|
|
207
|
+
keyboard = kb.keyboard
|
|
208
|
+
for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: `Focus ${s.n}: ${s.name}`, file: s.file, at: new Date().toISOString() })
|
|
209
|
+
}
|
|
171
210
|
// experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
|
|
172
211
|
let view = null
|
|
173
212
|
if (htmlView) {
|
|
@@ -189,6 +228,9 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
189
228
|
...extra,
|
|
190
229
|
http_status: extra.http_status ?? result.http_status ?? null,
|
|
191
230
|
capture: { format: String(image).split('.').pop(), scale: opts.scale, masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
|
|
231
|
+
...(ignore.length ? { ignore } : {}),
|
|
232
|
+
...(unstable ? { unstable } : {}),
|
|
233
|
+
...(keyboard ? { keyboard } : {}),
|
|
192
234
|
...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
|
|
193
235
|
...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
|
|
194
236
|
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
|
package/src/lib/design-cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { readJson, writeJson, UserError } from './util.mjs'
|
|
|
7
7
|
import { readCaptures, addCapture, runPaths } from './run.mjs'
|
|
8
8
|
import { latestCaptures, criteriaHash, evaluationSource } from './evaluate.mjs'
|
|
9
9
|
import { captureStep } from './capture.mjs'
|
|
10
|
+
import { loadTokens } from './drift.mjs'
|
|
10
11
|
import { layout } from './scenario.mjs'
|
|
11
12
|
import { launchEphemeral } from './browser.mjs'
|
|
12
13
|
import { observeConsole } from './console.mjs'
|
|
@@ -45,6 +46,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
45
46
|
const browser = await launchEphemeral(config)
|
|
46
47
|
const paths = runPaths(run.dir)
|
|
47
48
|
const mode = env.tokens_mode || ''
|
|
49
|
+
// the design folder's own set names the values in the step spec (the one its live pages reference)
|
|
50
|
+
const tokens = await loadTokens({ client: null, config, root, designDir: dir })
|
|
48
51
|
let rendered = 0, reused = 0
|
|
49
52
|
try {
|
|
50
53
|
const page = await (await browser.newContext()).newPage()
|
|
@@ -58,7 +61,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
58
61
|
for (const [vp, size] of Object.entries(viewports)) {
|
|
59
62
|
const ck = captureKey(rev.id, vp, mode)
|
|
60
63
|
const cached = noCache ? null : await fromCache({ root, output: config.output, key: ck })
|
|
61
|
-
|
|
64
|
+
// a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it
|
|
65
|
+
if (cached && cached.record.criteria_hash === criteriaHash(node) && cached.record.spec) {
|
|
62
66
|
const record = await restoreCached({ cached, runDir: run.dir, paths, node: node.id, locale: rev.locale, viewport: vp })
|
|
63
67
|
const file = paths.step(node.id, rev.locale, vp, 'capture.json')
|
|
64
68
|
await writeJson(file, record)
|
|
@@ -69,7 +73,7 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
69
73
|
if (Object.keys(todo).length) {
|
|
70
74
|
const since = new Date().toISOString()
|
|
71
75
|
await page.goto(`${server.url}/${rev.entry}`, { waitUntil: 'load' })
|
|
72
|
-
const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true })
|
|
76
|
+
const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true, tokens })
|
|
73
77
|
for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode), runDir: run.dir, record: r }); rendered++ }
|
|
74
78
|
}
|
|
75
79
|
say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${Object.keys(todo).length ? '' : ' (unchanged, reused)'}`)
|
|
@@ -231,7 +235,7 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
231
235
|
out[k] = h
|
|
232
236
|
}
|
|
233
237
|
const stored = await readJson(runPaths(run.dir).step(rev.node, rev.locale, vp, 'eval.json'), null)
|
|
234
|
-
out.record = slimCapture(rec)
|
|
238
|
+
out.record = { ...slimCapture(rec), ...(rec.spec ? { spec: rec.spec } : {}) }
|
|
235
239
|
out.evaluation = stored?.source === evaluationSource(node, rec) ? stored : null
|
|
236
240
|
out.mode = env.tokens_mode || ''
|
|
237
241
|
out.renderer = RENDERER
|