kaloko 0.4.11 → 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 +45 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +42 -2
- package/schemas/run.v1.json +33 -2
- package/schemas/scenario.v1.json +107 -16
- package/skills/kaloko/SKILL.md +8 -2
- 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/src/cli.mjs +326 -190
- package/src/lib/access.mjs +5 -2
- 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/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 +4 -1
- 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 +31 -6
- package/src/lib/drift.mjs +8 -4
- package/src/lib/evaluate.mjs +25 -7
- package/src/lib/import.mjs +2 -1
- 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/scenario.mjs +3 -0
- package/src/lib/stability-core.mjs +4 -2
- package/src/lib/walk-dimensions.mjs +83 -0
- package/viewer/index.html +282 -19
package/src/lib/access.mjs
CHANGED
|
@@ -126,9 +126,12 @@ export async function gateForRequests (env, { root } = {}) {
|
|
|
126
126
|
return { headers, toGate: Object.keys(headers).length ? originMatcher(gateOrigins(env)) : () => false, httpCredentials: options.httpCredentials || null, clientCertificates: options.clientCertificates || [] }
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* A new browser context with the environment's gate, policy and (optionally) an account's session; `extra` adds
|
|
131
|
+
* Playwright context options (a device profile: pixel ratio, mobile, touch, user agent).
|
|
132
|
+
*/
|
|
130
133
|
export async function newContext (browser, env, opts = {}) {
|
|
131
|
-
const context = await browser.newContext(await contextOptions(env, opts))
|
|
134
|
+
const context = await browser.newContext({ ...await contextOptions(env, opts), ...(opts.extra || {}) })
|
|
132
135
|
return prepareContext(context, env, opts)
|
|
133
136
|
}
|
|
134
137
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Devices of app walks (batch E): which emulator, simulator or real device a `devices:` name means, getting it
|
|
2
|
+
// ready (adb connect over Wi-Fi, starting an Android emulator, waking a phone, booting a simulator of a runtime),
|
|
3
|
+
// and the system settings a capture key asks for (dark mode, reduced motion) on Android and the iOS Simulator.
|
|
4
|
+
// Each device of a scenario becomes the `app.<platform>` block of its own walk; the drivers stay as they are.
|
|
5
|
+
import { execFile, spawn } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { UserError } from '../util.mjs'
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile)
|
|
10
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
11
|
+
|
|
12
|
+
async function exec (bin, args, { timeout = 60000, hint = '' } = {}) {
|
|
13
|
+
try { return (await run(bin, args, { timeout, maxBuffer: 16 * 1024 * 1024 })).stdout } catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT') throw new UserError(`${bin} is not installed or not on PATH.${hint ? ' ' + hint : ''}`)
|
|
15
|
+
throw new Error(`${bin} ${args.slice(0, 3).join(' ')}: ${String(error.stderr || error.message).trim().split('\n').pop()}`)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '')
|
|
20
|
+
|
|
21
|
+
/** "iphone-15-pro-max-ios-18" → { name: 'iPhone 15 Pro Max', runtime: 'iOS 18' } (simulator name and runtime). */
|
|
22
|
+
export function simulatorOf (id) {
|
|
23
|
+
const m = String(id).toLowerCase().match(/^(.*?)(?:-(ios|ipados)-(\d+(?:[.-]\d+)?))?$/)
|
|
24
|
+
const WORDS = { iphone: 'iPhone', ipad: 'iPad', pro: 'Pro', max: 'Max', plus: 'Plus', mini: 'mini', air: 'Air', se: 'SE', ultra: 'Ultra' }
|
|
25
|
+
const name = m[1].split('-').filter(Boolean).map((w) => WORDS[w] || (/^\d/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1))).join(' ')
|
|
26
|
+
// "iPhone SE 3rd generation" is "iphone-se-3": the generation in parentheses as Xcode names it
|
|
27
|
+
const fixed = name.replace(/^iPhone SE (\d)$/, (_, n) => `iPhone SE (${n}${['', 'st', 'nd', 'rd'][n] || 'th'} generation)`)
|
|
28
|
+
return { name: fixed, runtime: m[3] ? `iOS ${m[3].replace('-', '.')}` : null }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `app.<platform>` block for one device of the scenario: the environment's block with the device's entry
|
|
33
|
+
* (`devices: { <id>: { serial | avd | connect | udid | device | runtime | real } }`), or what the name says:
|
|
34
|
+
* an adb serial, an AVD (pixel-8-api-34 → Pixel_8_API_34) or a simulator (iphone-15-ios-18).
|
|
35
|
+
*/
|
|
36
|
+
export function deviceConfig (platform, cfg = {}, id = null) {
|
|
37
|
+
if (!id) return { ...cfg }
|
|
38
|
+
const own = cfg.devices?.[id] || null
|
|
39
|
+
const base = { ...cfg, devices: undefined }
|
|
40
|
+
if (platform === 'android') {
|
|
41
|
+
if (own) return { ...base, ...own, device: own.title || own.device || id }
|
|
42
|
+
// a serial (emulator-5554, R58M…, 192.168.1.20:5555) or an AVD name
|
|
43
|
+
if (/^emulator-\d+$|:\d+$|^[A-Z0-9]{8,}$/.test(id)) return { ...base, serial: id, ...(id.includes(':') ? { connect: id } : {}), device: id }
|
|
44
|
+
return { ...base, serial: undefined, avd: id, device: id }
|
|
45
|
+
}
|
|
46
|
+
if (platform === 'ios') {
|
|
47
|
+
if (own) return { ...base, ...own, ...(own.title ? { label: own.title } : {}) }
|
|
48
|
+
if (/^[0-9A-F]{8}-[0-9A-F]{4}-|^[0-9a-f]{40}$|^[0-9A-F]{8}-[0-9A-F]{16}$/i.test(id)) return { ...base, udid: id }
|
|
49
|
+
const sim = simulatorOf(id)
|
|
50
|
+
return { ...base, udid: undefined, device: sim.name, ...(sim.runtime ? { runtime: sim.runtime } : {}) }
|
|
51
|
+
}
|
|
52
|
+
return { ...base, ...(own || {}) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ---------- Android: adb connect, emulators, a phone that is awake ---------- */
|
|
56
|
+
|
|
57
|
+
const adbOf = (cfg) => (args, o) => exec(cfg.adb || 'adb', args, { hint: 'Install Android platform-tools (adb).', ...o })
|
|
58
|
+
|
|
59
|
+
/** Serials of attached devices and emulators (`adb devices`). */
|
|
60
|
+
export async function adbDevices (cfg = {}) {
|
|
61
|
+
const out = await adbOf(cfg)(['devices'])
|
|
62
|
+
return out.split('\n').slice(1).map((l) => l.trim().split(/\s+/)).filter((p) => p[0] && p[1] === 'device').map((p) => p[0])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** AVD names of the emulator binary. */
|
|
66
|
+
export async function listAvds (cfg = {}) {
|
|
67
|
+
return (await exec(emulatorBin(cfg), ['-list-avds'], { hint: 'Install the Android emulator (Android Studio → SDK Manager).' })).split('\n').map((s) => s.trim()).filter(Boolean)
|
|
68
|
+
}
|
|
69
|
+
const emulatorBin = (cfg) => cfg.emulator || (process.env.ANDROID_HOME ? `${process.env.ANDROID_HOME}/emulator/emulator` : process.env.ANDROID_SDK_ROOT ? `${process.env.ANDROID_SDK_ROOT}/emulator/emulator` : 'emulator')
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Makes the device of `cfg` reachable and returns the block with its serial: `connect` (a real device over Wi-Fi),
|
|
73
|
+
* a running emulator of `avd`, or that emulator started (headless unless KALOKO_EMULATOR_WINDOW=1) and booted.
|
|
74
|
+
*/
|
|
75
|
+
export async function prepareAndroid (cfg, { say = () => {}, bootTimeout = 240000 } = {}) {
|
|
76
|
+
const adb = adbOf(cfg)
|
|
77
|
+
if (cfg.connect) {
|
|
78
|
+
const out = await adb(['connect', cfg.connect])
|
|
79
|
+
if (!/connected/.test(out)) throw new UserError(`adb connect ${cfg.connect}: ${out.trim()}. Pair the phone once (Developer options → Wireless debugging → adb pair host:port code).`)
|
|
80
|
+
cfg = { ...cfg, serial: cfg.serial || cfg.connect }
|
|
81
|
+
}
|
|
82
|
+
if (cfg.avd && !cfg.serial) {
|
|
83
|
+
const running = await runningAvd(cfg, cfg.avd)
|
|
84
|
+
if (running) return { ...cfg, serial: running }
|
|
85
|
+
const avds = await listAvds(cfg)
|
|
86
|
+
const avd = avds.find((a) => a === cfg.avd) || avds.find((a) => norm(a) === norm(cfg.avd))
|
|
87
|
+
if (!avd) throw new UserError(`No Android emulator "${cfg.avd}". Known: ${avds.join(', ') || 'none'} (create one in Android Studio → Device Manager, or name it in app.android.devices).`)
|
|
88
|
+
say(`starting the ${avd} emulator…`)
|
|
89
|
+
const child = spawn(emulatorBin(cfg), ['-avd', avd, '-no-snapshot-save', '-no-boot-anim', ...(process.env.KALOKO_EMULATOR_WINDOW === '1' ? [] : ['-no-window'])], { detached: true, stdio: 'ignore' })
|
|
90
|
+
child.unref()
|
|
91
|
+
const end = Date.now() + bootTimeout
|
|
92
|
+
while (Date.now() < end) {
|
|
93
|
+
await sleep(2000)
|
|
94
|
+
const serial = await runningAvd(cfg, avd).catch(() => null)
|
|
95
|
+
if (serial && (await adb(['-s', serial, 'shell', 'getprop', 'sys.boot_completed']).catch(() => '')).trim() === '1') return { ...cfg, serial, avd }
|
|
96
|
+
}
|
|
97
|
+
throw new UserError(`The ${avd} emulator did not boot within ${Math.round(bootTimeout / 1000)} s.`)
|
|
98
|
+
}
|
|
99
|
+
return cfg
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Serial of the running emulator of an AVD (`adb -s emulator-5554 emu avd name`). */
|
|
103
|
+
async function runningAvd (cfg, avd) {
|
|
104
|
+
const adb = adbOf(cfg)
|
|
105
|
+
for (const serial of (await adbDevices(cfg)).filter((s) => s.startsWith('emulator-'))) {
|
|
106
|
+
const name = (await adb(['-s', serial, 'emu', 'avd', 'name']).catch(() => '')).split('\n')[0].trim()
|
|
107
|
+
if (name && norm(name) === norm(avd)) return serial
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A real phone for captures: screen on, keyguard away (no PIN), clean status bar (demo mode, 9:41). */
|
|
113
|
+
export async function readyAndroid (cfg) {
|
|
114
|
+
const adb = adbOf(cfg); const s = cfg.serial ? ['-s', cfg.serial] : []
|
|
115
|
+
if (cfg.wake !== false) {
|
|
116
|
+
await adb([...s, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP']).catch(() => {})
|
|
117
|
+
await adb([...s, 'shell', 'wm', 'dismiss-keyguard']).catch(() => {})
|
|
118
|
+
}
|
|
119
|
+
if (cfg.status_bar !== false) {
|
|
120
|
+
await adb([...s, 'shell', 'settings', 'put', 'global', 'sysui_demo_allowed', '1']).catch(() => {})
|
|
121
|
+
for (const extra of [['-e', 'command', 'enter'], ['-e', 'command', 'clock', '-e', 'hhmm', '0941'], ['-e', 'command', 'battery', '-e', 'level', '100', '-e', 'plugged', 'false'], ['-e', 'command', 'notifications', '-e', 'visible', 'false']]) {
|
|
122
|
+
await adb([...s, 'shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', ...extra]).catch(() => {})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** After the walk: the phone's own status bar again. */
|
|
128
|
+
export async function finishAndroid (cfg) {
|
|
129
|
+
if (cfg.status_bar === false) return
|
|
130
|
+
await adbOf(cfg)([...(cfg.serial ? ['-s', cfg.serial] : []), 'shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', '-e', 'command', 'exit']).catch(() => {})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---------- iOS Simulator: a device of a runtime ---------- */
|
|
134
|
+
|
|
135
|
+
/** The udid of the simulator `device` on `runtime` (newest matching runtime), booted when needed. */
|
|
136
|
+
export async function prepareSimulator (cfg) {
|
|
137
|
+
if (cfg.udid || cfg.real || !cfg.device) return cfg
|
|
138
|
+
const list = JSON.parse(await exec('xcrun', ['simctl', 'list', 'devices', 'available', '-j'], { hint: 'Install Xcode with an iOS Simulator runtime.' })).devices
|
|
139
|
+
const want = cfg.runtime ? norm(cfg.runtime) : null
|
|
140
|
+
const runtimes = Object.keys(list).filter((r) => !want || norm(r.split('.').pop()).startsWith(want)).sort().reverse()
|
|
141
|
+
const hits = runtimes.flatMap((r) => list[r].filter((d) => d.name === cfg.device || norm(d.name) === norm(cfg.device)))
|
|
142
|
+
// a simulator of that name that is already running wins (its app is installed); else the newest runtime
|
|
143
|
+
const dev = hits.find((d) => d.state === 'Booted') || hits[0]
|
|
144
|
+
if (dev) return { ...cfg, udid: dev.udid, device: dev.name }
|
|
145
|
+
throw new UserError(`No iOS simulator "${cfg.device}"${cfg.runtime ? ` on ${cfg.runtime}` : ''}. Create it in Xcode → Window → Devices and Simulators, or: xcrun simctl create "${cfg.device}" "${cfg.device}" <runtime>.`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ---------- system appearance per capture key ---------- */
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Sets dark mode and reduced motion on the device for a capture key; returns an undo. Android: `cmd uimode
|
|
152
|
+
* night` and the animator scales; iOS Simulator: `simctl ui appearance` (reduced motion has no simctl switch);
|
|
153
|
+
* Electron: emulated media. Other platforms report what they cannot set.
|
|
154
|
+
*/
|
|
155
|
+
export async function applyAppAppearance (platform, cfg, driver, variant) {
|
|
156
|
+
const notes = []
|
|
157
|
+
if (!variant || (!variant.color_scheme && !variant.reduced_motion)) return { undo: async () => {}, notes }
|
|
158
|
+
if (driver?.web && driver.page) {
|
|
159
|
+
await driver.page.emulateMedia({ ...(variant.color_scheme ? { colorScheme: variant.color_scheme } : {}), ...(variant.reduced_motion ? { reducedMotion: variant.reduced_motion } : {}) }).catch(() => {})
|
|
160
|
+
return { undo: async () => { await driver.page.emulateMedia({ colorScheme: null, reducedMotion: null }).catch(() => {}) }, notes }
|
|
161
|
+
}
|
|
162
|
+
if (platform === 'android') {
|
|
163
|
+
const adb = adbOf(cfg); const s = cfg.serial ? ['-s', cfg.serial] : []
|
|
164
|
+
const sh = (...a) => adb([...s, 'shell', ...a]).catch(() => '')
|
|
165
|
+
const before = { night: (await sh('cmd', 'uimode', 'night')).match(/yes|no|auto/)?.[0] || 'no', anim: (await sh('settings', 'get', 'global', 'animator_duration_scale')).trim() || '1' }
|
|
166
|
+
if (variant.color_scheme) await sh('cmd', 'uimode', 'night', variant.color_scheme === 'dark' ? 'yes' : 'no')
|
|
167
|
+
if (variant.reduced_motion) for (const k of ['animator_duration_scale', 'transition_animation_scale', 'window_animation_scale']) await sh('settings', 'put', 'global', k, variant.reduced_motion === 'reduce' ? '0' : '1')
|
|
168
|
+
// the app redraws (an activity may be recreated): give it a moment
|
|
169
|
+
await sleep(cfg.settle ?? 900)
|
|
170
|
+
return {
|
|
171
|
+
notes,
|
|
172
|
+
undo: async () => {
|
|
173
|
+
if (variant.color_scheme) await sh('cmd', 'uimode', 'night', before.night)
|
|
174
|
+
if (variant.reduced_motion) for (const k of ['animator_duration_scale', 'transition_animation_scale', 'window_animation_scale']) await sh('settings', 'put', 'global', k, before.anim === 'null' ? '1' : before.anim)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (platform === 'ios' && !cfg.real) {
|
|
179
|
+
const dev = cfg.udid || 'booted'
|
|
180
|
+
if (variant.color_scheme) await exec('xcrun', ['simctl', 'ui', dev, 'appearance', variant.color_scheme]).catch((e) => notes.push(`appearance: ${e.message}`))
|
|
181
|
+
if (variant.reduced_motion === 'reduce') notes.push('reduced motion cannot be switched on the iOS Simulator from the command line; the capture shows the current setting')
|
|
182
|
+
await sleep(cfg.settle ?? 700)
|
|
183
|
+
return { notes, undo: async () => { if (variant.color_scheme) await exec('xcrun', ['simctl', 'ui', dev, 'appearance', 'light']).catch(() => {}) } }
|
|
184
|
+
}
|
|
185
|
+
notes.push(`${platform}${cfg.real ? ' (real device)' : ''}: dark mode and motion are not switched by Kaloko; the capture shows the device's own setting`)
|
|
186
|
+
return { notes, undo: async () => {} }
|
|
187
|
+
}
|
package/src/lib/apps/drivers.mjs
CHANGED
|
@@ -15,10 +15,12 @@ import { createHash } from 'node:crypto'
|
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
import { UserError } from '../util.mjs'
|
|
17
17
|
import { readTree, query } from './tree.mjs'
|
|
18
|
+
import { iosRealDevice } from './ios-device.mjs'
|
|
19
|
+
import { windowsApp } from './windows.mjs'
|
|
18
20
|
|
|
19
21
|
const run = promisify(execFile)
|
|
20
22
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
21
|
-
export const PLATFORMS = ['web', 'android', 'ios', 'electron', 'desktop']
|
|
23
|
+
export const PLATFORMS = ['web', 'android', 'ios', 'electron', 'desktop', 'windows']
|
|
22
24
|
export const isApp = (platform) => !!platform && platform !== 'web'
|
|
23
25
|
|
|
24
26
|
async function exec (bin, args, opts = {}) {
|
|
@@ -199,7 +201,7 @@ function electron (cfg, ctx) {
|
|
|
199
201
|
|
|
200
202
|
/* ---------- macOS desktop apps: window screenshot, accessibility tree or OCR ---------- */
|
|
201
203
|
function desktop (cfg, ctx) {
|
|
202
|
-
if (process.platform !== 'darwin') throw new UserError('
|
|
204
|
+
if (process.platform !== 'darwin') throw new UserError('Platform desktop is a macOS app. A Windows app is platform: windows (app.windows, UI Automation); on Linux drive the app with your tool and capture with kaloko capture --image (and --tree).')
|
|
203
205
|
const name = cfg.name || cfg.bundle
|
|
204
206
|
if (!name) throw new UserError('environments.<env>.app.desktop needs name (the app as macOS shows it) or bundle.')
|
|
205
207
|
let win = null
|
|
@@ -272,7 +274,10 @@ export function driverFor (platform, env, ctx) {
|
|
|
272
274
|
const cfg = env.app?.[platform]
|
|
273
275
|
if (!cfg && platform !== 'desktop') throw new UserError(`Environment ${env.name} has no app.${platform} block in kaloko.config.yml.`)
|
|
274
276
|
if (platform === 'android') return android(cfg, ctx)
|
|
277
|
+
// batch E: a real iPhone (devicectl, screenshots), Windows apps (UI Automation): own modules, same interface
|
|
278
|
+
if (platform === 'ios' && cfg.real) return iosRealDevice(cfg, ctx, { ocr: (png) => ocr(png, ocrLangs(ctx.locale)) })
|
|
275
279
|
if (platform === 'ios') return ios(cfg, ctx)
|
|
280
|
+
if (platform === 'windows') return windowsApp(cfg, ctx)
|
|
276
281
|
if (platform === 'electron') return electron(cfg, ctx)
|
|
277
282
|
if (platform === 'desktop') return desktop(cfg || {}, ctx)
|
|
278
283
|
throw new UserError(`Unknown platform ${platform}.`)
|
|
@@ -280,5 +285,8 @@ export function driverFor (platform, env, ctx) {
|
|
|
280
285
|
|
|
281
286
|
/** Tools available for app platforms (doctor). */
|
|
282
287
|
export async function appTools () {
|
|
283
|
-
|
|
288
|
+
const mac = process.platform === 'darwin'
|
|
289
|
+
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT
|
|
290
|
+
const emulator = await which('emulator') || (sdk ? await access(join(sdk, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator')).then(() => true, () => false) : false)
|
|
291
|
+
return { adb: await which('adb'), emulator, simctl: mac && await which('xcrun'), devicectl: mac && await which('xcrun'), idb: await which('idb'), idevicescreenshot: await which('idevicescreenshot'), pymobiledevice3: await which('pymobiledevice3'), swiftc: mac && await which('xcrun'), powershell: process.platform === 'win32' && (await which('powershell') || await which('pwsh')) }
|
|
284
292
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// A real iPhone or iPad (batch E): what works from the command line without a test runner. Xcode's devicectl
|
|
2
|
+
// installs and launches the app and opens deep links; a screenshot comes from libimobiledevice (idevicescreenshot)
|
|
3
|
+
// or pymobiledevice3 (iOS 17+ needs its tunnel: sudo pymobiledevice3 remote tunneld). There is no UI tree and no
|
|
4
|
+
// tapping: the screen text is read by OCR on macOS, and flows that need taps are driven by XCUITest, Maestro or
|
|
5
|
+
// Appium, then captured with kaloko capture --image. Same driver interface as drivers.mjs.
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
import { UserError } from '../util.mjs'
|
|
12
|
+
import { readTree } from './tree.mjs'
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile)
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16
|
+
const which = async (bin) => { try { await run(process.platform === 'win32' ? 'where' : 'which', [bin]); return true } catch { return false } }
|
|
17
|
+
const pngSize = (buf) => (buf?.length > 24 && buf.readUInt32BE(12) === 0x49484452 ? [buf.readUInt32BE(16), buf.readUInt32BE(20)] : null)
|
|
18
|
+
|
|
19
|
+
async function exec (bin, args, { timeout = 120000, hint = '' } = {}) {
|
|
20
|
+
try { return (await run(bin, args, { timeout, maxBuffer: 64 * 1024 * 1024 })).stdout } catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT') throw new UserError(`${bin} is not installed or not on PATH.${hint ? ' ' + hint : ''}`)
|
|
22
|
+
throw new Error(`${bin} ${args.slice(0, 4).join(' ')}: ${String(error.stderr || error.message).trim().split('\n').pop()}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Which screenshot tool is installed: idevicescreenshot (libimobiledevice) or pymobiledevice3. */
|
|
27
|
+
export async function iosShotTool () {
|
|
28
|
+
if (await which('idevicescreenshot')) return 'idevicescreenshot'
|
|
29
|
+
if (await which('pymobiledevice3')) return 'pymobiledevice3'
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function iosRealDevice (cfg, ctx, { ocr = null } = {}) {
|
|
34
|
+
if (!cfg.udid) throw new UserError('A real iOS device needs its udid in app.ios (xcrun devicectl list devices, or idevice_id -l).')
|
|
35
|
+
const devicectl = (args, o) => exec('xcrun', ['devicectl', ...args], { hint: 'Install Xcode 15 or newer (devicectl).', ...o })
|
|
36
|
+
const scale = cfg.scale || 3
|
|
37
|
+
let tool = null
|
|
38
|
+
const d = {
|
|
39
|
+
platform: 'ios', real: true,
|
|
40
|
+
async open ({ locale, attach = false } = {}) {
|
|
41
|
+
tool = cfg.screenshot || await iosShotTool()
|
|
42
|
+
if (!tool) throw new UserError('Screenshots of a real iPhone need libimobiledevice (brew install libimobiledevice: idevicescreenshot) or pymobiledevice3 (pipx install pymobiledevice3; on iOS 17+ also sudo pymobiledevice3 remote tunneld).')
|
|
43
|
+
if (attach) return
|
|
44
|
+
if (cfg.app && cfg.install !== false) await devicectl(['device', 'install', 'app', '--device', cfg.udid, resolve(ctx.root, cfg.app)], { timeout: 600000 })
|
|
45
|
+
if (!cfg.bundle) return
|
|
46
|
+
const lang = locale ? ['-AppleLanguages', `(${locale})`, '-AppleLocale', locale.replace('-', '_')] : []
|
|
47
|
+
await devicectl(['device', 'process', 'launch', '--device', cfg.udid, '--terminate-existing', cfg.bundle, ...lang, ...(cfg.args || [])])
|
|
48
|
+
await sleep(cfg.launch_wait ?? 2000)
|
|
49
|
+
},
|
|
50
|
+
async link (url) {
|
|
51
|
+
if (!cfg.bundle) throw new UserError('Deep links on a real iOS device need app.ios.bundle (devicectl opens the URL with the app).')
|
|
52
|
+
await devicectl(['device', 'process', 'launch', '--device', cfg.udid, '--payload-url', url, cfg.bundle])
|
|
53
|
+
await sleep(cfg.settle ?? 1200)
|
|
54
|
+
},
|
|
55
|
+
async tap () { throw new UserError('Kaloko does not tap on a real iPhone. Drive the flow with XCUITest, Maestro or Appium and capture each screen with kaloko capture --image (and --tree when the tool dumps one).') },
|
|
56
|
+
async type () { throw new UserError('Kaloko does not type on a real iPhone; use XCUITest, Maestro or Appium.') },
|
|
57
|
+
async back () { throw new UserError('iOS has no system back: tap the app\'s own back button (with your test tool).') },
|
|
58
|
+
async wait (w) { await sleep(typeof w === 'number' ? w : 1000) },
|
|
59
|
+
async tree () { return null },
|
|
60
|
+
async snapshot () {
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), 'kaloko-iosdev-'))
|
|
62
|
+
try {
|
|
63
|
+
const f = join(dir, 's.png')
|
|
64
|
+
if (tool === 'pymobiledevice3') await exec('pymobiledevice3', ['developer', 'dvt', 'screenshot', '--udid', cfg.udid, f])
|
|
65
|
+
else await exec('idevicescreenshot', ['-u', cfg.udid, f])
|
|
66
|
+
const png = await readFile(f); const px = pngSize(png) || [1179, 2556]
|
|
67
|
+
let tree = null
|
|
68
|
+
if (ocr && cfg.ocr !== false && process.platform === 'darwin') {
|
|
69
|
+
const res = await ocr(png).catch(() => null)
|
|
70
|
+
if (res) tree = readTree({ ocr: res.ocr.map((l) => ({ text: l.text, x: Math.round(l.x / scale), y: Math.round(l.y / scale), w: Math.round(l.w / scale), h: Math.round(l.h / scale) })) })
|
|
71
|
+
}
|
|
72
|
+
return { png, tree, size: [Math.round(px[0] / scale), Math.round(px[1] / scale)], scale, device: cfg.label || cfg.device || `iPhone ${cfg.udid.slice(0, 8)}`, crash: null, logErrors: null }
|
|
73
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
74
|
+
},
|
|
75
|
+
async close () {}
|
|
76
|
+
}
|
|
77
|
+
return d
|
|
78
|
+
}
|
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
|
+
}
|