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.
Files changed (43) hide show
  1. package/README.md +45 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +42 -2
  4. package/schemas/run.v1.json +33 -2
  5. package/schemas/scenario.v1.json +107 -16
  6. package/skills/kaloko/SKILL.md +8 -2
  7. package/skills/kaloko/reference/acceptance.md +70 -1
  8. package/skills/kaloko/reference/apps.md +103 -8
  9. package/skills/kaloko/reference/ci.md +3 -1
  10. package/skills/kaloko/reference/design.md +39 -0
  11. package/skills/kaloko/reference/devices.md +109 -0
  12. package/skills/kaloko/reference/environments.md +6 -0
  13. package/skills/kaloko/reference/execution.md +6 -0
  14. package/src/cli.mjs +326 -190
  15. package/src/lib/access.mjs +5 -2
  16. package/src/lib/apps/devices.mjs +187 -0
  17. package/src/lib/apps/drivers.mjs +11 -3
  18. package/src/lib/apps/ios-device.mjs +78 -0
  19. package/src/lib/apps/tree.mjs +9 -1
  20. package/src/lib/apps/webview.mjs +54 -0
  21. package/src/lib/apps/windows.mjs +108 -0
  22. package/src/lib/apps/windows.ps1 +78 -0
  23. package/src/lib/assertions.mjs +300 -0
  24. package/src/lib/build.mjs +4 -1
  25. package/src/lib/capture.mjs +146 -119
  26. package/src/lib/ci.mjs +8 -4
  27. package/src/lib/design-cli.mjs +35 -6
  28. package/src/lib/design.mjs +3 -1
  29. package/src/lib/devices.mjs +85 -0
  30. package/src/lib/devspec.mjs +3 -2
  31. package/src/lib/dimensions.mjs +326 -0
  32. package/src/lib/doctor.mjs +31 -6
  33. package/src/lib/drift.mjs +8 -4
  34. package/src/lib/evaluate.mjs +25 -7
  35. package/src/lib/import.mjs +2 -1
  36. package/src/lib/jev.mjs +3 -1
  37. package/src/lib/manifest-core.mjs +12 -3
  38. package/src/lib/probe.mjs +60 -0
  39. package/src/lib/recheck.mjs +6 -1
  40. package/src/lib/scenario.mjs +3 -0
  41. package/src/lib/stability-core.mjs +4 -2
  42. package/src/lib/walk-dimensions.mjs +83 -0
  43. package/viewer/index.html +282 -19
@@ -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
- /** A new browser context with the environment's gate, policy and (optionally) an account's session. */
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
+ }
@@ -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('Built-in desktop capture supports macOS. On Windows or Linux drive the app with your tool and capture with kaloko capture --image (and --tree).')
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
- return { adb: await which('adb'), simctl: process.platform === 'darwin' && await which('xcrun'), idb: await which('idb'), swiftc: process.platform === 'darwin' && await which('xcrun') }
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
+ }
@@ -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, OCR lines, and Kaloko's own tree JSON.
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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;|&#39;/g, "'").replace(/&#10;/g, '\n').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))).replace(/&amp;/g, '&')
9
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
+ }