kaloko 0.3.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 +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- package/viewer/index.html +2772 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Access to an environment, in three layers:
|
|
2
|
+
// gate — protection in front of the whole environment (HTTP Basic, service-token headers,
|
|
3
|
+
// client certificate); applied by the CLI to every browser context it creates;
|
|
4
|
+
// accounts — people who sign in to the application (user, password, TOTP), handed to step
|
|
5
|
+
// scripts; SSO/MFA sessions come from a storage state a person saved once;
|
|
6
|
+
// policy — readonly and blocked paths (config.mjs), enforced in the same place.
|
|
7
|
+
// Secret values are read from the environment only here, and only when needed.
|
|
8
|
+
import { createHmac } from 'node:crypto'
|
|
9
|
+
import { access as exists } from 'node:fs/promises'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
import { policyViolation, baseOrigins } from './config.mjs'
|
|
12
|
+
import { secret, remember } from './secrets.mjs'
|
|
13
|
+
import { UserError } from './util.mjs'
|
|
14
|
+
|
|
15
|
+
/** The environment's gate: `access`, with the older `http_auth` read as `access.basic`. */
|
|
16
|
+
export function gateOf (env) {
|
|
17
|
+
const gate = { ...(env.access || {}) }
|
|
18
|
+
if (!gate.basic && env.http_auth?.user_env) gate.basic = env.http_auth
|
|
19
|
+
return gate
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Origins that receive the gate credentials: `access.origins`, else the base URL's origins. */
|
|
23
|
+
export function gateOrigins (env) {
|
|
24
|
+
const list = gateOf(env).origins
|
|
25
|
+
return list?.length ? list : baseOrigins(env)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Matcher for origins; `https://*.example.com` covers every subdomain (not the apex). */
|
|
29
|
+
export function originMatcher (origins) {
|
|
30
|
+
const rules = origins.map((o) => {
|
|
31
|
+
const m = String(o).match(/^(https?):\/\/(\*\.)?([^/:]+)(?::(\d+))?\/?$/)
|
|
32
|
+
if (!m) throw new UserError(`access.origins: "${o}" is not an origin like https://staging.example.com`)
|
|
33
|
+
return { scheme: m[1], wildcard: !!m[2], host: m[3].toLowerCase(), port: m[4] || (m[1] === 'https' ? '443' : '80') }
|
|
34
|
+
})
|
|
35
|
+
return (url) => {
|
|
36
|
+
let u
|
|
37
|
+
try { u = new URL(url) } catch { return false }
|
|
38
|
+
const scheme = u.protocol.slice(0, -1)
|
|
39
|
+
const port = u.port || (scheme === 'https' ? '443' : '80')
|
|
40
|
+
const host = u.hostname.toLowerCase()
|
|
41
|
+
return rules.some((r) => r.scheme === scheme && r.port === port && (r.wildcard ? host.endsWith(`.${r.host}`) : host === r.host))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolved gate headers (Authorization for Basic plus `access.headers`). */
|
|
46
|
+
export function gateHeaders (env, vars = process.env) {
|
|
47
|
+
const gate = gateOf(env)
|
|
48
|
+
const headers = {}
|
|
49
|
+
if (gate.basic) {
|
|
50
|
+
const user = secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`, vars)
|
|
51
|
+
const pass = secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`, vars)
|
|
52
|
+
headers.Authorization = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64')
|
|
53
|
+
remember(headers.Authorization.slice(6)) // the pair's base64 is not the password's
|
|
54
|
+
}
|
|
55
|
+
for (const [name, spec] of Object.entries(gate.headers || {})) headers[name] = typeof spec === 'string' ? spec : secret(spec.env, `header ${name}`, vars)
|
|
56
|
+
return headers
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Playwright `clientCertificates` for the gate's exact origins (mutual TLS). */
|
|
60
|
+
export function clientCertificates (env, root = process.cwd()) {
|
|
61
|
+
const cert = gateOf(env).client_certificate
|
|
62
|
+
if (!cert) return []
|
|
63
|
+
const pem = (fileKey, envKey, what) => {
|
|
64
|
+
if (cert[fileKey]) return { path: resolve(root, cert[fileKey]) }
|
|
65
|
+
if (!cert[envKey]) return null
|
|
66
|
+
const value = secret(cert[envKey], what)
|
|
67
|
+
return value.includes('-----BEGIN') ? { data: Buffer.from(value) } : { path: resolve(root, value) }
|
|
68
|
+
}
|
|
69
|
+
const c = pem('cert_file', 'cert_env', `client certificate for ${env.name}`)
|
|
70
|
+
const k = pem('key_file', 'key_env', `client key for ${env.name}`)
|
|
71
|
+
if (!c || !k) throw new UserError(`access.client_certificate for ${env.name} needs a certificate and a key (cert_file/cert_env, key_file/key_env).`)
|
|
72
|
+
const passphrase = cert.passphrase_env ? secret(cert.passphrase_env, `client key passphrase for ${env.name}`) : undefined
|
|
73
|
+
return gateOrigins(env).filter((o) => !o.includes('*')).map((origin) => ({
|
|
74
|
+
origin: new URL(origin).origin,
|
|
75
|
+
...(c.path ? { certPath: c.path } : { cert: c.data }),
|
|
76
|
+
...(k.path ? { keyPath: k.path } : { key: k.data }),
|
|
77
|
+
...(passphrase ? { passphrase } : {})
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Enforces the environment on a browser context: the readonly policy for every request and the
|
|
83
|
+
* gate headers for the gate's origins only (third parties never see them). Headers the page set
|
|
84
|
+
* itself win, so an application's own Authorization header is left alone.
|
|
85
|
+
*/
|
|
86
|
+
export async function prepareContext (context, env, { log } = {}) {
|
|
87
|
+
const headers = gateHeaders(env)
|
|
88
|
+
const names = Object.keys(headers)
|
|
89
|
+
const toGate = names.length ? originMatcher(gateOrigins(env)) : () => false
|
|
90
|
+
await context.route('**/*', (route) => {
|
|
91
|
+
const request = route.request()
|
|
92
|
+
const reason = policyViolation(env, { method: request.method(), url: request.url() })
|
|
93
|
+
if (reason) {
|
|
94
|
+
log?.({ type: 'blocked', url: request.url(), text: reason })
|
|
95
|
+
return route.abort('blockedbyclient')
|
|
96
|
+
}
|
|
97
|
+
if (toGate(request.url())) {
|
|
98
|
+
const current = request.headers()
|
|
99
|
+
const extra = names.filter((n) => !(n.toLowerCase() in current))
|
|
100
|
+
if (extra.length) return route.continue({ headers: { ...current, ...Object.fromEntries(extra.map((n) => [n, headers[n]])) } })
|
|
101
|
+
}
|
|
102
|
+
return route.continue()
|
|
103
|
+
})
|
|
104
|
+
return context
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Context options for the environment: client certificates and, for an account, its saved session. */
|
|
108
|
+
export async function contextOptions (env, { root, config, account } = {}) {
|
|
109
|
+
const options = {}
|
|
110
|
+
const certs = clientCertificates(env, root)
|
|
111
|
+
if (certs.length) options.clientCertificates = certs
|
|
112
|
+
// Basic auth also for navigations that arrive through a redirect (#56): route headers do not apply to redirected
|
|
113
|
+
// requests, so answer the 401 challenge with httpCredentials — only for one exact gate origin, never elsewhere
|
|
114
|
+
const gate = gateOf(env); const origins = gateOrigins(env)
|
|
115
|
+
if (gate.basic && origins.length === 1 && !String(origins[0]).includes('*')) {
|
|
116
|
+
options.httpCredentials = { username: secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`), password: secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`), origin: new URL(origins[0]).origin }
|
|
117
|
+
}
|
|
118
|
+
if (account?.storage_state && await exists(account.storage_state).then(() => true, () => false)) options.storageState = account.storage_state
|
|
119
|
+
return options
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A new browser context with the environment's gate, policy and (optionally) an account's session. */
|
|
123
|
+
export async function newContext (browser, env, opts = {}) {
|
|
124
|
+
const context = await browser.newContext(await contextOptions(env, opts))
|
|
125
|
+
return prepareContext(context, env, opts)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where an account's saved browser session lives (cookies, storage): never inside a run. */
|
|
129
|
+
export function storageStatePath (env, key, { root, config }) {
|
|
130
|
+
const custom = env.accounts?.[key]?.storage_state
|
|
131
|
+
return custom ? resolve(root, custom) : join(resolve(root, config.output || 'tmp/kaloko'), '.auth', env.name, `${key}.json`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Credentials of a scenario session's `account` in this environment. Values are read lazily,
|
|
136
|
+
* so a script that signs in with a saved session never needs the password.
|
|
137
|
+
*/
|
|
138
|
+
export function accountFor (env, key, ctx) {
|
|
139
|
+
if (!key) return null
|
|
140
|
+
const spec = env.accounts?.[key]
|
|
141
|
+
const account = { key, storage_state: storageStatePath(env, key, ctx), configured: !!spec }
|
|
142
|
+
if (!spec) return account
|
|
143
|
+
Object.defineProperties(account, {
|
|
144
|
+
user: { enumerable: true, get: () => spec.user ?? (spec.user_env ? secret(spec.user_env, `user of account ${key}`) : null) },
|
|
145
|
+
password: { enumerable: false, get: () => (spec.password_env ? secret(spec.password_env, `password of account ${key}`) : null) }
|
|
146
|
+
})
|
|
147
|
+
account.totp = () => {
|
|
148
|
+
if (!spec.totp_env) throw new UserError(`Account ${key} has no totp_env in environment ${env.name}.`)
|
|
149
|
+
return totp(secret(spec.totp_env, `TOTP secret of account ${key}`))
|
|
150
|
+
}
|
|
151
|
+
return account
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** RFC 6238 time-based one-time password from a base32 secret. */
|
|
155
|
+
export function totp (base32, { step = 30, digits = 6, now = Date.now() } = {}) {
|
|
156
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
157
|
+
let bits = ''
|
|
158
|
+
for (const ch of base32.replace(/[\s=]/g, '').toUpperCase()) {
|
|
159
|
+
const v = alphabet.indexOf(ch)
|
|
160
|
+
if (v < 0) throw new UserError('The TOTP secret is not base32.')
|
|
161
|
+
bits += v.toString(2).padStart(5, '0')
|
|
162
|
+
}
|
|
163
|
+
const key = Buffer.from((bits.match(/.{8}/g) || []).map((b) => parseInt(b, 2)))
|
|
164
|
+
const counter = Buffer.alloc(8)
|
|
165
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(now / 1000 / step)))
|
|
166
|
+
const h = createHmac('sha1', key).update(counter).digest()
|
|
167
|
+
const o = h[h.length - 1] & 0xf
|
|
168
|
+
return String((h.readUInt32BE(o) & 0x7fffffff) % 10 ** digits).padStart(digits, '0')
|
|
169
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Capture records of native app screens: a screenshot, the UI tree (qawalk.tree.v1), deterministic checks on the
|
|
2
|
+
// tree and a text state for the semantic evaluator. Same record shape as web captures (schema qawalk.capture.v1)
|
|
3
|
+
// plus `platform`, `device`, `scale` and `files.tree`; `html` is absent. Used by walk (drivers) and by
|
|
4
|
+
// `kaloko capture --image` (screens a person, an agent, Maestro, Appium or XCUITest took).
|
|
5
|
+
import { mkdir, writeFile, rm } from 'node:fs/promises'
|
|
6
|
+
import { dirname, relative } from 'node:path'
|
|
7
|
+
import { runPaths, addCapture } from '../run.mjs'
|
|
8
|
+
import { writeJson } from '../util.mjs'
|
|
9
|
+
import { redactDeep } from '../secrets.mjs'
|
|
10
|
+
import { criteriaHash } from '../evaluate.mjs'
|
|
11
|
+
import { encodeImage } from '../capture.mjs'
|
|
12
|
+
import { treeChecks, outline, treeFile } from './tree.mjs'
|
|
13
|
+
import { pngSize } from './drivers.mjs'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* snapshot: { png, tree: { root, format } | null, size: [w, h] logical, scale, device, crash, logErrors }
|
|
17
|
+
* encoder: a Playwright page used to encode WebP and the thumbnail (optional: PNG only without it).
|
|
18
|
+
*/
|
|
19
|
+
export async function captureApp ({ snapshot, node, locale, viewport, run, platform, encoder = null, maxChars = 90000, extra = {}, format = 'webp' }) {
|
|
20
|
+
const paths = runPaths(run.dir)
|
|
21
|
+
const stepId = node.id
|
|
22
|
+
const png = snapshot.png
|
|
23
|
+
const px = pngSize(png) || [0, 0]
|
|
24
|
+
const scale = snapshot.scale || (snapshot.size?.[0] ? px[0] / snapshot.size[0] : 1)
|
|
25
|
+
const size = snapshot.size || [Math.round(px[0] / scale), Math.round(px[1] / scale)]
|
|
26
|
+
const vp = viewport || (size[0] < 700 ? 'phone' : size[0] < 1100 ? 'tablet' : 'desktop')
|
|
27
|
+
const base = paths.step(stepId, locale, vp, 'x')
|
|
28
|
+
await mkdir(dirname(base), { recursive: true })
|
|
29
|
+
for (const ext of ['eval.json', 'html']) await rm(paths.step(stepId, locale, vp, ext), { force: true })
|
|
30
|
+
let image = paths.step(stepId, locale, vp, format === 'png' ? 'png' : 'webp')
|
|
31
|
+
const thumb = paths.step(stepId, locale, vp, 'thumb.webp')
|
|
32
|
+
let thumbWritten = false
|
|
33
|
+
if (encoder) {
|
|
34
|
+
try { const enc = await encodeImage(encoder, png, { format }); await writeFile(image, enc.image); await writeFile(thumb, enc.thumb); thumbWritten = true } catch { image = paths.step(stepId, locale, vp, 'png'); await writeFile(image, png) }
|
|
35
|
+
} else { image = paths.step(stepId, locale, vp, 'png'); await writeFile(image, png) }
|
|
36
|
+
const tree = snapshot.tree
|
|
37
|
+
const { checks, boxes } = tree ? treeChecks(node.criteria, tree.root, { platform, format: tree.format, crash: snapshot.crash, logErrors: snapshot.logErrors }) : { checks: Object.fromEntries((node.criteria || []).filter((c) => c.check.type === 'deterministic').map((c) => [c.id, c.check.assert === 'app_no_crash' ? { ok: !snapshot.crash, evidence: snapshot.crash || 'the app is running' } : { ok: null, evidence: 'no UI tree for this capture (pass --tree, or install idb for the iOS Simulator)' }])), boxes: {} }
|
|
38
|
+
const treePath = paths.step(stepId, locale, vp, 'tree.json')
|
|
39
|
+
if (tree) await writeJson(treePath, redactDeep(treeFile({ root: tree.root, format: tree.format, platform, device: snapshot.device, scale, size })))
|
|
40
|
+
const state = tree ? outline(tree.root, maxChars) : ''
|
|
41
|
+
const record = {
|
|
42
|
+
schema: 'qawalk.capture.v1',
|
|
43
|
+
node: node.id, locale, viewport: vp, url: extra.url || null,
|
|
44
|
+
captured_at: new Date().toISOString(),
|
|
45
|
+
criteria_hash: criteriaHash(node),
|
|
46
|
+
platform, device: snapshot.device || null, scale,
|
|
47
|
+
console_errors: snapshot.logErrors || [], blocked_requests: [],
|
|
48
|
+
state, scoped: {}, checks, boxes, overflow: [], meta: null, metadata: null,
|
|
49
|
+
size: { width: size[0], height: size[1], viewport: size },
|
|
50
|
+
...(snapshot.crash ? { crash: String(snapshot.crash).slice(0, 2000) } : {}),
|
|
51
|
+
...extra,
|
|
52
|
+
capture: { format: String(image).split('.').pop(), scale: 1, masked: 0, annotations: [], source: extra.source || 'driver', tree: tree?.format || null },
|
|
53
|
+
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: null, tree: tree ? relative(run.dir, treePath) : null }
|
|
54
|
+
}
|
|
55
|
+
const file = paths.step(stepId, locale, vp, 'capture.json')
|
|
56
|
+
await writeJson(file, redactDeep(record))
|
|
57
|
+
await addCapture(run.dir, { type: 'capture', node: node.id, locale, viewport: vp, file: relative(run.dir, file), at: record.captured_at })
|
|
58
|
+
return record
|
|
59
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Drivers for native app captures: one small interface over adb (Android devices and emulators), simctl (iOS
|
|
2
|
+
// Simulator, with idb for the UI tree and taps when installed), Playwright's Electron support, and macOS windows.
|
|
3
|
+
// open({ locale, reset, attach }) start the app for a locale (attach: use what is on the screen now)
|
|
4
|
+
// link(url) open a deep link (the step's path)
|
|
5
|
+
// tap(target) type(text) back() wait(ms|{ text }) interactions (target: selector string or { x, y })
|
|
6
|
+
// snapshot() → { png: Buffer, tree: { root, format } | null, size: [w, h] logical, scale, device, crash, logErrors }
|
|
7
|
+
// close()
|
|
8
|
+
// Electron drivers also expose `page` (a Playwright page): their captures are web captures with the DOM.
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
import { promisify } from 'node:util'
|
|
11
|
+
import { mkdtemp, readFile, rm, mkdir, access, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { tmpdir, homedir } from 'node:os'
|
|
13
|
+
import { join, resolve, dirname } from 'node:path'
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { UserError } from '../util.mjs'
|
|
17
|
+
import { readTree, query } from './tree.mjs'
|
|
18
|
+
|
|
19
|
+
const run = promisify(execFile)
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
21
|
+
export const PLATFORMS = ['web', 'android', 'ios', 'electron', 'desktop']
|
|
22
|
+
export const isApp = (platform) => !!platform && platform !== 'web'
|
|
23
|
+
|
|
24
|
+
async function exec (bin, args, opts = {}) {
|
|
25
|
+
try { return await run(bin, args, { maxBuffer: 64 * 1024 * 1024, encoding: opts.buffer ? 'buffer' : 'utf8', timeout: opts.timeout || 60000, env: { ...process.env, ...(opts.env || {}) } }) } catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT') throw new UserError(`${bin} is not installed or not on PATH.${opts.hint ? ' ' + opts.hint : ''}`)
|
|
27
|
+
throw new Error(`${bin} ${args.slice(0, 3).join(' ')}: ${String(error.stderr || error.message).trim().split('\n').pop()}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const which = async (bin) => { try { await run(process.platform === 'win32' ? 'where' : 'which', [bin]); return true } catch { return false } }
|
|
31
|
+
|
|
32
|
+
/** PNG width and height from the IHDR chunk. */
|
|
33
|
+
export const pngSize = (buf) => (buf?.length > 24 && buf.readUInt32BE(12) === 0x49484452 ? [buf.readUInt32BE(16), buf.readUInt32BE(20)] : null)
|
|
34
|
+
|
|
35
|
+
/* ---------- macOS helper (OCR, windows, accessibility), compiled on first use ---------- */
|
|
36
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
37
|
+
let helperPath = null
|
|
38
|
+
export async function helper () {
|
|
39
|
+
if (helperPath) return helperPath
|
|
40
|
+
if (process.platform !== 'darwin') throw new UserError('Text recognition and desktop window capture need macOS.')
|
|
41
|
+
const src = join(here, 'helper.swift')
|
|
42
|
+
const hash = createHash('sha1').update(await readFile(src)).digest('hex').slice(0, 10)
|
|
43
|
+
const dir = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'qawalk')
|
|
44
|
+
const bin = join(dir, `helper-${hash}`)
|
|
45
|
+
try { await access(bin); helperPath = bin; return bin } catch {}
|
|
46
|
+
await mkdir(dir, { recursive: true })
|
|
47
|
+
await exec('xcrun', ['swiftc', '-O', '-o', bin, src], { timeout: 300000, hint: 'Install the Xcode command line tools (xcode-select --install).' })
|
|
48
|
+
helperPath = bin
|
|
49
|
+
return bin
|
|
50
|
+
}
|
|
51
|
+
/** macOS permissions of this terminal for desktop capture; request=true shows the system dialogs. */
|
|
52
|
+
export async function macPermissions (request = false) {
|
|
53
|
+
const { stdout } = await exec(await helper(), ['permissions', ...(request ? ['request'] : [])], { timeout: 60000 })
|
|
54
|
+
return JSON.parse(stdout)
|
|
55
|
+
}
|
|
56
|
+
export async function ocr (png, langs) {
|
|
57
|
+
const dir = await mkdtemp(join(tmpdir(), 'qawalk-ocr-'))
|
|
58
|
+
try {
|
|
59
|
+
const f = join(dir, 'shot.png'); await writeFile(f, png)
|
|
60
|
+
const { stdout } = await exec(await helper(), ['ocr', f, ...(langs?.length ? [langs.join(',')] : [])], { timeout: 120000 })
|
|
61
|
+
return JSON.parse(stdout)
|
|
62
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
63
|
+
}
|
|
64
|
+
const ocrLangs = (locale) => (locale ? [locale.includes('-') ? locale : { cs: 'cs-CZ', en: 'en-US', de: 'de-DE', sk: 'sk-SK', pl: 'pl-PL', fr: 'fr-FR', es: 'es-ES', it: 'it-IT' }[locale] || locale, 'en-US'] : undefined)
|
|
65
|
+
/** OCR lines (pixels) → tree in logical units. */
|
|
66
|
+
const ocrTree = (res, scale) => 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) })) })
|
|
67
|
+
|
|
68
|
+
/* ---------- Android (adb) ---------- */
|
|
69
|
+
function android (cfg, ctx) {
|
|
70
|
+
const serial = cfg.serial || process.env.ANDROID_SERIAL || null
|
|
71
|
+
const adb = (args, o) => exec(cfg.adb || 'adb', [...(serial ? ['-s', serial] : []), ...args], { hint: 'Install Android platform-tools and start an emulator or connect a device (adb devices).', ...o })
|
|
72
|
+
const shell = (...a) => adb(['shell', ...a])
|
|
73
|
+
let density = 1; let device = cfg.device || 'android'
|
|
74
|
+
const logStart = { t: null }
|
|
75
|
+
const d = {
|
|
76
|
+
platform: 'android',
|
|
77
|
+
async open ({ locale, reset, attach = false } = {}) {
|
|
78
|
+
const { stdout } = await adb(['get-state']).catch((e) => { throw new UserError(`No Android device: ${e.message}. Start an emulator or connect a device (adb devices).`) })
|
|
79
|
+
if (!/device/.test(stdout)) throw new UserError('The Android device is not ready (adb get-state).')
|
|
80
|
+
const dens = (await shell('wm', 'density')).stdout.match(/(\d+)\s*$/m)
|
|
81
|
+
density = dens ? Number(dens[1]) / 160 : 1
|
|
82
|
+
if (!cfg.device) device = (await shell('getprop', 'ro.product.model')).stdout.trim() || 'android'
|
|
83
|
+
if (attach) return // capture what is on the screen now
|
|
84
|
+
if (cfg.apk && cfg.install !== false) await adb(['install', '-r', '-g', resolve(ctx.root, cfg.apk)], { timeout: 300000 })
|
|
85
|
+
if (!cfg.package) return
|
|
86
|
+
if (reset ?? cfg.reset) await shell('pm', 'clear', cfg.package)
|
|
87
|
+
if (locale) await shell('cmd', 'locale', 'set-app-locales', cfg.package, '--locales', locale).catch(() => {}) // Android 13+; older devices keep the system language
|
|
88
|
+
await shell('am', 'force-stop', cfg.package)
|
|
89
|
+
await adb(['logcat', '-c']).catch(() => {})
|
|
90
|
+
logStart.t = Date.now()
|
|
91
|
+
if (cfg.activity) await shell('am', 'start', '-W', '-n', `${cfg.package}/${cfg.activity}`)
|
|
92
|
+
else await shell('monkey', '-p', cfg.package, '-c', 'android.intent.category.LAUNCHER', '1')
|
|
93
|
+
await sleep(cfg.launch_wait ?? 1500)
|
|
94
|
+
},
|
|
95
|
+
async link (url) { await shell('am', 'start', '-W', '-a', 'android.intent.action.VIEW', '-d', `'${String(url).replace(/'/g, "'\\''")}'`, ...(cfg.package ? [cfg.package] : [])); await sleep(cfg.settle ?? 800) },
|
|
96
|
+
async tap (target) { const p = await point(d, target); await shell('input', 'tap', String(Math.round(p.x * density)), String(Math.round(p.y * density))); await sleep(cfg.settle ?? 600) },
|
|
97
|
+
async type (text) { await shell('input', 'text', `'${String(text).replace(/ /g, '%s').replace(/'/g, "'\\''")}'`); await sleep(300) },
|
|
98
|
+
async back () { await shell('input', 'keyevent', '4'); await sleep(cfg.settle ?? 600) },
|
|
99
|
+
async wait (w) { await waitFor(d, w) },
|
|
100
|
+
async tree () { const { stdout } = await adb(['exec-out', 'uiautomator', 'dump', '/dev/tty']); const xml = stdout.slice(stdout.indexOf('<'), stdout.lastIndexOf('>') + 1); return readTree(xml, { density }) },
|
|
101
|
+
async snapshot () {
|
|
102
|
+
const png = (await adb(['exec-out', 'screencap', '-p'], { buffer: true })).stdout
|
|
103
|
+
const px = pngSize(png) || [1080, 2400]
|
|
104
|
+
const tree = await d.tree().catch(() => null)
|
|
105
|
+
let crash = null; let logErrors = null
|
|
106
|
+
if (cfg.package) {
|
|
107
|
+
const log = (await adb(['logcat', '-d', '-v', 'brief', '*:E']).catch(() => ({ stdout: '' }))).stdout
|
|
108
|
+
const fatal = log.match(/FATAL EXCEPTION[\s\S]{0,600}/)
|
|
109
|
+
const anr = log.match(/ANR in [^\n]*/)
|
|
110
|
+
crash = fatal ? fatal[0] : anr ? anr[0] : null
|
|
111
|
+
logErrors = log.split('\n').filter((l) => /^E\//.test(l) && (l.includes(cfg.package) || /AndroidRuntime/.test(l))).slice(-20)
|
|
112
|
+
}
|
|
113
|
+
return { png, tree, size: [Math.round(px[0] / density), Math.round(px[1] / density)], scale: density, device, crash, logErrors }
|
|
114
|
+
},
|
|
115
|
+
async close () {}
|
|
116
|
+
}
|
|
117
|
+
return d
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---------- iOS Simulator (simctl; idb for the tree and taps) ---------- */
|
|
121
|
+
function ios (cfg, ctx) {
|
|
122
|
+
let udid = cfg.udid || null; let device = cfg.device || 'iPhone'
|
|
123
|
+
let idb = false; let scale = cfg.scale || 3
|
|
124
|
+
const simctl = (args, o) => exec('xcrun', ['simctl', ...args], { hint: 'Install Xcode with an iOS Simulator runtime.', ...o })
|
|
125
|
+
const d = {
|
|
126
|
+
platform: 'ios',
|
|
127
|
+
async open ({ locale, reset, attach = false } = {}) {
|
|
128
|
+
const list = JSON.parse((await simctl(['list', 'devices', 'available', '-j'])).stdout).devices
|
|
129
|
+
const all = Object.values(list).flat()
|
|
130
|
+
let dev = udid ? all.find((x) => x.udid === udid) : cfg.device ? all.find((x) => x.name === cfg.device && x.state === 'Booted') || all.find((x) => x.name === cfg.device) : all.find((x) => x.state === 'Booted') || all.find((x) => /iPhone/.test(x.name))
|
|
131
|
+
if (!dev) throw new UserError(`No iOS simulator${cfg.device ? ` named "${cfg.device}"` : ''} (xcrun simctl list devices).`)
|
|
132
|
+
udid = dev.udid; device = dev.name
|
|
133
|
+
if (dev.state !== 'Booted') { await simctl(['boot', udid]); await simctl(['bootstatus', udid, '-b'], { timeout: 300000 }) }
|
|
134
|
+
if (/iPad|mini/.test(device) && !cfg.scale) scale = 2
|
|
135
|
+
if (cfg.status_bar !== false) await simctl(['status_bar', udid, 'override', '--time', '9:41', '--batteryState', 'charged', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3']).catch(() => {})
|
|
136
|
+
idb = cfg.idb !== false && await which('idb')
|
|
137
|
+
if (attach) return
|
|
138
|
+
if (cfg.app && cfg.install !== false) await simctl(['install', udid, resolve(ctx.root, cfg.app)], { timeout: 300000 })
|
|
139
|
+
if (!cfg.bundle) return
|
|
140
|
+
await simctl(['terminate', udid, cfg.bundle]).catch(() => {})
|
|
141
|
+
if (reset ?? cfg.reset) { const c = (await simctl(['get_app_container', udid, cfg.bundle, 'data']).catch(() => ({ stdout: '' }))).stdout.trim(); if (c) await rm(join(c, 'Library', 'Preferences'), { recursive: true, force: true }).catch(() => {}) }
|
|
142
|
+
const lang = locale ? ['-AppleLanguages', `(${locale})`, '-AppleLocale', locale.replace('-', '_')] : []
|
|
143
|
+
await simctl(['launch', udid, cfg.bundle, ...lang, ...(cfg.args || [])])
|
|
144
|
+
await sleep(cfg.launch_wait ?? 1500)
|
|
145
|
+
},
|
|
146
|
+
async link (url) { await simctl(['openurl', udid, url]); await sleep(cfg.settle ?? 1000) },
|
|
147
|
+
async tap (target) {
|
|
148
|
+
if (!idb) throw new UserError('Tapping on the iOS Simulator needs idb (brew install facebook/fb/idb-companion && pip install fb-idb), or drive the app with your own tool and capture with kaloko capture --image.')
|
|
149
|
+
const p = await point(d, target); await exec('idb', ['ui', 'tap', '--udid', udid, String(Math.round(p.x)), String(Math.round(p.y))]); await sleep(cfg.settle ?? 600)
|
|
150
|
+
},
|
|
151
|
+
async type (text) { if (!idb) throw new UserError('Typing on the iOS Simulator needs idb.'); await exec('idb', ['ui', 'text', '--udid', udid, String(text)]); await sleep(300) },
|
|
152
|
+
async back () { throw new UserError('iOS has no system back: tap the app\'s own back button.') },
|
|
153
|
+
async wait (w) { await waitFor(d, w) },
|
|
154
|
+
async tree () {
|
|
155
|
+
if (idb) { const { stdout } = await exec('idb', ['ui', 'describe-all', '--udid', udid, '--json']); return readTree(stdout) }
|
|
156
|
+
return null
|
|
157
|
+
},
|
|
158
|
+
async snapshot () {
|
|
159
|
+
const dir = await mkdtemp(join(tmpdir(), 'qawalk-ios-'))
|
|
160
|
+
try {
|
|
161
|
+
const f = join(dir, 's.png'); await simctl(['io', udid, 'screenshot', '--type=png', f])
|
|
162
|
+
const png = await readFile(f); const px = pngSize(png) || [1179, 2556]
|
|
163
|
+
let tree = await d.tree().catch(() => null)
|
|
164
|
+
if (!tree && cfg.ocr !== false && process.platform === 'darwin') tree = ocrTree(await ocr(png, ocrLangs(ctx.locale)), scale)
|
|
165
|
+
let crash = null
|
|
166
|
+
if (cfg.bundle) { const { stdout } = await simctl(['spawn', udid, 'launchctl', 'list']).catch(() => ({ stdout: '' })); if (stdout && !stdout.includes(cfg.bundle)) crash = `${cfg.bundle} is not running (crashed or quit)` }
|
|
167
|
+
return { png, tree, size: [Math.round(px[0] / scale), Math.round(px[1] / scale)], scale, device, crash, logErrors: null }
|
|
168
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
169
|
+
},
|
|
170
|
+
async close () {}
|
|
171
|
+
}
|
|
172
|
+
return d
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ---------- Electron (Playwright): the window is a page, captures are web captures ---------- */
|
|
176
|
+
function electron (cfg, ctx) {
|
|
177
|
+
let app = null
|
|
178
|
+
const d = {
|
|
179
|
+
platform: 'electron', page: null, web: true,
|
|
180
|
+
async open ({ locale } = {}) {
|
|
181
|
+
if (app) await app.close().catch(() => {})
|
|
182
|
+
const { _electron } = await import('playwright')
|
|
183
|
+
const args = [...(cfg.main ? [resolve(ctx.root, cfg.main)] : []), ...(cfg.args || []), ...(locale ? [`--lang=${locale}`] : [])]
|
|
184
|
+
app = await _electron.launch({ ...(cfg.executable ? { executablePath: resolve(ctx.root, cfg.executable) } : {}), args, cwd: ctx.root, env: { ...process.env, ...(locale ? { LANG: `${locale.replace('-', '_')}.UTF-8` } : {}), ...(cfg.env || {}) } })
|
|
185
|
+
d.page = await app.firstWindow()
|
|
186
|
+
await d.page.waitForLoadState('domcontentloaded')
|
|
187
|
+
if (cfg.size) await d.page.setViewportSize({ width: cfg.size[0], height: cfg.size[1] }).catch(() => {})
|
|
188
|
+
d.app = app
|
|
189
|
+
},
|
|
190
|
+
async link (url) { await d.page.goto(url, { waitUntil: 'domcontentloaded' }) },
|
|
191
|
+
async tap (target) { if (typeof target === 'string') await d.page.locator(target).first().click(); else await d.page.mouse.click(target.x, target.y) },
|
|
192
|
+
async type (text) { await d.page.keyboard.type(String(text)) },
|
|
193
|
+
async back () { await d.page.goBack() },
|
|
194
|
+
async wait (w) { if (typeof w === 'number') await sleep(w); else if (w?.text) await d.page.getByText(w.text).first().waitFor(); else if (w?.selector) await d.page.locator(w.selector).first().waitFor() },
|
|
195
|
+
async close () { if (app) await app.close().catch(() => {}); app = null }
|
|
196
|
+
}
|
|
197
|
+
return d
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ---------- macOS desktop apps: window screenshot, accessibility tree or OCR ---------- */
|
|
201
|
+
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).')
|
|
203
|
+
const name = cfg.name || cfg.bundle
|
|
204
|
+
if (!name) throw new UserError('environments.<env>.app.desktop needs name (the app as macOS shows it) or bundle.')
|
|
205
|
+
let win = null
|
|
206
|
+
const d = {
|
|
207
|
+
platform: 'desktop',
|
|
208
|
+
async open ({ reset, attach = false } = {}) {
|
|
209
|
+
if (cfg.launch !== false && !attach) {
|
|
210
|
+
if (reset ?? cfg.reset) await exec('osascript', ['-e', `quit app "${name.replace(/"/g, '')}"`]).catch(() => {})
|
|
211
|
+
await exec('open', [...(cfg.bundle ? ['-b', cfg.bundle] : ['-a', cfg.path ? resolve(ctx.root, cfg.path) : name]), ...(cfg.args?.length ? ['--args', ...cfg.args] : [])])
|
|
212
|
+
await sleep(cfg.launch_wait ?? 2000)
|
|
213
|
+
}
|
|
214
|
+
win = JSON.parse((await exec(await helper(), ['window', name])).stdout)
|
|
215
|
+
},
|
|
216
|
+
async link (url) { await exec('open', [...(cfg.bundle ? ['-b', cfg.bundle] : []), url]); await sleep(cfg.settle ?? 1000) },
|
|
217
|
+
async tap () { throw new UserError('Built-in desktop capture does not click; drive the app with your tool (or AppleScript in a step script) and let Kaloko capture.') },
|
|
218
|
+
async type () { throw new UserError('Built-in desktop capture does not type; use your tool or AppleScript in a step script.') },
|
|
219
|
+
async back () { throw new UserError('Desktop apps have no system back.') },
|
|
220
|
+
async wait (w) { if (typeof w === 'number') await sleep(w); else await waitFor(d, w) },
|
|
221
|
+
async tree () {
|
|
222
|
+
const ax = await exec(await helper(), ['ax', String(win.pid)]).then((r) => JSON.parse(r.stdout)).catch(() => null)
|
|
223
|
+
if (!ax) return null
|
|
224
|
+
const t = readTree(ax)
|
|
225
|
+
// window-relative logical coordinates
|
|
226
|
+
const shift = (n) => { if (n.bounds) n.bounds = { ...n.bounds, x: Math.round(n.bounds.x - win.x), y: Math.round(n.bounds.y - win.y) }; (n.children || []).forEach(shift) }
|
|
227
|
+
shift(t.root)
|
|
228
|
+
return t
|
|
229
|
+
},
|
|
230
|
+
async snapshot () {
|
|
231
|
+
win = JSON.parse((await exec(await helper(), ['window', name])).stdout)
|
|
232
|
+
const dir = await mkdtemp(join(tmpdir(), 'qawalk-mac-'))
|
|
233
|
+
try {
|
|
234
|
+
const f = join(dir, 'w.png')
|
|
235
|
+
await exec('screencapture', ['-x', '-o', `-l${win.id}`, f]).catch(async (e) => {
|
|
236
|
+
if (!/could not create image/i.test(e.message)) throw e
|
|
237
|
+
// the first refusal also puts this terminal into the Screen Recording list, so one switch grants it
|
|
238
|
+
await macPermissions(true).catch(() => {})
|
|
239
|
+
throw new UserError('macOS did not allow a screenshot of the window. Turn on this terminal (or the agent app) in System Settings → Privacy & Security → Screen Recording, restart it, and walk again. `kaloko doctor` shows the state.')
|
|
240
|
+
})
|
|
241
|
+
const png = await readFile(f); const px = pngSize(png) || [win.w, win.h]
|
|
242
|
+
const scale = Math.max(1, Math.round(px[0] / win.w))
|
|
243
|
+
let tree = await d.tree()
|
|
244
|
+
if (!tree && cfg.ocr !== false) tree = ocrTree(await ocr(png, ocrLangs(ctx.locale)), scale)
|
|
245
|
+
return { png, tree, size: [Math.round(px[0] / scale), Math.round(px[1] / scale)], scale, device: `macOS · ${win.owner}`, crash: null, logErrors: null }
|
|
246
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
247
|
+
},
|
|
248
|
+
async close () {}
|
|
249
|
+
}
|
|
250
|
+
return d
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function point (d, target) {
|
|
254
|
+
if (target && typeof target === 'object' && 'x' in target) return target
|
|
255
|
+
const t = await d.tree()
|
|
256
|
+
if (!t) throw new UserError('No UI tree to find the element in; tap by coordinates { x, y }.')
|
|
257
|
+
const n = query(t.root, String(target)).find((x) => x.bounds)
|
|
258
|
+
if (!n) throw new Error(`no visible ${target}`)
|
|
259
|
+
return { x: n.bounds.x + n.bounds.w / 2, y: n.bounds.y + n.bounds.h / 2 }
|
|
260
|
+
}
|
|
261
|
+
async function waitFor (d, w, timeout = 15000) {
|
|
262
|
+
if (typeof w === 'number') return sleep(w)
|
|
263
|
+
const sel = w?.selector || (w?.text ? `[text*="${String(w.text).replace(/"/g, '')}"],[name*="${String(w.text).replace(/"/g, '')}"]` : null)
|
|
264
|
+
if (!sel) return
|
|
265
|
+
const end = Date.now() + (w.timeout || timeout)
|
|
266
|
+
while (Date.now() < end) { const t = await d.tree().catch(() => null); if (t && query(t.root, sel).length) return; await sleep(500) }
|
|
267
|
+
throw new Error(`timed out waiting for ${sel}`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The driver of a scenario's platform, configured by the environment's `app.<platform>` block. */
|
|
271
|
+
export function driverFor (platform, env, ctx) {
|
|
272
|
+
const cfg = env.app?.[platform]
|
|
273
|
+
if (!cfg && platform !== 'desktop') throw new UserError(`Environment ${env.name} has no app.${platform} block in kaloko.config.yml.`)
|
|
274
|
+
if (platform === 'android') return android(cfg, ctx)
|
|
275
|
+
if (platform === 'ios') return ios(cfg, ctx)
|
|
276
|
+
if (platform === 'electron') return electron(cfg, ctx)
|
|
277
|
+
if (platform === 'desktop') return desktop(cfg || {}, ctx)
|
|
278
|
+
throw new UserError(`Unknown platform ${platform}.`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Tools available for app platforms (doctor). */
|
|
282
|
+
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') }
|
|
284
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Kaloko macOS helper: text recognition for screenshots without a UI tree (iOS Simulator without idb, any desktop
|
|
2
|
+
// app), window lookup for desktop captures, and the accessibility tree of a running app. Compiled on first use
|
|
3
|
+
// by the CLI (xcrun swiftc) into the user's cache; prints JSON on stdout.
|
|
4
|
+
// qawalk-helper ocr <image.png> [lang,lang] → { "ocr": [{ text, x, y, w, h }], "width", "height" } (pixels)
|
|
5
|
+
// qawalk-helper window <app name or bundle id> → { id, pid, owner, title, x, y, w, h } of its largest window
|
|
6
|
+
// qawalk-helper ax <pid> → { "ax": tree } (needs Accessibility permission)
|
|
7
|
+
// qawalk-helper permissions [request] → { screen, accessibility } granted?; request asks macOS
|
|
8
|
+
// (the system dialog adds this terminal to both lists)
|
|
9
|
+
import Foundation
|
|
10
|
+
import Vision
|
|
11
|
+
import AppKit
|
|
12
|
+
import ApplicationServices
|
|
13
|
+
|
|
14
|
+
func out(_ obj: Any) {
|
|
15
|
+
let data = try! JSONSerialization.data(withJSONObject: obj, options: [])
|
|
16
|
+
FileHandle.standardOutput.write(data)
|
|
17
|
+
}
|
|
18
|
+
func fail(_ msg: String) -> Never { FileHandle.standardError.write((msg + "\n").data(using: .utf8)!); exit(1) }
|
|
19
|
+
|
|
20
|
+
let args = CommandLine.arguments
|
|
21
|
+
guard args.count >= 2 else { fail("usage: qawalk-helper ocr|window|ax|permissions <arg>") }
|
|
22
|
+
if args[1] != "permissions" && args.count < 3 { fail("usage: qawalk-helper ocr|window|ax <arg>") }
|
|
23
|
+
|
|
24
|
+
switch args[1] {
|
|
25
|
+
case "ocr":
|
|
26
|
+
guard let img = NSImage(contentsOfFile: args[2]), let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else { fail("cannot read image") }
|
|
27
|
+
let req = VNRecognizeTextRequest()
|
|
28
|
+
req.recognitionLevel = .accurate
|
|
29
|
+
req.usesLanguageCorrection = true
|
|
30
|
+
if args.count > 3 { req.recognitionLanguages = args[3].split(separator: ",").map(String.init) }
|
|
31
|
+
let handler = VNImageRequestHandler(cgImage: cg, options: [:])
|
|
32
|
+
do { try handler.perform([req]) } catch { fail("ocr failed: \(error)") }
|
|
33
|
+
let w = Double(cg.width), h = Double(cg.height)
|
|
34
|
+
var lines: [[String: Any]] = []
|
|
35
|
+
for o in req.results ?? [] {
|
|
36
|
+
guard let c = o.topCandidates(1).first else { continue }
|
|
37
|
+
let b = o.boundingBox // normalized, origin bottom-left
|
|
38
|
+
lines.append(["text": c.string, "x": Int(b.minX * w), "y": Int((1 - b.maxY) * h), "w": Int(b.width * w), "h": Int(b.height * h), "confidence": Double(c.confidence)])
|
|
39
|
+
}
|
|
40
|
+
lines.sort { (($0["y"] as! Int), ($0["x"] as! Int)) < (($1["y"] as! Int), ($1["x"] as! Int)) }
|
|
41
|
+
out(["ocr": lines, "width": Int(w), "height": Int(h)])
|
|
42
|
+
|
|
43
|
+
case "window":
|
|
44
|
+
let want = args[2].lowercased()
|
|
45
|
+
let apps = NSWorkspace.shared.runningApplications.filter { ($0.localizedName ?? "").lowercased() == want || ($0.bundleIdentifier ?? "").lowercased() == want }
|
|
46
|
+
let pids = Set(apps.map { $0.processIdentifier })
|
|
47
|
+
let list = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? []
|
|
48
|
+
var best: [String: Any]? = nil; var area = 0.0
|
|
49
|
+
for w in list {
|
|
50
|
+
let pid = (w[kCGWindowOwnerPID as String] as? Int32) ?? -1
|
|
51
|
+
let owner = (w[kCGWindowOwnerName as String] as? String) ?? ""
|
|
52
|
+
guard pids.contains(pid) || owner.lowercased() == want else { continue }
|
|
53
|
+
guard (w[kCGWindowLayer as String] as? Int) == 0, let b = w[kCGWindowBounds as String] as? [String: Double] else { continue }
|
|
54
|
+
let a = (b["Width"] ?? 0) * (b["Height"] ?? 0)
|
|
55
|
+
if a > area { area = a; best = ["id": w[kCGWindowNumber as String] as? Int ?? 0, "pid": Int(pid), "owner": owner, "title": w[kCGWindowName as String] as? String ?? "", "x": b["X"] ?? 0, "y": b["Y"] ?? 0, "w": b["Width"] ?? 0, "h": b["Height"] ?? 0] }
|
|
56
|
+
}
|
|
57
|
+
guard let found = best else { fail("no window of \(args[2]) on screen") }
|
|
58
|
+
out(found)
|
|
59
|
+
|
|
60
|
+
case "ax":
|
|
61
|
+
guard AXIsProcessTrusted() else { fail("accessibility: grant this terminal Accessibility access (System Settings → Privacy & Security → Accessibility)") }
|
|
62
|
+
guard let pid = Int32(args[2]) else { fail("pid expected") }
|
|
63
|
+
let app = AXUIElementCreateApplication(pid)
|
|
64
|
+
func attr(_ e: AXUIElement, _ name: String) -> AnyObject? { var v: AnyObject?; return AXUIElementCopyAttributeValue(e, name as CFString, &v) == .success ? v : nil }
|
|
65
|
+
var count = 0
|
|
66
|
+
func node(_ e: AXUIElement, _ depth: Int) -> [String: Any] {
|
|
67
|
+
count += 1
|
|
68
|
+
var n: [String: Any] = [:]
|
|
69
|
+
n["role"] = attr(e, kAXRoleAttribute) as? String ?? ""
|
|
70
|
+
if let t = attr(e, kAXTitleAttribute) as? String, !t.isEmpty { n["title"] = t }
|
|
71
|
+
if let d = attr(e, kAXDescriptionAttribute) as? String, !d.isEmpty { n["description"] = d }
|
|
72
|
+
if let v = attr(e, kAXValueAttribute) { if let s = v as? String { n["value"] = s } else if let num = v as? NSNumber { n["value"] = num.stringValue } }
|
|
73
|
+
if let i = attr(e, "AXIdentifier") as? String, !i.isEmpty { n["identifier"] = i }
|
|
74
|
+
if let en = attr(e, kAXEnabledAttribute) as? Bool { n["enabled"] = en }
|
|
75
|
+
if let f = attr(e, kAXFocusedAttribute) as? Bool, f { n["focused"] = true }
|
|
76
|
+
var pos = CGPoint.zero, size = CGSize.zero
|
|
77
|
+
if let p = attr(e, kAXPositionAttribute) { AXValueGetValue(p as! AXValue, .cgPoint, &pos) }
|
|
78
|
+
if let s = attr(e, kAXSizeAttribute) { AXValueGetValue(s as! AXValue, .cgSize, &size) }
|
|
79
|
+
n["frame"] = ["x": Double(pos.x), "y": Double(pos.y), "w": Double(size.width), "h": Double(size.height)]
|
|
80
|
+
var names: CFArray?
|
|
81
|
+
if AXUIElementCopyActionNames(e, &names) == .success, let a = names as? [String], !a.isEmpty { n["actions"] = a }
|
|
82
|
+
if depth < 40 && count < 4000, let kids = attr(e, kAXChildrenAttribute) as? [AXUIElement] { n["children"] = kids.map { node($0, depth + 1) } }
|
|
83
|
+
return n
|
|
84
|
+
}
|
|
85
|
+
// only real windows: without full access macOS answers window queries with the application element itself
|
|
86
|
+
let isWindow = { (e: AXUIElement) -> Bool in (attr(e, kAXRoleAttribute) as? String) == "AXWindow" }
|
|
87
|
+
var windows = ((attr(app, kAXWindowsAttribute) as? [AXUIElement]) ?? []).filter(isWindow)
|
|
88
|
+
if windows.isEmpty { windows = ((attr(app, kAXChildrenAttribute) as? [AXUIElement]) ?? []).filter(isWindow) }
|
|
89
|
+
guard let win = windows.first else { fail("accessibility: the app's windows are not readable; give this terminal Accessibility and Screen Recording access and restart it") }
|
|
90
|
+
out(["ax": node(win, 0)])
|
|
91
|
+
|
|
92
|
+
case "permissions":
|
|
93
|
+
let ask = args.count > 2 && args[2] == "request"
|
|
94
|
+
var screen = CGPreflightScreenCaptureAccess()
|
|
95
|
+
if !screen && ask { screen = CGRequestScreenCaptureAccess() }
|
|
96
|
+
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: ask] as CFDictionary
|
|
97
|
+
let ax = AXIsProcessTrustedWithOptions(opts)
|
|
98
|
+
out(["screen": screen, "accessibility": ax])
|
|
99
|
+
|
|
100
|
+
default:
|
|
101
|
+
fail("unknown command \(args[1])")
|
|
102
|
+
}
|