mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
531 lines
14 KiB
JavaScript
531 lines
14 KiB
JavaScript
import { spawn } from 'node:child_process'
|
|
|
|
const EMULATOR_COMMAND_TIMEOUT_MS = 30_000
|
|
const EMULATOR_COMMAND_MAX_BYTES = 2 * 1024 * 1024
|
|
const ACCESSIBILITY_NODE_LIMIT = 2_000
|
|
const EMULATOR_REATTACH_ATTEMPTS = 3
|
|
|
|
export async function tapHostedIosAccessibilityControl(
|
|
args,
|
|
label,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const point = await waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
label,
|
|
0,
|
|
timeoutMs,
|
|
runCommand
|
|
)
|
|
await runCommand(args, ['tap', String(point.x), String(point.y)])
|
|
return point
|
|
}
|
|
|
|
export async function tapHostedIosPoint(args, point, runCommand = runHostedIosEmulatorCommand) {
|
|
const command = ['tap', String(point.x), String(point.y)]
|
|
try {
|
|
await runCommand(args, command)
|
|
} catch (error) {
|
|
if (!isMissingActiveEmulatorError(error)) {
|
|
throw error
|
|
}
|
|
await restartHostedIosEmulatorController(args, runCommand)
|
|
await runCommand(args, command)
|
|
}
|
|
return point
|
|
}
|
|
|
|
export async function typeHostedIosText(args, value, runCommand = runHostedIosEmulatorCommand) {
|
|
for (const character of value) {
|
|
const command = ['type', character]
|
|
try {
|
|
await runCommand(args, command)
|
|
} catch (error) {
|
|
if (!isMissingActiveEmulatorError(error)) {
|
|
throw error
|
|
}
|
|
await restartHostedIosEmulatorController(args, runCommand)
|
|
await runCommand(args, command)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function tapHostedIosAccessibilityControlAtOccurrence(
|
|
args,
|
|
label,
|
|
occurrence,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const point = await waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
label,
|
|
occurrence,
|
|
timeoutMs,
|
|
runCommand
|
|
)
|
|
await runCommand(args, ['tap', String(point.x), String(point.y)])
|
|
return point
|
|
}
|
|
|
|
export async function tapHostedIosAccessibilityControlAtLastOccurrence(
|
|
args,
|
|
label,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const point = await waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
label,
|
|
-1,
|
|
timeoutMs,
|
|
runCommand
|
|
)
|
|
await runCommand(args, ['tap', String(point.x), String(point.y)])
|
|
return point
|
|
}
|
|
|
|
export async function tapHostedIosAccessibilityControlByLabelPrefix(
|
|
args,
|
|
labelPrefix,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const point = await waitForHostedIosAccessibilityControlByLabelPrefix(
|
|
args,
|
|
labelPrefix,
|
|
timeoutMs,
|
|
runCommand
|
|
)
|
|
await runCommand(args, ['tap', String(point.x), String(point.y)])
|
|
return point
|
|
}
|
|
|
|
export async function tapHostedIosAccessibilityControlByLabelPrefixAtPosition(
|
|
args,
|
|
labelPrefix,
|
|
position,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const point = await waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
labelPrefix,
|
|
0,
|
|
timeoutMs,
|
|
runCommand,
|
|
(node) => node.label === labelPrefix || node.label?.startsWith(`${labelPrefix},`),
|
|
(frame) => ({
|
|
x: frame.x + frame.width * position.x,
|
|
y: frame.y + frame.height * position.y
|
|
})
|
|
)
|
|
await runCommand(args, ['tap', String(point.x), String(point.y)])
|
|
return point
|
|
}
|
|
|
|
export async function tapHostedIosAccessibilityControlStartingWith(
|
|
args,
|
|
labelPrefix,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const point = await waitForHostedIosAccessibilityControlStartingWith(
|
|
args,
|
|
labelPrefix,
|
|
timeoutMs,
|
|
runCommand
|
|
)
|
|
await runCommand(args, ['tap', String(point.x), String(point.y)])
|
|
return point
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityControlByLabelPrefix(
|
|
args,
|
|
labelPrefix,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
return waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
labelPrefix,
|
|
0,
|
|
timeoutMs,
|
|
runCommand,
|
|
(node) => node.label === labelPrefix || node.label?.startsWith(`${labelPrefix},`)
|
|
)
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityControlStartingWith(
|
|
args,
|
|
labelPrefix,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
return waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
labelPrefix,
|
|
0,
|
|
timeoutMs,
|
|
runCommand,
|
|
(node) => node.label?.startsWith(labelPrefix) || node.value?.startsWith(labelPrefix)
|
|
)
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityControlEndingWith(
|
|
args,
|
|
labelSuffix,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
return waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
labelSuffix,
|
|
0,
|
|
timeoutMs,
|
|
runCommand,
|
|
(node) => node.label?.endsWith(labelSuffix) || node.value?.endsWith(labelSuffix)
|
|
)
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityControl(
|
|
args,
|
|
label,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
return waitForHostedIosAccessibilityControlOccurrence(args, label, 0, timeoutMs, runCommand)
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityControlMatch(
|
|
args,
|
|
labels,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const deadline = Date.now() + timeoutMs
|
|
let lastLabels = []
|
|
while (Date.now() < deadline) {
|
|
const nodes = await readAccessibilityNodesUntilDeadline(args, runCommand, deadline)
|
|
lastLabels = accessibilityLabels(nodes)
|
|
for (const label of labels) {
|
|
const control = nodes.find(
|
|
(node) =>
|
|
(node.label === label || node.value === label) &&
|
|
node.enabled !== false &&
|
|
isFiniteFrame(node.frame)
|
|
)
|
|
if (!control) {
|
|
continue
|
|
}
|
|
const point = {
|
|
x: control.frame.x + control.frame.width / 2,
|
|
y: control.frame.y + control.frame.height / 2
|
|
}
|
|
if (isNormalizedPoint(point)) {
|
|
return { label, ...point }
|
|
}
|
|
}
|
|
await delay(250)
|
|
}
|
|
throw new Error(
|
|
`${labels.join(' or ')} was not accessible. Last labels: ${lastLabels.slice(-40).join(', ')}`
|
|
)
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityControlMatching(
|
|
args,
|
|
matches,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
return waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
'matching accessibility control',
|
|
0,
|
|
timeoutMs,
|
|
runCommand,
|
|
matches,
|
|
(frame) => ({
|
|
x: frame.x + frame.width / 2,
|
|
y: frame.y + frame.height / 2
|
|
}),
|
|
(node, point) => ({ ...point, label: node.label ?? '', value: node.value ?? '' })
|
|
)
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityLabel(
|
|
args,
|
|
label,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const nodes = await readAccessibilityNodesUntilDeadline(args, runCommand, deadline)
|
|
const node = nodes.find(
|
|
(candidate) =>
|
|
(candidate.label === label || candidate.value === label) &&
|
|
candidate.enabled !== false &&
|
|
isVisibleFrame(candidate.frame)
|
|
)
|
|
if (node) {
|
|
return { frame: node.frame, label: node.label ?? '', value: node.value ?? '' }
|
|
}
|
|
await delay(250)
|
|
}
|
|
throw new Error(`${label} was not accessible in the visible viewport`)
|
|
}
|
|
|
|
export async function rotateHostedIosEmulator(
|
|
args,
|
|
orientation,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
await runCommand(args, ['rotate', orientation])
|
|
}
|
|
|
|
async function waitForHostedIosAccessibilityControlOccurrence(
|
|
args,
|
|
label,
|
|
occurrence,
|
|
timeoutMs,
|
|
runCommand,
|
|
matchesLabel = (node) => node.label === label || node.value === label,
|
|
pointForFrame = (frame) => ({
|
|
x: frame.x + frame.width / 2,
|
|
y: frame.y + frame.height / 2
|
|
}),
|
|
resultForControl = (_node, point) => point
|
|
) {
|
|
const deadline = Date.now() + timeoutMs
|
|
let lastLabels = []
|
|
while (Date.now() < deadline) {
|
|
const nodes = await readAccessibilityNodesUntilDeadline(args, runCommand, deadline)
|
|
lastLabels = accessibilityLabels(nodes)
|
|
const controls = nodes.filter(
|
|
(node) => matchesLabel(node) && node.enabled !== false && isFiniteFrame(node.frame)
|
|
)
|
|
const control = occurrence === -1 ? controls.at(-1) : controls[occurrence]
|
|
if (control) {
|
|
const point = pointForFrame(control.frame)
|
|
if (isNormalizedPoint(point)) {
|
|
return resultForControl(control, point)
|
|
}
|
|
}
|
|
await delay(250)
|
|
}
|
|
throw new Error(`${label} was not accessible. Last labels: ${lastLabels.slice(-40).join(', ')}`)
|
|
}
|
|
|
|
export async function waitForHostedIosAccessibilityLabelToDisappear(
|
|
args,
|
|
label,
|
|
timeoutMs,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const labels = accessibilityLabels(
|
|
await readAccessibilityNodesUntilDeadline(args, runCommand, deadline)
|
|
)
|
|
if (!labels.includes(label)) {
|
|
return
|
|
}
|
|
await delay(250)
|
|
}
|
|
throw new Error(`${label} remained accessible after the timeout`)
|
|
}
|
|
|
|
export function runHostedIosEmulatorCommand(args, command) {
|
|
const deviceArgs = command[0] === 'attach' ? [] : ['--device', args.deviceUdid]
|
|
const argv = [
|
|
'emulator',
|
|
...command,
|
|
...deviceArgs,
|
|
'--worktree',
|
|
`path:${args.worktree}`,
|
|
'--json'
|
|
]
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(args.orcaCli, argv, {
|
|
cwd: args.worktree,
|
|
detached: true,
|
|
env: {
|
|
...process.env,
|
|
ORCA_DEV_USER_DATA_PATH: args.userDataDir,
|
|
ORCA_USER_DATA_PATH: args.userDataDir
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
let settled = false
|
|
let stderr = ''
|
|
let stdout = ''
|
|
const timer = setTimeout(() => {
|
|
killProcessGroup(child)
|
|
finish(new Error(`Orca emulator command timed out: ${command[0] ?? 'unknown'}`))
|
|
}, EMULATOR_COMMAND_TIMEOUT_MS)
|
|
const finish = (error) => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timer)
|
|
if (error) {
|
|
reject(error)
|
|
} else {
|
|
resolve({ stderr, stdout })
|
|
}
|
|
}
|
|
const append = (current, chunk) => {
|
|
const next = current + String(chunk)
|
|
if (Buffer.byteLength(next) > EMULATOR_COMMAND_MAX_BYTES) {
|
|
killProcessGroup(child)
|
|
finish(new Error('Orca emulator command exceeded its output limit'))
|
|
return current
|
|
}
|
|
return next
|
|
}
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout = append(stdout, chunk)
|
|
})
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr = append(stderr, chunk)
|
|
})
|
|
child.once('error', finish)
|
|
child.once('close', (code, signal) => {
|
|
if (code === 0) {
|
|
finish()
|
|
} else {
|
|
finish(
|
|
new Error(
|
|
`Orca emulator command failed (${code ?? signal ?? 'unknown'}): ${stderr || stdout}`
|
|
)
|
|
)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async function readAccessibilityNodes(args, runCommand) {
|
|
const { stdout } = await runCommand(args, ['ax'])
|
|
const response = JSON.parse(stdout)
|
|
if (response?.ok !== true || !Array.isArray(response.result)) {
|
|
throw new Error('Orca emulator returned an invalid accessibility response')
|
|
}
|
|
const roots = response.result.filter(isAccessibilityNode)
|
|
const nodes = []
|
|
const pending = [...roots]
|
|
while (pending.length > 0 && nodes.length < ACCESSIBILITY_NODE_LIMIT) {
|
|
const node = pending.shift()
|
|
nodes.push(node)
|
|
if (Array.isArray(node.children)) {
|
|
pending.push(...node.children.filter(isAccessibilityNode))
|
|
}
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
async function readAccessibilityNodesUntilDeadline(args, runCommand, deadline) {
|
|
let didRestartController = false
|
|
while (true) {
|
|
try {
|
|
return await readAccessibilityNodes(args, runCommand)
|
|
} catch (error) {
|
|
if (!isTransientAccessibilityReadError(error) || Date.now() >= deadline) {
|
|
throw error
|
|
}
|
|
if (!didRestartController) {
|
|
didRestartController = true
|
|
await restartHostedIosEmulatorController(args, runCommand)
|
|
}
|
|
await delay(250)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function restartHostedIosEmulatorController(
|
|
args,
|
|
runCommand = runHostedIosEmulatorCommand
|
|
) {
|
|
await runCommand(args, ['kill']).catch(() => {})
|
|
let lastError
|
|
for (let attempt = 0; attempt < EMULATOR_REATTACH_ATTEMPTS; attempt += 1) {
|
|
await delay(250)
|
|
try {
|
|
await runCommand(args, ['attach', args.deviceUdid])
|
|
return
|
|
} catch (error) {
|
|
lastError = error
|
|
if (!isTransientAccessibilityReadError(error)) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
throw lastError
|
|
}
|
|
|
|
function isTransientAccessibilityReadError(error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return (
|
|
isMissingActiveEmulatorError(error) ||
|
|
message.includes('emulator_helper_failed') ||
|
|
message.includes('request timed out') ||
|
|
message.includes('emulator command timed out')
|
|
)
|
|
}
|
|
|
|
function isMissingActiveEmulatorError(error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return message.includes('emulator_no_active')
|
|
}
|
|
|
|
function accessibilityLabels(nodes) {
|
|
return nodes.flatMap((node) =>
|
|
[node.label, node.value].filter((value) => typeof value === 'string' && value.length > 0)
|
|
)
|
|
}
|
|
|
|
function isAccessibilityNode(value) {
|
|
return Boolean(value && typeof value === 'object')
|
|
}
|
|
|
|
function isFiniteFrame(frame) {
|
|
return (
|
|
frame &&
|
|
[frame.x, frame.y, frame.width, frame.height].every(
|
|
(value) => typeof value === 'number' && Number.isFinite(value)
|
|
)
|
|
)
|
|
}
|
|
|
|
function isVisibleFrame(frame) {
|
|
return (
|
|
isFiniteFrame(frame) &&
|
|
frame.x + frame.width > 0 &&
|
|
frame.y + frame.height > 0 &&
|
|
frame.x < 1 &&
|
|
frame.y < 1
|
|
)
|
|
}
|
|
|
|
function isNormalizedPoint(point) {
|
|
return point.x >= 0 && point.x <= 1 && point.y >= 0 && point.y <= 1
|
|
}
|
|
|
|
function killProcessGroup(child) {
|
|
if (!child.pid) {
|
|
return
|
|
}
|
|
try {
|
|
process.kill(-child.pid, 'SIGKILL')
|
|
} catch {
|
|
child.kill('SIGKILL')
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|