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,222 @@
1
+ // UI trees of native apps (Android, iOS, desktop) in one shape, and the deterministic checks that run on them.
2
+ // A node: { role, name, text, id, value, bounds: { x, y, w, h }, clickable, enabled, focused, checked, children }.
3
+ // Units are logical (dp on Android, points on iOS and macOS) so touch targets compare across devices; `scale` in
4
+ // the capture converts them to screenshot pixels.
5
+ // Formats read: Android uiautomator / Appium Android XML, Appium iOS (XCUIElementType…) XML, idb describe-all JSON,
6
+ // Maestro hierarchy JSON, the macOS accessibility dump of the Kaloko helper, OCR lines, and Kaloko's own tree JSON.
7
+
8
+ const decode = (s) => String(s ?? '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;|&#39;/g, "'").replace(/&#10;/g, '\n').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))).replace(/&amp;/g, '&')
9
+ const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
10
+ const bool = (v) => v === true || v === 'true'
11
+
12
+ /** Minimal XML to { tag, attrs, children } (UI dumps: no text nodes, no namespaces that matter). */
13
+ export function parseXml (xml) {
14
+ const root = { tag: '#root', attrs: {}, children: [] }
15
+ const stack = [root]
16
+ const re = /<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|<\?[\s\S]*?\?>|<!--[\s\S]*?-->/g
17
+ let m
18
+ while ((m = re.exec(xml))) {
19
+ if (!m[2]) continue
20
+ if (m[1]) { if (stack.length > 1) stack.pop(); continue }
21
+ const attrs = {}
22
+ for (const a of m[3].matchAll(/([\w.:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[a[1]] = decode(a[2] ?? a[3])
23
+ const el = { tag: m[2], attrs, children: [] }
24
+ stack[stack.length - 1].children.push(el)
25
+ if (!m[4]) stack.push(el)
26
+ }
27
+ return root
28
+ }
29
+
30
+ const ANDROID_ROLES = [[/Button|ImageButton|FloatingActionButton|Chip/, 'button'], [/EditText|TextInput|SearchView/, 'textbox'], [/CheckBox/, 'checkbox'], [/Switch|Toggle/, 'switch'], [/RadioButton/, 'radio'], [/ImageView|Image$/, 'image'], [/TextView|Text$/, 'text'], [/RecyclerView|ListView|ScrollView|LazyColumn/, 'list'], [/WebView/, 'webview'], [/ProgressBar/, 'progressbar'], [/SeekBar|Slider/, 'slider'], [/Spinner/, 'combobox'], [/TabWidget|TabLayout/, 'tablist'], [/Toolbar|ActionBar/, 'toolbar']]
31
+ const roleOfClass = (cls) => { for (const [re, r] of ANDROID_ROLES) if (re.test(cls || '')) return r; return 'group' }
32
+ const IOS_ROLES = { Button: 'button', StaticText: 'text', TextField: 'textbox', SecureTextField: 'textbox', SearchField: 'textbox', TextView: 'textbox', Image: 'image', Switch: 'switch', Slider: 'slider', Cell: 'cell', Table: 'list', CollectionView: 'list', ScrollView: 'list', NavigationBar: 'toolbar', TabBar: 'tablist', Tab: 'tab', Link: 'link', Alert: 'alert', Sheet: 'dialog', Window: 'window', Application: 'application', Other: 'group', Picker: 'combobox', SegmentedControl: 'tablist', ActivityIndicator: 'progressbar', WebView: 'webview', Keyboard: 'keyboard', Key: 'key', Toolbar: 'toolbar', CheckBox: 'checkbox', MenuItem: 'menuitem', Menu: 'menu', PopUpButton: 'combobox', RadioButton: 'radio' }
33
+ const iosRole = (type) => IOS_ROLES[String(type || '').replace(/^(XCUIElementType|AX)/, '')] || String(type || 'group').replace(/^(XCUIElementType|AX)/, '').toLowerCase() || 'group'
34
+ const INTERACTIVE = new Set(['button', 'textbox', 'checkbox', 'switch', 'radio', 'slider', 'combobox', 'link', 'tab', 'cell', 'menuitem', 'key'])
35
+ const boundsOf = (s) => { const m = String(s || '').match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/); return m ? { x: +m[1], y: +m[2], w: +m[3] - +m[1], h: +m[4] - +m[2] } : null }
36
+
37
+ function fromAndroid (el, density) {
38
+ const a = el.attrs
39
+ const d = density || 1
40
+ const b = boundsOf(a.bounds)
41
+ const node = {
42
+ role: roleOfClass(a.class), name: clean(a['content-desc'] || a.text || ''), text: clean(a.text || ''), id: a['resource-id'] || '',
43
+ cls: a.class || '', bounds: b ? { x: Math.round(b.x / d), y: Math.round(b.y / d), w: Math.round(b.w / d), h: Math.round(b.h / d) } : null,
44
+ clickable: bool(a.clickable) || bool(a['long-clickable']) || bool(a.checkable), enabled: a.enabled !== 'false', focused: bool(a.focused), checked: bool(a.checked), password: bool(a.password),
45
+ children: el.children.map((c) => fromAndroid(c, density))
46
+ }
47
+ if (node.clickable && node.role === 'group') node.role = 'button'
48
+ if (node.role === 'text' && node.clickable) node.role = 'button'
49
+ return node
50
+ }
51
+ function fromAppiumIos (el) {
52
+ const a = el.attrs
53
+ const has = a.x != null && a.width != null
54
+ const role = iosRole(a.type || el.tag)
55
+ return {
56
+ role, name: clean(a.label || a.name || ''), text: clean(a.value || a.label || ''), id: a.name || '', cls: a.type || el.tag,
57
+ bounds: has ? { x: +a.x, y: +a.y, w: +a.width, h: +a.height } : null,
58
+ clickable: INTERACTIVE.has(role), enabled: a.enabled !== 'false', focused: bool(a.focused), checked: a.value === '1' && role === 'switch', visible: a.visible !== 'false',
59
+ children: el.children.map(fromAppiumIos)
60
+ }
61
+ }
62
+ const idbFrame = (e) => { if (e.frame && typeof e.frame === 'object') return { x: e.frame.x, y: e.frame.y, w: e.frame.width, h: e.frame.height }; const m = String(e.AXFrame || '').match(/\{\{(-?[\d.]+),\s*(-?[\d.]+)\},\s*\{([\d.]+),\s*([\d.]+)\}\}/); return m ? { x: +m[1], y: +m[2], w: +m[3], h: +m[4] } : null }
63
+ function fromIdb (e) {
64
+ const role = iosRole(e.type || e.role)
65
+ return { role, name: clean(e.AXLabel || e.title || ''), text: clean(e.AXValue || e.AXLabel || ''), id: e.AXUniqueId || '', cls: e.type || e.role || '', bounds: idbFrame(e), clickable: INTERACTIVE.has(role), enabled: e.enabled !== false, focused: false, children: (e.children || []).map(fromIdb) }
66
+ }
67
+ function fromMaestro (e) {
68
+ const a = e.attributes || {}
69
+ const b = boundsOf(a.bounds)
70
+ const cls = a.class || ''
71
+ const role = cls ? roleOfClass(cls) : (bool(a.clickable) ? 'button' : a.text ? 'text' : 'group')
72
+ return { role: bool(a.clickable) && role === 'group' ? 'button' : role, name: clean(a.accessibilityText || a['content-desc'] || a.text || a.hintText || ''), text: clean(a.text || ''), id: a['resource-id'] || a.identifier || '', cls, bounds: b, clickable: bool(a.clickable), enabled: a.enabled !== 'false', focused: bool(a.focused), checked: bool(a.checked), children: (e.children || []).map(fromMaestro) }
73
+ }
74
+ function fromAx (e) {
75
+ const role = iosRole(e.role)
76
+ return { role, name: clean(e.title || e.description || e.label || ''), text: clean(e.value ?? e.title ?? ''), id: e.identifier || '', cls: e.role || '', bounds: e.frame ? { x: e.frame.x, y: e.frame.y, w: e.frame.w ?? e.frame.width, h: e.frame.h ?? e.frame.height } : null, clickable: INTERACTIVE.has(role) || (e.actions || []).includes('AXPress'), enabled: e.enabled !== false, focused: !!e.focused, children: (e.children || []).map(fromAx) }
77
+ }
78
+ const fromOwn = (e) => ({ role: e.role || 'group', name: clean(e.name || ''), text: clean(e.text || ''), id: e.id || '', cls: e.cls || '', bounds: e.bounds || null, clickable: !!e.clickable, enabled: e.enabled !== false, focused: !!e.focused, checked: !!e.checked, children: (e.children || []).map(fromOwn) })
79
+
80
+ /**
81
+ * Reads any supported dump into { root, format, source }. `density` converts Android pixels to dp.
82
+ * OCR results ({ ocr: [{ text, x, y, w, h }] }) become a flat tree of text nodes (no roles: only text checks work).
83
+ */
84
+ export function readTree (input, { density = 1 } = {}) {
85
+ const text = typeof input === 'string' ? input.trim() : null
86
+ if (text && text.startsWith('<')) {
87
+ const doc = parseXml(text)
88
+ const first = doc.children[0]
89
+ if (!first) throw new Error('empty UI dump')
90
+ if (first.tag === 'hierarchy' || first.tag === 'node' || first.children.some((c) => c.tag === 'node') || 'bounds' in (first.children[0]?.attrs || {})) {
91
+ const root = { role: 'application', name: '', text: '', id: '', bounds: null, clickable: false, enabled: true, children: (first.tag === 'hierarchy' ? first.children : [first]).map((c) => fromAndroid(c, density)) }
92
+ return { root, format: 'android' }
93
+ }
94
+ if (/^XCUIElementType|^AppiumAUT$/.test(first.tag)) {
95
+ const top = first.tag === 'AppiumAUT' ? first.children : [first]
96
+ return { root: { role: 'application', name: '', text: '', id: '', bounds: null, clickable: false, enabled: true, children: top.map(fromAppiumIos) }, format: 'ios-appium' }
97
+ }
98
+ throw new Error(`unknown XML UI dump <${first.tag}>`)
99
+ }
100
+ const data = text ? JSON.parse(text) : input
101
+ if (data?.schema === 'qawalk.tree.v1') return { root: fromOwn(data.root), format: data.format || 'qawalk' }
102
+ if (Array.isArray(data?.ocr)) return { root: { role: 'application', name: '', text: '', id: '', bounds: null, clickable: false, enabled: true, children: data.ocr.map((l) => ({ role: 'text', name: clean(l.text), text: clean(l.text), id: '', bounds: { x: l.x, y: l.y, w: l.w, h: l.h }, clickable: false, enabled: true, children: [] })) }, format: 'ocr' }
103
+ if (data?.ax) return { root: fromAx(data.ax), format: 'macos-ax' }
104
+ if (Array.isArray(data) && (data[0]?.AXLabel !== undefined || data[0]?.AXFrame !== undefined || data[0]?.type)) return { root: { role: 'application', name: '', text: '', id: '', bounds: null, clickable: false, enabled: true, children: data.map(fromIdb) }, format: 'ios-idb' }
105
+ if (data?.attributes || data?.children && !data.role) return { root: fromMaestro(data), format: 'maestro' }
106
+ if (data?.role) return { root: fromOwn(data), format: 'qawalk' }
107
+ throw new Error('unknown UI dump format')
108
+ }
109
+
110
+ export const walkTree = (root, fn) => { const visit = (n, depth, parent) => { fn(n, depth, parent); for (const c of n.children || []) visit(c, depth + 1, n) }; visit(root, 0, null) }
111
+ export const flatten = (root) => { const out = []; walkTree(root, (n) => out.push(n)); return out }
112
+ const visible = (n) => n.visible !== false && (!n.bounds || (n.bounds.w > 0 && n.bounds.h > 0))
113
+
114
+ /**
115
+ * Selectors for app trees, CSS-like: `button`, `[text="Sign in"]`, `button[name*=close i]`, `[id$=login]`,
116
+ * `text[text~="^\\d+ Kč$"]` (regex), `*` for any role; alternatives separated by commas.
117
+ * Attributes: name, text, id, role, cls, value; operators = ^= $= *= ~= (regex); a trailing ` i` ignores case.
118
+ */
119
+ export function matcher (selector) {
120
+ const alts = String(selector).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map((s) => s.trim()).filter(Boolean).map((alt) => {
121
+ const m = alt.match(/^([a-z*][\w-]*)?((?:\[(?:[^\]"']|"[^"]*"|'[^']*')+\])*)$/i)
122
+ if (!m) throw new Error(`bad app selector "${alt}"`)
123
+ const role = m[1] && m[1] !== '*' ? m[1].toLowerCase() : null
124
+ const conds = [...(m[2] || '').matchAll(/\[\s*([\w-]+)\s*(?:([~^$*]?=)\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+)))?\s*(i)?\s*\]/g)].map((c) => ({ attr: c[1], op: c[2] || null, value: c[3] ?? c[4] ?? c[5] ?? '', ci: !!c[6] }))
125
+ return (n) => (!role || n.role === role) && conds.every(({ attr, op, value, ci }) => {
126
+ let v = attr === 'label' ? n.name : n[attr]
127
+ if (v == null || v === '') return false
128
+ if (!op) return true
129
+ v = String(v)
130
+ if (op === '~=') return new RegExp(value, ci ? 'iu' : 'u').test(v)
131
+ const [a, b] = ci ? [v.toLowerCase(), value.toLowerCase()] : [v, value]
132
+ return op === '=' ? a === b : op === '^=' ? a.startsWith(b) : op === '$=' ? a.endsWith(b) : a.includes(b)
133
+ })
134
+ })
135
+ return (n) => alts.some((f) => f(n))
136
+ }
137
+ export const query = (root, selector) => { const f = matcher(selector); return flatten(root).filter((n) => n !== root && visible(n) && f(n)) }
138
+
139
+ const label = (n) => `${n.role}${n.name ? ` "${n.name.slice(0, 60)}"` : n.text ? ` "${n.text.slice(0, 60)}"` : ''}${n.id ? ` #${String(n.id).split('/').pop()}` : ''}`
140
+ const textOf = (n) => { const parts = []; walkTree(n, (x) => { const t = x.text || x.name; if (t && !parts.includes(t)) parts.push(t) }); return parts.join('\n') }
141
+
142
+ /** Readable state for the semantic evaluator: an indented outline of roles and texts (like the web page outline). */
143
+ export function outline (root, maxChars = 90000) {
144
+ const lines = []
145
+ walkTree(root, (n, depth) => {
146
+ if (n === root || !visible(n)) return
147
+ const t = n.name || n.text
148
+ if (!t && !n.clickable && n.role === 'group') return
149
+ lines.push(`${' '.repeat(Math.min(depth - 1, 12))}${n.role}${t ? `: ${t}` : ''}${n.value ? ` = ${n.value}` : ''}${n.enabled === false ? ' (disabled)' : ''}${n.checked ? ' (checked)' : ''}`)
150
+ })
151
+ return lines.join('\n').slice(0, maxChars)
152
+ }
153
+
154
+ /** Assert names a tree check can answer; the rest are web-only (not evaluated on app captures). */
155
+ export const APP_ASSERTS = new Set(['exists', 'absent', 'count', 'text_matches', 'text_absent', 'attr_equals', 'a11y_names', 'app_touch_targets', 'app_no_truncation', 'app_no_crash', 'app_labels', 'no_console_errors'])
156
+
157
+ /**
158
+ * Deterministic checks of a node's criteria on a tree. `ctx`: { platform, crash: string|null, logErrors: string[] }.
159
+ * Returns { checks: { id: { ok, evidence } }, boxes: { id: [{ x, y, w, h }] } } in logical units.
160
+ */
161
+ export function treeChecks (criteria, root, ctx = {}) {
162
+ const checks = {}; const boxes = {}
163
+ const all = flatten(root).filter((n) => n !== root && visible(n))
164
+ const interactive = all.filter((n) => n.clickable || INTERACTIVE.has(n.role))
165
+ const ocr = ctx.format === 'ocr'
166
+ const box = (id, list) => { const b = list.map((n) => n.bounds).filter(Boolean).slice(0, 20); if (b.length) boxes[id] = b }
167
+ for (const c of criteria || []) {
168
+ const ch = c.check
169
+ if (ch.type !== 'deterministic') continue
170
+ let ok = null; let evidence = ''
171
+ try {
172
+ const re = ch.pattern ? new RegExp(ch.pattern, 'u' + String(ch.flags || '').replace(/[^ims]/g, '')) : null
173
+ switch (ch.assert) {
174
+ case 'exists': { const l = query(root, ch.selector); ok = l.length > 0; evidence = l[0] ? label(l[0]) : `no visible ${ch.selector}`; box(c.id, l); break }
175
+ case 'absent': { const l = query(root, ch.selector); ok = l.length === 0; evidence = l[0] ? label(l[0]) : `no visible ${ch.selector}`; box(c.id, l); break }
176
+ case 'count': { const l = query(root, ch.selector); ok = l.length >= (ch.min ?? 0) && l.length <= (ch.max ?? Infinity); evidence = `${l.length}× ${ch.selector}`; box(c.id, l); break }
177
+ case 'text_matches': case 'text_absent': {
178
+ const scope = ch.selector ? query(root, ch.selector) : [root]
179
+ const hit = scope.find((n) => re.test(textOf(n)))
180
+ ok = ch.assert === 'text_matches' ? !!hit : !hit
181
+ const t = hit ? textOf(hit) : ''
182
+ const m = hit ? t.match(re) : null
183
+ evidence = hit ? (m ? `${t.slice(Math.max(0, m.index - 60), m.index)}[${m[0]}]${t.slice(m.index + m[0].length, m.index + m[0].length + 60)}`.replace(/\s+/g, ' ') : label(hit)) : scope[0] ? `/${ch.pattern}/ not found` : `no visible ${ch.selector}`
184
+ if (hit && hit !== root) box(c.id, [hit])
185
+ break
186
+ }
187
+ case 'attr_equals': { const n = query(root, ch.selector)[0]; if (!n) { ok = false; evidence = `no ${ch.selector}`; break } const v = n[ch.attr === 'label' ? 'name' : ch.attr]; ok = String(v) === String(ch.value); evidence = `${ch.attr}=${v}`; box(c.id, [n]); break }
188
+ case 'a11y_names': case 'app_labels': {
189
+ if (ocr) { evidence = 'OCR text only: no roles to check'; break }
190
+ const bad = interactive.filter((n) => !n.name && !n.text && !(n.children || []).some((k) => k.name || k.text))
191
+ ok = bad.length === 0; evidence = bad.length ? `${bad.length} control(s) without a label: ${bad.slice(0, 4).map(label).join('; ')}` : `${interactive.length} controls have labels`; box(c.id, bad); break
192
+ }
193
+ case 'app_touch_targets': {
194
+ if (ocr) { evidence = 'OCR text only: no controls to measure'; break }
195
+ const min = ch.min ?? (ctx.platform === 'android' ? 48 : ctx.platform === 'ios' ? 44 : 24)
196
+ const small = interactive.filter((n) => n.bounds && n.role !== 'key' && (n.bounds.w < min || n.bounds.h < min) && n.bounds.w > 0)
197
+ ok = small.length === 0; evidence = small.length ? `${small.length} target(s) below ${min}×${min}: ${small.slice(0, 4).map((n) => `${label(n)} ${n.bounds.w}×${n.bounds.h}`).join('; ')}` : `${interactive.length} targets ≥ ${min}×${min}`; box(c.id, small); break
198
+ }
199
+ case 'app_no_truncation': {
200
+ const cut = all.filter((n) => /(…|\.\.\.)$/.test(n.text || n.name || ''))
201
+ ok = cut.length === 0; evidence = cut.length ? `${cut.length} truncated text(s): ${cut.slice(0, 4).map((n) => `"${(n.text || n.name).slice(-40)}"`).join('; ')}` : 'no truncated texts'; box(c.id, cut); break
202
+ }
203
+ case 'app_no_crash': {
204
+ const dialog = all.find((n) => /(isn't responding|has stopped|keeps stopping|neodpovídá|byla ukončena|se neustále zastavuje|quit unexpectedly)/i.test(n.text || n.name || ''))
205
+ ok = !ctx.crash && !dialog; evidence = ctx.crash ? ctx.crash.slice(0, 300) : dialog ? label(dialog) : 'no crash or not-responding dialog'; if (dialog) box(c.id, [dialog]); break
206
+ }
207
+ case 'no_console_errors': { if (!ctx.logErrors) { evidence = 'App log was not observed.'; break } ok = ctx.logErrors.length === 0; evidence = ok ? 'no errors in the app log' : ctx.logErrors.slice(0, 3).join(' | '); break }
208
+ default: evidence = `${ch.assert} is a web check; it does not apply to ${ctx.platform || 'app'} captures`
209
+ }
210
+ } catch (error) { ok = false; evidence = `check error: ${error.message}` }
211
+ checks[c.id] = { ok, evidence }
212
+ }
213
+ // highlights for criteria that name an element (semantic criteria with a target selector)
214
+ for (const c of criteria || []) {
215
+ if (boxes[c.id] || !c.target) continue
216
+ try { box(c.id, query(root, c.target)) } catch {}
217
+ }
218
+ return { checks, boxes }
219
+ }
220
+
221
+ /** Serializable tree file (qawalk.tree.v1). */
222
+ export const treeFile = ({ root, format, platform, device, scale, size }) => ({ schema: 'qawalk.tree.v1', format, platform, device, scale, size, root })
@@ -0,0 +1,41 @@
1
+ // Long-lived browser process started by `kaloko browser start`.
2
+ // Holds a persistent Chromium context with CDP, enforces the environment
3
+ // policy and gate on every request and records console errors for captures.
4
+ // Secrets are resolved here from the inherited environment, never passed in argv.
5
+ import { chromium } from 'playwright'
6
+ import { writeJson, appendJsonl } from './util.mjs'
7
+ import { prepareContext, clientCertificates } from './access.mjs'
8
+
9
+ const opts = JSON.parse(process.argv[2])
10
+ const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
11
+ const context = await chromium.launchPersistentContext(opts.profile, {
12
+ headless: !opts.headed,
13
+ channel: opts.channel || undefined,
14
+ viewport: { width: 1280, height: 800 },
15
+ ...(certs.length ? { clientCertificates: certs } : {}),
16
+ args: [`--remote-debugging-port=${opts.port}`]
17
+ })
18
+
19
+ const log = (entry) => appendJsonl(opts.consoleFile, { ts: new Date().toISOString(), ...entry }).catch(() => {})
20
+ const watch = (page) => {
21
+ page.on('console', (m) => { if (m.type() === 'error') log({ type: 'console', url: page.url(), text: m.text(), src: m.location()?.url || null }) })
22
+ page.on('pageerror', (e) => log({ type: 'pageerror', url: page.url(), text: e.message }))
23
+ }
24
+ context.pages().forEach(watch)
25
+ context.on('page', watch)
26
+
27
+ if (opts.env) await prepareContext(context, opts.env, { log })
28
+
29
+ await writeJson(opts.stateFile, {
30
+ pid: process.pid,
31
+ port: opts.port,
32
+ cdp: `http://127.0.0.1:${opts.port}`,
33
+ environment: opts.env?.name || null,
34
+ readonly: !!opts.env?.readonly,
35
+ started_at: new Date().toISOString()
36
+ })
37
+
38
+ const shutdown = async () => { try { await context.close() } finally { process.exit(0) } }
39
+ process.on('SIGTERM', shutdown)
40
+ process.on('SIGINT', shutdown)
41
+ context.on('close', () => process.exit(0))
@@ -0,0 +1,161 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { join, resolve, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { readJson } from './util.mjs'
5
+ import { writeFile, rm, mkdir } from 'node:fs/promises'
6
+ import { UserError } from './util.mjs'
7
+ import { gateHeaders, clientCertificates } from './access.mjs'
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url))
10
+
11
+ // One shared browser per environment: own profile, state, console log and port,
12
+ // so a production (readonly) run never shares a page or a cookie jar with local work.
13
+ const browserDir = (root, output, envName) => join(resolve(root, output), '.browser', envName || 'default')
14
+ export const browserState = (root, output, envName) => join(browserDir(root, output, envName), 'browser.json')
15
+ export const consoleLog = (root, output, envName) => join(browserDir(root, output, envName), 'console.jsonl')
16
+ const legacyState = (root, output) => join(resolve(root, output), '.browser', 'browser.json')
17
+
18
+ /** Base port from config plus the environment's position in the config. */
19
+ export function portFor (config, envName) {
20
+ const index = Math.max(0, Object.keys(config.environments || {}).indexOf(envName))
21
+ return config.browser.port + index
22
+ }
23
+
24
+ async function stateFor ({ root, config, env }) {
25
+ const scoped = await readJson(browserState(root, config.output, env.name), null)
26
+ if (scoped && await alive(scoped.pid)) return scoped
27
+ const legacy = await readJson(legacyState(root, config.output), null) // browsers started before per-environment state
28
+ if (legacy && legacy.environment === env.name && await alive(legacy.pid)) return legacy
29
+ return null
30
+ }
31
+
32
+ /** The shared browser must run with the policy of the run's environment. */
33
+ function assertPolicy (state, env) {
34
+ if (state.environment !== env.name) throw new UserError(`Shared browser runs for environment "${state.environment}", the run is "${env.name}". Start one for it: kaloko browser start`)
35
+ if (env.readonly && !state.readonly) throw new UserError(`Shared browser for "${env.name}" runs without the readonly policy. Restart it: kaloko browser stop && kaloko browser start`)
36
+ }
37
+
38
+ async function portBusy (port) {
39
+ const { createServer } = await import('node:net')
40
+ return new Promise((done) => {
41
+ const s = createServer().once('error', () => done(true)).once('listening', () => s.close(() => done(false)))
42
+ s.listen(port, '127.0.0.1')
43
+ })
44
+ }
45
+
46
+ async function alive (pid) {
47
+ try { process.kill(pid, 0); return true } catch { return false }
48
+ }
49
+
50
+ /** Starts a detached Chromium with CDP. Agents, scripts and humans share it. */
51
+ export async function startBrowser ({ root, config, env, headed }) {
52
+ if (!env) throw new UserError('The shared browser belongs to an environment: start a run first (kaloko start) or pass --env <name>.')
53
+ const existing = await stateFor({ root, config, env })
54
+ if (existing) { assertPolicy(existing, env); return { ...existing, reused: true } }
55
+ // fail here with the variable's name rather than in the detached daemon
56
+ gateHeaders(env); clientCertificates(env, root)
57
+ const port = portFor(config, env.name)
58
+ if (await portBusy(port)) throw new UserError(`Port ${port} is in use (another project's browser?). Set a different browser.port in kaloko.config.yml.`)
59
+ const stateFile = browserState(root, config.output, env.name)
60
+ const args = JSON.stringify({
61
+ stateFile,
62
+ consoleFile: consoleLog(root, config.output, env.name),
63
+ profile: join(browserDir(root, config.output, env.name), 'profile'),
64
+ port,
65
+ channel: config.browser.channel || null,
66
+ headed: !!headed,
67
+ root,
68
+ env
69
+ })
70
+ const child = spawn(process.execPath, [join(here, 'browser-daemon.mjs'), args], { detached: true, stdio: 'ignore' })
71
+ child.unref()
72
+ for (let i = 0; i < 100; i++) {
73
+ await new Promise((r) => setTimeout(r, 200))
74
+ const state = await readJson(stateFile, null)
75
+ if (state && state.pid === child.pid) return state
76
+ }
77
+ throw new UserError('Browser did not start within 20 s. Try "kaloko browser start --headed" to see what happens.')
78
+ }
79
+
80
+ /** Stops the browser of one environment, or every shared browser of the project. */
81
+ export async function stopBrowser ({ root, config, env = null }) {
82
+ const states = env ? [await stateFor({ root, config, env })] : await allStates({ root, config })
83
+ let stopped = 0
84
+ for (const state of states.filter(Boolean)) { process.kill(state.pid, 'SIGTERM'); stopped++ }
85
+ return stopped > 0
86
+ }
87
+
88
+ /** Running shared browsers of the project, one per environment. */
89
+ export async function allStates ({ root, config }) {
90
+ const names = Object.keys(config.environments || {})
91
+ const states = []
92
+ for (const name of names) {
93
+ const s = await stateFor({ root, config, env: { name } })
94
+ if (s) states.push(s)
95
+ }
96
+ return states
97
+ }
98
+
99
+ /** Connects over CDP to the environment's shared browser; null when it is not running. */
100
+ export async function connectBrowser ({ root, config, env }) {
101
+ const state = await stateFor({ root, config, env })
102
+ if (!state) return null
103
+ assertPolicy(state, env)
104
+ const { chromium } = await import('playwright')
105
+ const browser = await chromium.connectOverCDP(state.cdp)
106
+ return { browser, context: browser.contexts()[0], state }
107
+ }
108
+
109
+ /** The page the user or agent is on: focused+visible first, otherwise the last real page. */
110
+ /**
111
+ * New-visitor session: drops cookies and, for the environment origin, local
112
+ * and session storage. Used before the first step of a run.
113
+ */
114
+ export async function resetSession (context, page, baseUrl) {
115
+ if (baseUrl && typeof baseUrl === 'object') baseUrl = baseUrl.default || Object.values(baseUrl)[0]
116
+ await context.clearCookies()
117
+ if (!baseUrl) return
118
+ try {
119
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' })
120
+ await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
121
+ await context.clearCookies() // the visit itself may have set some
122
+ } catch {}
123
+ }
124
+
125
+ export async function currentPage (context, match) {
126
+ const pages = context.pages().filter((p) => !p.url().startsWith('devtools://'))
127
+ if (match) {
128
+ const hit = pages.find((p) => p.url().includes(match))
129
+ if (!hit) throw new UserError(`No open page URL contains "${match}". Open pages: ${pages.map((p) => p.url()).join(', ')}`)
130
+ return hit
131
+ }
132
+ for (const p of pages) {
133
+ try {
134
+ if (await p.evaluate(() => document.visibilityState === 'visible' && document.hasFocus())) return p
135
+ } catch {}
136
+ }
137
+ const real = pages.filter((p) => p.url() !== 'about:blank')
138
+ return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
139
+ }
140
+
141
+ export async function launchEphemeral (config) {
142
+ const { chromium } = await import('playwright')
143
+ const browser = await chromium.launch({ headless: true, channel: config.browser.channel || undefined })
144
+ return browser
145
+ }
146
+
147
+ /**
148
+ * One walk at a time on a shared browser (#57): walks share its default context, so one run's session reset signs
149
+ * the other out and tabs get mixed up. The lock names the run holding it; a dead process or a lock older than six
150
+ * hours is stale. Returns a release function.
151
+ */
152
+ export async function acquireWalkLock ({ root, config, env, run }) {
153
+ const file = join(browserDir(root, config.output, env.name), 'walk.lock')
154
+ const held = await readJson(file, null)
155
+ if (held && held.run !== run && held.pid !== process.pid && await alive(held.pid) && Date.now() - Date.parse(held.since) < 6 * 3600e3) {
156
+ throw new UserError(`Another run (${held.run}, pid ${held.pid}) has been walking on the shared ${env.name} browser since ${held.since}. Walks on one shared browser share its cookies and tabs and sign each other out. Wait for it, or walk this run in its own browser: kaloko walk --ephemeral.`)
157
+ }
158
+ await mkdir(dirname(file), { recursive: true })
159
+ await writeFile(file, JSON.stringify({ run, pid: process.pid, since: new Date().toISOString() }) + '\n')
160
+ return async () => { const now = await readJson(file, null); if (now?.pid === process.pid && now.run === run) await rm(file, { force: true }) }
161
+ }
@@ -0,0 +1,149 @@
1
+ import { parsePlan, coverage, flaky } from './coverage.mjs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+ import { readJson, writeJson } from './util.mjs'
5
+ import { readCaptures, runPaths, listRuns } from './run.mjs'
6
+ import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
7
+ import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
8
+ import { layout } from './scenario.mjs'
9
+ import { assembleNode, summarize, statusOf, worst } from './manifest-core.mjs'
10
+ import { packageRoot } from './schema.mjs'
11
+
12
+ export { targetOf, statusOf } from './manifest-core.mjs'
13
+
14
+ export async function buildManifest ({ run, scenario, config, root, catalog = [] }) {
15
+ const meta = run.meta
16
+ const entries = await readCaptures(run.dir)
17
+ const captures = latestCaptures(entries)
18
+ const attachments = entries.filter((e) => e.type === 'attachment')
19
+ const pos = new Map(layout(scenario).map((p) => [p.id, p]))
20
+ const locales = meta.scope.locales
21
+ const viewportNames = Object.keys(meta.scope.viewports)
22
+ const nodes = []
23
+ for (const node of scenario.nodes) {
24
+ if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane }, captures, attachments })); continue }
25
+ const caps = []
26
+ // pages of a stack live in its items index; normal steps take their own captures only
27
+ for (const c of captures.filter((x) => x.node === node.id && !x.item)) {
28
+ const cap = await readJson(join(run.dir, c.file))
29
+ const stored = await readJson(runPaths(run.dir).step(c.node, c.locale, c.viewport, 'eval.json'), null)
30
+ const ev = stored?.source === evaluationSource(node, cap) ? stored : null
31
+ caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
32
+ }
33
+ nodes.push(assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id) }))
34
+ }
35
+ const summary = summarize(nodes, scenario.sessions)
36
+
37
+ const allRuns = await listRuns(root, config.output)
38
+ // flaky: a criterion whose verdict differs from another run of this scenario on the same commit and environment
39
+ const flakyKeys = flaky({ nodes, git: meta.git, environment: meta.environment }, allRuns.filter((r) => r.scenario?.id === scenario.id && r.id !== meta.id))
40
+ for (const n of nodes) for (const c of n.criteria || []) if (flakyKeys.has(`${n.id}|${c.id}`)) c.flaky = true
41
+ if (flakyKeys.size) summary.flaky = flakyKeys.size
42
+ // coverage against the acceptance plan when the scenario names one
43
+ let cov = null
44
+ if (scenario.source) {
45
+ for (const cand of [resolve(root, scenario.source), resolve(root, meta.scenario_file, '..', scenario.source), resolve(root, meta.scenario_file, '..', '..', scenario.source)]) {
46
+ try { cov = coverage(scenario, parsePlan(await readFile(cand, 'utf8'))); break } catch {}
47
+ }
48
+ }
49
+ const history = allRuns
50
+ .filter((r) => r.scenario?.id === scenario.id && r.id !== meta.id)
51
+ .map((r) => ({ id: r.id, started_at: r.started_at, git: r.git, environment: r.environment, summary: r.summary, statuses: Object.fromEntries(r.nodes.filter((n) => n.status).map((n) => [n.id, n.status])) }))
52
+ .sort((a, b) => b.started_at.localeCompare(a.started_at))
53
+
54
+ return {
55
+ schema: 'qawalk.run.v1',
56
+ id: meta.id,
57
+ org: config.org,
58
+ scenario: { ...meta.scenario, file: meta.scenario_file },
59
+ git: meta.git,
60
+ project: meta.project || null,
61
+ issue: meta.issue || null,
62
+ // version of the application under test; `version` is the number of a design version (#42)
63
+ app_version: meta.app_version ?? meta.version ?? null,
64
+ environment: meta.environment,
65
+ executor: meta.executor,
66
+ started_at: meta.started_at,
67
+ finished_at: new Date().toISOString(),
68
+ scope: meta.scope,
69
+ lanes: scenario.lanes || [],
70
+ sessions: scenario.sessions || null,
71
+ nodes,
72
+ edges: scenario.edges.map((e) => ({ from: e.from, to: e.to, label: e.label || '', kind: e.kind || 'main' })),
73
+ summary,
74
+ coverage: cov,
75
+ history,
76
+ catalog
77
+ }
78
+ }
79
+
80
+
81
+ /**
82
+ * A stack step: its pages go to steps/<node>/items.json (qawalk.stack.v1: fields, criteria, and per page the path,
83
+ * status, index fields, capture files and compact results); the node keeps counts, a visual sample and the path.
84
+ */
85
+ async function buildStack ({ run, node, base, captures, attachments }) {
86
+ const paths = runPaths(run.dir)
87
+ const fields = fieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
88
+ const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null }))
89
+ const byItem = new Map()
90
+ for (const c of captures.filter((x) => x.node === node.id && x.item)) {
91
+ const cap = await readJson(join(run.dir, c.file))
92
+ const evPath = paths.step(stepIdOf(c), c.locale, c.viewport, 'eval.json')
93
+ const stored = await readJson(evPath, null)
94
+ const ev = stored?.source === evaluationSource(node, cap) ? stored : null
95
+ let it = byItem.get(c.item)
96
+ if (!it) { it = { id: c.item, path: cap.item?.path || null, url: cap.url, title: cap.item?.title || cap.fields?.title || cap.metadata?.title || null, fields: cap.fields || {}, captures: {}, results: {}, order: byItem.size }; byItem.set(c.item, it) }
97
+ ;(it.captures[c.locale] ||= {})[c.viewport] = { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, url: cap.url, size: cap.size ? [cap.size.width, cap.size.height] : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), eval: ev ? `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` : null }
98
+ it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries(ev.criteria.map((r) => [r.id, [r.verdict, r.evidence ? String(r.evidence).slice(0, 200) : null, r.score ?? null]])) : Object.fromEntries(crit.filter((x) => x.type !== 'semantic').map((x) => [x.id, ['not_evaluated', null, null]]))
99
+ if (!ev) it.stale = true
100
+ }
101
+ const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
102
+ const per = Object.values(it.results)
103
+ const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
104
+ const verdicts = crit.map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
105
+ const status = statusOf(verdicts, per.length > 0)
106
+ const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.values(r).map((x) => x[0])), true)]))
107
+ return { ...it, status, by_locale, fails: crit.filter((c) => verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
108
+ })
109
+ const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
110
+ for (const it of items) summary[it.status]++
111
+ const criteria = crit.map((c) => {
112
+ const counts = {}
113
+ for (const it of items) { const v = worst(Object.values(it.results).map((r) => r[c.id]?.[0]).filter(Boolean)); if (v !== 'not_reached') counts[v] = (counts[v] || 0) + 1 }
114
+ return { ...c, counts, verdict: counts.fail ? 'fail' : counts.needs_review ? 'needs_review' : counts.not_evaluated ? 'not_evaluated' : counts.pass ? 'pass' : 'not_reached', by_locale: {} }
115
+ })
116
+ const semanticSample = node.items?.semantic_sample ?? DEFAULT_SEMANTIC_SAMPLE
117
+ const file = `steps/${node.id}/items.json`
118
+ await writeJson(join(run.dir, file), { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, items })
119
+ const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
120
+ const sample = [...items.filter((it) => it.status === 'fail'), ...items.filter((it) => it.status !== 'fail')].slice(0, 4).map((it) => ({ id: it.id, title: it.title, path: it.path, status: it.status, thumb: thumbOf(it) }))
121
+ const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
122
+ const locales = [...new Set(items.flatMap((it) => Object.keys(it.by_locale)))]
123
+ const by_locale = Object.fromEntries(locales.map((l) => { const s = items.map((it) => it.by_locale[l]).filter(Boolean); return [l, s.includes('fail') ? 'fail' : s.some((x) => x !== 'pass') ? 'partial' : 'pass'] }))
124
+ const src = node.items || {}
125
+ return {
126
+ ...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
127
+ execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
128
+ attachments: attachments.filter((a) => a.node === node.id).map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
129
+ stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
130
+ }
131
+ }
132
+
133
+ export async function writePreview ({ run, manifest }) {
134
+ const template = await readFile(join(packageRoot, 'viewer', 'index.html'), 'utf8')
135
+ // the local preview opens from file://, where fetch cannot read files: stack indexes go inline
136
+ // file:// cannot fetch: stack indexes and app UI trees travel inside the preview
137
+ const withTrees = async (n) => {
138
+ if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree))) return n
139
+ const captures = {}
140
+ for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree ? { ...c, tree_inline: await readJson(join(run.dir, c.tree), null) } : c }
141
+ return { ...n, captures }
142
+ }
143
+ const preview = { ...manifest, nodes: await Promise.all(manifest.nodes.map(async (n) => (n.stack ? { ...n, stack: { ...n.stack, items_inline: (await readJson(join(run.dir, n.stack.items), { items: [] })).items } } : withTrees(n)))) }
144
+ const json = JSON.stringify(preview).replace(/</g, '\\u003c')
145
+ const html = template.replace('/*__QAWALK_RUN__*/null', () => json)
146
+ await writeJson(runPaths(run.dir).manifest, manifest)
147
+ await writeFile(runPaths(run.dir).index, html)
148
+ return runPaths(run.dir).index
149
+ }