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,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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/ /g, '\n').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))).replace(/&/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
|
+
}
|