kaloko 0.4.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +64 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +70 -2
  4. package/schemas/run.v1.json +33 -2
  5. package/schemas/scenario.v1.json +130 -17
  6. package/skills/kaloko/SKILL.md +18 -5
  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/skills/kaloko/reference/mcp.md +1 -0
  15. package/skills/kaloko/reference/recording.md +95 -0
  16. package/src/cli.mjs +381 -167
  17. package/src/lib/access.mjs +5 -2
  18. package/src/lib/anim.mjs +125 -0
  19. package/src/lib/apps/devices.mjs +187 -0
  20. package/src/lib/apps/drivers.mjs +11 -3
  21. package/src/lib/apps/ios-device.mjs +78 -0
  22. package/src/lib/apps/recording-native.mjs +212 -0
  23. package/src/lib/apps/tree.mjs +9 -1
  24. package/src/lib/apps/webview.mjs +54 -0
  25. package/src/lib/apps/windows.mjs +108 -0
  26. package/src/lib/apps/windows.ps1 +78 -0
  27. package/src/lib/assertions.mjs +300 -0
  28. package/src/lib/build.mjs +63 -4
  29. package/src/lib/capture.mjs +146 -119
  30. package/src/lib/ci.mjs +8 -4
  31. package/src/lib/design-cli.mjs +35 -6
  32. package/src/lib/design.mjs +3 -1
  33. package/src/lib/devices.mjs +85 -0
  34. package/src/lib/devspec.mjs +3 -2
  35. package/src/lib/dimensions.mjs +326 -0
  36. package/src/lib/doctor.mjs +35 -6
  37. package/src/lib/drift.mjs +8 -4
  38. package/src/lib/evaluate.mjs +25 -7
  39. package/src/lib/import.mjs +3 -2
  40. package/src/lib/jev.mjs +3 -1
  41. package/src/lib/manifest-core.mjs +12 -3
  42. package/src/lib/probe.mjs +60 -0
  43. package/src/lib/recheck.mjs +6 -1
  44. package/src/lib/recording.mjs +373 -0
  45. package/src/lib/scenario.mjs +3 -0
  46. package/src/lib/stability-core.mjs +4 -2
  47. package/src/lib/trace.mjs +109 -0
  48. package/src/lib/video-cli.mjs +209 -0
  49. package/src/lib/video.mjs +427 -0
  50. package/src/lib/walk-dimensions.mjs +83 -0
  51. package/src/lib/webm.mjs +144 -0
  52. package/viewer/index.html +467 -25
@@ -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,125 @@
1
+ // Animated previews in pure Node: an animated WebP assembled from single-frame WebP images (the browser encodes each
2
+ // frame, this only writes the container), and a GIF encoder for browsers that cannot encode WebP. Both stay small:
3
+ // a preview is a few seconds of a step at thumbnail size.
4
+
5
+ const chunk = (fourcc, payload) => {
6
+ const pad = payload.length % 2
7
+ const head = Buffer.alloc(8); head.write(fourcc, 0, 'ascii'); head.writeUInt32LE(payload.length, 4)
8
+ return Buffer.concat([head, payload, pad ? Buffer.alloc(1) : Buffer.alloc(0)])
9
+ }
10
+ const u24 = (n) => { const b = Buffer.alloc(3); b.writeUIntLE(n, 0, 3); return b }
11
+
12
+ /** Chunks of a still WebP: { width, height, alpha, frame: Buffer of the ALPH + VP8/VP8L chunks }. */
13
+ export function readWebp (buf) {
14
+ if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') throw new Error('not a WebP image')
15
+ let p = 12; let width = 0; let height = 0; let alpha = false
16
+ const keep = []
17
+ while (p + 8 <= buf.length) {
18
+ const fourcc = buf.toString('ascii', p, p + 4); const size = buf.readUInt32LE(p + 4)
19
+ const body = buf.subarray(p + 8, p + 8 + size)
20
+ if (fourcc === 'VP8X') { width = body.readUIntLE(4, 3) + 1; height = body.readUIntLE(7, 3) + 1 } else if (fourcc === 'ALPH') { alpha = true; keep.push(chunk('ALPH', body)) } else if (fourcc === 'VP8 ') {
21
+ // key frame header: 3-byte tag, start code 9d 01 2a, then 14-bit width and height
22
+ if (!width) { width = body.readUInt16LE(6) & 0x3fff; height = body.readUInt16LE(8) & 0x3fff }
23
+ keep.push(chunk('VP8 ', body))
24
+ } else if (fourcc === 'VP8L') {
25
+ if (!width) { const bits = body.readUInt32LE(1); width = (bits & 0x3fff) + 1; height = ((bits >>> 14) & 0x3fff) + 1; alpha = !!((bits >>> 28) & 1) }
26
+ keep.push(chunk('VP8L', body))
27
+ }
28
+ p += 8 + size + (size % 2)
29
+ }
30
+ if (!keep.length) throw new Error('WebP without image data')
31
+ return { width, height, alpha, frame: Buffer.concat(keep) }
32
+ }
33
+
34
+ /** frames: [{ webp: Buffer, ms }] → animated WebP looping forever. */
35
+ export function animatedWebp (frames, { loop = 0, background = [255, 255, 255, 255] } = {}) {
36
+ if (!frames.length) throw new Error('no frames')
37
+ const parsed = frames.map((f) => ({ ...readWebp(f.webp), ms: Math.max(20, Math.round(f.ms)) }))
38
+ const W = Math.max(...parsed.map((f) => f.width)); const H = Math.max(...parsed.map((f) => f.height))
39
+ const alpha = parsed.some((f) => f.alpha)
40
+ const vp8x = Buffer.alloc(10); vp8x[0] = 0x02 | (alpha ? 0x10 : 0); vp8x.writeUIntLE(W - 1, 4, 3); vp8x.writeUIntLE(H - 1, 7, 3)
41
+ const anim = Buffer.alloc(6); anim[0] = background[2]; anim[1] = background[1]; anim[2] = background[0]; anim[3] = background[3]; anim.writeUInt16LE(loop, 4)
42
+ // each frame covers the canvas without blending (bit 1) and is kept until the next (no dispose)
43
+ const anmf = parsed.map((f) => chunk('ANMF', Buffer.concat([u24(0), u24(0), u24(f.width - 1), u24(f.height - 1), u24(Math.min(0xffffff, f.ms)), Buffer.from([0x02]), f.frame])))
44
+ const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8X', vp8x), chunk('ANIM', anim), ...anmf])
45
+ const head = Buffer.alloc(8); head.write('RIFF', 0, 'ascii'); head.writeUInt32LE(body.length, 4)
46
+ return Buffer.concat([head, body])
47
+ }
48
+
49
+ /** Frames and loop count of an animated WebP (tests). */
50
+ export function webpFrames (buf) {
51
+ let p = 12; const out = []; let loop = null
52
+ while (p + 8 <= buf.length) {
53
+ const fourcc = buf.toString('ascii', p, p + 4); const size = buf.readUInt32LE(p + 4)
54
+ if (fourcc === 'ANIM') loop = buf.readUInt16LE(p + 12)
55
+ if (fourcc === 'ANMF') out.push({ width: buf.readUIntLE(p + 14, 3) + 1, height: buf.readUIntLE(p + 17, 3) + 1, ms: buf.readUIntLE(p + 20, 3) })
56
+ p += 8 + size + (size % 2)
57
+ }
58
+ return { loop, frames: out }
59
+ }
60
+
61
+ // ---------- GIF ----------
62
+ // A fixed 6×7×6 colour cube (252 colours) plus four greys, with 4×4 ordered dithering: no per-frame palette search,
63
+ // stable colours from frame to frame, and small files for flat UI screens.
64
+ const LEVELS = [6, 7, 6]
65
+ const PALETTE = (() => {
66
+ const p = []
67
+ for (let r = 0; r < 6; r++) for (let g = 0; g < 7; g++) for (let b = 0; b < 6; b++) p.push([Math.round(r * 255 / 5), Math.round(g * 255 / 6), Math.round(b * 255 / 5)])
68
+ for (const v of [32, 96, 160, 224]) p.push([v, v, v])
69
+ return p
70
+ })()
71
+ const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16 - 0.5)
72
+
73
+ /** RGBA pixels → palette indices of the colour cube, dithered. */
74
+ export function quantize (rgba, width, height) {
75
+ const out = new Uint8Array(width * height)
76
+ for (let y = 0; y < height; y++) {
77
+ for (let x = 0; x < width; x++) {
78
+ const i = y * width + x; const d = BAYER[(y & 3) * 4 + (x & 3)]
79
+ const q = (v, n) => Math.max(0, Math.min(n - 1, Math.round(v / 255 * (n - 1) + d)))
80
+ out[i] = q(rgba[i * 4], LEVELS[0]) * 42 + q(rgba[i * 4 + 1], LEVELS[1]) * 6 + q(rgba[i * 4 + 2], LEVELS[2])
81
+ }
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** GIF LZW with 8-bit codes, packed into 255-byte sub-blocks. */
87
+ export function lzw (indices, minCode = 8) {
88
+ const clear = 1 << minCode; const eoi = clear + 1
89
+ const bytes = []; let cur = 0; let nbits = 0
90
+ let size = minCode + 1; let next = eoi + 1
91
+ let dict = new Map()
92
+ const emit = (code) => { cur |= code << nbits; nbits += size; while (nbits >= 8) { bytes.push(cur & 0xff); cur >>>= 8; nbits -= 8 } }
93
+ emit(clear)
94
+ let prefix = indices[0]
95
+ for (let i = 1; i < indices.length; i++) {
96
+ const k = indices[i]; const key = prefix * 256 + k
97
+ const hit = dict.get(key)
98
+ if (hit !== undefined) { prefix = hit; continue }
99
+ emit(prefix)
100
+ if (next < 4096) { dict.set(key, next++); if (next > (1 << size) && size < 12) size++ } else { emit(clear); dict = new Map(); size = minCode + 1; next = eoi + 1 }
101
+ prefix = k
102
+ }
103
+ emit(prefix); emit(eoi)
104
+ if (nbits > 0) bytes.push(cur & 0xff)
105
+ const out = [minCode]
106
+ for (let i = 0; i < bytes.length; i += 255) { const n = Math.min(255, bytes.length - i); out.push(n, ...bytes.slice(i, i + n)) }
107
+ out.push(0)
108
+ return Buffer.from(out)
109
+ }
110
+
111
+ /** frames: [{ rgba, ms }] of one size → looping GIF. */
112
+ export function encodeGif (frames, { width, height, loop = 0 }) {
113
+ const parts = [Buffer.from('GIF89a')]
114
+ const lsd = Buffer.alloc(7); lsd.writeUInt16LE(width, 0); lsd.writeUInt16LE(height, 2); lsd[4] = 0xf7; parts.push(lsd) // global table of 256
115
+ const table = Buffer.alloc(256 * 3); PALETTE.forEach(([r, g, b], i) => { table[i * 3] = r; table[i * 3 + 1] = g; table[i * 3 + 2] = b }); parts.push(table)
116
+ parts.push(Buffer.from([0x21, 0xff, 0x0b, ...Buffer.from('NETSCAPE2.0'), 0x03, 0x01, loop & 0xff, loop >> 8, 0x00]))
117
+ for (const f of frames) {
118
+ const delay = Math.max(2, Math.round(f.ms / 10))
119
+ parts.push(Buffer.from([0x21, 0xf9, 0x04, 0x04, delay & 0xff, delay >> 8, 0x00, 0x00]))
120
+ const desc = Buffer.alloc(10); desc[0] = 0x2c; desc.writeUInt16LE(width, 5); desc.writeUInt16LE(height, 7); parts.push(desc)
121
+ parts.push(lzw(quantize(f.rgba, width, height)))
122
+ }
123
+ parts.push(Buffer.from([0x3b]))
124
+ return Buffer.concat(parts)
125
+ }
@@ -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
+ }