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.
Files changed (68) hide show
  1. package/README.md +119 -0
  2. package/bin/kaloko.mjs +4 -0
  3. package/bin/qawalk.mjs +4 -0
  4. package/package.json +42 -0
  5. package/schemas/bundle.v1.json +19 -0
  6. package/schemas/config.v1.json +268 -0
  7. package/schemas/revision.v1.json +36 -0
  8. package/schemas/run.v1.json +549 -0
  9. package/schemas/scenario.v1.json +668 -0
  10. package/schemas/tokens.v1.json +102 -0
  11. package/skills/kaloko/SKILL.md +94 -0
  12. package/skills/kaloko/reference/acceptance.md +208 -0
  13. package/skills/kaloko/reference/apps.md +161 -0
  14. package/skills/kaloko/reference/collaboration.md +218 -0
  15. package/skills/kaloko/reference/design.md +143 -0
  16. package/skills/kaloko/reference/documentation.md +72 -0
  17. package/skills/kaloko/reference/environments.md +140 -0
  18. package/skills/kaloko/reference/evaluation.md +62 -0
  19. package/skills/kaloko/reference/execution.md +75 -0
  20. package/skills/kaloko/reference/mcp.md +69 -0
  21. package/skills/kaloko/reference/stacks.md +122 -0
  22. package/src/cli.mjs +1344 -0
  23. package/src/lib/access.mjs +169 -0
  24. package/src/lib/apps/capture.mjs +59 -0
  25. package/src/lib/apps/drivers.mjs +284 -0
  26. package/src/lib/apps/helper.swift +102 -0
  27. package/src/lib/apps/tree.mjs +222 -0
  28. package/src/lib/browser-daemon.mjs +41 -0
  29. package/src/lib/browser.mjs +161 -0
  30. package/src/lib/build.mjs +149 -0
  31. package/src/lib/calibrate.mjs +97 -0
  32. package/src/lib/capture.mjs +233 -0
  33. package/src/lib/compare.mjs +88 -0
  34. package/src/lib/config.mjs +117 -0
  35. package/src/lib/console.mjs +13 -0
  36. package/src/lib/coverage.mjs +75 -0
  37. package/src/lib/design-cli.mjs +440 -0
  38. package/src/lib/design-core.mjs +163 -0
  39. package/src/lib/design.mjs +164 -0
  40. package/src/lib/doctor.mjs +149 -0
  41. package/src/lib/draft.mjs +76 -0
  42. package/src/lib/drift.mjs +145 -0
  43. package/src/lib/evaluate.mjs +222 -0
  44. package/src/lib/git.mjs +70 -0
  45. package/src/lib/htmlview.mjs +130 -0
  46. package/src/lib/jev.mjs +95 -0
  47. package/src/lib/mail.mjs +182 -0
  48. package/src/lib/manifest-core.mjs +127 -0
  49. package/src/lib/pack-help.mjs +55 -0
  50. package/src/lib/packs.mjs +121 -0
  51. package/src/lib/pr.mjs +78 -0
  52. package/src/lib/probe.mjs +379 -0
  53. package/src/lib/recheck.mjs +70 -0
  54. package/src/lib/run.mjs +85 -0
  55. package/src/lib/scenario.mjs +98 -0
  56. package/src/lib/schema.mjs +25 -0
  57. package/src/lib/secrets.mjs +59 -0
  58. package/src/lib/service.mjs +52 -0
  59. package/src/lib/stack.mjs +130 -0
  60. package/src/lib/tokens.mjs +352 -0
  61. package/src/lib/util.mjs +97 -0
  62. package/templates/ACCEPTANCE.md +55 -0
  63. package/templates/cs/ACCEPTANCE.md +55 -0
  64. package/templates/kaloko.config.yml +76 -0
  65. package/templates/mail/letter-opener.mjs +47 -0
  66. package/templates/scenario.yml +119 -0
  67. package/templates/steps/consent.mjs +33 -0
  68. 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, '&quot;')}">`
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
+ }
@@ -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
+ }