kaloko 0.4.10 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +70 -2
- package/schemas/run.v1.json +33 -2
- package/schemas/scenario.v1.json +130 -17
- package/skills/kaloko/SKILL.md +18 -5
- package/skills/kaloko/reference/acceptance.md +70 -1
- package/skills/kaloko/reference/apps.md +103 -8
- package/skills/kaloko/reference/ci.md +3 -1
- package/skills/kaloko/reference/design.md +39 -0
- package/skills/kaloko/reference/devices.md +109 -0
- package/skills/kaloko/reference/environments.md +6 -0
- package/skills/kaloko/reference/execution.md +6 -0
- package/skills/kaloko/reference/mcp.md +1 -0
- package/skills/kaloko/reference/recording.md +95 -0
- package/src/cli.mjs +381 -167
- package/src/lib/access.mjs +5 -2
- package/src/lib/anim.mjs +125 -0
- package/src/lib/apps/devices.mjs +187 -0
- package/src/lib/apps/drivers.mjs +11 -3
- package/src/lib/apps/ios-device.mjs +78 -0
- package/src/lib/apps/recording-native.mjs +212 -0
- package/src/lib/apps/tree.mjs +9 -1
- package/src/lib/apps/webview.mjs +54 -0
- package/src/lib/apps/windows.mjs +108 -0
- package/src/lib/apps/windows.ps1 +78 -0
- package/src/lib/assertions.mjs +300 -0
- package/src/lib/build.mjs +63 -4
- package/src/lib/capture.mjs +146 -119
- package/src/lib/ci.mjs +8 -4
- package/src/lib/design-cli.mjs +35 -6
- package/src/lib/design.mjs +3 -1
- package/src/lib/devices.mjs +85 -0
- package/src/lib/devspec.mjs +3 -2
- package/src/lib/dimensions.mjs +326 -0
- package/src/lib/doctor.mjs +35 -6
- package/src/lib/drift.mjs +8 -4
- package/src/lib/evaluate.mjs +25 -7
- package/src/lib/import.mjs +3 -2
- package/src/lib/jev.mjs +3 -1
- package/src/lib/manifest-core.mjs +12 -3
- package/src/lib/probe.mjs +60 -0
- package/src/lib/recheck.mjs +6 -1
- package/src/lib/recording.mjs +373 -0
- package/src/lib/scenario.mjs +3 -0
- package/src/lib/stability-core.mjs +4 -2
- package/src/lib/trace.mjs +109 -0
- package/src/lib/video-cli.mjs +209 -0
- package/src/lib/video.mjs +427 -0
- package/src/lib/walk-dimensions.mjs +83 -0
- package/src/lib/webm.mjs +144 -0
- package/viewer/index.html +467 -25
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
|
|
package/src/lib/anim.mjs
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|