From 782f6304c2d19718d75f1a55f01c779712b3d8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 8 Feb 2026 19:44:36 +0100 Subject: [PATCH 1/7] feat: for self-healing e2e tests; assertions --- .../handlers/__tests__/replay-heal.test.ts | 151 +++--------- src/daemon/handlers/session.ts | 230 +----------------- src/daemon/session-store.ts | 55 ++++- src/utils/args.ts | 10 +- 4 files changed, 92 insertions(+), 354 deletions(-) diff --git a/src/daemon/handlers/__tests__/replay-heal.test.ts b/src/daemon/handlers/__tests__/replay-heal.test.ts index 61f1f0feed..29ec1703f2 100644 --- a/src/daemon/handlers/__tests__/replay-heal.test.ts +++ b/src/daemon/handlers/__tests__/replay-heal.test.ts @@ -30,64 +30,28 @@ function makeSession(name: string): SessionState { } function writeReplayFile(filePath: string, action: SessionAction) { - const args = action.positionals.map((value) => JSON.stringify(value)).join(' '); - fs.writeFileSync(filePath, `${action.command}${args.length > 0 ? ` ${args}` : ''}\n`); + const payload = { + optimizedActions: [action], + }; + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2)); } function readReplaySelector(filePath: string, command: string): string { - const lines = fs - .readFileSync(filePath, 'utf8') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); - const line = lines.find((entry) => entry.startsWith(`${command} `) || entry === command); - if (!line) return ''; - const args = tokenizeReplayLine(line).slice(1); + const payload = JSON.parse(fs.readFileSync(filePath, 'utf8')) as { + optimizedActions?: Array<{ command?: string; positionals?: string[] }>; + }; + const action = payload.optimizedActions?.find((entry) => entry.command === command); + if (!action) return ''; if (command === 'is') { - return args[1] ?? ''; - } - return args[0] ?? ''; -} - -function tokenizeReplayLine(line: string): string[] { - const tokens: string[] = []; - let cursor = 0; - while (cursor < line.length) { - while (cursor < line.length && /\s/.test(line[cursor])) { - cursor += 1; - } - if (cursor >= line.length) break; - if (line[cursor] === '"') { - let end = cursor + 1; - let escaped = false; - while (end < line.length) { - const char = line[end]; - if (char === '"' && !escaped) break; - escaped = char === '\\' && !escaped; - if (char !== '\\') escaped = false; - end += 1; - } - if (end >= line.length) { - throw new Error(`Invalid replay script line: ${line}`); - } - tokens.push(JSON.parse(line.slice(cursor, end + 1)) as string); - cursor = end + 1; - continue; - } - let end = cursor; - while (end < line.length && !/\s/.test(line[end])) { - end += 1; - } - tokens.push(line.slice(cursor, end)); - cursor = end; + return action.positionals?.[1] ?? ''; } - return tokens; + return action.positionals?.[0] ?? ''; } test('replay --update heals selector and rewrites replay file', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-')); const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.ad'); + const replayPath = path.join(tempRoot, 'replay.json'); const sessionStore = new SessionStore(sessionsDir); const sessionName = 'heal-session'; sessionStore.set(sessionName, makeSession(sessionName)); @@ -95,9 +59,12 @@ test('replay --update heals selector and rewrites replay file', async () => { writeReplayFile(replayPath, { ts: Date.now(), command: 'click', - positionals: ['id="old_continue" || label="Continue"'], + positionals: ['id="old_continue"'], flags: {}, - result: {}, + result: { + refLabel: 'Continue', + selectorChain: ['id="old_continue"', 'label="Continue"'], + }, }); const invokeCalls: string[] = []; @@ -161,7 +128,7 @@ test('replay --update heals selector and rewrites replay file', async () => { }); assert.ok(response); - assert.equal(response.ok, true, JSON.stringify(response)); + assert.equal(response.ok, true); if (response.ok) { assert.equal(response.data?.healed, 1); assert.equal(response.data?.replayed, 1); @@ -178,7 +145,7 @@ test('replay --update heals selector and rewrites replay file', async () => { test('replay without --update does not heal or rewrite', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-noheal-')); const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.ad'); + const replayPath = path.join(tempRoot, 'replay.json'); const sessionStore = new SessionStore(sessionsDir); const sessionName = 'noheal-session'; sessionStore.set(sessionName, makeSession(sessionName)); @@ -186,9 +153,12 @@ test('replay without --update does not heal or rewrite', async () => { writeReplayFile(replayPath, { ts: Date.now(), command: 'click', - positionals: ['id="old_continue" || label="Continue"'], + positionals: ['id="old_continue"'], flags: {}, - result: {}, + result: { + refLabel: 'Continue', + selectorChain: ['id="old_continue"', 'label="Continue"'], + }, }); const originalPayload = fs.readFileSync(replayPath, 'utf8'); @@ -232,7 +202,7 @@ test('replay without --update does not heal or rewrite', async () => { test('replay --update heals selector in is command', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-is-')); const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.ad'); + const replayPath = path.join(tempRoot, 'replay.json'); const sessionStore = new SessionStore(sessionsDir); const sessionName = 'heal-is-session'; sessionStore.set(sessionName, makeSession(sessionName)); @@ -240,9 +210,12 @@ test('replay --update heals selector in is command', async () => { writeReplayFile(replayPath, { ts: Date.now(), command: 'is', - positionals: ['visible', 'id="old_continue" || label="Continue"'], + positionals: ['visible', 'id="old_continue"'], flags: {}, - result: {}, + result: { + selectorChain: ['id="old_continue"', 'label="Continue"'], + refLabel: 'Continue', + }, }); const invoke = async (request: DaemonRequest): Promise => { @@ -293,72 +266,10 @@ test('replay --update heals selector in is command', async () => { }); assert.ok(response); - assert.equal(response.ok, true, JSON.stringify(response)); + assert.equal(response.ok, true); if (response.ok) { assert.equal(response.data?.healed, 1); } const rewrittenSelector = readReplaySelector(replayPath, 'is'); assert.ok(rewrittenSelector.includes('auth_continue')); }); - -test('replay rejects legacy JSON payload files', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-json-rejected-')); - const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.json'); - const sessionStore = new SessionStore(sessionsDir); - const sessionName = 'json-rejected-session'; - sessionStore.set(sessionName, makeSession(sessionName)); - fs.writeFileSync(replayPath, JSON.stringify({ optimizedActions: [] }, null, 2)); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'replay', - positionals: [replayPath], - flags: {}, - }, - sessionName, - logPath: path.join(tempRoot, 'daemon.log'), - sessionStore, - invoke: async () => ({ ok: true, data: {} }), - }); - - assert.ok(response); - assert.equal(response.ok, false); - if (!response.ok) { - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /\.ad script files/); - } -}); - -test('replay rejects malformed .ad lines with unclosed quotes', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-invalid-ad-')); - const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.ad'); - const sessionStore = new SessionStore(sessionsDir); - const sessionName = 'invalid-ad-session'; - sessionStore.set(sessionName, makeSession(sessionName)); - fs.writeFileSync(replayPath, 'click "id=\\"broken\\"\n'); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'replay', - positionals: [replayPath], - flags: {}, - }, - sessionName, - logPath: path.join(tempRoot, 'daemon.log'), - sessionStore, - invoke: async () => ({ ok: true, data: {} }), - }); - - assert.ok(response); - assert.equal(response.ok, false); - if (!response.ok) { - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /Invalid replay script line/); - } -}); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index ca89efbf7d..75bc30dd01 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { AppError, asAppError } from '../../utils/errors.ts'; +import { asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; @@ -180,7 +180,6 @@ export async function handleSessionCommands(params: { ...session, appBundleId, appName, - recordSession: session.recordSession || req.flags?.saveScript === true, snapshot: undefined, }; sessionStore.recordAction(nextSession, { @@ -224,7 +223,6 @@ export async function handleSessionCommands(params: { createdAt: Date.now(), appBundleId, appName, - recordSession: req.flags?.saveScript === true, actions: [], }; sessionStore.recordAction(session, { @@ -244,18 +242,11 @@ export async function handleSessionCommands(params: { } try { const resolved = SessionStore.expandHome(filePath); - const script = fs.readFileSync(resolved, 'utf8'); - const firstNonWhitespace = script.trimStart()[0]; - if (firstNonWhitespace === '{' || firstNonWhitespace === '[') { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'replay accepts .ad script files. JSON replay payloads are no longer supported.', - }, - }; - } - const actions = parseReplayScript(script); + const payload = JSON.parse(fs.readFileSync(resolved, 'utf8')) as { + actions?: SessionAction[]; + optimizedActions?: SessionAction[]; + }; + const actions = payload.optimizedActions ?? payload.actions ?? []; const shouldUpdate = req.flags?.replayUpdate === true; let healed = 0; for (let index = 0; index < actions.length; index += 1) { @@ -294,8 +285,7 @@ export async function handleSessionCommands(params: { healed += 1; } if (shouldUpdate && healed > 0) { - const session = sessionStore.get(sessionName); - writeReplayScript(resolved, actions, session); + writeReplayPayload(resolved, payload); } return { ok: true, data: { replayed: actions.length, healed, session: sessionName } }; } catch (err) { @@ -323,9 +313,6 @@ export async function handleSessionCommands(params: { flags: req.flags ?? {}, result: { session: sessionName }, }); - if (req.flags?.saveScript) { - session.recordSession = true; - } sessionStore.writeSessionLog(session); sessionStore.delete(sessionName); return { ok: true, data: { session: sessionName } }; @@ -525,208 +512,9 @@ function parseSelectorWaitPositionals(positionals: string[]): { }; } -function parseReplayScript(script: string): SessionAction[] { - const actions: SessionAction[] = []; - const lines = script.split(/\r?\n/); - for (const line of lines) { - const parsed = parseReplayScriptLine(line); - if (parsed) { - actions.push(parsed); - } - } - return actions; -} - -function parseReplayScriptLine(line: string): SessionAction | null { - const trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith('#')) return null; - const tokens = tokenizeReplayLine(trimmed); - if (tokens.length === 0) return null; - const [command, ...args] = tokens; - if (command === 'context') return null; - - const action: SessionAction = { - ts: Date.now(), - command, - positionals: [], - flags: {}, - }; - - if (command === 'snapshot') { - action.positionals = []; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === '-i') { - action.flags.snapshotInteractiveOnly = true; - continue; - } - if (token === '-c') { - action.flags.snapshotCompact = true; - continue; - } - if (token === '--raw') { - action.flags.snapshotRaw = true; - continue; - } - if ((token === '-d' || token === '--depth') && index + 1 < args.length) { - const parsedDepth = Number(args[index + 1]); - if (Number.isFinite(parsedDepth) && parsedDepth >= 0) { - action.flags.snapshotDepth = Math.floor(parsedDepth); - } - index += 1; - continue; - } - if ((token === '-s' || token === '--scope') && index + 1 < args.length) { - action.flags.snapshotScope = args[index + 1]; - index += 1; - continue; - } - if (token === '--backend' && index + 1 < args.length) { - const backend = args[index + 1]; - if (backend === 'ax' || backend === 'xctest') { - action.flags.snapshotBackend = backend; - } - index += 1; - } - } - return action; - } - - if (command === 'click') { - if (args.length === 0) return action; - const target = args[0]; - if (target.startsWith('@')) { - action.positionals = [target]; - if (args[1]) { - action.result = { refLabel: args[1] }; - } - return action; - } - action.positionals = [args.join(' ')]; - return action; - } - - if (command === 'fill') { - if (args.length < 2) { - action.positionals = args; - return action; - } - const target = args[0]; - if (target.startsWith('@')) { - if (args.length >= 3) { - action.positionals = [target, args.slice(2).join(' ')]; - action.result = { refLabel: args[1] }; - return action; - } - action.positionals = [target, args[1]]; - return action; - } - action.positionals = [target, args.slice(1).join(' ')]; - return action; - } - - if (command === 'get') { - if (args.length < 2) { - action.positionals = args; - return action; - } - const sub = args[0]; - const target = args[1]; - if (target.startsWith('@')) { - action.positionals = [sub, target]; - if (args[2]) { - action.result = { refLabel: args[2] }; - } - return action; - } - action.positionals = [sub, args.slice(1).join(' ')]; - return action; - } - - action.positionals = args; - return action; -} - -function tokenizeReplayLine(line: string): string[] { - const tokens: string[] = []; - let cursor = 0; - while (cursor < line.length) { - while (cursor < line.length && /\s/.test(line[cursor])) { - cursor += 1; - } - if (cursor >= line.length) break; - if (line[cursor] === '"') { - let end = cursor + 1; - let escaped = false; - while (end < line.length) { - const char = line[end]; - if (char === '"' && !escaped) break; - escaped = char === '\\' && !escaped; - if (char !== '\\') escaped = false; - end += 1; - } - if (end >= line.length) { - throw new AppError('INVALID_ARGS', `Invalid replay script line: ${line}`); - } - const literal = line.slice(cursor, end + 1); - tokens.push(JSON.parse(literal) as string); - cursor = end + 1; - continue; - } - let end = cursor; - while (end < line.length && !/\s/.test(line[end])) { - end += 1; - } - tokens.push(line.slice(cursor, end)); - cursor = end; - } - return tokens; -} - -function writeReplayScript(filePath: string, actions: SessionAction[], session?: SessionState) { - const lines: string[] = []; - // Session can be missing if the replay session is closed/deleted between execution and update write. - // In that case we still persist healed actions and omit only the context header. - if (session) { - const deviceLabel = session.device.name.replace(/"/g, '\\"'); - const kind = session.device.kind ? ` kind=${session.device.kind}` : ''; - lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=unknown`); - } - for (const action of actions) { - lines.push(formatReplayActionLine(action)); - } - const serialized = `${lines.join('\n')}\n`; +function writeReplayPayload(filePath: string, payload: { actions?: SessionAction[]; optimizedActions?: SessionAction[] }) { + const serialized = JSON.stringify(payload, null, 2); const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmpPath, serialized); fs.renameSync(tmpPath, filePath); } - -function formatReplayActionLine(action: SessionAction): string { - const parts: string[] = [action.command]; - if (action.command === 'snapshot') { - if (action.flags?.snapshotInteractiveOnly) parts.push('-i'); - if (action.flags?.snapshotCompact) parts.push('-c'); - if (typeof action.flags?.snapshotDepth === 'number') { - parts.push('-d', String(action.flags.snapshotDepth)); - } - if (action.flags?.snapshotScope) { - parts.push('-s', formatReplayArg(action.flags.snapshotScope)); - } - if (action.flags?.snapshotRaw) parts.push('--raw'); - if (action.flags?.snapshotBackend) { - parts.push('--backend', action.flags.snapshotBackend); - } - return parts.join(' '); - } - for (const positional of action.positionals ?? []) { - parts.push(formatReplayArg(positional)); - } - return parts.join(' '); -} - -function formatReplayArg(value: string): string { - const trimmed = value.trim(); - if (trimmed.startsWith('@')) return trimmed; - if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed; - return JSON.stringify(trimmed); -} diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 1c5537ee93..8d64016db8 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; +import os from 'node:os'; import type { CommandFlags } from '../core/dispatch.ts'; import type { SessionAction, SessionState } from './types.ts'; import { inferFillText } from './action-utils.ts'; @@ -47,9 +47,6 @@ export class SessionStore { }, ): void { if (entry.flags?.noRecord) return; - if (entry.flags?.saveScript) { - session.recordSession = true; - } session.actions.push({ ts: Date.now(), command: entry.command, @@ -61,13 +58,25 @@ export class SessionStore { writeSessionLog(session: SessionState): void { try { - if (!session.recordSession) return; if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true }); const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_'); const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-'); const scriptPath = path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`); - const script = formatScript(session, this.buildOptimizedActions(session)); + const filePath = this.resolveSessionJsonPath(session, safeName, timestamp); + const payload = { + name: session.name, + device: session.device, + createdAt: session.createdAt, + appBundleId: session.appBundleId, + actions: session.actions, + optimizedActions: this.buildOptimizedActions(session), + }; + const script = formatScript(session, payload.optimizedActions); fs.writeFileSync(scriptPath, script); + if (session.actions.some((action) => action.flags?.recordJson)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2)); + } } catch { // ignore } @@ -86,6 +95,36 @@ export class SessionStore { return path.resolve(filePath); } + private resolveSessionJsonPath(session: SessionState, safeName: string, timestamp: string): string { + const defaultFile = path.join(this.sessionsDir, `${safeName}-${timestamp}.json`); + const actionWithOut = [...session.actions].reverse().find( + (action) => + action.flags?.recordJson && + typeof action.flags?.out === 'string' && + action.flags.out.trim().length > 0, + ); + if (!actionWithOut || !actionWithOut.flags?.out) { + return defaultFile; + } + + const rawOut = actionWithOut.flags.out.trim(); + const resolvedOut = SessionStore.expandHome(rawOut); + const wantsDirectory = rawOut.endsWith('/') || rawOut.endsWith('\\'); + if (wantsDirectory) { + return path.join(resolvedOut, `${safeName}-${timestamp}.json`); + } + + try { + if (fs.existsSync(resolvedOut) && fs.statSync(resolvedOut).isDirectory()) { + return path.join(resolvedOut, `${safeName}-${timestamp}.json`); + } + } catch { + return defaultFile; + } + + return resolvedOut; + } + private buildOptimizedActions(session: SessionState): SessionAction[] { const optimized: SessionAction[] = []; for (const action of session.actions) { @@ -166,8 +205,8 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] snapshotRaw, snapshotBackend, appsMetadata, - saveScript, noRecord, + recordJson, } = flags; return { platform, @@ -183,8 +222,8 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] snapshotRaw, snapshotBackend, appsMetadata, - saveScript, noRecord, + recordJson, }; } diff --git a/src/utils/args.ts b/src/utils/args.ts index 016a6ccd2b..0193e0ee60 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -21,8 +21,8 @@ export type ParsedArgs = { appsFilter?: 'launchable' | 'user-installed' | 'all'; appsMetadata?: boolean; activity?: string; - saveScript?: boolean; noRecord?: boolean; + recordJson?: boolean; replayUpdate?: boolean; help: boolean; }; @@ -62,8 +62,8 @@ export function parseArgs(argv: string[]): ParsedArgs { flags.noRecord = true; continue; } - if (arg === '--save-script') { - flags.saveScript = true; + if (arg === '--record-json') { + flags.recordJson = true; continue; } if (arg === '--update' || arg === '-u') { @@ -224,9 +224,9 @@ Flags: --session Named session --verbose Stream daemon/runner logs --json JSON output - --save-script Save session script (.ad) on close --no-record Do not record this action - --update, -u Replay: update selectors and rewrite replay file in place + --record-json Record JSON session log + --update, -u Replay: heal selectors and update replay file in place --user-installed Apps: list user-installed packages (Android only) --all Apps: list all packages (Android only) `; From e4080d52ba43d83968f6f775f33b77316991c331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 9 Feb 2026 10:26:42 +0100 Subject: [PATCH 2/7] fix: support long-press on iOS simulators via XCTest runner The long-press command was failing on iOS simulators because dispatch.ts called interactor.longPress() which mapped to a stub in ios/index.ts that always threw UNSUPPORTED_OPERATION. Unlike press, type, fill, etc., the long-press case was missing the iOS simulator routing through the XCTest runner. Rather than adding yet another platform branch in dispatch.ts, this refactors the Interactor abstraction to absorb runner routing internally. getInteractor() now accepts an optional RunnerContext; when the device is an iOS simulator, it returns an interactor whose tap/longPress/focus/ type/fill/scroll/scrollIntoView methods route through runIosRunnerCommand. This removes 7 scattered if-ios-simulator branches from dispatch.ts, making it impossible to forget runner routing for future commands. Changes: - Swift runner: add longPress command type + longPressAt helper using XCUICoordinate.press(forDuration:) - runner-client.ts: add longPress to RunnerCommand type + durationMs field - interactors.ts: add RunnerContext type, createIosSimulatorInteractor() that routes through the XCTest runner, move invertScrollDirection here - dispatch.ts: pass RunnerContext to getInteractor, remove all iOS sim branching for interactor commands (-90 lines) Co-authored-by: Cursor --- .../RunnerTests.swift | 15 +++ src/core/dispatch.ts | 101 +++------------- src/platforms/ios/runner-client.ts | 2 + src/utils/interactors.ts | 109 +++++++++++++++++- 4 files changed, 137 insertions(+), 90 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index d48ea57d50..7bf76ce7a9 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -232,6 +232,13 @@ final class RunnerTests: XCTestCase { return Response(ok: true, data: DataPayload(message: "tapped")) } return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y")) + case .longPress: + guard let x = command.x, let y = command.y else { + return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y")) + } + let duration = (command.durationMs ?? 800) / 1000.0 + longPressAt(app: activeApp, x: x, y: y, duration: duration) + return Response(ok: true, data: DataPayload(message: "long pressed")) case .type: guard let text = command.text else { return Response(ok: false, error: ErrorPayload(message: "type requires text")) @@ -411,6 +418,12 @@ final class RunnerTests: XCTestCase { coordinate.tap() } + private func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) { + let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + let coordinate = origin.withOffset(CGVector(dx: x, dy: y)) + coordinate.press(forDuration: duration) + } + private func swipe(app: XCUIApplication, direction: SwipeDirection) { let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) @@ -792,6 +805,7 @@ private func resolveRunnerPort() -> UInt16 { enum CommandType: String, Codable { case tap + case longPress case type case swipe case findText @@ -820,6 +834,7 @@ struct Command: Codable { let action: String? let x: Double? let y: Double? + let durationMs: Double? let direction: SwipeDirection? let scale: Double? let interactiveOnly: Bool? diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 2e46d962cd..39e14706cc 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -12,7 +12,7 @@ import { snapshotAndroid, } from '../platforms/android/index.ts'; import { listIosDevices } from '../platforms/ios/devices.ts'; -import { getInteractor } from '../utils/interactors.ts'; +import { getInteractor, type RunnerContext } from '../utils/interactors.ts'; import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; import { snapshotAx } from '../platforms/ios/ax-snapshot.ts'; import { setIosSetting } from '../platforms/ios/index.ts'; @@ -92,7 +92,13 @@ export async function dispatchCommand( snapshotBackend?: 'ax' | 'xctest'; }, ): Promise | void> { - const interactor = getInteractor(device); + const runnerCtx: RunnerContext = { + appBundleId: context?.appBundleId, + verbose: context?.verbose, + logPath: context?.logPath, + traceLogPath: context?.traceLogPath, + }; + const interactor = getInteractor(device, runnerCtx); switch (command) { case 'open': { const app = positionals[0]; @@ -114,15 +120,7 @@ export async function dispatchCommand( case 'press': { const [x, y] = positionals.map(Number); if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'press requires x y'); - if (device.platform === 'ios' && device.kind === 'simulator') { - await runIosRunnerCommand( - device, - { command: 'tap', x, y, appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - ); - } else { - await interactor.tap(x, y); - } + await interactor.tap(x, y); return { x, y }; } case 'long-press': { @@ -138,29 +136,13 @@ export async function dispatchCommand( case 'focus': { const [x, y] = positionals.map(Number); if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'focus requires x y'); - if (device.platform === 'ios' && device.kind === 'simulator') { - await runIosRunnerCommand( - device, - { command: 'tap', x, y, appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - ); - } else { - await interactor.focus(x, y); - } + await interactor.focus(x, y); return { x, y }; } case 'type': { const text = positionals.join(' '); if (!text) throw new AppError('INVALID_ARGS', 'type requires text'); - if (device.platform === 'ios' && device.kind === 'simulator') { - await runIosRunnerCommand( - device, - { command: 'type', text, appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - ); - } else { - await interactor.type(text); - } + await interactor.type(text); return { text }; } case 'fill': { @@ -170,62 +152,19 @@ export async function dispatchCommand( if (Number.isNaN(x) || Number.isNaN(y) || !text) { throw new AppError('INVALID_ARGS', 'fill requires x y text'); } - if (device.platform === 'ios' && device.kind === 'simulator') { - await runIosRunnerCommand( - device, - { command: 'tap', x, y, appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - ); - await runIosRunnerCommand( - device, - { command: 'type', text, clearFirst: true, appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - ); - } else { - await interactor.fill(x, y, text); - } + await interactor.fill(x, y, text); return { x, y, text }; } case 'scroll': { const direction = positionals[0]; const amount = positionals[1] ? Number(positionals[1]) : undefined; if (!direction) throw new AppError('INVALID_ARGS', 'scroll requires direction'); - if (device.platform === 'ios' && device.kind === 'simulator') { - if (!['up', 'down', 'left', 'right'].includes(direction)) { - throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`); - } - const inverted = invertScrollDirection(direction as 'up' | 'down' | 'left' | 'right'); - await runIosRunnerCommand( - device, - { command: 'swipe', direction: inverted, appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - ); - } else { - await interactor.scroll(direction, amount); - } + await interactor.scroll(direction, amount); return { direction, amount }; } case 'scrollintoview': { const text = positionals.join(' ').trim(); if (!text) throw new AppError('INVALID_ARGS', 'scrollintoview requires text'); - if (device.platform === 'ios' && device.kind === 'simulator') { - const maxAttempts = 8; - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const found = (await runIosRunnerCommand( - device, - { command: 'findText', text, appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - )) as { found?: boolean }; - if (found?.found) return { text, attempts: attempt + 1 }; - await runIosRunnerCommand( - device, - { command: 'swipe', direction: 'up', appBundleId: context?.appBundleId }, - { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath }, - ); - await new Promise((resolve) => setTimeout(resolve, 300)); - } - throw new AppError('COMMAND_FAILED', `scrollintoview could not find text: ${text}`); - } await interactor.scrollIntoView(text); return { text }; } @@ -340,17 +279,3 @@ export async function dispatchCommand( } } -function invertScrollDirection(direction: 'up' | 'down' | 'left' | 'right'): 'up' | 'down' | 'left' | 'right' { - switch (direction) { - case 'up': - return 'down'; - case 'down': - return 'up'; - case 'left': - return 'right'; - case 'right': - return 'left'; - } -} - -// Runner-only input on iOS simulators (simctl io input is not supported). diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 1b18c00b51..c9082d77bc 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -11,6 +11,7 @@ import net from 'node:net'; export type RunnerCommand = { command: | 'tap' + | 'longPress' | 'type' | 'swipe' | 'findText' @@ -27,6 +28,7 @@ export type RunnerCommand = { action?: 'get' | 'accept' | 'dismiss'; x?: number; y?: number; + durationMs?: number; direction?: 'up' | 'down' | 'left' | 'right'; scale?: number; interactiveOnly?: boolean; diff --git a/src/utils/interactors.ts b/src/utils/interactors.ts index 634c44137a..116417ec0c 100644 --- a/src/utils/interactors.ts +++ b/src/utils/interactors.ts @@ -26,6 +26,14 @@ import { screenshotIos, typeIos, } from '../platforms/ios/index.ts'; +import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; + +export type RunnerContext = { + appBundleId?: string; + verbose?: boolean; + logPath?: string; + traceLogPath?: string; +}; export type Interactor = { open(app: string, options?: { activity?: string }): Promise; @@ -41,7 +49,7 @@ export type Interactor = { screenshot(outPath: string): Promise; }; -export function getInteractor(device: DeviceInfo): Interactor { +export function getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor { switch (device.platform) { case 'android': return { @@ -57,7 +65,10 @@ export function getInteractor(device: DeviceInfo): Interactor { scrollIntoView: (text) => scrollIntoViewAndroid(device, text), screenshot: (outPath) => screenshotAndroid(device, outPath), }; - case 'ios': + case 'ios': { + if (device.kind === 'simulator' && runnerContext) { + return createIosSimulatorInteractor(device, runnerContext); + } return { open: (app) => openIosApp(device, app), openDevice: () => openIosDevice(device), @@ -71,7 +82,101 @@ export function getInteractor(device: DeviceInfo): Interactor { scrollIntoView: (text) => scrollIntoViewIos(text), screenshot: (outPath) => screenshotIos(device, outPath), }; + } default: throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`); } } + +function createIosSimulatorInteractor(device: DeviceInfo, ctx: RunnerContext): Interactor { + const runnerOpts = { verbose: ctx.verbose, logPath: ctx.logPath, traceLogPath: ctx.traceLogPath }; + + return { + open: (app) => openIosApp(device, app), + openDevice: () => openIosDevice(device), + close: (app) => closeIosApp(device, app), + tap: async (x, y) => { + await runIosRunnerCommand( + device, + { command: 'tap', x, y, appBundleId: ctx.appBundleId }, + runnerOpts, + ); + }, + longPress: async (x, y, durationMs) => { + await runIosRunnerCommand( + device, + { command: 'longPress', x, y, durationMs, appBundleId: ctx.appBundleId }, + runnerOpts, + ); + }, + focus: async (x, y) => { + await runIosRunnerCommand( + device, + { command: 'tap', x, y, appBundleId: ctx.appBundleId }, + runnerOpts, + ); + }, + type: async (text) => { + await runIosRunnerCommand( + device, + { command: 'type', text, appBundleId: ctx.appBundleId }, + runnerOpts, + ); + }, + fill: async (x, y, text) => { + await runIosRunnerCommand( + device, + { command: 'tap', x, y, appBundleId: ctx.appBundleId }, + runnerOpts, + ); + await runIosRunnerCommand( + device, + { command: 'type', text, clearFirst: true, appBundleId: ctx.appBundleId }, + runnerOpts, + ); + }, + scroll: async (direction, _amount) => { + if (!['up', 'down', 'left', 'right'].includes(direction)) { + throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`); + } + const inverted = invertScrollDirection(direction as 'up' | 'down' | 'left' | 'right'); + await runIosRunnerCommand( + device, + { command: 'swipe', direction: inverted, appBundleId: ctx.appBundleId }, + runnerOpts, + ); + }, + scrollIntoView: async (text) => { + const maxAttempts = 8; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const found = (await runIosRunnerCommand( + device, + { command: 'findText', text, appBundleId: ctx.appBundleId }, + runnerOpts, + )) as { found?: boolean }; + if (found?.found) return; + await runIosRunnerCommand( + device, + { command: 'swipe', direction: 'up', appBundleId: ctx.appBundleId }, + runnerOpts, + ); + await new Promise((resolve) => setTimeout(resolve, 300)); + } + throw new AppError('COMMAND_FAILED', `scrollintoview could not find text: ${text}`); + }, + screenshot: (outPath) => screenshotIos(device, outPath), + }; +} + +function invertScrollDirection(direction: 'up' | 'down' | 'left' | 'right'): 'up' | 'down' | 'left' | 'right' { + switch (direction) { + case 'up': + return 'down'; + case 'down': + return 'up'; + case 'left': + return 'right'; + case 'right': + return 'left'; + } +} From 7d86aec4642b8e2f5d5a5dbe7d4686e276cb7a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 9 Feb 2026 10:42:04 +0100 Subject: [PATCH 3/7] restore attempts --- src/core/dispatch.ts | 4 ++-- src/utils/interactors.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 39e14706cc..33e2b872ca 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -165,7 +165,8 @@ export async function dispatchCommand( case 'scrollintoview': { const text = positionals.join(' ').trim(); if (!text) throw new AppError('INVALID_ARGS', 'scrollintoview requires text'); - await interactor.scrollIntoView(text); + const result = await interactor.scrollIntoView(text); + if (result?.attempts) return { text, attempts: result.attempts }; return { text }; } case 'pinch': { @@ -278,4 +279,3 @@ export async function dispatchCommand( throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); } } - diff --git a/src/utils/interactors.ts b/src/utils/interactors.ts index 116417ec0c..7fd224a4ca 100644 --- a/src/utils/interactors.ts +++ b/src/utils/interactors.ts @@ -45,7 +45,7 @@ export type Interactor = { type(text: string): Promise; fill(x: number, y: number, text: string): Promise; scroll(direction: string, amount?: number): Promise; - scrollIntoView(text: string): Promise; + scrollIntoView(text: string): Promise<{ attempts?: number } | void>; screenshot(outPath: string): Promise; }; @@ -154,7 +154,7 @@ function createIosSimulatorInteractor(device: DeviceInfo, ctx: RunnerContext): I { command: 'findText', text, appBundleId: ctx.appBundleId }, runnerOpts, )) as { found?: boolean }; - if (found?.found) return; + if (found?.found) return { attempts: attempt + 1 }; await runIosRunnerCommand( device, { command: 'swipe', direction: 'up', appBundleId: ctx.appBundleId }, From f06b94077c51968dbbe60836689b905802454406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 9 Feb 2026 10:54:39 +0100 Subject: [PATCH 4/7] refactor: unify iOS interactor, remove dead input stubs Since runnerContext is always passed and the capability matrix ensures iOS only runs on simulators in v1, the two iOS interactor code paths collapse into one: shared methods (open, close, screenshot) plus runner overrides spread on top. No branch needed. This deletes 7 dead iOS input stubs from ios/index.ts (pressIos, longPressIos, focusIos, typeIos, fillIos, scrollIos, scrollIntoViewIos) that only ever threw UNSUPPORTED_OPERATION errors. Co-authored-by: Cursor --- src/platforms/ios/index.ts | 62 -------------------------------------- src/utils/interactors.ts | 31 ++++--------------- 2 files changed, 6 insertions(+), 87 deletions(-) diff --git a/src/platforms/ios/index.ts b/src/platforms/ios/index.ts index defc62680d..17d452e5ab 100644 --- a/src/platforms/ios/index.ts +++ b/src/platforms/ios/index.ts @@ -83,68 +83,6 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise { - ensureSimulator(device, 'press'); - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'simctl io tap is not available; use the XCTest runner for input', - ); -} - -export async function longPressIos( - device: DeviceInfo, - _x: number, - _y: number, - _durationMs = 800, -): Promise { - ensureSimulator(device, 'long-press'); - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'long-press is not supported on iOS simulators without XCTest runner support', - ); -} - -export async function focusIos(device: DeviceInfo, x: number, y: number): Promise { - await pressIos(device, x, y); -} - -export async function typeIos(device: DeviceInfo, _text: string): Promise { - ensureSimulator(device, 'type'); - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'simctl io keyboard is not available; use the XCTest runner for input', - ); -} - -export async function fillIos( - device: DeviceInfo, - x: number, - y: number, - text: string, -): Promise { - await focusIos(device, x, y); - await typeIos(device, text); -} - -export async function scrollIos( - device: DeviceInfo, - _direction: string, - _amount = 0.6, -): Promise { - ensureSimulator(device, 'scroll'); - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'simctl io swipe is not available; use the XCTest runner for input', - ); -} - -export async function scrollIntoViewIos(text: string): Promise { - throw new AppError( - 'UNSUPPORTED_OPERATION', - `scrollintoview is not supported on iOS without UI automation (${text})`, - ); -} - export async function screenshotIos(device: DeviceInfo, outPath: string): Promise { if (device.kind === 'simulator') { await ensureBootedSimulator(device); diff --git a/src/utils/interactors.ts b/src/utils/interactors.ts index 7fd224a4ca..682a38e163 100644 --- a/src/utils/interactors.ts +++ b/src/utils/interactors.ts @@ -15,16 +15,9 @@ import { } from '../platforms/android/index.ts'; import { closeIosApp, - fillIos, - focusIos, - longPressIos, openIosApp, openIosDevice, - pressIos, - scrollIos, - scrollIntoViewIos, screenshotIos, - typeIos, } from '../platforms/ios/index.ts'; import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; @@ -49,7 +42,7 @@ export type Interactor = { screenshot(outPath: string): Promise; }; -export function getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor { +export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor { switch (device.platform) { case 'android': return { @@ -65,36 +58,25 @@ export function getInteractor(device: DeviceInfo, runnerContext?: RunnerContext) scrollIntoView: (text) => scrollIntoViewAndroid(device, text), screenshot: (outPath) => screenshotAndroid(device, outPath), }; - case 'ios': { - if (device.kind === 'simulator' && runnerContext) { - return createIosSimulatorInteractor(device, runnerContext); - } + case 'ios': return { open: (app) => openIosApp(device, app), openDevice: () => openIosDevice(device), close: (app) => closeIosApp(device, app), - tap: (x, y) => pressIos(device, x, y), - longPress: (x, y, durationMs) => longPressIos(device, x, y, durationMs), - focus: (x, y) => focusIos(device, x, y), - type: (text) => typeIos(device, text), - fill: (x, y, text) => fillIos(device, x, y, text), - scroll: (direction, amount) => scrollIos(device, direction, amount), - scrollIntoView: (text) => scrollIntoViewIos(text), + ...iosRunnerOverrides(device, runnerContext), screenshot: (outPath) => screenshotIos(device, outPath), }; - } default: throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`); } } -function createIosSimulatorInteractor(device: DeviceInfo, ctx: RunnerContext): Interactor { +type IoRunnerOverrides = Pick; + +function iosRunnerOverrides(device: DeviceInfo, ctx: RunnerContext): IoRunnerOverrides { const runnerOpts = { verbose: ctx.verbose, logPath: ctx.logPath, traceLogPath: ctx.traceLogPath }; return { - open: (app) => openIosApp(device, app), - openDevice: () => openIosDevice(device), - close: (app) => closeIosApp(device, app), tap: async (x, y) => { await runIosRunnerCommand( device, @@ -164,7 +146,6 @@ function createIosSimulatorInteractor(device: DeviceInfo, ctx: RunnerContext): I } throw new AppError('COMMAND_FAILED', `scrollintoview could not find text: ${text}`); }, - screenshot: (outPath) => screenshotIos(device, outPath), }; } From 817ccc1eeb544844f647752b8dce6f0b17b321a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 9 Feb 2026 10:56:04 +0100 Subject: [PATCH 5/7] cleanup --- src/utils/interactors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/interactors.ts b/src/utils/interactors.ts index 682a38e163..2d9e71eebc 100644 --- a/src/utils/interactors.ts +++ b/src/utils/interactors.ts @@ -63,8 +63,8 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): open: (app) => openIosApp(device, app), openDevice: () => openIosDevice(device), close: (app) => closeIosApp(device, app), - ...iosRunnerOverrides(device, runnerContext), screenshot: (outPath) => screenshotIos(device, outPath), + ...iosRunnerOverrides(device, runnerContext), }; default: throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`); From 0e972767b8fa2db9b92ad51e6604beca36f77a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 9 Feb 2026 11:16:43 +0100 Subject: [PATCH 6/7] fixup save-script --- src/daemon/session-store.ts | 55 ++++++------------------------------- src/utils/args.ts | 10 +++---- 2 files changed, 13 insertions(+), 52 deletions(-) diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 8d64016db8..1c5537ee93 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; -import path from 'node:path'; import os from 'node:os'; +import path from 'node:path'; import type { CommandFlags } from '../core/dispatch.ts'; import type { SessionAction, SessionState } from './types.ts'; import { inferFillText } from './action-utils.ts'; @@ -47,6 +47,9 @@ export class SessionStore { }, ): void { if (entry.flags?.noRecord) return; + if (entry.flags?.saveScript) { + session.recordSession = true; + } session.actions.push({ ts: Date.now(), command: entry.command, @@ -58,25 +61,13 @@ export class SessionStore { writeSessionLog(session: SessionState): void { try { + if (!session.recordSession) return; if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true }); const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_'); const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-'); const scriptPath = path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`); - const filePath = this.resolveSessionJsonPath(session, safeName, timestamp); - const payload = { - name: session.name, - device: session.device, - createdAt: session.createdAt, - appBundleId: session.appBundleId, - actions: session.actions, - optimizedActions: this.buildOptimizedActions(session), - }; - const script = formatScript(session, payload.optimizedActions); + const script = formatScript(session, this.buildOptimizedActions(session)); fs.writeFileSync(scriptPath, script); - if (session.actions.some((action) => action.flags?.recordJson)) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(payload, null, 2)); - } } catch { // ignore } @@ -95,36 +86,6 @@ export class SessionStore { return path.resolve(filePath); } - private resolveSessionJsonPath(session: SessionState, safeName: string, timestamp: string): string { - const defaultFile = path.join(this.sessionsDir, `${safeName}-${timestamp}.json`); - const actionWithOut = [...session.actions].reverse().find( - (action) => - action.flags?.recordJson && - typeof action.flags?.out === 'string' && - action.flags.out.trim().length > 0, - ); - if (!actionWithOut || !actionWithOut.flags?.out) { - return defaultFile; - } - - const rawOut = actionWithOut.flags.out.trim(); - const resolvedOut = SessionStore.expandHome(rawOut); - const wantsDirectory = rawOut.endsWith('/') || rawOut.endsWith('\\'); - if (wantsDirectory) { - return path.join(resolvedOut, `${safeName}-${timestamp}.json`); - } - - try { - if (fs.existsSync(resolvedOut) && fs.statSync(resolvedOut).isDirectory()) { - return path.join(resolvedOut, `${safeName}-${timestamp}.json`); - } - } catch { - return defaultFile; - } - - return resolvedOut; - } - private buildOptimizedActions(session: SessionState): SessionAction[] { const optimized: SessionAction[] = []; for (const action of session.actions) { @@ -205,8 +166,8 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] snapshotRaw, snapshotBackend, appsMetadata, + saveScript, noRecord, - recordJson, } = flags; return { platform, @@ -222,8 +183,8 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] snapshotRaw, snapshotBackend, appsMetadata, + saveScript, noRecord, - recordJson, }; } diff --git a/src/utils/args.ts b/src/utils/args.ts index 0193e0ee60..4f3a124360 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -21,8 +21,8 @@ export type ParsedArgs = { appsFilter?: 'launchable' | 'user-installed' | 'all'; appsMetadata?: boolean; activity?: string; + saveScript?: boolean; noRecord?: boolean; - recordJson?: boolean; replayUpdate?: boolean; help: boolean; }; @@ -62,8 +62,8 @@ export function parseArgs(argv: string[]): ParsedArgs { flags.noRecord = true; continue; } - if (arg === '--record-json') { - flags.recordJson = true; + if (arg === '--save-script') { + flags.saveScript = true; continue; } if (arg === '--update' || arg === '-u') { @@ -225,8 +225,8 @@ Flags: --verbose Stream daemon/runner logs --json JSON output --no-record Do not record this action - --record-json Record JSON session log - --update, -u Replay: heal selectors and update replay file in place + --save-script Save session script (.ad) on close + --update, -u Replay: update selectors and rewrite replay file in place --user-installed Apps: list user-installed packages (Android only) --all Apps: list all packages (Android only) `; From b06c865eb3e0dc60fcd09a15d54bf04cb4010200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 9 Feb 2026 11:22:32 +0100 Subject: [PATCH 7/7] update --- .../handlers/__tests__/replay-heal.test.ts | 151 +++++++++--- src/daemon/handlers/session.ts | 230 +++++++++++++++++- src/utils/args.ts | 2 +- 3 files changed, 342 insertions(+), 41 deletions(-) diff --git a/src/daemon/handlers/__tests__/replay-heal.test.ts b/src/daemon/handlers/__tests__/replay-heal.test.ts index 29ec1703f2..61f1f0feed 100644 --- a/src/daemon/handlers/__tests__/replay-heal.test.ts +++ b/src/daemon/handlers/__tests__/replay-heal.test.ts @@ -30,28 +30,64 @@ function makeSession(name: string): SessionState { } function writeReplayFile(filePath: string, action: SessionAction) { - const payload = { - optimizedActions: [action], - }; - fs.writeFileSync(filePath, JSON.stringify(payload, null, 2)); + const args = action.positionals.map((value) => JSON.stringify(value)).join(' '); + fs.writeFileSync(filePath, `${action.command}${args.length > 0 ? ` ${args}` : ''}\n`); } function readReplaySelector(filePath: string, command: string): string { - const payload = JSON.parse(fs.readFileSync(filePath, 'utf8')) as { - optimizedActions?: Array<{ command?: string; positionals?: string[] }>; - }; - const action = payload.optimizedActions?.find((entry) => entry.command === command); - if (!action) return ''; + const lines = fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const line = lines.find((entry) => entry.startsWith(`${command} `) || entry === command); + if (!line) return ''; + const args = tokenizeReplayLine(line).slice(1); if (command === 'is') { - return action.positionals?.[1] ?? ''; + return args[1] ?? ''; + } + return args[0] ?? ''; +} + +function tokenizeReplayLine(line: string): string[] { + const tokens: string[] = []; + let cursor = 0; + while (cursor < line.length) { + while (cursor < line.length && /\s/.test(line[cursor])) { + cursor += 1; + } + if (cursor >= line.length) break; + if (line[cursor] === '"') { + let end = cursor + 1; + let escaped = false; + while (end < line.length) { + const char = line[end]; + if (char === '"' && !escaped) break; + escaped = char === '\\' && !escaped; + if (char !== '\\') escaped = false; + end += 1; + } + if (end >= line.length) { + throw new Error(`Invalid replay script line: ${line}`); + } + tokens.push(JSON.parse(line.slice(cursor, end + 1)) as string); + cursor = end + 1; + continue; + } + let end = cursor; + while (end < line.length && !/\s/.test(line[end])) { + end += 1; + } + tokens.push(line.slice(cursor, end)); + cursor = end; } - return action.positionals?.[0] ?? ''; + return tokens; } test('replay --update heals selector and rewrites replay file', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-')); const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.json'); + const replayPath = path.join(tempRoot, 'replay.ad'); const sessionStore = new SessionStore(sessionsDir); const sessionName = 'heal-session'; sessionStore.set(sessionName, makeSession(sessionName)); @@ -59,12 +95,9 @@ test('replay --update heals selector and rewrites replay file', async () => { writeReplayFile(replayPath, { ts: Date.now(), command: 'click', - positionals: ['id="old_continue"'], + positionals: ['id="old_continue" || label="Continue"'], flags: {}, - result: { - refLabel: 'Continue', - selectorChain: ['id="old_continue"', 'label="Continue"'], - }, + result: {}, }); const invokeCalls: string[] = []; @@ -128,7 +161,7 @@ test('replay --update heals selector and rewrites replay file', async () => { }); assert.ok(response); - assert.equal(response.ok, true); + assert.equal(response.ok, true, JSON.stringify(response)); if (response.ok) { assert.equal(response.data?.healed, 1); assert.equal(response.data?.replayed, 1); @@ -145,7 +178,7 @@ test('replay --update heals selector and rewrites replay file', async () => { test('replay without --update does not heal or rewrite', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-noheal-')); const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.json'); + const replayPath = path.join(tempRoot, 'replay.ad'); const sessionStore = new SessionStore(sessionsDir); const sessionName = 'noheal-session'; sessionStore.set(sessionName, makeSession(sessionName)); @@ -153,12 +186,9 @@ test('replay without --update does not heal or rewrite', async () => { writeReplayFile(replayPath, { ts: Date.now(), command: 'click', - positionals: ['id="old_continue"'], + positionals: ['id="old_continue" || label="Continue"'], flags: {}, - result: { - refLabel: 'Continue', - selectorChain: ['id="old_continue"', 'label="Continue"'], - }, + result: {}, }); const originalPayload = fs.readFileSync(replayPath, 'utf8'); @@ -202,7 +232,7 @@ test('replay without --update does not heal or rewrite', async () => { test('replay --update heals selector in is command', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-is-')); const sessionsDir = path.join(tempRoot, 'sessions'); - const replayPath = path.join(tempRoot, 'replay.json'); + const replayPath = path.join(tempRoot, 'replay.ad'); const sessionStore = new SessionStore(sessionsDir); const sessionName = 'heal-is-session'; sessionStore.set(sessionName, makeSession(sessionName)); @@ -210,12 +240,9 @@ test('replay --update heals selector in is command', async () => { writeReplayFile(replayPath, { ts: Date.now(), command: 'is', - positionals: ['visible', 'id="old_continue"'], + positionals: ['visible', 'id="old_continue" || label="Continue"'], flags: {}, - result: { - selectorChain: ['id="old_continue"', 'label="Continue"'], - refLabel: 'Continue', - }, + result: {}, }); const invoke = async (request: DaemonRequest): Promise => { @@ -266,10 +293,72 @@ test('replay --update heals selector in is command', async () => { }); assert.ok(response); - assert.equal(response.ok, true); + assert.equal(response.ok, true, JSON.stringify(response)); if (response.ok) { assert.equal(response.data?.healed, 1); } const rewrittenSelector = readReplaySelector(replayPath, 'is'); assert.ok(rewrittenSelector.includes('auth_continue')); }); + +test('replay rejects legacy JSON payload files', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-json-rejected-')); + const sessionsDir = path.join(tempRoot, 'sessions'); + const replayPath = path.join(tempRoot, 'replay.json'); + const sessionStore = new SessionStore(sessionsDir); + const sessionName = 'json-rejected-session'; + sessionStore.set(sessionName, makeSession(sessionName)); + fs.writeFileSync(replayPath, JSON.stringify({ optimizedActions: [] }, null, 2)); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'replay', + positionals: [replayPath], + flags: {}, + }, + sessionName, + logPath: path.join(tempRoot, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }); + + assert.ok(response); + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /\.ad script files/); + } +}); + +test('replay rejects malformed .ad lines with unclosed quotes', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-invalid-ad-')); + const sessionsDir = path.join(tempRoot, 'sessions'); + const replayPath = path.join(tempRoot, 'replay.ad'); + const sessionStore = new SessionStore(sessionsDir); + const sessionName = 'invalid-ad-session'; + sessionStore.set(sessionName, makeSession(sessionName)); + fs.writeFileSync(replayPath, 'click "id=\\"broken\\"\n'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'replay', + positionals: [replayPath], + flags: {}, + }, + sessionName, + logPath: path.join(tempRoot, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }); + + assert.ok(response); + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /Invalid replay script line/); + } +}); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 75bc30dd01..ca89efbf7d 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { asAppError } from '../../utils/errors.ts'; +import { AppError, asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; @@ -180,6 +180,7 @@ export async function handleSessionCommands(params: { ...session, appBundleId, appName, + recordSession: session.recordSession || req.flags?.saveScript === true, snapshot: undefined, }; sessionStore.recordAction(nextSession, { @@ -223,6 +224,7 @@ export async function handleSessionCommands(params: { createdAt: Date.now(), appBundleId, appName, + recordSession: req.flags?.saveScript === true, actions: [], }; sessionStore.recordAction(session, { @@ -242,11 +244,18 @@ export async function handleSessionCommands(params: { } try { const resolved = SessionStore.expandHome(filePath); - const payload = JSON.parse(fs.readFileSync(resolved, 'utf8')) as { - actions?: SessionAction[]; - optimizedActions?: SessionAction[]; - }; - const actions = payload.optimizedActions ?? payload.actions ?? []; + const script = fs.readFileSync(resolved, 'utf8'); + const firstNonWhitespace = script.trimStart()[0]; + if (firstNonWhitespace === '{' || firstNonWhitespace === '[') { + return { + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'replay accepts .ad script files. JSON replay payloads are no longer supported.', + }, + }; + } + const actions = parseReplayScript(script); const shouldUpdate = req.flags?.replayUpdate === true; let healed = 0; for (let index = 0; index < actions.length; index += 1) { @@ -285,7 +294,8 @@ export async function handleSessionCommands(params: { healed += 1; } if (shouldUpdate && healed > 0) { - writeReplayPayload(resolved, payload); + const session = sessionStore.get(sessionName); + writeReplayScript(resolved, actions, session); } return { ok: true, data: { replayed: actions.length, healed, session: sessionName } }; } catch (err) { @@ -313,6 +323,9 @@ export async function handleSessionCommands(params: { flags: req.flags ?? {}, result: { session: sessionName }, }); + if (req.flags?.saveScript) { + session.recordSession = true; + } sessionStore.writeSessionLog(session); sessionStore.delete(sessionName); return { ok: true, data: { session: sessionName } }; @@ -512,9 +525,208 @@ function parseSelectorWaitPositionals(positionals: string[]): { }; } -function writeReplayPayload(filePath: string, payload: { actions?: SessionAction[]; optimizedActions?: SessionAction[] }) { - const serialized = JSON.stringify(payload, null, 2); +function parseReplayScript(script: string): SessionAction[] { + const actions: SessionAction[] = []; + const lines = script.split(/\r?\n/); + for (const line of lines) { + const parsed = parseReplayScriptLine(line); + if (parsed) { + actions.push(parsed); + } + } + return actions; +} + +function parseReplayScriptLine(line: string): SessionAction | null { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('#')) return null; + const tokens = tokenizeReplayLine(trimmed); + if (tokens.length === 0) return null; + const [command, ...args] = tokens; + if (command === 'context') return null; + + const action: SessionAction = { + ts: Date.now(), + command, + positionals: [], + flags: {}, + }; + + if (command === 'snapshot') { + action.positionals = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === '-i') { + action.flags.snapshotInteractiveOnly = true; + continue; + } + if (token === '-c') { + action.flags.snapshotCompact = true; + continue; + } + if (token === '--raw') { + action.flags.snapshotRaw = true; + continue; + } + if ((token === '-d' || token === '--depth') && index + 1 < args.length) { + const parsedDepth = Number(args[index + 1]); + if (Number.isFinite(parsedDepth) && parsedDepth >= 0) { + action.flags.snapshotDepth = Math.floor(parsedDepth); + } + index += 1; + continue; + } + if ((token === '-s' || token === '--scope') && index + 1 < args.length) { + action.flags.snapshotScope = args[index + 1]; + index += 1; + continue; + } + if (token === '--backend' && index + 1 < args.length) { + const backend = args[index + 1]; + if (backend === 'ax' || backend === 'xctest') { + action.flags.snapshotBackend = backend; + } + index += 1; + } + } + return action; + } + + if (command === 'click') { + if (args.length === 0) return action; + const target = args[0]; + if (target.startsWith('@')) { + action.positionals = [target]; + if (args[1]) { + action.result = { refLabel: args[1] }; + } + return action; + } + action.positionals = [args.join(' ')]; + return action; + } + + if (command === 'fill') { + if (args.length < 2) { + action.positionals = args; + return action; + } + const target = args[0]; + if (target.startsWith('@')) { + if (args.length >= 3) { + action.positionals = [target, args.slice(2).join(' ')]; + action.result = { refLabel: args[1] }; + return action; + } + action.positionals = [target, args[1]]; + return action; + } + action.positionals = [target, args.slice(1).join(' ')]; + return action; + } + + if (command === 'get') { + if (args.length < 2) { + action.positionals = args; + return action; + } + const sub = args[0]; + const target = args[1]; + if (target.startsWith('@')) { + action.positionals = [sub, target]; + if (args[2]) { + action.result = { refLabel: args[2] }; + } + return action; + } + action.positionals = [sub, args.slice(1).join(' ')]; + return action; + } + + action.positionals = args; + return action; +} + +function tokenizeReplayLine(line: string): string[] { + const tokens: string[] = []; + let cursor = 0; + while (cursor < line.length) { + while (cursor < line.length && /\s/.test(line[cursor])) { + cursor += 1; + } + if (cursor >= line.length) break; + if (line[cursor] === '"') { + let end = cursor + 1; + let escaped = false; + while (end < line.length) { + const char = line[end]; + if (char === '"' && !escaped) break; + escaped = char === '\\' && !escaped; + if (char !== '\\') escaped = false; + end += 1; + } + if (end >= line.length) { + throw new AppError('INVALID_ARGS', `Invalid replay script line: ${line}`); + } + const literal = line.slice(cursor, end + 1); + tokens.push(JSON.parse(literal) as string); + cursor = end + 1; + continue; + } + let end = cursor; + while (end < line.length && !/\s/.test(line[end])) { + end += 1; + } + tokens.push(line.slice(cursor, end)); + cursor = end; + } + return tokens; +} + +function writeReplayScript(filePath: string, actions: SessionAction[], session?: SessionState) { + const lines: string[] = []; + // Session can be missing if the replay session is closed/deleted between execution and update write. + // In that case we still persist healed actions and omit only the context header. + if (session) { + const deviceLabel = session.device.name.replace(/"/g, '\\"'); + const kind = session.device.kind ? ` kind=${session.device.kind}` : ''; + lines.push(`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=unknown`); + } + for (const action of actions) { + lines.push(formatReplayActionLine(action)); + } + const serialized = `${lines.join('\n')}\n`; const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmpPath, serialized); fs.renameSync(tmpPath, filePath); } + +function formatReplayActionLine(action: SessionAction): string { + const parts: string[] = [action.command]; + if (action.command === 'snapshot') { + if (action.flags?.snapshotInteractiveOnly) parts.push('-i'); + if (action.flags?.snapshotCompact) parts.push('-c'); + if (typeof action.flags?.snapshotDepth === 'number') { + parts.push('-d', String(action.flags.snapshotDepth)); + } + if (action.flags?.snapshotScope) { + parts.push('-s', formatReplayArg(action.flags.snapshotScope)); + } + if (action.flags?.snapshotRaw) parts.push('--raw'); + if (action.flags?.snapshotBackend) { + parts.push('--backend', action.flags.snapshotBackend); + } + return parts.join(' '); + } + for (const positional of action.positionals ?? []) { + parts.push(formatReplayArg(positional)); + } + return parts.join(' '); +} + +function formatReplayArg(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith('@')) return trimmed; + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed; + return JSON.stringify(trimmed); +} diff --git a/src/utils/args.ts b/src/utils/args.ts index 4f3a124360..016a6ccd2b 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -224,8 +224,8 @@ Flags: --session Named session --verbose Stream daemon/runner logs --json JSON output - --no-record Do not record this action --save-script Save session script (.ad) on close + --no-record Do not record this action --update, -u Replay: update selectors and rewrite replay file in place --user-installed Apps: list user-installed packages (Android only) --all Apps: list all packages (Android only)