kaloko 0.4.10 → 0.5.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 +64 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +70 -2
- package/schemas/run.v1.json +33 -2
- package/schemas/scenario.v1.json +130 -17
- package/skills/kaloko/SKILL.md +18 -5
- package/skills/kaloko/reference/acceptance.md +70 -1
- package/skills/kaloko/reference/apps.md +103 -8
- package/skills/kaloko/reference/ci.md +3 -1
- package/skills/kaloko/reference/design.md +39 -0
- package/skills/kaloko/reference/devices.md +109 -0
- package/skills/kaloko/reference/environments.md +6 -0
- package/skills/kaloko/reference/execution.md +6 -0
- package/skills/kaloko/reference/mcp.md +1 -0
- package/skills/kaloko/reference/recording.md +95 -0
- package/src/cli.mjs +381 -167
- package/src/lib/access.mjs +5 -2
- package/src/lib/anim.mjs +125 -0
- package/src/lib/apps/devices.mjs +187 -0
- package/src/lib/apps/drivers.mjs +11 -3
- package/src/lib/apps/ios-device.mjs +78 -0
- package/src/lib/apps/recording-native.mjs +212 -0
- package/src/lib/apps/tree.mjs +9 -1
- package/src/lib/apps/webview.mjs +54 -0
- package/src/lib/apps/windows.mjs +108 -0
- package/src/lib/apps/windows.ps1 +78 -0
- package/src/lib/assertions.mjs +300 -0
- package/src/lib/build.mjs +63 -4
- package/src/lib/capture.mjs +146 -119
- package/src/lib/ci.mjs +8 -4
- package/src/lib/design-cli.mjs +35 -6
- package/src/lib/design.mjs +3 -1
- package/src/lib/devices.mjs +85 -0
- package/src/lib/devspec.mjs +3 -2
- package/src/lib/dimensions.mjs +326 -0
- package/src/lib/doctor.mjs +35 -6
- package/src/lib/drift.mjs +8 -4
- package/src/lib/evaluate.mjs +25 -7
- package/src/lib/import.mjs +3 -2
- package/src/lib/jev.mjs +3 -1
- package/src/lib/manifest-core.mjs +12 -3
- package/src/lib/probe.mjs +60 -0
- package/src/lib/recheck.mjs +6 -1
- package/src/lib/recording.mjs +373 -0
- package/src/lib/scenario.mjs +3 -0
- package/src/lib/stability-core.mjs +4 -2
- package/src/lib/trace.mjs +109 -0
- package/src/lib/video-cli.mjs +209 -0
- package/src/lib/video.mjs +427 -0
- package/src/lib/walk-dimensions.mjs +83 -0
- package/src/lib/webm.mjs +144 -0
- package/viewer/index.html +467 -25
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Recordings of native app steps (batch D): the device or the desktop records itself while the step runs.
|
|
2
|
+
// android adb shell screenrecord, in segments (the tool stops after 3 minutes), touches shown while recording
|
|
3
|
+
// ios xcrun simctl io <device> recordVideo (H.264)
|
|
4
|
+
// desktop macOS screencapture -v with clicks shown (needs Screen Recording permission)
|
|
5
|
+
// Electron windows are web pages and use the screencast recorder (recording.mjs).
|
|
6
|
+
// Each finished clip is MP4 H.264, which every browser plays; ffmpeg joins segments, remuxes the macOS .mov and makes
|
|
7
|
+
// the poster and preview. Taps the step script made through the driver are kept as markers for the canvas.
|
|
8
|
+
import { mkdir, rm, rename, writeFile, stat, copyFile } from 'node:fs/promises'
|
|
9
|
+
import { join, dirname } from 'node:path'
|
|
10
|
+
import { execFile } from 'node:child_process'
|
|
11
|
+
import { promisify } from 'node:util'
|
|
12
|
+
import { startProcess, ffmpeg, mediaDuration, stillsFromVideo, videoTools, installHint } from '../video.mjs'
|
|
13
|
+
import { recBase, saveRecording, stepRecorded, parseRecord } from '../recording.mjs'
|
|
14
|
+
import { addCapture, runPaths } from '../run.mjs'
|
|
15
|
+
import { writeJson } from '../util.mjs'
|
|
16
|
+
import { summarizeTrace } from '../trace.mjs'
|
|
17
|
+
|
|
18
|
+
const run = promisify(execFile)
|
|
19
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
20
|
+
/** screenrecord stops at 180 s; segments a little shorter leave time to start the next one. */
|
|
21
|
+
export const ANDROID_SEGMENT = 170
|
|
22
|
+
const ANDROID_BITRATE = { low: 2_000_000, medium: 4_000_000, high: 8_000_000 }
|
|
23
|
+
|
|
24
|
+
/** Shell arguments of one Android segment (tests read them). */
|
|
25
|
+
export function screenrecordArgs ({ serial, file, quality = 'medium', seconds = ANDROID_SEGMENT }) {
|
|
26
|
+
return [...(serial ? ['-s', serial] : []), 'shell', 'screenrecord', '--time-limit', String(Math.min(180, seconds)), '--bit-rate', String(ANDROID_BITRATE[quality] || ANDROID_BITRATE.medium), file]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The booted simulator a config means (udid, device name, or the first booted one), as the iOS driver picks it. */
|
|
30
|
+
export async function bootedSimulator (cfg = {}, exec = run) {
|
|
31
|
+
const { stdout } = await exec('xcrun', ['simctl', 'list', 'devices', 'booted', '-j'])
|
|
32
|
+
const all = Object.values(JSON.parse(stdout).devices || {}).flat()
|
|
33
|
+
const dev = cfg.udid ? all.find((d) => d.udid === cfg.udid) : cfg.device ? all.find((d) => d.name === cfg.device) : all[0]
|
|
34
|
+
return dev?.udid || null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One recorder for one step: start() before the step, stop() at its capture → { file (.mp4 or .mov), source } or null.
|
|
39
|
+
* `tools` from videoTools(). Throws nothing: a recording that cannot start leaves `reason`.
|
|
40
|
+
*/
|
|
41
|
+
export function nativeRecorder ({ platform, cfg = {}, tools, quality = 'medium', maxSeconds = 60, tmp }) {
|
|
42
|
+
const r = { platform, reason: null, segments: [], child: null, started: 0 }
|
|
43
|
+
if (platform === 'android') {
|
|
44
|
+
const adb = cfg.adb || process.env.KALOKO_ADB || 'adb'
|
|
45
|
+
const serial = cfg.serial || process.env.ANDROID_SERIAL || null
|
|
46
|
+
let n = 0; let stopping = false; let showTouches = null
|
|
47
|
+
const shell = (...a) => run(adb, [...(serial ? ['-s', serial] : []), 'shell', ...a], { timeout: 20000 })
|
|
48
|
+
const next = () => {
|
|
49
|
+
const remote = `/sdcard/kaloko-rec-${process.pid}-${Date.now().toString(36)}-${n++}.mp4`
|
|
50
|
+
r.segments.push(remote)
|
|
51
|
+
const left = Math.max(1, Math.ceil(maxSeconds - (Date.now() - r.started) / 1000))
|
|
52
|
+
r.child = startProcess(adb, screenrecordArgs({ serial, file: remote, quality, seconds: Math.min(ANDROID_SEGMENT, left) }))
|
|
53
|
+
r.child.done.then(() => { if (!stopping && (Date.now() - r.started) / 1000 < maxSeconds - 1) next() })
|
|
54
|
+
}
|
|
55
|
+
r.start = async () => {
|
|
56
|
+
if (!tools.adb) { r.reason = `adb is not installed (${installHint('adb')})`; return false }
|
|
57
|
+
showTouches = (await shell('settings', 'get', 'system', 'show_touches').catch(() => ({ stdout: '' }))).stdout.trim()
|
|
58
|
+
await shell('settings', 'put', 'system', 'show_touches', '1').catch(() => {})
|
|
59
|
+
r.started = Date.now(); next(); await sleep(700)
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
r.stop = async () => {
|
|
63
|
+
stopping = true
|
|
64
|
+
await shell('pkill', '-INT', 'screenrecord').catch(() => {})
|
|
65
|
+
await Promise.race([r.child?.done, sleep(6000)])
|
|
66
|
+
if (showTouches !== '1') await shell('settings', 'put', 'system', 'show_touches', showTouches || '0').catch(() => {})
|
|
67
|
+
await sleep(400) // the device finishes writing the file
|
|
68
|
+
const local = []
|
|
69
|
+
for (const [i, remote] of r.segments.entries()) {
|
|
70
|
+
const f = join(tmp, `seg${i}.mp4`)
|
|
71
|
+
try { await run(adb, [...(serial ? ['-s', serial] : []), 'pull', remote, f], { timeout: 120000 }); local.push(f) } catch {}
|
|
72
|
+
await shell('rm', '-f', remote).catch(() => {})
|
|
73
|
+
}
|
|
74
|
+
if (!local.length) { r.reason = 'the device kept no recording'; return null }
|
|
75
|
+
return { file: await joinSegments(tools, local, join(tmp, 'android.mp4')), source: 'adb', segments: local.length }
|
|
76
|
+
}
|
|
77
|
+
} else if (platform === 'ios') {
|
|
78
|
+
let file = null
|
|
79
|
+
r.start = async () => {
|
|
80
|
+
if (!tools.simctl) { r.reason = `simctl is missing (${installHint('simctl', 'darwin')})`; return false }
|
|
81
|
+
const udid = await bootedSimulator(cfg).catch(() => null)
|
|
82
|
+
if (!udid) { r.reason = 'no booted iOS Simulator'; return false }
|
|
83
|
+
file = join(tmp, 'ios.mp4')
|
|
84
|
+
r.child = startProcess('xcrun', ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--force', file])
|
|
85
|
+
r.started = Date.now()
|
|
86
|
+
// simctl says when the first frame is in; a step that starts before would miss its beginning
|
|
87
|
+
for (let i = 0; i < 50 && !/Recording started/i.test(r.child.output()); i++) await sleep(100)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
r.stop = async () => {
|
|
91
|
+
r.child?.kill('SIGINT')
|
|
92
|
+
await Promise.race([r.child?.done, sleep(8000)])
|
|
93
|
+
return (await stat(file).catch(() => null))?.size ? { file, source: 'simctl' } : null
|
|
94
|
+
}
|
|
95
|
+
} else if (platform === 'desktop') {
|
|
96
|
+
let file = null
|
|
97
|
+
r.start = async () => {
|
|
98
|
+
if (process.platform !== 'darwin') { r.reason = 'desktop recording needs macOS'; return false }
|
|
99
|
+
file = join(tmp, 'desktop.mov')
|
|
100
|
+
// -v video, -C cursor, -k clicks, -x no sound, -V the longest it may take
|
|
101
|
+
r.child = startProcess('/usr/sbin/screencapture', ['-v', '-C', '-k', '-x', '-V', String(Math.max(2, Math.round(maxSeconds))), file])
|
|
102
|
+
r.started = Date.now(); await sleep(800)
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
r.stop = async () => {
|
|
106
|
+
r.child?.kill('SIGINT')
|
|
107
|
+
await Promise.race([r.child?.done, sleep(8000)])
|
|
108
|
+
if (!(await stat(file).catch(() => null))?.size) { r.reason = 'screencapture kept no recording (allow Screen Recording for this terminal in System Settings → Privacy & Security)'; return null }
|
|
109
|
+
if (!tools.ffmpeg) { r.reason = `the macOS recording is QuickTime (.mov); ffmpeg turns it into MP4 for the canvas (${installHint('ffmpeg', 'darwin')})`; return { file, source: 'screencapture', mov: true } }
|
|
110
|
+
const mp4 = join(tmp, 'desktop.mp4')
|
|
111
|
+
try { await ffmpeg(tools, ['-i', file, '-c:v', 'copy', '-movflags', '+faststart', '-an', mp4]) } catch { await ffmpeg(tools, ['-i', file, '-c:v', tools.ffmpeg.encoders.h264 || 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', mp4]) }
|
|
112
|
+
return { file: mp4, source: 'screencapture' }
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
r.start = async () => { r.reason = `no native recorder for ${platform}`; return false }
|
|
116
|
+
r.stop = async () => null
|
|
117
|
+
}
|
|
118
|
+
return r
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Segments of one recording as one MP4: ffmpeg concatenates them; without ffmpeg the last segment is kept. */
|
|
122
|
+
export async function joinSegments (tools, files, out) {
|
|
123
|
+
if (files.length === 1) { await rename(files[0], out); return out }
|
|
124
|
+
if (!tools.ffmpeg) { await rename(files.at(-1), out); return out }
|
|
125
|
+
const list = join(dirname(out), 'segments.txt')
|
|
126
|
+
await writeFile(list, files.map((f) => `file '${f.replaceAll("'", "'\\''")}'`).join('\n') + '\n')
|
|
127
|
+
await ffmpeg(tools, ['-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', '-movflags', '+faststart', out])
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The driver with its interactions logged ({ t, type, target }) for the trace and the tap markers; typed text is
|
|
133
|
+
* never kept, only its length.
|
|
134
|
+
*/
|
|
135
|
+
export function instrumentDriver (driver, log) {
|
|
136
|
+
const wrap = (name, describe) => {
|
|
137
|
+
const fn = driver[name]
|
|
138
|
+
if (typeof fn !== 'function') return
|
|
139
|
+
driver[name] = async (...a) => { log.push({ t: Date.now(), type: name, ...describe(...a) }); return fn.apply(driver, a) }
|
|
140
|
+
}
|
|
141
|
+
wrap('tap', (target) => (target && typeof target === 'object' && target.x != null ? { x: target.x, y: target.y } : { target: String(target ?? '').slice(0, 120) }))
|
|
142
|
+
wrap('type', (text) => ({ target: `${String(text ?? '').length} characters` }))
|
|
143
|
+
wrap('back', () => ({}))
|
|
144
|
+
wrap('link', (url) => ({ url: String(url ?? '').slice(0, 300) }))
|
|
145
|
+
wrap('swipe', (...a) => ({ target: a.map((x) => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ').slice(0, 120) }))
|
|
146
|
+
return driver
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Per locale of an app walk (android, ios, desktop): beforeStep(node) starts the device recording when the step is
|
|
151
|
+
* recorded; afterStep(node, { record, error }) stops it, stores the MP4, poster, preview, taps and a trace of the
|
|
152
|
+
* driver's actions. `encoder` returns a Chrome page for the WebP preview (optional).
|
|
153
|
+
*/
|
|
154
|
+
export async function nativeSession ({ platform, env, run, scenario, locale, settings, driver, encoder, say = () => {} }) {
|
|
155
|
+
const anyRecord = !!settings.record || scenario.nodes.some((n) => parseRecord(n.record ?? n.capture?.record))
|
|
156
|
+
if (!anyRecord && !settings.trace) return null
|
|
157
|
+
const tools = anyRecord ? await videoTools() : {}
|
|
158
|
+
const actions = []
|
|
159
|
+
instrumentDriver(driver, actions)
|
|
160
|
+
const cfg = env.app?.[platform] || {}
|
|
161
|
+
let cur = null
|
|
162
|
+
const notes = new Set()
|
|
163
|
+
return {
|
|
164
|
+
async beforeStep (node) {
|
|
165
|
+
actions.length = 0
|
|
166
|
+
cur = { node, t0: Date.now(), t1: null, rec: null }
|
|
167
|
+
const want = settings.record?.mode === 'flow' ? { ...settings.record, mode: 'step' } : stepRecorded(settings, node)
|
|
168
|
+
if (!want) return
|
|
169
|
+
const tmp = join(run.dir, '.rec-tmp', `${node.id}-${locale}`)
|
|
170
|
+
await mkdir(tmp, { recursive: true })
|
|
171
|
+
const r = nativeRecorder({ platform, cfg, tools, quality: want.quality, maxSeconds: want.max_seconds || 60, tmp })
|
|
172
|
+
if (await r.start()) cur.rec = { r, want, tmp }
|
|
173
|
+
else if (r.reason) notes.add(`recording ${platform}: ${r.reason}`)
|
|
174
|
+
},
|
|
175
|
+
async beforeCapture () { if (cur && !cur.t1) { cur.t1 = Date.now(); if (cur.rec) cur.clip = await cur.rec.r.stop().catch(() => null) } },
|
|
176
|
+
async afterStep (node, { record = null, error = null } = {}) {
|
|
177
|
+
if (!cur || cur.node !== node) return
|
|
178
|
+
const { t0, rec } = cur
|
|
179
|
+
const t1 = cur.t1 || Date.now()
|
|
180
|
+
const clip = cur.clip !== undefined ? cur.clip : rec ? await rec.r.stop().catch(() => null) : null
|
|
181
|
+
cur = null
|
|
182
|
+
const vp = record?.viewport || Object.keys(run.meta.scope.viewports || {})[0] || 'phone'
|
|
183
|
+
const failed = !!error || Object.values(record?.checks || {}).some((c) => c?.ok === false)
|
|
184
|
+
const size = record?.size?.viewport || null
|
|
185
|
+
if (settings.trace) {
|
|
186
|
+
const file = runPaths(run.dir).step(node.id, locale, vp, 'trace.json')
|
|
187
|
+
const trace = { schema: 'qawalk.trace.v1', node: node.id, locale, viewport: vp, platform, started_at: new Date(t0).toISOString(), duration_ms: t1 - t0, actions: actions.filter((a) => a.t <= t1).map((a) => ({ ...a, t: Math.max(0, a.t - t0) })), network: [], console: [] }
|
|
188
|
+
trace.summary = summarizeTrace(trace)
|
|
189
|
+
await mkdir(dirname(file), { recursive: true })
|
|
190
|
+
await writeJson(file, trace)
|
|
191
|
+
await addCapture(run.dir, { type: 'trace', node: node.id, locale, viewport: vp, file: file.slice(run.dir.length + 1), captured_at: record?.captured_at || null, at: new Date().toISOString() })
|
|
192
|
+
}
|
|
193
|
+
if (!rec) return
|
|
194
|
+
try {
|
|
195
|
+
if (!clip || (rec.want.mode === 'failed' && !failed)) { if (!clip && rec.r.reason) notes.add(`recording ${platform}: ${rec.r.reason}`); return }
|
|
196
|
+
const base = recBase(run.dir, node.id, locale, vp)
|
|
197
|
+
await mkdir(dirname(base), { recursive: true })
|
|
198
|
+
if (clip.mov) { await copyFile(clip.file, `${base}.mov`); notes.add(rec.r.reason); return }
|
|
199
|
+
await copyFile(clip.file, `${base}.mp4`)
|
|
200
|
+
const duration = (tools.ffmpeg && await mediaDuration(tools, `${base}.mp4`)) || (t1 - t0)
|
|
201
|
+
const stills = await stillsFromVideo({ tools, page: await encoder?.(), file: `${base}.mp4`, out: base, quality: rec.want.quality, duration })
|
|
202
|
+
const files = { mp4: `${base}.mp4`, ...(stills.poster ? { poster: stills.poster } : {}), ...(stills.preview ? { preview: stills.preview } : {}) }
|
|
203
|
+
const bytes = {}
|
|
204
|
+
for (const [k, f] of Object.entries(files)) bytes[k] = (await stat(f)).size
|
|
205
|
+
// taps with coordinates, as fractions of the screen, relative to the clip
|
|
206
|
+
const taps = size ? actions.filter((a) => a.type === 'tap' && a.x != null && a.t <= t1).map((a) => ({ t: Math.max(0, a.t - t0), x: +(a.x / size[0]).toFixed(4), y: +(a.y / size[1]).toFixed(4) })) : []
|
|
207
|
+
await saveRecording({ run, node, locale, viewport: vp, result: { files, bytes, duration_ms: duration, width: null, height: null, codec: 'h264' }, mode: rec.want.mode, failed, error, captured_at: record?.captured_at || null, taps, source: clip.source })
|
|
208
|
+
} catch (e) { notes.add(`recording ${platform}: ${String(e.message).split('\n')[0]}`) } finally { await rm(rec.tmp, { recursive: true, force: true }) }
|
|
209
|
+
},
|
|
210
|
+
async finish () { await rm(join(run.dir, '.rec-tmp'), { recursive: true, force: true }); return [...notes].filter(Boolean).map((n) => `${n} (${locale})`) }
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/lib/apps/tree.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Units are logical (dp on Android, points on iOS and macOS) so touch targets compare across devices; `scale` in
|
|
4
4
|
// the capture converts them to screenshot pixels.
|
|
5
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,
|
|
6
|
+
// Maestro hierarchy JSON, the macOS accessibility dump of the Kaloko helper, Windows UI Automation (windows.ps1 tree),
|
|
7
|
+
// OCR lines, and Kaloko's own tree JSON.
|
|
7
8
|
|
|
8
9
|
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
10
|
const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
@@ -75,6 +76,12 @@ function fromAx (e) {
|
|
|
75
76
|
const role = iosRole(e.role)
|
|
76
77
|
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
|
}
|
|
79
|
+
const UIA_ROLES = { Button: 'button', SplitButton: 'button', Edit: 'textbox', Document: 'textbox', CheckBox: 'checkbox', RadioButton: 'radio', ComboBox: 'combobox', List: 'list', ListItem: 'cell', DataGrid: 'list', DataItem: 'cell', Table: 'list', Tree: 'list', TreeItem: 'cell', Text: 'text', ToolTip: 'text', HeaderItem: 'text', Hyperlink: 'link', Tab: 'tablist', TabItem: 'tab', Menu: 'menu', MenuBar: 'menu', MenuItem: 'menuitem', Image: 'image', Window: 'window', ToolBar: 'toolbar', TitleBar: 'toolbar', AppBar: 'toolbar', Slider: 'slider', Spinner: 'slider', ProgressBar: 'progressbar' }
|
|
80
|
+
/** Windows UI Automation (windows.ps1 `tree`): control types, AutomationId, names and values; offscreen nodes are hidden. */
|
|
81
|
+
function fromUia (e) {
|
|
82
|
+
const role = UIA_ROLES[e.type] || 'group'
|
|
83
|
+
return { role, name: clean(e.name || ''), text: clean(e.value ?? e.name ?? ''), id: e.id || '', cls: e.cls || e.type || '', bounds: e.w > 0 && e.h > 0 ? { x: e.x, y: e.y, w: e.w, h: e.h } : null, clickable: INTERACTIVE.has(role), enabled: e.enabled !== false, focused: !!e.focused, ...(e.offscreen ? { visible: false } : {}), children: (e.children || []).map(fromUia) }
|
|
84
|
+
}
|
|
78
85
|
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
86
|
|
|
80
87
|
/**
|
|
@@ -101,6 +108,7 @@ export function readTree (input, { density = 1 } = {}) {
|
|
|
101
108
|
if (data?.schema === 'qawalk.tree.v1') return { root: fromOwn(data.root), format: data.format || 'qawalk' }
|
|
102
109
|
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
110
|
if (data?.ax) return { root: fromAx(data.ax), format: 'macos-ax' }
|
|
111
|
+
if (data?.uia) return { root: fromUia(data.uia), format: 'windows-uia' }
|
|
104
112
|
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
113
|
if (data?.attributes || data?.children && !data.role) return { root: fromMaestro(data), format: 'maestro' }
|
|
106
114
|
if (data?.role) return { root: fromOwn(data), format: 'qawalk' }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// WebViews inside native apps (batch E): an Android app's WebView with debugging on (WebView.setWebContentsDebuggingEnabled
|
|
2
|
+
// or a debuggable build) is a Chromium page over the DevTools protocol. Kaloko forwards its socket with adb and
|
|
3
|
+
// connects Playwright to it, so a step script can drive the page and the capture is a web capture with the DOM and
|
|
4
|
+
// the web checks. iOS WKWebViews (isInspectable) speak Safari's Web Inspector protocol, which Playwright cannot attach
|
|
5
|
+
// to; capture those screens as app screens (tree, OCR) or test the web content in WebKit on its own.
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import { createServer } from 'node:net'
|
|
9
|
+
import { UserError } from '../util.mjs'
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile)
|
|
12
|
+
|
|
13
|
+
const freePort = () => new Promise((done, fail) => { const s = createServer().once('error', fail).listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => done(p)) }) })
|
|
14
|
+
|
|
15
|
+
/** DevTools sockets of WebViews on the device: [{ socket, pid }] from /proc/net/unix. */
|
|
16
|
+
export function webviewSockets (procNetUnix) {
|
|
17
|
+
const out = []
|
|
18
|
+
for (const line of String(procNetUnix).split('\n')) {
|
|
19
|
+
const m = line.match(/@((?:webview_devtools_remote|chrome_devtools_remote)(?:_(\d+))?)\s*$/)
|
|
20
|
+
if (m && !out.some((x) => x.socket === m[1])) out.push({ socket: m[1], pid: m[2] ? Number(m[2]) : null })
|
|
21
|
+
}
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Attaches to the WebView of `cfg.package` (or the only one) on the device of `cfg` (adb, serial): forwards a
|
|
27
|
+
* local port and connects Playwright over CDP. Returns { browser, page, pages, close }.
|
|
28
|
+
*/
|
|
29
|
+
export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
|
|
30
|
+
const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH.'); throw e })
|
|
31
|
+
const end = Date.now() + timeout
|
|
32
|
+
let socket = null
|
|
33
|
+
while (!socket && Date.now() < end) {
|
|
34
|
+
const all = webviewSockets(await adb(['shell', 'cat', '/proc/net/unix']).catch(() => ''))
|
|
35
|
+
const pid = cfg.package ? Number((await adb(['shell', 'pidof', cfg.package]).catch(() => '')).trim().split(/\s+/)[0]) || null : null
|
|
36
|
+
socket = (pid && all.find((x) => x.pid === pid)) || (!cfg.package && all.length === 1 ? all[0] : null) || (cfg.package ? null : all[0]) || null
|
|
37
|
+
if (!socket) await new Promise((r) => setTimeout(r, 500))
|
|
38
|
+
}
|
|
39
|
+
if (!socket) throw new UserError(`No debuggable WebView${cfg.package ? ` in ${cfg.package}` : ''} on the device. Turn on WebView.setWebContentsDebuggingEnabled(true) in debug builds (or open the screen with the WebView first).`)
|
|
40
|
+
const port = await freePort()
|
|
41
|
+
await adb(['forward', `tcp:${port}`, `localabstract:${socket.socket}`])
|
|
42
|
+
const { chromium } = await import('playwright')
|
|
43
|
+
let browser
|
|
44
|
+
try { browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout }) } catch (e) {
|
|
45
|
+
await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {})
|
|
46
|
+
throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}`)
|
|
47
|
+
}
|
|
48
|
+
const pages = browser.contexts().flatMap((c) => c.pages())
|
|
49
|
+
// the visible page first: a WebView app often keeps hidden ones around
|
|
50
|
+
let page = pages[0] || null
|
|
51
|
+
for (const p of pages) { if (await p.evaluate(() => document.visibilityState === 'visible').catch(() => false)) { page = p; break } }
|
|
52
|
+
if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open.') }
|
|
53
|
+
return { browser, page, pages, socket: socket.socket, close: async () => { await browser.close().catch(() => {}); await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {}) } }
|
|
54
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Windows desktop apps (batch E): the app's window as a screenshot, its UI Automation tree and clicks or typing,
|
|
2
|
+
// through windows.ps1 in the PowerShell every Windows has (no WinAppDriver, FlaUI or Appium to install). Same driver
|
|
3
|
+
// interface as drivers.mjs; captures are app captures (tree, checks, OCR-free text from UIA names and values).
|
|
4
|
+
// Not on Windows: walk on a Windows machine or CI runner, or capture with kaloko capture --image --tree uia.json.
|
|
5
|
+
import { execFile, spawn } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join, resolve, dirname, basename } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { UserError } from '../util.mjs'
|
|
12
|
+
import { readTree, query } from './tree.mjs'
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile)
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
export const WINDOWS_SCRIPT = join(here, 'windows.ps1')
|
|
18
|
+
const pngSize = (buf) => (buf?.length > 24 && buf.readUInt32BE(12) === 0x49484452 ? [buf.readUInt32BE(16), buf.readUInt32BE(20)] : null)
|
|
19
|
+
|
|
20
|
+
/** SendKeys treats + ^ % ~ ( ) { } [ ] as commands: braces make them plain characters. */
|
|
21
|
+
export const sendKeysText = (text) => String(text).replace(/[+^%~(){}[\]]/g, (c) => `{${c}}`).replace(/\n/g, '{ENTER}')
|
|
22
|
+
|
|
23
|
+
/** UIA tree in screen pixels → window-relative logical units (what touch targets and boxes use). */
|
|
24
|
+
export function uiaToWindow (node, win) {
|
|
25
|
+
const s = win.scale || 1
|
|
26
|
+
const map = (n) => ({ ...n, x: Math.round((n.x - win.x) / s), y: Math.round((n.y - win.y) / s), w: Math.round(n.w / s), h: Math.round(n.h / s), children: (n.children || []).map(map) })
|
|
27
|
+
return map(node)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function windowsApp (cfg, ctx) {
|
|
31
|
+
const ps = cfg.powershell || (process.platform === 'win32' ? 'powershell.exe' : null)
|
|
32
|
+
if (!ps) throw new UserError('Windows apps are walked on Windows (a machine or a windows-latest CI runner). Elsewhere capture the screens with kaloko capture --image shot.png [--tree uia.json] --platform windows.')
|
|
33
|
+
const name = cfg.name || (cfg.path ? basename(cfg.path).replace(/\.exe$/i, '') : null)
|
|
34
|
+
if (!name) throw new UserError('environments.<env>.app.windows needs name (process name or window title) or path.')
|
|
35
|
+
const call = async (...args) => {
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await run(ps, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', WINDOWS_SCRIPT, ...args.map(String)], { timeout: 120000, maxBuffer: 64 * 1024 * 1024, windowsHide: true })
|
|
38
|
+
return JSON.parse(stdout.trim() || '{}')
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT') throw new UserError(`${ps} was not found. Set app.windows.powershell to powershell.exe or pwsh.`)
|
|
41
|
+
throw new Error(`windows ${args[0]}: ${String(error.stderr || error.message).trim().split('\n').filter(Boolean).pop()}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let win = null
|
|
45
|
+
const findWindow = async (timeout = 15000) => {
|
|
46
|
+
const end = Date.now() + timeout
|
|
47
|
+
let last
|
|
48
|
+
while (Date.now() < end) { try { return await call('window', name) } catch (e) { last = e; await sleep(500) } }
|
|
49
|
+
throw new UserError(`No window of ${name} on the screen: ${last?.message || 'not found'}`)
|
|
50
|
+
}
|
|
51
|
+
const d = {
|
|
52
|
+
platform: 'windows',
|
|
53
|
+
async open ({ reset, attach = false } = {}) {
|
|
54
|
+
if (cfg.launch !== false && !attach) {
|
|
55
|
+
if (reset ?? cfg.reset) { await call('close', name).catch(() => {}); await sleep(800) }
|
|
56
|
+
if (cfg.aumid) spawn('explorer.exe', [`shell:AppsFolder\\${cfg.aumid}`], { detached: true, stdio: 'ignore' }).unref()
|
|
57
|
+
else if (cfg.path) spawn(resolve(ctx.root, cfg.path), cfg.args || [], { detached: true, stdio: 'ignore', cwd: ctx.root }).unref()
|
|
58
|
+
await sleep(cfg.launch_wait ?? 2000)
|
|
59
|
+
}
|
|
60
|
+
win = await findWindow()
|
|
61
|
+
},
|
|
62
|
+
async link (url) { spawn(ps, ['-NoProfile', '-Command', 'Start-Process', `'${String(url).replace(/'/g, "''")}'`], { detached: true, stdio: 'ignore' }).unref(); await sleep(cfg.settle ?? 1000) },
|
|
63
|
+
async tap (target) {
|
|
64
|
+
const p = await point(d, target)
|
|
65
|
+
win = await call('window', name)
|
|
66
|
+
await call('click', Math.round(win.x + p.x * (win.scale || 1)), Math.round(win.y + p.y * (win.scale || 1)))
|
|
67
|
+
await sleep(cfg.settle ?? 500)
|
|
68
|
+
},
|
|
69
|
+
async type (text) { await call('type', sendKeysText(text)); await sleep(300) },
|
|
70
|
+
async back () { throw new UserError('Windows apps have no system back: tap the app\'s own back button.') },
|
|
71
|
+
async wait (w) {
|
|
72
|
+
if (typeof w === 'number') return sleep(w)
|
|
73
|
+
const sel = w?.selector || (w?.text ? `[name*="${String(w.text).replace(/"/g, '')}"]` : null)
|
|
74
|
+
if (!sel) return
|
|
75
|
+
const end = Date.now() + (w.timeout || 15000)
|
|
76
|
+
while (Date.now() < end) { const t = await d.tree().catch(() => null); if (t && query(t.root, sel).length) return; await sleep(500) }
|
|
77
|
+
throw new Error(`timed out waiting for ${sel}`)
|
|
78
|
+
},
|
|
79
|
+
async tree () {
|
|
80
|
+
win ||= await call('window', name)
|
|
81
|
+
const raw = await call('tree', win.hwnd)
|
|
82
|
+
return raw?.uia ? readTree({ uia: uiaToWindow(raw.uia, win) }) : null
|
|
83
|
+
},
|
|
84
|
+
async snapshot () {
|
|
85
|
+
win = await call('window', name)
|
|
86
|
+
const dir = await mkdtemp(join(tmpdir(), 'kaloko-win-'))
|
|
87
|
+
try {
|
|
88
|
+
const f = join(dir, 'w.png')
|
|
89
|
+
await call('shot', win.hwnd, f)
|
|
90
|
+
const png = await readFile(f); const px = pngSize(png) || [win.w, win.h]
|
|
91
|
+
const scale = win.scale || 1
|
|
92
|
+
const tree = await d.tree().catch(() => null)
|
|
93
|
+
return { png, tree, size: [Math.round(px[0] / scale), Math.round(px[1] / scale)], scale, device: `Windows · ${win.owner || name}`, crash: null, logErrors: null }
|
|
94
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
95
|
+
},
|
|
96
|
+
async close () {}
|
|
97
|
+
}
|
|
98
|
+
return d
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function point (d, target) {
|
|
102
|
+
if (target && typeof target === 'object' && 'x' in target) return target
|
|
103
|
+
const t = await d.tree()
|
|
104
|
+
if (!t) throw new UserError('No UI tree to find the element in; tap by coordinates { x, y }.')
|
|
105
|
+
const n = query(t.root, String(target)).find((x) => x.bounds && x.bounds.w > 0)
|
|
106
|
+
if (!n) throw new Error(`no visible ${target}`)
|
|
107
|
+
return { x: n.bounds.x + n.bounds.w / 2, y: n.bounds.y + n.bounds.h / 2 }
|
|
108
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Kaloko helper for Windows desktop apps: window lookup, window screenshot, UI Automation tree, click and typing.
|
|
2
|
+
# Runs in Windows PowerShell 5.1 (built in) or PowerShell 7 on Windows; nothing to install. Prints JSON.
|
|
3
|
+
# window <process name or title> { pid, hwnd, title, owner, x, y, w, h, scale } (physical pixels, scale = DPI / 96)
|
|
4
|
+
# shot <hwnd> <file.png> the window as PNG (PrintWindow, else a screen copy)
|
|
5
|
+
# tree <hwnd> { uia: { type, name, id, cls, value, x, y, w, h, enabled, focused, offscreen, children } }
|
|
6
|
+
# click <x> <y> left click at screen pixels
|
|
7
|
+
# type <text> SendKeys text (already escaped by the caller)
|
|
8
|
+
# close <process name> asks the app's main window to close
|
|
9
|
+
param([string]$Cmd, [string]$A1, [string]$A2)
|
|
10
|
+
$ErrorActionPreference = 'Stop'
|
|
11
|
+
# Node reads UTF-8; Windows PowerShell 5.1 would write the OEM code page (Czech names arrive garbled)
|
|
12
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
13
|
+
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Drawing, System.Windows.Forms
|
|
14
|
+
Add-Type @"
|
|
15
|
+
using System; using System.Runtime.InteropServices;
|
|
16
|
+
public static class KalokoWin {
|
|
17
|
+
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
18
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
19
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);
|
|
20
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
21
|
+
[DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h);
|
|
22
|
+
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
23
|
+
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, IntPtr e);
|
|
24
|
+
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr hdc, uint flags);
|
|
25
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
|
26
|
+
}
|
|
27
|
+
"@
|
|
28
|
+
[KalokoWin]::SetProcessDPIAware() | Out-Null
|
|
29
|
+
|
|
30
|
+
function Get-Rect($h) { $r = New-Object KalokoWin+RECT; [KalokoWin]::GetWindowRect($h, [ref]$r) | Out-Null; return $r }
|
|
31
|
+
|
|
32
|
+
function Find-Window($name) {
|
|
33
|
+
$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and ($_.ProcessName -ieq $name -or $_.MainWindowTitle -like "*$name*") } | Select-Object -First 1
|
|
34
|
+
if (-not $p) { throw "No window of '$name' (is the app running and not minimised?)." }
|
|
35
|
+
$r = Get-Rect $p.MainWindowHandle
|
|
36
|
+
$dpi = 96; try { $dpi = [KalokoWin]::GetDpiForWindow($p.MainWindowHandle) } catch {}
|
|
37
|
+
[pscustomobject]@{ pid = $p.Id; hwnd = [int64]$p.MainWindowHandle; title = $p.MainWindowTitle; owner = $p.ProcessName; x = $r.Left; y = $r.Top; w = $r.Right - $r.Left; h = $r.Bottom - $r.Top; scale = $dpi / 96 }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Num($v) { if ([double]::IsInfinity($v) -or [double]::IsNaN($v)) { return 0 } return [int]$v }
|
|
41
|
+
|
|
42
|
+
switch ($Cmd) {
|
|
43
|
+
'window' { Find-Window $A1 | ConvertTo-Json -Compress }
|
|
44
|
+
'shot' {
|
|
45
|
+
$h = [IntPtr][int64]$A1
|
|
46
|
+
[KalokoWin]::ShowWindow($h, 9) | Out-Null; [KalokoWin]::SetForegroundWindow($h) | Out-Null; Start-Sleep -Milliseconds 300
|
|
47
|
+
$r = Get-Rect $h; $w = $r.Right - $r.Left; $hh = $r.Bottom - $r.Top
|
|
48
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $hh
|
|
49
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
50
|
+
$hdc = $g.GetHdc(); $ok = [KalokoWin]::PrintWindow($h, $hdc, 2); $g.ReleaseHdc($hdc)
|
|
51
|
+
if (-not $ok) { $g.CopyFromScreen($r.Left, $r.Top, 0, 0, $bmp.Size) }
|
|
52
|
+
$bmp.Save($A2, [System.Drawing.Imaging.ImageFormat]::Png); $g.Dispose(); $bmp.Dispose()
|
|
53
|
+
'{"ok":true}'
|
|
54
|
+
}
|
|
55
|
+
'tree' {
|
|
56
|
+
$root = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr][int64]$A1)
|
|
57
|
+
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
58
|
+
$script:count = 0
|
|
59
|
+
function Node($el, $depth) {
|
|
60
|
+
$script:count++
|
|
61
|
+
if ($script:count -gt 2500 -or $depth -gt 40) { return $null }
|
|
62
|
+
# an element that disappears while the tree is read is left out, not the whole tree
|
|
63
|
+
try { $c = $el.Current; $b = $c.BoundingRectangle } catch { return $null }
|
|
64
|
+
$kids = @(); $ch = $null; try { $ch = $walker.GetFirstChild($el) } catch {}
|
|
65
|
+
while ($ch -ne $null) { $k = Node $ch ($depth + 1); if ($k) { $kids += $k }; try { $ch = $walker.GetNextSibling($ch) } catch { $ch = $null } }
|
|
66
|
+
$v = $null; try { $v = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Current.Value } catch {}
|
|
67
|
+
$empty = $b.IsEmpty
|
|
68
|
+
[pscustomobject]@{ type = ($c.ControlType.ProgrammaticName -replace '^ControlType\.', ''); name = $c.Name; id = $c.AutomationId; cls = $c.ClassName; value = $v
|
|
69
|
+
x = $(if ($empty) { 0 } else { Num $b.X }); y = $(if ($empty) { 0 } else { Num $b.Y }); w = $(if ($empty) { 0 } else { Num $b.Width }); h = $(if ($empty) { 0 } else { Num $b.Height })
|
|
70
|
+
enabled = $c.IsEnabled; focused = $c.HasKeyboardFocus; offscreen = $c.IsOffscreen; children = $kids }
|
|
71
|
+
}
|
|
72
|
+
@{ uia = (Node $root 0) } | ConvertTo-Json -Depth 90 -Compress
|
|
73
|
+
}
|
|
74
|
+
'click' { [KalokoWin]::SetCursorPos([int]$A1, [int]$A2) | Out-Null; [KalokoWin]::mouse_event(2, 0, 0, 0, [IntPtr]::Zero); [KalokoWin]::mouse_event(4, 0, 0, 0, [IntPtr]::Zero); '{"ok":true}' }
|
|
75
|
+
'type' { [System.Windows.Forms.SendKeys]::SendWait($A1); '{"ok":true}' }
|
|
76
|
+
'close' { Get-Process | Where-Object { $_.ProcessName -ieq $A1 } | ForEach-Object { $_.CloseMainWindow() | Out-Null }; '{"ok":true}' }
|
|
77
|
+
default { throw "unknown command $Cmd" }
|
|
78
|
+
}
|