kaloko 0.3.0
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 +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- package/viewer/index.html +2772 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page probe. Runs inside the browser via page.evaluate, so it must stay
|
|
3
|
+
* self-contained (no imports, no outer variables).
|
|
4
|
+
*
|
|
5
|
+
* Produces:
|
|
6
|
+
* - state: reduced, evaluator-friendly outline of the visible page
|
|
7
|
+
* - scoped: reduced outlines limited to criterion scopes
|
|
8
|
+
* - checks: deterministic check results with evidence
|
|
9
|
+
* - boxes: highlight rectangles in page coordinates
|
|
10
|
+
* - overflow, meta, size
|
|
11
|
+
* - design (when asked): structure (headings, landmarks, actions, fields) and used style values,
|
|
12
|
+
* compared with the design reference and the token set outside the page (#42)
|
|
13
|
+
*/
|
|
14
|
+
export function probe ({ checks = [], highlights = [], scopes = [], maxChars = 90000, design = false }) {
|
|
15
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', 'OBJECT', 'LINK', 'META'])
|
|
16
|
+
const LANDMARK = { HEADER: 'header', NAV: 'nav', MAIN: 'main', FOOTER: 'footer', ASIDE: 'aside', DIALOG: 'dialog', FORM: 'form' }
|
|
17
|
+
const ROLE_LANDMARK = { banner: 'header', navigation: 'nav', main: 'main', contentinfo: 'footer', complementary: 'aside', dialog: 'dialog', alertdialog: 'dialog', form: 'form' }
|
|
18
|
+
const mask = (s) => s
|
|
19
|
+
.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '<email>')
|
|
20
|
+
.replace(/\b[a-f0-9]{24,}\b/gi, '<token>')
|
|
21
|
+
const clean = (s) => mask(String(s || '').replace(/\s+/g, ' ').trim())
|
|
22
|
+
// Signed download links (CloudFront Policy/Signature, S3 X-Amz-*, GCS X-Goog-*, Azure sig, token) carry ~1 kB of
|
|
23
|
+
// credentials per attachment: noise for criteria and the evaluator, and a secret on the canvas.
|
|
24
|
+
const SIGNED = /^(policy|signature|key-pair-id|x-amz-[\w-]+|x-goog-[\w-]+|sig|token|access_token)$/i
|
|
25
|
+
const href = (h) => {
|
|
26
|
+
const raw = String(h || '')
|
|
27
|
+
const q = raw.indexOf('?')
|
|
28
|
+
if (q < 0) return raw
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(raw, location.href)
|
|
31
|
+
const keys = [...u.searchParams.keys()]
|
|
32
|
+
if (!keys.some((k) => SIGNED.test(k))) return raw
|
|
33
|
+
const kept = keys.filter((k) => !SIGNED.test(k))
|
|
34
|
+
const rest = new URLSearchParams(kept.map((k) => [k, u.searchParams.get(k)])).toString()
|
|
35
|
+
return `${raw.slice(0, q)}?${rest ? `${rest}&` : ''}<signed>`
|
|
36
|
+
} catch { return raw }
|
|
37
|
+
}
|
|
38
|
+
// Match on the raw text, as the verdict does, and mask only the snippet that is returned.
|
|
39
|
+
// Masking first turned e-mails into <email>, so a pattern containing an address decided the
|
|
40
|
+
// verdict but never matched the evidence text (evidence fell back to the page start or
|
|
41
|
+
// "found: undefined").
|
|
42
|
+
const matchIn = (raw, re) => {
|
|
43
|
+
const text = String(raw || '')
|
|
44
|
+
const m = text.match(re)
|
|
45
|
+
if (!m) return { match: null }
|
|
46
|
+
const at = m.index
|
|
47
|
+
const pre = clean(text.slice(Math.max(0, at - 80), at)).slice(-40)
|
|
48
|
+
const post = clean(text.slice(at + m[0].length, at + m[0].length + 80)).slice(0, 40)
|
|
49
|
+
const hit = clean(m[0])
|
|
50
|
+
return {
|
|
51
|
+
match: hit.length > 200 ? `${hit.slice(0, 120)}…${hit.slice(-60)}` : hit,
|
|
52
|
+
before: `${at > 40 ? '…' : ''}${pre}${pre && /\s/.test(text[at - 1] || '') ? ' ' : ''}`,
|
|
53
|
+
after: `${post && /\s/.test(text[at + m[0].length] || '') ? ' ' : ''}${post}${text.length > at + m[0].length + 40 ? '…' : ''}`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const visible = (el) => {
|
|
57
|
+
if (!(el instanceof Element)) return true
|
|
58
|
+
if (el.closest('head')) return true // meta, link, title: present means visible to crawlers
|
|
59
|
+
if (el.closest('[aria-hidden="true"],[hidden]')) return false
|
|
60
|
+
if (typeof el.checkVisibility === 'function') return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
|
61
|
+
const cs = getComputedStyle(el)
|
|
62
|
+
return cs.display !== 'none' && cs.visibility !== 'hidden'
|
|
63
|
+
}
|
|
64
|
+
const labelFor = (el) => {
|
|
65
|
+
if (el.labels && el.labels.length) return clean([...el.labels].map((l) => l.innerText).join(' '))
|
|
66
|
+
return clean(el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('title') || el.name || '')
|
|
67
|
+
}
|
|
68
|
+
const hooks = (el) => {
|
|
69
|
+
const out = []
|
|
70
|
+
for (const a of el.attributes) {
|
|
71
|
+
if (a.name.startsWith('data-test') || a.name === 'data-qa' || a.name === 'id' && /^[a-z][\w-]{2,40}$/i.test(a.value)) out.push(`${a.name}=${a.value}`)
|
|
72
|
+
}
|
|
73
|
+
return out.length ? ` {${out.join(' ')}}` : ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function outline (root) {
|
|
77
|
+
const sections = {}
|
|
78
|
+
const push = (section, line) => { if (line) (sections[section] ||= []).push(line) }
|
|
79
|
+
const walk = (node, section) => {
|
|
80
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
81
|
+
const text = clean(node.textContent)
|
|
82
|
+
if (text) push(section, text)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
86
|
+
const el = node
|
|
87
|
+
if (SKIP.has(el.tagName.toUpperCase())) return
|
|
88
|
+
const tag = el.tagName
|
|
89
|
+
// custom-styled checkboxes/radios: the native input is hidden, its label is what people see
|
|
90
|
+
if (tag === 'LABEL' && el.control && ['checkbox', 'radio'].includes(el.control.type) && !visible(el.control) && visible(el)) {
|
|
91
|
+
const c = el.control
|
|
92
|
+
push(LANDMARK[tag] || section, `[${c.type} ${c.checked ? 'checked' : 'unchecked'}${c.required ? ' required' : ''}] ${clean(el.innerText)}${hooks(c)}`)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
if (!visible(el)) return
|
|
96
|
+
const role = el.getAttribute('role')
|
|
97
|
+
const next = LANDMARK[tag] || ROLE_LANDMARK[role] || section
|
|
98
|
+
if (/^H[1-6]$/.test(tag)) { push(next, `${'#'.repeat(+tag[1])} ${clean(el.innerText)}${hooks(el)}`); return }
|
|
99
|
+
if (tag === 'A') { push(next, `[link → ${href(el.getAttribute('href'))}] ${clean(el.innerText || el.getAttribute('aria-label'))}${hooks(el)}`); return }
|
|
100
|
+
if (tag === 'BUTTON' || role === 'button') { push(next, `[button${el.disabled ? ' disabled' : ''}] ${clean(el.innerText || el.getAttribute('aria-label'))}${hooks(el)}`); return }
|
|
101
|
+
if (tag === 'IMG') { const alt = clean(el.getAttribute('alt')); if (alt) push(next, `[image] ${alt}`); return }
|
|
102
|
+
if (tag === 'INPUT') {
|
|
103
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase()
|
|
104
|
+
if (type === 'hidden') { if (el.name) push(next, `[hidden ${el.name}=${clean(el.value).slice(0, 60)}]`); return }
|
|
105
|
+
if (type === 'checkbox' || type === 'radio') { push(next, `[${type} ${el.checked ? 'checked' : 'unchecked'}${el.required ? ' required' : ''}] ${labelFor(el)}${hooks(el)}`); return }
|
|
106
|
+
if (type === 'submit' || type === 'button') { push(next, `[button] ${clean(el.value)}${hooks(el)}`); return }
|
|
107
|
+
push(next, `[input ${type}${el.required ? ' required' : ''}] ${labelFor(el)}${el.value && type !== 'password' ? ` = ${clean(el.value).slice(0, 80)}` : ''}${hooks(el)}`)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (tag === 'SELECT') { push(next, `[select] ${labelFor(el)} = ${clean(el.selectedOptions?.[0]?.text)}`); return }
|
|
111
|
+
if (tag === 'TEXTAREA') { push(next, `[textarea] ${labelFor(el)}`); return }
|
|
112
|
+
if (tag === 'LABEL' && el.control && visible(el.control)) return // rendered with its control
|
|
113
|
+
for (const child of el.childNodes) walk(child, next)
|
|
114
|
+
}
|
|
115
|
+
walk(root, 'body')
|
|
116
|
+
// merge consecutive text fragments
|
|
117
|
+
for (const key of Object.keys(sections)) {
|
|
118
|
+
const merged = []
|
|
119
|
+
for (const line of sections[key]) {
|
|
120
|
+
const prev = merged[merged.length - 1]
|
|
121
|
+
if (prev && !line.startsWith('[') && !line.startsWith('#') && !prev.startsWith('[') && !prev.startsWith('#') && prev.length < 400) merged[merged.length - 1] = `${prev} ${line}`
|
|
122
|
+
else merged.push(line)
|
|
123
|
+
}
|
|
124
|
+
sections[key] = merged
|
|
125
|
+
}
|
|
126
|
+
let size = JSON.stringify(sections).length
|
|
127
|
+
if (size > maxChars) {
|
|
128
|
+
for (const key of ['footer', 'nav', 'header', 'aside', 'body']) {
|
|
129
|
+
if (size <= maxChars) break
|
|
130
|
+
if (sections[key]) { sections[key] = [`(${sections[key].length} lines omitted)`]; size = JSON.stringify(sections).length }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return sections
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const meta = {
|
|
137
|
+
title: document.title,
|
|
138
|
+
lang: document.documentElement.lang || null,
|
|
139
|
+
description: document.querySelector('meta[name="description"]')?.content || null,
|
|
140
|
+
canonical: document.querySelector('link[rel="canonical"]')?.href || null,
|
|
141
|
+
robots: document.querySelector('meta[name="robots"]')?.content || null,
|
|
142
|
+
hreflang: [...document.querySelectorAll('link[rel="alternate"][hreflang]')].map((l) => ({ lang: l.hreflang, href: l.href })),
|
|
143
|
+
h1: [...document.querySelectorAll('h1')].filter(visible).map((h) => clean(h.innerText))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const overflowIn = (root) => {
|
|
147
|
+
const w = document.documentElement.clientWidth
|
|
148
|
+
const found = []
|
|
149
|
+
for (const el of root.querySelectorAll('*')) {
|
|
150
|
+
if (found.length >= 10) break
|
|
151
|
+
const r = el.getBoundingClientRect()
|
|
152
|
+
if (!r.width || !visible(el)) continue
|
|
153
|
+
if (r.right > w + 1 || r.left < -1) {
|
|
154
|
+
let clipped = false
|
|
155
|
+
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
156
|
+
const ox = getComputedStyle(p).overflowX
|
|
157
|
+
if (ox === 'hidden' || ox === 'auto' || ox === 'scroll' || ox === 'clip') { clipped = true; break }
|
|
158
|
+
}
|
|
159
|
+
if (!clipped) found.push({ el: `${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''}`, right: Math.round(r.right), width: w, text: clean(el.innerText).slice(0, 60) })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return found
|
|
163
|
+
}
|
|
164
|
+
const overflow = overflowIn(document.body)
|
|
165
|
+
|
|
166
|
+
const snippet = (el) => clean(el.outerHTML).slice(0, 220)
|
|
167
|
+
// accessibility and performance measurements for the a11y and perf packs (own rules, no library)
|
|
168
|
+
const describe = (el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''}`
|
|
169
|
+
const nameOf = (el) => clean(el.getAttribute('aria-label') || (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean).map((i) => document.getElementById(i)?.innerText || '').join(' ') || el.innerText || el.getAttribute('title') || [...el.querySelectorAll('img[alt], svg[aria-label]')].map((i) => i.getAttribute('alt') || i.getAttribute('aria-label')).join(' ') || (el.tagName === 'INPUT' ? el.value : ''))
|
|
170
|
+
const rgba = (v) => { const m = String(v).match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(/[\s,/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1] }
|
|
171
|
+
const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) }
|
|
172
|
+
const bgOf = (el) => { for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const cs = getComputedStyle(e); if (cs.backgroundImage && cs.backgroundImage !== 'none') return null; const c = rgba(cs.backgroundColor); if (c && c[3] >= 0.99) return c } return [255, 255, 255, 1] }
|
|
173
|
+
const listOf = (els, max = 5) => els.slice(0, max).map(describe).join(', ') + (els.length > max ? ` (+${els.length - max})` : '')
|
|
174
|
+
const a11y = (kind) => {
|
|
175
|
+
if (kind === 'images_alt') { const bad = [...visibleAll('img').filter((i) => !i.hasAttribute('alt') && !/^(presentation|none)$/.test(i.getAttribute('role') || '')), ...visibleAll('[role=img]').filter((e) => !nameOf(e))]; return { ok: bad.length === 0, evidence: bad.length ? `${bad.length} image(s) without alt: ${listOf(bad)}` : 'every image has alt' } }
|
|
176
|
+
if (kind === 'names') { const bad = visibleAll('a[href], button, [role=button], [role=link], input[type=submit], input[type=button]').filter((e) => !nameOf(e)); return { ok: bad.length === 0, evidence: bad.length ? `${bad.length} control(s) without an accessible name: ${listOf(bad)}` : 'every link and button has a name' } }
|
|
177
|
+
if (kind === 'labels') { const bad = visibleAll('input:not([type=hidden]):not([type=submit]):not([type=button]):not([type=image]):not([type=reset]), select, textarea').filter((e) => !(e.labels?.length || e.getAttribute('aria-label') || e.getAttribute('aria-labelledby') || e.getAttribute('title'))); return { ok: bad.length === 0, evidence: bad.length ? `${bad.length} field(s) without a label: ${listOf(bad)}` : 'every field has a label' } }
|
|
178
|
+
if (kind === 'headings') { const hs = visibleAll('h1, h2, h3, h4, h5, h6'); const bad = []; let prev = 0; for (const h of hs) { const l = Number(h.tagName[1]); if (prev && l > prev + 1) bad.push(`h${prev}→h${l} "${clean(h.innerText).slice(0, 40)}"`); prev = l } return { ok: bad.length === 0, evidence: bad.length ? `heading level skipped: ${bad.slice(0, 3).join('; ')}` : `${hs.length} headings in order` } }
|
|
179
|
+
if (kind === 'main') { const m = document.querySelector('main, [role=main]'); return { ok: !!m, evidence: m ? describe(m) : 'no main landmark' } }
|
|
180
|
+
if (kind === 'unique_ids') { const seen = new Map(); for (const e of document.querySelectorAll('[id]')) seen.set(e.id, (seen.get(e.id) || 0) + 1); const dup = [...seen].filter(([, n]) => n > 1).map(([i, n]) => `#${i}×${n}`); return { ok: dup.length === 0, evidence: dup.length ? `duplicate ids: ${dup.slice(0, 5).join(', ')}` : 'ids are unique' } }
|
|
181
|
+
if (kind === 'contrast') {
|
|
182
|
+
const bad = []; const seen = new Set(); let checked = 0
|
|
183
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
|
|
184
|
+
for (let t = walker.nextNode(); t && checked < 400; t = walker.nextNode()) {
|
|
185
|
+
const el = t.parentElement
|
|
186
|
+
if (!el || seen.has(el) || !t.textContent.trim() || !visible(el)) continue
|
|
187
|
+
seen.add(el); checked++
|
|
188
|
+
const cs = getComputedStyle(el); const fg = rgba(cs.color); const bg = bgOf(el)
|
|
189
|
+
if (!fg || !bg) continue
|
|
190
|
+
const mix = fg.slice(0, 3).map((v, i) => v * fg[3] + bg[i] * (1 - fg[3]))
|
|
191
|
+
const [a, b] = [lum(mix), lum(bg)].sort((x, y) => y - x)
|
|
192
|
+
const ratio = (a + 0.05) / (b + 0.05)
|
|
193
|
+
const size = parseFloat(cs.fontSize); const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700)
|
|
194
|
+
if (ratio < (large ? 3 : 4.5)) bad.push(`${describe(el)} "${clean(t.textContent).slice(0, 30)}" ${ratio.toFixed(2)}:1`)
|
|
195
|
+
}
|
|
196
|
+
return { ok: bad.length === 0, evidence: bad.length ? `${bad.length} text(s) below WCAG AA contrast: ${bad.slice(0, 4).join('; ')}` : `${checked} texts meet WCAG AA contrast` }
|
|
197
|
+
}
|
|
198
|
+
return { ok: null, evidence: `unknown a11y check ${kind}` }
|
|
199
|
+
}
|
|
200
|
+
const buffered = (type) => { try { const po = new PerformanceObserver(() => {}); po.observe({ type, buffered: true }); const r = po.takeRecords(); po.disconnect(); return r } catch { return null } }
|
|
201
|
+
const perf = (kind, check) => {
|
|
202
|
+
const nav = performance.getEntriesByType('navigation')[0]
|
|
203
|
+
const sec = (ms) => `${(ms / 1000).toFixed(2)} s`
|
|
204
|
+
if (kind === 'lcp') { const r = buffered('largest-contentful-paint'); const v = r?.length ? r[r.length - 1].startTime : null; const max = check.max ?? 2500; return v == null ? { ok: null, evidence: 'LCP not measured (no paint entries)' } : { ok: v <= max, evidence: `LCP ${sec(v)} since page load (limit ${sec(max)})` } }
|
|
205
|
+
if (kind === 'cls') { const r = buffered('layout-shift'); if (!r) return { ok: null, evidence: 'CLS not measured' }; const v = r.filter((e) => !e.hadRecentInput).reduce((s, e) => s + e.value, 0); const max = check.max ?? 0.1; return { ok: v <= max, evidence: `CLS ${v.toFixed(3)} (limit ${max})` } }
|
|
206
|
+
if (kind === 'ttfb') { if (!nav) return { ok: null, evidence: 'no navigation timing' }; const v = nav.responseStart; const max = check.max ?? 800; return { ok: v <= max, evidence: `TTFB ${Math.round(v)} ms (limit ${max} ms)` } }
|
|
207
|
+
if (kind === 'weight') { const res = performance.getEntriesByType('resource'); const bytes = (nav?.transferSize || 0) + res.reduce((s, e) => s + (e.transferSize || 0), 0); const opaque = res.filter((e) => !e.transferSize).length; const max = (check.max ?? 3000) * 1024; return { ok: bytes <= max, evidence: `${Math.round(bytes / 1024)} kB transferred in ${res.length + 1} requests (limit ${Math.round(max / 1024)} kB)${opaque ? `; ${opaque} cached or cross-origin without timing count as 0` : ''}` } }
|
|
208
|
+
return { ok: null, evidence: `unknown perf check ${kind}` }
|
|
209
|
+
}
|
|
210
|
+
const visibleAll = (sel) => [...document.querySelectorAll(sel)].filter(visible)
|
|
211
|
+
// HTTP status of the document (Chrome 109+); null for pages set from stored HTML
|
|
212
|
+
const httpStatus = performance.getEntriesByType('navigation')[0]?.responseStatus || null
|
|
213
|
+
const results = {}
|
|
214
|
+
for (const { id, check } of checks) {
|
|
215
|
+
try {
|
|
216
|
+
// optional flags (i, m, s); page text is innerText, which follows CSS text-transform (#54)
|
|
217
|
+
const re = check.pattern ? new RegExp(check.pattern, 'u' + [...new Set(String(check.flags || '').replace(/[^ims]/g, ''))].join('')) : null
|
|
218
|
+
let ok = false
|
|
219
|
+
let evidence = ''
|
|
220
|
+
switch (check.assert) {
|
|
221
|
+
case 'exists': { const els = visibleAll(check.selector); ok = els.length > 0; evidence = els[0] ? snippet(els[0]) : `no visible ${check.selector}`; break }
|
|
222
|
+
case 'absent': { const els = visibleAll(check.selector); ok = els.length === 0; evidence = els[0] ? snippet(els[0]) : `no visible ${check.selector}`; break }
|
|
223
|
+
case 'count': { const n = visibleAll(check.selector).length; ok = n >= (check.min ?? 0) && n <= (check.max ?? Infinity); evidence = `${n}× ${check.selector}`; break }
|
|
224
|
+
case 'text_matches': {
|
|
225
|
+
const els = check.selector ? visibleAll(check.selector) : [document.body]
|
|
226
|
+
const hit = els.find((el) => re.test(el.innerText))
|
|
227
|
+
ok = !!hit
|
|
228
|
+
if (hit) {
|
|
229
|
+
const { before, match, after } = matchIn(hit.innerText, re)
|
|
230
|
+
evidence = match != null ? `${before}[${match}]${after}` : clean(hit.innerText).slice(0, 220)
|
|
231
|
+
} else evidence = els[0] ? `"${clean(els[0].innerText).slice(0, 160)}" does not match /${check.pattern}/` : `no visible ${check.selector}`
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
case 'text_absent': {
|
|
235
|
+
const els = check.selector ? visibleAll(check.selector) : [document.body]
|
|
236
|
+
const hit = els.find((el) => re.test(el.innerText))
|
|
237
|
+
ok = !hit
|
|
238
|
+
evidence = hit ? `found: ${matchIn(hit.innerText, re).match ?? clean(hit.innerText).slice(0, 160)}` : `/${check.pattern}/ not found`
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
case 'attr_equals': {
|
|
242
|
+
const el = document.querySelector(check.selector)
|
|
243
|
+
if (!el) { evidence = `no ${check.selector}`; break }
|
|
244
|
+
const v = (check.attr in el && typeof el[check.attr] === 'boolean') ? el[check.attr] : el.getAttribute(check.attr)
|
|
245
|
+
ok = String(v) === String(check.value)
|
|
246
|
+
evidence = `${check.attr}=${v}`
|
|
247
|
+
break
|
|
248
|
+
}
|
|
249
|
+
case 'no_overflow': { const list = check.selector ? [...document.querySelectorAll(check.selector)].flatMap(overflowIn) : overflow; ok = list.length === 0; evidence = ok ? 'no horizontal overflow' : list.slice(0, 3).map((o) => `${o.el} right=${o.right}px > ${o.width}px`).join('; '); break }
|
|
250
|
+
case 'title_matches': ok = re.test(document.title); evidence = document.title; break
|
|
251
|
+
case 'meta_matches': { const el = document.querySelector(check.selector); const v = el?.getAttribute('content'); ok = v != null && re.test(v); evidence = v ?? `no ${check.selector}`; break }
|
|
252
|
+
case 'http_status': {
|
|
253
|
+
if (!httpStatus) { ok = null; evidence = 'HTTP status not known for this capture'; break }
|
|
254
|
+
ok = check.value != null ? httpStatus === Number(check.value) : httpStatus >= (check.min ?? 200) && httpStatus <= (check.max ?? 299)
|
|
255
|
+
evidence = `HTTP ${httpStatus} ${location.pathname}${location.search}`
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
case 'link_home': {
|
|
259
|
+
// a visible link to the site's home: /, or a language home such as /cs/ or /en-us
|
|
260
|
+
const home = visibleAll('a[href]').find((a) => { try { const u = new URL(a.href); return u.origin === location.origin && /^\/([a-z]{2}([-_][a-z]{2})?\/?)?$/i.test(u.pathname) } catch { return false } })
|
|
261
|
+
ok = !!home; evidence = home ? snippet(home) : 'no visible link to the home page'
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
case 'link_matches': { const el = document.querySelector(check.selector); const v = el?.href; ok = v != null && re.test(v); evidence = v ?? `no ${check.selector}`; break }
|
|
265
|
+
default: {
|
|
266
|
+
if (/^a11y_/.test(check.assert)) { ({ ok, evidence } = a11y(check.assert.slice(5))); break }
|
|
267
|
+
if (/^perf_/.test(check.assert)) { ({ ok, evidence } = perf(check.assert.slice(5), check)); break }
|
|
268
|
+
evidence = `assert ${check.assert} is evaluated outside the page`; ok = null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
results[id] = { ok, evidence }
|
|
272
|
+
} catch (error) {
|
|
273
|
+
results[id] = { ok: false, evidence: `check error: ${error.message}` }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const boxes = {}
|
|
278
|
+
for (const { id, selector } of highlights) {
|
|
279
|
+
try {
|
|
280
|
+
boxes[id] = [...document.querySelectorAll(selector)].filter(visible).slice(0, 5).map((el) => {
|
|
281
|
+
const r = el.getBoundingClientRect()
|
|
282
|
+
return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) }
|
|
283
|
+
})
|
|
284
|
+
} catch { boxes[id] = [] }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// failing overflow checks point at the overflowing elements
|
|
288
|
+
for (const { id, check } of checks) {
|
|
289
|
+
if (check.assert !== 'no_overflow' || results[id]?.ok !== false) continue
|
|
290
|
+
const root = check.selector ? document.querySelector(check.selector) : document.body
|
|
291
|
+
const w = document.documentElement.clientWidth
|
|
292
|
+
boxes[id] = [...(root || document.body).querySelectorAll('*')].filter((el) => { const r = el.getBoundingClientRect(); return r.width && visible(el) && (r.right > w + 1 || r.left < -1) }).slice(0, 5).map((el) => {
|
|
293
|
+
const r = el.getBoundingClientRect()
|
|
294
|
+
return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) }
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const metadata = collectMetadata()
|
|
299
|
+
|
|
300
|
+
// design summary: what the page is made of (structure) and which style values it uses
|
|
301
|
+
function designSummary () {
|
|
302
|
+
const vis = (sel) => [...document.querySelectorAll(sel)].filter(visible)
|
|
303
|
+
const labelOf = (el) => clean(el.labels?.[0]?.innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.name || '').slice(0, 80)
|
|
304
|
+
const structure = {
|
|
305
|
+
headings: metadata.headings.slice(0, 40),
|
|
306
|
+
landmarks: [...new Set(vis('header,nav,main,footer,aside,form,dialog,[role]').map((el) => ROLE_LANDMARK[el.getAttribute('role')] || LANDMARK[el.tagName]).filter(Boolean))].sort(),
|
|
307
|
+
actions: vis('button,a[href],[role=button],input[type=submit],input[type=button]').map((el) => clean(el.innerText || el.value || el.getAttribute('aria-label') || '').slice(0, 60)).filter(Boolean).slice(0, 40),
|
|
308
|
+
fields: vis('input:not([type=hidden]):not([type=submit]):not([type=button]),select,textarea').map((el) => ({ type: el.type || el.tagName.toLowerCase(), label: labelOf(el) })).slice(0, 40)
|
|
309
|
+
}
|
|
310
|
+
const PROPS = ['color', 'background-color', 'font-family', 'font-size', 'border-radius', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-bottom', 'gap', 'border-top-color']
|
|
311
|
+
const styles = {}
|
|
312
|
+
let n = 0
|
|
313
|
+
// browser defaults are not authored values: compare with the same tag in an empty document
|
|
314
|
+
let blank = null
|
|
315
|
+
try { const f = document.createElement('iframe'); f.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'; f.srcdoc = '<!doctype html><body></body>'; document.documentElement.appendChild(f); blank = f } catch {}
|
|
316
|
+
const defaults = new Map()
|
|
317
|
+
const uaDefault = (tag, prop) => {
|
|
318
|
+
const doc = blank?.contentDocument
|
|
319
|
+
if (!doc?.body) return null
|
|
320
|
+
if (!defaults.has(tag)) { try { const el = doc.createElement(tag); doc.body.appendChild(el); defaults.set(tag, doc.defaultView.getComputedStyle(el)) } catch { defaults.set(tag, null) } }
|
|
321
|
+
return defaults.get(tag)?.getPropertyValue(prop) ?? null
|
|
322
|
+
}
|
|
323
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
324
|
+
if (n++ > 3000) break
|
|
325
|
+
if (!visible(el)) continue
|
|
326
|
+
const cs = getComputedStyle(el)
|
|
327
|
+
for (const p of PROPS) {
|
|
328
|
+
const v = cs.getPropertyValue(p)
|
|
329
|
+
if (!v || v === '0px' || v === 'normal' || v === 'auto' || /rgba\(0, 0, 0, 0\)/.test(v)) continue
|
|
330
|
+
if (p === 'border-top-color' && cs.borderTopWidth === '0px') continue
|
|
331
|
+
if (p === 'color' && ![...el.childNodes].some((x) => x.nodeType === 3 && x.textContent.trim())) continue
|
|
332
|
+
if (uaDefault(el.tagName.toLowerCase(), p) === v) continue
|
|
333
|
+
const bucket = (styles[p] ||= {})
|
|
334
|
+
if (!bucket[v]) bucket[v] = { count: 0, example: snippet(el).slice(0, 120) }
|
|
335
|
+
bucket[v].count++
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
try { blank?.remove() } catch {}
|
|
339
|
+
return { structure, styles }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const scoped = {}
|
|
343
|
+
for (const { id, selector } of scopes) {
|
|
344
|
+
const root = document.querySelector(selector)
|
|
345
|
+
scoped[id] = root ? outline(root) : null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function collectMetadata () {
|
|
349
|
+
const attr = (el, n) => el.getAttribute(n)
|
|
350
|
+
const metaTags = [...document.querySelectorAll('meta')].map((m) => ({ key: attr(m, 'property') || attr(m, 'name') || attr(m, 'http-equiv') || attr(m, 'itemprop') || (attr(m, 'charset') ? 'charset' : null), kind: attr(m, 'property') ? 'property' : attr(m, 'name') ? 'name' : attr(m, 'http-equiv') ? 'http-equiv' : attr(m, 'itemprop') ? 'itemprop' : 'charset', content: attr(m, 'content') ?? attr(m, 'charset') })).filter((m) => m.key)
|
|
351
|
+
const links = [...document.querySelectorAll('link[rel]')].filter((l) => !/stylesheet|preload|prefetch|preconnect|dns-prefetch|modulepreload/i.test(l.rel)).map((l) => ({ rel: l.rel, href: l.href, hreflang: attr(l, 'hreflang'), type: attr(l, 'type'), sizes: attr(l, 'sizes') }))
|
|
352
|
+
const jsonld = [...document.querySelectorAll('script[type="application/ld+json"]')].map((sc) => { try { return { ok: true, data: JSON.parse(sc.textContent) } } catch (e) { return { ok: false, error: e.message, raw: sc.textContent.slice(0, 500) } } })
|
|
353
|
+
const microdata = [...document.querySelectorAll('[itemscope]:not([itemprop])')].slice(0, 30).map(function item (el) {
|
|
354
|
+
const props = {}
|
|
355
|
+
for (const p of el.querySelectorAll('[itemprop]')) {
|
|
356
|
+
if (p.parentElement.closest('[itemscope]') !== el) continue
|
|
357
|
+
const v = p.hasAttribute('itemscope') ? item(p) : (attr(p, 'content') ?? attr(p, 'href') ?? attr(p, 'src') ?? clean(p.textContent).slice(0, 200))
|
|
358
|
+
const k = attr(p, 'itemprop')
|
|
359
|
+
props[k] = props[k] === undefined ? v : [].concat(props[k], v)
|
|
360
|
+
}
|
|
361
|
+
return { type: attr(el, 'itemtype'), props }
|
|
362
|
+
})
|
|
363
|
+
const headings = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter(visible).slice(0, 80).map((h) => ({ level: +h.tagName[1], text: clean(h.innerText).slice(0, 160) }))
|
|
364
|
+
return { title: document.title, lang: document.documentElement.lang || null, url: location.href, meta: metaTags, links, jsonld, microdata, headings }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
state: outline(document.body),
|
|
369
|
+
http_status: httpStatus,
|
|
370
|
+
scoped,
|
|
371
|
+
checks: results,
|
|
372
|
+
boxes,
|
|
373
|
+
overflow,
|
|
374
|
+
meta,
|
|
375
|
+
metadata,
|
|
376
|
+
...(design ? { design: designSummary() } : {}),
|
|
377
|
+
size: { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight, viewport: [innerWidth, innerHeight] }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// `kaloko evaluate --recheck` (#55): after a scenario fix, re-run the deterministic checks against the stored HTML of
|
|
2
|
+
// each capture instead of walking again (some steps cannot be repeated: data created, e-mails sent once, states that
|
|
3
|
+
// moved on). Page scripts stay off, so nothing in the stored page runs; checks that only the live page can answer
|
|
4
|
+
// (console errors, overflow, performance) keep their result from the capture.
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { readJson, writeJson } from './util.mjs'
|
|
8
|
+
import { readCaptures } from './run.mjs'
|
|
9
|
+
import { latestCaptures, criteriaHash } from './evaluate.mjs'
|
|
10
|
+
import { probe } from './probe.mjs'
|
|
11
|
+
import { probeArgs } from './capture.mjs'
|
|
12
|
+
import { contextOptions, prepareContext } from './access.mjs'
|
|
13
|
+
import { launchEphemeral } from './browser.mjs'
|
|
14
|
+
import { treeChecks } from './apps/tree.mjs'
|
|
15
|
+
|
|
16
|
+
export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || /^perf_/.test(assert)
|
|
17
|
+
|
|
18
|
+
const withBase = (html, url) => {
|
|
19
|
+
if (!url || /<base\s/i.test(html)) return html
|
|
20
|
+
const tag = `<base href="${String(url).replace(/"/g, '"')}">`
|
|
21
|
+
return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + tag) : tag + html
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function recheckCaptures ({ run, scenario, config, root, env, only = null }) {
|
|
25
|
+
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
26
|
+
const entries = latestCaptures(await readCaptures(run.dir)).filter((e) => nodes.has(e.node) && (!only || only.includes(e.node)))
|
|
27
|
+
if (!entries.length) return { rechecked: 0, skipped: [] }
|
|
28
|
+
const browser = await launchEphemeral(config)
|
|
29
|
+
const contexts = new Map()
|
|
30
|
+
const report = { rechecked: 0, skipped: [] }
|
|
31
|
+
try {
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
const node = nodes.get(e.node)
|
|
34
|
+
const file = join(run.dir, e.file)
|
|
35
|
+
const cap = await readJson(file)
|
|
36
|
+
if (cap.files?.tree) {
|
|
37
|
+
// app capture: the deterministic checks run again on the stored UI tree
|
|
38
|
+
const t = await readJson(join(run.dir, cap.files.tree), null)
|
|
39
|
+
if (!t) { report.skipped.push(`${e.node}/${e.locale}/${e.viewport}: no stored UI tree`); continue }
|
|
40
|
+
const { checks, boxes } = treeChecks(node.criteria, t.root, { platform: cap.platform, format: t.format, crash: cap.crash || null, logErrors: cap.console_errors })
|
|
41
|
+
await writeJson(file, { ...cap, checks, boxes, criteria_hash: criteriaHash(node), rechecked_at: new Date().toISOString() })
|
|
42
|
+
report.rechecked++
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
const htmlPath = cap.files?.html ? join(run.dir, cap.files.html) : null
|
|
46
|
+
const html = htmlPath ? await readFile(htmlPath, 'utf8').catch(() => null) : null
|
|
47
|
+
if (!html) { report.skipped.push(`${e.node}/${e.locale}/${e.viewport}: no stored HTML`); continue }
|
|
48
|
+
const [width, height] = run.meta.scope.viewports?.[e.viewport] || [1280, 800]
|
|
49
|
+
const key = `${width}x${height}`
|
|
50
|
+
if (!contexts.has(key)) {
|
|
51
|
+
const ctx = await browser.newContext({ ...(await contextOptions(env, { root, config })), javaScriptEnabled: false, viewport: { width, height } })
|
|
52
|
+
await prepareContext(ctx, env)
|
|
53
|
+
contexts.set(key, ctx)
|
|
54
|
+
}
|
|
55
|
+
const page = await contexts.get(key).newPage()
|
|
56
|
+
try {
|
|
57
|
+
await page.setContent(withBase(html, cap.url), { waitUntil: 'load', timeout: 20000 }).catch(() => {})
|
|
58
|
+
const result = await page.evaluate(probe, probeArgs(node, config.evaluators?.jev?.max_state_chars))
|
|
59
|
+
const checks = {}
|
|
60
|
+
for (const c of node.criteria || []) {
|
|
61
|
+
if (c.check.type !== 'deterministic') continue
|
|
62
|
+
checks[c.id] = LIVE_ONLY(c.check.assert) ? (cap.checks?.[c.id] ?? { ok: null, evidence: 'live-page check; capture the step again to evaluate it' }) : result.checks[c.id]
|
|
63
|
+
}
|
|
64
|
+
await writeJson(file, { ...cap, checks, boxes: result.boxes, criteria_hash: criteriaHash(node), rechecked_at: new Date().toISOString() })
|
|
65
|
+
report.rechecked++
|
|
66
|
+
} finally { await page.close() }
|
|
67
|
+
}
|
|
68
|
+
} finally { await browser.close() }
|
|
69
|
+
return report
|
|
70
|
+
}
|
package/src/lib/run.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdir, writeFile, readFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { join, relative, resolve } from 'node:path'
|
|
3
|
+
import { readJson, writeJson, readJsonl, appendJsonl, shortId, slugify, today, UserError } from './util.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A run directory:
|
|
7
|
+
* meta.json run metadata (scenario, env, scope, git, executor)
|
|
8
|
+
* captures.jsonl one line per capture
|
|
9
|
+
* steps/<node>/<locale>/<viewport>.{png,html,capture.json,eval.json}
|
|
10
|
+
* attachments/ crops and other files
|
|
11
|
+
* run.json built manifest
|
|
12
|
+
* index.html canvas
|
|
13
|
+
*/
|
|
14
|
+
export function runPaths (dir) {
|
|
15
|
+
return {
|
|
16
|
+
dir,
|
|
17
|
+
meta: join(dir, 'meta.json'),
|
|
18
|
+
captures: join(dir, 'captures.jsonl'),
|
|
19
|
+
manifest: join(dir, 'run.json'),
|
|
20
|
+
index: join(dir, 'index.html'),
|
|
21
|
+
step: (node, locale, viewport, ext) => join(dir, 'steps', node, locale, `${viewport}.${ext}`),
|
|
22
|
+
attachment: (name) => join(dir, 'attachments', name)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function newRunId (scenario, date = new Date()) {
|
|
27
|
+
const short = shortId()
|
|
28
|
+
return { id: `${today(date)}-${slugify(scenario.id)}-${short}`, short }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null }) {
|
|
32
|
+
const { id, short } = newRunId(scenario)
|
|
33
|
+
const dir = resolve(root, output, id)
|
|
34
|
+
await mkdir(dir, { recursive: true })
|
|
35
|
+
const meta = {
|
|
36
|
+
id,
|
|
37
|
+
short,
|
|
38
|
+
scenario_file: relative(root, scenarioFile),
|
|
39
|
+
scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}) },
|
|
40
|
+
environment: { name: env.name, base_url: env.base_url, readonly: env.readonly, ...(env.kind === 'design' ? { kind: 'design' } : {}) },
|
|
41
|
+
scope,
|
|
42
|
+
git,
|
|
43
|
+
executor,
|
|
44
|
+
project,
|
|
45
|
+
issue,
|
|
46
|
+
app_version: appVersion,
|
|
47
|
+
started_at: new Date().toISOString()
|
|
48
|
+
}
|
|
49
|
+
await writeJson(join(dir, 'meta.json'), meta)
|
|
50
|
+
await writeFile(join(resolve(root, output), 'CURRENT'), id + '\n')
|
|
51
|
+
return { dir, meta }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolves --run (id or path) or the CURRENT pointer in the output directory. */
|
|
55
|
+
export async function resolveRun ({ root, output, run }) {
|
|
56
|
+
const base = resolve(root, output)
|
|
57
|
+
let dir
|
|
58
|
+
run ||= process.env.KALOKO_RUN // several agents in one repository: each pins its run
|
|
59
|
+
if (run) dir = run.includes('/') ? resolve(run) : join(base, run)
|
|
60
|
+
else {
|
|
61
|
+
let id
|
|
62
|
+
try { id = (await readFile(join(base, 'CURRENT'), 'utf8')).trim() } catch {}
|
|
63
|
+
if (!id) throw new UserError('No current run. Start one with "kaloko start --scenario <file> --env <name>".')
|
|
64
|
+
dir = join(base, id)
|
|
65
|
+
}
|
|
66
|
+
const meta = await readJson(join(dir, 'meta.json'), null)
|
|
67
|
+
if (!meta) throw new UserError(`Run directory ${dir} has no meta.json`)
|
|
68
|
+
return { dir, meta, paths: runPaths(dir) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const readCaptures = (dir) => readJsonl(join(dir, 'captures.jsonl'))
|
|
72
|
+
export const addCapture = (dir, entry) => appendJsonl(join(dir, 'captures.jsonl'), entry)
|
|
73
|
+
|
|
74
|
+
/** Lists other runs in the output directory (for history in the canvas). */
|
|
75
|
+
export async function listRuns (root, output) {
|
|
76
|
+
const base = resolve(root, output)
|
|
77
|
+
let names = []
|
|
78
|
+
try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name) } catch { return [] }
|
|
79
|
+
const runs = []
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
const manifest = await readJson(join(base, name, 'run.json'), null)
|
|
82
|
+
if (manifest?.schema === 'qawalk.run.v1') runs.push(manifest)
|
|
83
|
+
}
|
|
84
|
+
return runs
|
|
85
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import yaml from 'js-yaml'
|
|
3
|
+
import { schemaErrors } from './schema.mjs'
|
|
4
|
+
import { UserError } from './util.mjs'
|
|
5
|
+
import { expandPacks } from './packs.mjs'
|
|
6
|
+
|
|
7
|
+
export async function loadScenario (file) {
|
|
8
|
+
const data = yaml.load(await readFile(file, 'utf8'))
|
|
9
|
+
const problems = validateScenario(data)
|
|
10
|
+
if (!problems.length) expandPacks(data)
|
|
11
|
+
if (problems.length) throw new UserError(`Scenario ${file} is invalid:\n - ${problems.join('\n - ')}`)
|
|
12
|
+
return data
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Schema validation plus cross-reference rules the schema cannot express. */
|
|
16
|
+
export function validateScenario (data) {
|
|
17
|
+
const problems = schemaErrors('scenario.v1', data)
|
|
18
|
+
if (problems.length) return problems
|
|
19
|
+
try { expandPacks(JSON.parse(JSON.stringify(data))) } catch (e) { problems.push(e.message) }
|
|
20
|
+
const ids = new Set()
|
|
21
|
+
for (const node of data.nodes) {
|
|
22
|
+
if (ids.has(node.id)) problems.push(`node id "${node.id}" is duplicated`)
|
|
23
|
+
ids.add(node.id)
|
|
24
|
+
const acIds = new Set()
|
|
25
|
+
for (const c of node.criteria || []) {
|
|
26
|
+
if (acIds.has(c.id)) problems.push(`node "${node.id}": criterion ${c.id} is duplicated`)
|
|
27
|
+
acIds.add(c.id)
|
|
28
|
+
}
|
|
29
|
+
if (!['decision', 'stack'].includes(node.kind) && !node.execution) problems.push(`node "${node.id}": execution is required for ${node.kind} steps`)
|
|
30
|
+
if (node.kind === 'stack' && !node.items) problems.push(`node "${node.id}": stack steps need items (urls, file, sitemap, crawl or script)`)
|
|
31
|
+
if (node.kind === 'stack' && node.items && ['urls', 'file', 'sitemap', 'crawl', 'script'].filter((k) => node.items[k]).length !== 1) problems.push(`node "${node.id}": items needs exactly one source of urls, file, sitemap, crawl or script`)
|
|
32
|
+
if (node.kind !== 'stack' && (node.items || node.fields)) problems.push(`node "${node.id}": items and fields belong to stack steps`)
|
|
33
|
+
// app screens are reached by a script or a person as often as by a deep link
|
|
34
|
+
if (node.kind === 'screen' && !node.path && (!data.platform || data.platform === 'web')) problems.push(`node "${node.id}": screen steps need a path`)
|
|
35
|
+
if (node.kind === 'stack' && data.platform && data.platform !== 'web') problems.push(`node "${node.id}": stack steps list web pages; they are not available on platform ${data.platform}`)
|
|
36
|
+
if (node.kind === 'email' && !node.mail) problems.push(`node "${node.id}": email steps need mail.subject`)
|
|
37
|
+
}
|
|
38
|
+
const sessions = data.sessions ? Object.keys(data.sessions) : []
|
|
39
|
+
for (const node of data.nodes) {
|
|
40
|
+
if (node.session && !sessions.includes(node.session)) problems.push(`node "${node.id}": session "${node.session}" is not declared in sessions`)
|
|
41
|
+
if (sessions.length && !node.session && !['decision', 'external'].includes(node.kind)) problems.push(`node "${node.id}": session is required when the scenario declares sessions (${sessions.join(', ')})`)
|
|
42
|
+
}
|
|
43
|
+
for (const e of data.edges) {
|
|
44
|
+
if (!ids.has(e.from)) problems.push(`edge ${e.from} → ${e.to}: unknown node "${e.from}"`)
|
|
45
|
+
if (!ids.has(e.to)) problems.push(`edge ${e.from} → ${e.to}: unknown node "${e.to}"`)
|
|
46
|
+
}
|
|
47
|
+
if ((data.environments || []).includes('production') && !data.readonly) {
|
|
48
|
+
problems.push('scenarios allowed on production must set readonly: true')
|
|
49
|
+
}
|
|
50
|
+
return problems
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Layered layout: column = longest path from a root, lane = explicit `lane`
|
|
55
|
+
* or inherited from the parent (+1 for each extra `alt` branch).
|
|
56
|
+
*/
|
|
57
|
+
export function layout (scenario) {
|
|
58
|
+
const nodes = scenario.nodes
|
|
59
|
+
const incoming = new Map(nodes.map((n) => [n.id, []]))
|
|
60
|
+
const outgoing = new Map(nodes.map((n) => [n.id, []]))
|
|
61
|
+
for (const e of scenario.edges) {
|
|
62
|
+
incoming.get(e.to)?.push(e)
|
|
63
|
+
outgoing.get(e.from)?.push(e)
|
|
64
|
+
}
|
|
65
|
+
const col = new Map()
|
|
66
|
+
const visiting = new Set()
|
|
67
|
+
const depth = (id) => {
|
|
68
|
+
if (col.has(id)) return col.get(id)
|
|
69
|
+
if (visiting.has(id)) return 0 // cycle: stop
|
|
70
|
+
visiting.add(id)
|
|
71
|
+
const parents = incoming.get(id).filter((e) => !isBackEdge(e))
|
|
72
|
+
const d = parents.length ? Math.max(...parents.map((e) => depth(e.from) + 1)) : 0
|
|
73
|
+
visiting.delete(id)
|
|
74
|
+
col.set(id, d)
|
|
75
|
+
return d
|
|
76
|
+
}
|
|
77
|
+
const order = new Map(nodes.map((n, i) => [n.id, i]))
|
|
78
|
+
const isBackEdge = (e) => order.get(e.to) <= order.get(e.from)
|
|
79
|
+
nodes.forEach((n) => depth(n.id))
|
|
80
|
+
|
|
81
|
+
const lane = new Map()
|
|
82
|
+
const taken = new Set()
|
|
83
|
+
const claim = (c, l) => { while (taken.has(`${c}:${l}`)) l++; taken.add(`${c}:${l}`); return l }
|
|
84
|
+
// sessions without explicit lanes: one swimlane per person, in declaration order
|
|
85
|
+
const sessionLane = scenario.sessions && !(scenario.lanes || []).length ? new Map(Object.keys(scenario.sessions).map((k, i) => [k, i])) : null
|
|
86
|
+
for (const n of [...nodes].sort((a, b) => col.get(a.id) - col.get(b.id) || order.get(a.id) - order.get(b.id))) {
|
|
87
|
+
let wanted = n.lane
|
|
88
|
+
if (wanted == null && sessionLane && n.session != null && sessionLane.has(n.session)) wanted = sessionLane.get(n.session)
|
|
89
|
+
if (wanted == null) {
|
|
90
|
+
const parents = incoming.get(n.id).filter((e) => !isBackEdge(e) && lane.has(e.from))
|
|
91
|
+
const main = parents.find((e) => (e.kind || 'main') === 'main')
|
|
92
|
+
const via = main || parents[0]
|
|
93
|
+
wanted = via ? lane.get(via.from) + ((via.kind === 'alt' && !main) ? 1 : 0) : 0
|
|
94
|
+
}
|
|
95
|
+
lane.set(n.id, claim(col.get(n.id), wanted))
|
|
96
|
+
}
|
|
97
|
+
return nodes.map((n) => ({ id: n.id, col: col.get(n.id), lane: lane.get(n.id) }))
|
|
98
|
+
}
|