agent-device 0.16.7 → 0.16.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.7.apk → agent-device-android-multitouch-helper-0.16.8.apk} +0 -0
  2. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.8.apk.sha256 +1 -0
  3. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.7.manifest.json → agent-device-android-multitouch-helper-0.16.8.manifest.json} +4 -4
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.7.apk → agent-device-android-snapshot-helper-0.16.8.apk} +0 -0
  5. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.8.apk.sha256 +1 -0
  6. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.7.manifest.json → agent-device-android-snapshot-helper-0.16.8.manifest.json} +6 -6
  7. package/dist/src/1352.js +1 -1
  8. package/dist/src/2415.js +29 -29
  9. package/dist/src/2805.js +1 -1
  10. package/dist/src/6232.js +1 -0
  11. package/dist/src/7455.js +1 -0
  12. package/dist/src/8699.js +1 -1
  13. package/dist/src/940.js +1 -1
  14. package/dist/src/9471.js +1 -1
  15. package/dist/src/9533.js +1 -1
  16. package/dist/src/9542.js +1 -1
  17. package/dist/src/9818.js +1 -1
  18. package/dist/src/android-adb.d.ts +2 -0
  19. package/dist/src/android-snapshot-helper.d.ts +2 -0
  20. package/dist/src/args.js +5 -4
  21. package/dist/src/cli.js +6 -6
  22. package/dist/src/command-metadata.js +1 -1
  23. package/dist/src/find.js +1 -1
  24. package/dist/src/generic.js +10 -7
  25. package/dist/src/interaction.js +1 -1
  26. package/dist/src/react-native.js +1 -1
  27. package/dist/src/record-trace.js +3 -3
  28. package/dist/src/selector-runtime.js +1 -1
  29. package/dist/src/session.js +9 -9
  30. package/dist/src/snapshot.js +2 -2
  31. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +20 -6
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +178 -74
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -33
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +71 -1
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +80 -10
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +34 -6
  37. package/package.json +4 -6
  38. package/server.json +2 -2
  39. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.7.apk.sha256 +0 -1
  40. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.7.apk.sha256 +0 -1
  41. package/dist/src/5186.js +0 -1
@@ -1,2 +1,2 @@
1
- import{__webpack_require__ as e}from"./rslib-runtime.js";import{promises as t}from"node:fs";import n from"node:os";import a from"node:path";import{centerOfRect as s}from"./4057.js";import{normalizeError as o,AppError as r}from"./9152.js";import{withDiagnosticTimer as i,emitDiagnostic as l}from"./7599.js";import{pressAndroid as d,backAndroid as p}from"./input-actions.js";import{createDaemonRuntimeSessionStore as u,captureSnapshot as c,dispatchCommand as f,resolveSnapshotScope as m,runMacOsAlertAction as h,errorResponse as w,runIosRunnerCommand as _,isCommandSupportedOnDevice as g,snapshotAndroid as y,annotateScreenshotWithRefs as v,context_contextFromFlags as S}from"./2415.js";import{successText as b}from"./1998.js";import{sleep as I}from"./4829.js";import{buildSnapshotSession as A,dispatchWaitViaRuntime as C,createDaemonRuntimePolicy as R,recordIfSession as N,resolveSessionDevice as M,withSessionlessRunnerCleanup as P}from"./selector-runtime.js";import{parseTimeout as D}from"./6085.js";import{SETTINGS_INVALID_ARGS_MESSAGE as k,isMacOsSettingSupported as E,getUnsupportedMacOsSettingMessage as K}from"./1352.js";import{createAgentDevice as O}from"./9533.js";import{DAEMON_COMMAND_GROUPS as U}from"./5792.js";var x={};e.r(x),e.d(x,{SNAPSHOT_COMMAND_HANDLERS:()=>eh,handleSnapshotCommands:()=>ew});let H=new Set(["com.android.permissioncontroller","com.google.android.permissioncontroller","com.google.android.packageinstaller","com.android.packageinstaller"]),L=new Set(["android","com.android.systemui"]),$=/^android:id\/(?:alertTitle|message|button[123]|parentPanel|buttonPanel|contentPanel)$/i,T=/^android:id\/button[123]$/i,B=/(?:^|:)id\/permission_/i,q=/\b(?:is(?:n't| not) responding|keeps stopping|has stopped|close app|app info)\b/i,F=/^(?:ok|allow|allow all|while using the app|only this time|yes|continue|save|confirm|turn on|open settings)$/i,j=/^(?:cancel|deny|don.t allow|don’t allow|not now|no|dismiss|close|close app|later|skip)$/i;function z(e,t){let n=t[0];if(!n)return;let a=W(e,n.index);for(let n of t.slice(1)){let t=new Set(W(e,n.index));for(let e=a.length-1;e>=0;e-=1)t.has(a[e])||a.splice(e,1)}return a[a.length-1]}function W(e,t){let n=new Map(e.map(e=>[e.index,e])),a=[],s=n.get(t);for(;s;)a.push(s.index),s=void 0===s.parentIndex?void 0:n.get(s.parentIndex);return a.reverse()}function G(e,t){let n=new Map;for(let t of e){if(void 0===t.parentIndex)continue;let e=n.get(t.parentIndex)??[];e.push(t),n.set(t.parentIndex,e)}let a=new Set([t]),s=[t];for(let e of s)for(let t of n.get(e)??[])a.has(t.index)||(a.add(t.index),s.push(t.index));return e.filter(e=>a.has(e.index))}function V(e){let t=e.type??"",n=e.identifier??"";return!!(e.hittable||/\bbutton\b/i.test(t)||T.test(n)||/(?:^|:)id\/permission_(?:allow|deny)/i.test(n))}function J(e){if(!e)return"";let t=[e.label,e.value].filter(e=>"string"==typeof e&&e.trim().length>0);return t[0]?.trim()??""}async function Q(e,t,n={}){if("wait"===t)return await X(e,n.timeoutMs??1e4);if("get"===t){var a;let t=await ee(e);return{kind:"alertStatus",platform:"android",action:"get",alert:a=t?.alert??null,...a?b("Alert visible"):b("No alert visible")}}return await Y(e,t)}async function X(e,t){let n=Date.now(),a=await Z(e,t);if(!a)throw new r("COMMAND_FAILED","alert wait timed out");return{kind:"alertWait",platform:"android",action:"wait",alert:a.alert,waitedMs:Date.now()-n,...b("Alert visible")}}async function Y(e,t){var n;let a,s,o=await Z(e,2e3);if(!o)throw new r("COMMAND_FAILED","alert not found",{hint:"If a sheet is visible in snapshot but alert reports no alert, it is likely app-owned UI. Use snapshot -i and press the visible label/ref."});let i=(n=o.buttons,a="accept"===t?"accept":"dismiss",(s=n.find(e=>e.role===a))||("dismiss"===t?n.find(e=>"neutral"===e.role)??null:1===n.length?n[0]??null:null));if(i)return await d(e,i.x,i.y),et(t,o.alert,i.label);if("dismiss"===t)return await p(e),et(t,o.alert,"Back");throw new r("COMMAND_FAILED","alert accept found an alert but no accept button",{alert:o.alert,hint:"Inspect alert get --json for visible buttons, then use press by visible label/ref if needed."})}async function Z(e,t){let n=Date.now();for(;Date.now()-n<t;){let t=await ee(e);if(t)return t;await I(300)}return null}async function ee(e){return function(e){var t,n,a,o;let r,i=function(e){let t=e.filter(e=>{var t;let n;return n=(t=e).bundleId??"",H.has(n)||B.test(t.identifier??"")});if(t.length)return{nodes:t,source:"permission"};let n=function(e){let t=e.filter(e=>{var t;let n;return n=(t=e).bundleId??"",L.has(n)&&q.test(J(t))});if(0===t.length)return[];let n=z(e,t);return void 0===n?t:G(e,n).filter(e=>e.bundleId&&L.has(e.bundleId))}(e);return n.length?{nodes:n,source:"system-dialog"}:{nodes:function(e){var t;let n,a,s=e.filter(e=>{var t;return t=e.type??"",/(?:^|[.$])[^.]*Dialog$/i.test(t)}),o=e.filter(e=>$.test(e.identifier??"")),r=s.length?[...s,...o]:(n=(t=o).some(e=>T.test(e.identifier??"")),a=t.some(e=>!T.test(e.identifier??"")),n&&a?t:[]);if(0===r.length)return[];let i=z(e,r);return void 0===i?r:G(e,i)}(e),source:"native-dialog"}}(e),l=i.nodes;if(0===l.length)return null;let d=function(e){let t=new Set,n=[];for(let a of e){let e=J(a);if(!e||!a.rect||!V(a))continue;let o=e.trim().toLowerCase();if(!o||t.has(o))continue;t.add(o);let r=s(a.rect);n.push({label:e,x:r.x,y:r.y,role:function(e,t){var n;let a=(n=e.identifier??"",/(?:^|:)id\/button1$/i.test(n)?"accept":/(?:^|:)id\/button2$/i.test(n)?"dismiss":/(?:^|:)id\/button3$/i.test(n)?"neutral":/(?:^|:)id\/permission_allow/i.test(n)?"accept":/(?:^|:)id\/permission_deny/i.test(n)?"dismiss":null);return a||(F.test(t.trim())?"accept":j.test(t.trim())?"dismiss":"neutral")}(a,e)})}return n}(l),p=l.filter(e=>J(e)&&!V(e)),u=J((t=p).find(e=>/(?:^|:)id\/(?:alertTitle|permission_message)$/i.test(e.identifier??"")))||J(t[0]),c=(n=p,a=u,(r=n.map(e=>J(e)).filter(e=>e&&e!==a)).length?[...new Set(r)].join("\n"):void 0),f=(o=l,o.find(e=>e.bundleId)?.bundleId);return{alert:{...u?{title:u}:{},...c?{message:c}:{},buttons:d.map(e=>e.label),platform:"android",source:i.source,...f?{packageName:f}:{}},buttons:d}}((await i("snapshot_capture",async()=>await y(e,{helperWaitForIdleTimeoutMs:0,includeHiddenContentHints:!1}),{backend:"android",purpose:"alert"})).nodes)}function et(e,t,n){return{kind:"alertHandled",platform:"android",action:e,handled:!0,alert:t,button:n,...b(`Alert ${e}ed`)}}async function en(e){var t;let{req:n,logPath:a,session:s,device:o}=e,r="accept"===(t=n.positionals?.[0])||"dismiss"===t||"wait"===t?t:"get",i=s?"frontmost-app"===s.surface?{surface:"frontmost-app"}:{bundleId:s.appBundleId,surface:s.surface}:{};if(!g("alert",o))return w("UNSUPPORTED_OPERATION","alert is not supported on this device");if("android"===o.platform){let t=D(n.positionals?.[1])??1e4;return er(e,await Q(o,r,{timeoutMs:t}))}if("macos"===o.platform){let t=async e=>await h(e,i);return await ea(e,r,t)}let l={verbose:n.flags?.verbose,logPath:a,traceLogPath:s?.trace?.outPath,requestId:n.meta?.requestId},d=async e=>await _(o,{command:"alert",action:e,appBundleId:s?.appBundleId},l);return await ea(e,r,d)}async function ea(e,t,n){if("wait"===t)return await es(e,n);let a="accept"===t||"dismiss"===t?t:"get";return"accept"===a||"dismiss"===a?await eo(e,a,n):er(e,await n("get"))}async function es(e,t){let n=D(e.req.positionals?.[1])??1e4,a=Date.now();for(;Date.now()-a<n;){try{return er(e,await t("get"))}catch{}await I(300)}return w("COMMAND_FAILED","alert wait timed out")}async function eo(e,t,n){var a,s;let o,i,l=Date.now();for(;Date.now()-l<2e3;){try{return er(e,await n(t))}catch(t){i=t;let e=String(t?.message??"").toLowerCase();if(!e.includes("alert not found")&&!e.includes("no alert"))break}await I(300)}throw(a=i)instanceof r&&(s=a,(o=String(s?.message??"").toLowerCase()).includes("alert not found")||o.includes("no alert"))?new r(a.code,a.message,{...a.details??{},hint:"If the permission sheet is visible in snapshot or screenshot but alert reports no alert, take a scoped snapshot around the visible button label and use press @ref."}):a}function er(e,t){return N(e.sessionStore,e.session,e.req,t),{ok:!0,data:t}}async function ei(e){let{req:t,logPath:n,sessionStore:a,session:s,device:o,parsed:r}=e,{setting:i,state:l,permissionTarget:d,latitude:p,longitude:u}=r;if(!g("settings",o))return w("UNSUPPORTED_OPERATION","settings is not supported on this device");if("macos"===o.platform&&!E(i))return w("INVALID_ARGS",K(i));let c=s?.appBundleId,m="permission"===i?[i,l,d??"",t.positionals?.[3]??"",c??""]:"location"===i&&"set"===l?[i,l,p??"",u??"",c??""]:[i,l,c??""],h=await f(o,"settings",m,t.flags?.out,{...S(n,t.flags,c,s?.trace?.outPath)});return N(a,s,t,h??{setting:i,state:l}),{ok:!0,data:h??{setting:i,state:l}}}async function el(e){var t,n;let a;if("snapshot"!==e.command||"android"!==e.device.platform)return;let s=o(e.error);if("COMMAND_FAILED"===(t=s).code&&(n=t,a=`${n.message}
2
- ${n.hint??""}`,/Android UI hierarchy dump timed out/i.test(a)||/Stock UIAutomator fallback was skipped/i.test(a)||/Android accessibility snapshots can be blocked/i.test(a)||function(e){if(!e||"object"!=typeof e)return!1;let t=String(e.errorType??""),n=String(e.message??"");return/TimeoutException/i.test(t)||/timed out/i.test(n)}(t.details?.helper)||function(e){var t;if(!e)return!1;let n=e?.timeoutMs,a=e?.cmd,s=Array.isArray(t=e?.args)?t.map(String):"string"==typeof t?t.split(/\s+/):[];return"number"==typeof n&&"adb"===a&&s.includes("uiautomator")&&s.includes("dump")}(t.details)))return{ok:!1,error:{...s,details:{...s.details??{},androidSnapshotTimeoutScreenshot:await ed(e)}}}}async function ed(e){try{var s,r,i;let o=await t.mkdtemp(a.join(n.tmpdir(),"agent-device-android-snapshot-timeout-")),d=a.join(o,"snapshot-timeout-overlay-refs.png"),p=(s=await f(e.device,"screenshot",[d],void 0,{...S(e.logPath,{screenshotNoStabilize:!0},e.session?.appBundleId,e.session?.trace?.outPath),surface:e.session?.surface}),r=d,(i=s,"object"==typeof i&&null!==i&&"path"in i&&"string"==typeof i.path)?s.path:r);await t.access(p);let u=await ep(p,e.session);return l({level:"warn",phase:"android_snapshot_timeout_screenshot_captured",data:{path:p,overlayRefCount:"overlayRefCount"in u?u.overlayRefCount:void 0,overlayRefsAnnotated:"overlayRefsAnnotated"in u?u.overlayRefsAnnotated:void 0}}),u}catch(t){let e=o(t);return l({level:"warn",phase:"android_snapshot_timeout_screenshot_failed",data:{error:e.message}}),{captureFailed:!0,error:e.message}}}async function ep(e,t){if(!t?.snapshot)return{path:e,overlayRefsRequested:!0,overlayRefsAnnotated:!1,overlayRefSource:"unavailable",overlayRefCount:0};try{let n=await v({screenshotPath:e,snapshot:t.snapshot});return{path:e,overlayRefsRequested:!0,overlayRefsAnnotated:n.length>0,overlayRefCount:n.length,overlayRefSource:"session-snapshot",overlayRefs:n}}catch(n){let t=o(n);return l({level:"warn",phase:"android_snapshot_timeout_screenshot_overlay_failed",data:{path:e,error:t.message}}),{path:e,overlayRefsRequested:!0,overlayRefsAnnotated:!1,overlayRefSource:"session-snapshot",overlayRefCount:0,overlayAnnotationError:t.message}}}async function eu(e){return await ef({...e,command:"snapshot",unsupportedMessage:"snapshot is not supported on this device",execute:async({runtime:e,sessionName:t,req:n,snapshotScope:a})=>{let s=await e.capture.snapshot({session:t,interactiveOnly:n.flags?.snapshotInteractiveOnly,compact:n.flags?.snapshotCompact,depth:n.flags?.snapshotDepth,scope:a,raw:n.flags?.snapshotRaw,forceFull:n.flags?.snapshotForceFull});return{data:s,record:{kind:"snapshot",nodes:s.nodes.length,truncated:s.truncated}}}})}async function ec(e){return await ef({...e,command:"diff",unsupportedMessage:"diff is not supported on this device",execute:async({runtime:e,sessionName:t,req:n,snapshotScope:a})=>{let s=await e.capture.diffSnapshot({session:t,interactiveOnly:n.flags?.snapshotInteractiveOnly,compact:n.flags?.snapshotCompact,depth:n.flags?.snapshotDepth,scope:a,raw:n.flags?.snapshotRaw});return{data:s,record:{kind:"diff",mode:"snapshot",baselineInitialized:s.baselineInitialized,summary:s.summary}}}})}async function ef(e){let{req:t,sessionName:n,logPath:a,sessionStore:s}=e,{session:o,device:i}=await M(s,n,t.flags);if(!g(e.command,i))return w("UNSUPPORTED_OPERATION",e.unsupportedMessage);let l=m(t.flags?.snapshotScope,o);return l.ok?await P(o,i,async()=>{var d,p;let f,m,h=function(e){let{req:t,sessionName:n,logPath:a,sessionStore:s,session:o,device:i,snapshotScope:l}=e;return O({backend:function(e){let{req:t,logPath:n,session:a,device:s,snapshotScope:o}=e;return{platform:s.platform,captureSnapshot:async(e,r)=>{let i=await c({device:s,session:a,flags:t.flags,outPath:r?.outPath??t.flags?.out,logPath:n,snapshotScope:o});return{snapshot:i.snapshot,analysis:i.analysis,androidSnapshot:i.androidSnapshot,freshness:i.freshness,appName:a?.appBundleId?a.appName??a.appBundleId:void 0,appBundleId:a?.appBundleId}}}}({req:t,logPath:a,session:o,device:i,snapshotScope:l}),...R("snapshot"),sessions:u({sessionName:n,getSession:()=>s.get(n),recordOptions:{includeSnapshot:!0},setRecord:e=>{var a;let o=function(e){if(!e.snapshot)throw new r("UNKNOWN","snapshot runtime did not produce session state");return e}(e),l=s.get(n);s.set(n,function(e){var t,n;let{current:a,sessionName:s,device:o,record:r,refScopedSnapshot:i}=e,l=(t=a,n=r,i&&n.snapshot?.nodes.length===0&&t?.snapshot!==void 0),d=l?a.snapshot:r.snapshot,p=A({session:a,sessionName:s,device:o,snapshot:d,appBundleId:r.appBundleId});return p.snapshotScopeSource=function(e){let{current:t,keepCurrentSnapshot:n,refScopedSnapshot:a}=e;if(a)return n?t?.snapshotScopeSource:t?.snapshotScopeSource??t?.snapshot}({current:a,keepCurrentSnapshot:l,refScopedSnapshot:i}),r.appName&&(p.appName=r.appName),p}({current:l,sessionName:n,device:i,record:o,refScopedSnapshot:(a=t,a.flags?.snapshotScope?.trim().startsWith("@")===!0)}))}})})}({req:t,sessionName:n,logPath:a,sessionStore:s,session:o,device:i,snapshotScope:l.scope});try{m=await e.execute({runtime:h,sessionName:n,req:t,snapshotScope:l.scope})}catch(n){let t=await el({error:n,command:e.command,logPath:a,session:o,device:i});if(!t)throw n;return t}return(f=(d={req:t,sessionName:n,sessionStore:s,result:m.record}).sessionStore.get(d.sessionName))&&d.sessionStore.recordAction(f,{command:d.req.command,positionals:d.req.positionals??[],flags:d.req.flags??{},result:"snapshot"===(p=d.result).kind?{nodes:p.nodes,truncated:p.truncated}:{mode:p.mode,baselineInitialized:p.baselineInitialized,summary:p.summary}}),{ok:!0,data:m.data}}):l}let em=U.snapshot,eh={snapshot:async({req:e,sessionName:t,logPath:n,sessionStore:a})=>await eu({req:e,sessionName:t,logPath:n,sessionStore:a}),diff:async({req:e,sessionName:t,logPath:n,sessionStore:a})=>e.positionals?.[0]!=="snapshot"?w("INVALID_ARGS","diff currently supports only: diff snapshot"):await ec({req:e,sessionName:t,logPath:n,sessionStore:a}),wait:async({req:e,sessionName:t,logPath:n,sessionStore:a})=>await C({req:e,sessionName:t,logPath:n,sessionStore:a}),alert:async({req:e,sessionName:t,logPath:n,sessionStore:a})=>{let{session:s,device:o}=await M(a,t,e.flags);return await P(s,o,async()=>await en({req:e,logPath:n,sessionStore:a,session:s,device:o}))},settings:async({req:e,sessionName:t,logPath:n,sessionStore:a})=>{let s,o,r,i=(s=e.positionals?.[0]?.toLowerCase(),o=e.positionals?.[1]?.toLowerCase(),r=e.positionals?.[2]?.toLowerCase(),s&&o&&("permission"!==s||r)&&("location"!==s||"set"!==o||e.positionals?.[2]&&e.positionals?.[3])?{ok:!0,parsed:{setting:s,state:o,permissionTarget:r,latitude:e.positionals?.[2],longitude:e.positionals?.[3]}}:w("INVALID_ARGS",k));if(!i.ok)return i;let{session:l,device:d}=await M(a,t,e.flags);return await P(l,d,async()=>await ei({req:e,logPath:n,sessionStore:a,session:l,device:d,parsed:i.parsed}))}};async function ew(e){let t=e.req.command;if(!em.has(t))return null;let n=eh[t];return n?await n(e):w("COMMAND_FAILED",`Snapshot command has no handler: ${t}`)}export{x as snapshot_namespaceObject};
1
+ import{__webpack_require__ as e}from"./rslib-runtime.js";import{promises as t}from"node:fs";import a from"node:os";import n from"node:path";import{centerOfRect as s}from"./4057.js";import{normalizeError as o,AppError as r}from"./9152.js";import{withDiagnosticTimer as i,emitDiagnostic as l}from"./7599.js";import{pressAndroid as d,backAndroid as p}from"./input-actions.js";import{createDaemonRuntimeSessionStore as u,captureSnapshot as c,dispatchCommand as f,resolveSnapshotScope as m,runMacOsAlertAction as h,errorResponse as w,runIosRunnerCommand as _,isCommandSupportedOnDevice as g,snapshotAndroid as y,annotateScreenshotWithRefs as v,context_contextFromFlags as S}from"./2415.js";import{successText as b}from"./1998.js";import{sleep as I}from"./4829.js";import{buildSnapshotSession as A,dispatchWaitViaRuntime as C,createDaemonRuntimePolicy as R,recordIfSession as N,resolveSessionDevice as M,withSessionlessRunnerCleanup as P}from"./selector-runtime.js";import{parseTimeout as D}from"./6085.js";import{SETTINGS_INVALID_ARGS_MESSAGE as k,isMacOsSettingSupported as E,getUnsupportedMacOsSettingMessage as K}from"./1352.js";import{createAgentDevice as O}from"./9533.js";import{DAEMON_COMMAND_GROUPS as U}from"./5792.js";var x={};e.r(x),e.d(x,{SNAPSHOT_COMMAND_HANDLERS:()=>eh,handleSnapshotCommands:()=>ew});let H=new Set(["com.android.permissioncontroller","com.google.android.permissioncontroller","com.google.android.packageinstaller","com.android.packageinstaller"]),L=new Set(["android","com.android.systemui"]),B=/^android:id\/(?:alertTitle|message|button[123]|parentPanel|buttonPanel|contentPanel)$/i,$=/^android:id\/button[123]$/i,T=/(?:^|:)id\/permission_/i,q=/\b(?:is(?:n't| not) responding|keeps stopping|has stopped|close app|app info)\b/i,F=/^(?:ok|allow|allow all|while using the app|only this time|yes|continue|save|confirm|turn on|open settings)$/i,j=/^(?:cancel|deny|don.t allow|don’t allow|not now|no|dismiss|close|close app|later|skip)$/i;function z(e,t){let a=t[0];if(!a)return;let n=G(e,a.index);for(let a of t.slice(1)){let t=new Set(G(e,a.index));for(let e=n.length-1;e>=0;e-=1)t.has(n[e])||n.splice(e,1)}return n[n.length-1]}function G(e,t){let a=new Map(e.map(e=>[e.index,e])),n=[],s=a.get(t);for(;s;)n.push(s.index),s=void 0===s.parentIndex?void 0:a.get(s.parentIndex);return n.reverse()}function V(e,t){let a=new Map;for(let t of e){if(void 0===t.parentIndex)continue;let e=a.get(t.parentIndex)??[];e.push(t),a.set(t.parentIndex,e)}let n=new Set([t]),s=[t];for(let e of s)for(let t of a.get(e)??[])n.has(t.index)||(n.add(t.index),s.push(t.index));return e.filter(e=>n.has(e.index))}function W(e){let t=e.type??"",a=e.identifier??"";return!!(e.hittable||/\bbutton\b/i.test(t)||$.test(a)||/(?:^|:)id\/permission_(?:allow|deny)/i.test(a))}function J(e){if(!e)return"";let t=[e.label,e.value].filter(e=>"string"==typeof e&&e.trim().length>0);return t[0]?.trim()??""}async function Q(e,t,a={}){if("wait"===t)return await X(e,a.timeoutMs??1e4);if("get"===t){var n;let t=await ee(e);return{kind:"alertStatus",platform:"android",action:"get",alert:n=t?.alert??null,...n?b("Alert visible"):b("No alert visible")}}return await Y(e,t)}async function X(e,t){let a=Date.now(),n=await Z(e,t);if(!n)throw new r("COMMAND_FAILED","alert wait timed out");return{kind:"alertWait",platform:"android",action:"wait",alert:n.alert,waitedMs:Date.now()-a,...b("Alert visible")}}async function Y(e,t){var a;let n,s,o=await Z(e,2e3);if(!o)throw new r("COMMAND_FAILED","alert not found",{hint:"If a sheet is visible in snapshot but alert reports no alert, it is likely app-owned UI. Use snapshot -i and press the visible label/ref."});let i=(a=o.buttons,n="accept"===t?"accept":"dismiss",(s=a.find(e=>e.role===n))||("dismiss"===t?a.find(e=>"neutral"===e.role)??null:1===a.length?a[0]??null:null));if(i)return await d(e,i.x,i.y),et(t,o.alert,i.label);if("dismiss"===t)return await p(e),et(t,o.alert,"Back");throw new r("COMMAND_FAILED","alert accept found an alert but no accept button",{alert:o.alert,hint:"Inspect alert get --json for visible buttons, then use press by visible label/ref if needed."})}async function Z(e,t){let a=Date.now();for(;Date.now()-a<t;){let t=await ee(e);if(t)return t;await I(300)}return null}async function ee(e){return function(e){var t,a,n,o;let r,i=function(e){let t=e.filter(e=>{var t;let a;return a=(t=e).bundleId??"",H.has(a)||T.test(t.identifier??"")});if(t.length)return{nodes:t,source:"permission"};let a=function(e){let t=e.filter(e=>{var t;let a;return a=(t=e).bundleId??"",L.has(a)&&q.test(J(t))});if(0===t.length)return[];let a=z(e,t);return void 0===a?t:V(e,a).filter(e=>e.bundleId&&L.has(e.bundleId))}(e);return a.length?{nodes:a,source:"system-dialog"}:{nodes:function(e){var t;let a,n,s=e.filter(e=>{var t;return t=e.type??"",/(?:^|[.$])[^.]*Dialog$/i.test(t)}),o=e.filter(e=>B.test(e.identifier??"")),r=s.length?[...s,...o]:(a=(t=o).some(e=>$.test(e.identifier??"")),n=t.some(e=>!$.test(e.identifier??"")),a&&n?t:[]);if(0===r.length)return[];let i=z(e,r);return void 0===i?r:V(e,i)}(e),source:"native-dialog"}}(e),l=i.nodes;if(0===l.length)return null;let d=function(e){let t=new Set,a=[];for(let n of e){let e=J(n);if(!e||!n.rect||!W(n))continue;let o=e.trim().toLowerCase();if(!o||t.has(o))continue;t.add(o);let r=s(n.rect);a.push({label:e,x:r.x,y:r.y,role:function(e,t){var a;let n=(a=e.identifier??"",/(?:^|:)id\/button1$/i.test(a)?"accept":/(?:^|:)id\/button2$/i.test(a)?"dismiss":/(?:^|:)id\/button3$/i.test(a)?"neutral":/(?:^|:)id\/permission_allow/i.test(a)?"accept":/(?:^|:)id\/permission_deny/i.test(a)?"dismiss":null);return n||(F.test(t.trim())?"accept":j.test(t.trim())?"dismiss":"neutral")}(n,e)})}return a}(l),p=l.filter(e=>J(e)&&!W(e)),u=J((t=p).find(e=>/(?:^|:)id\/(?:alertTitle|permission_message)$/i.test(e.identifier??"")))||J(t[0]),c=(a=p,n=u,(r=a.map(e=>J(e)).filter(e=>e&&e!==n)).length?[...new Set(r)].join("\n"):void 0),f=(o=l,o.find(e=>e.bundleId)?.bundleId);return{alert:{...u?{title:u}:{},...c?{message:c}:{},buttons:d.map(e=>e.label),platform:"android",source:i.source,...f?{packageName:f}:{}},buttons:d}}((await i("snapshot_capture",async()=>await y(e,{helperWaitForIdleTimeoutMs:0,includeHiddenContentHints:!1}),{backend:"android",purpose:"alert"})).nodes)}function et(e,t,a){return{kind:"alertHandled",platform:"android",action:e,handled:!0,alert:t,button:a,...b(`Alert ${e}ed`)}}async function ea(e){var t;let{req:a,logPath:n,session:s,device:o}=e,r="accept"===(t=a.positionals?.[0])||"dismiss"===t||"wait"===t?t:"get",i=s?"frontmost-app"===s.surface?{surface:"frontmost-app"}:{bundleId:s.appBundleId,surface:s.surface}:{};if(!g("alert",o))return w("UNSUPPORTED_OPERATION","alert is not supported on this device");if("android"===o.platform){let t=D(a.positionals?.[1])??1e4;return er(e,await Q(o,r,{timeoutMs:t}))}if("macos"===o.platform){let t=async e=>await h(e,i);return await en(e,r,t)}let l={verbose:a.flags?.verbose,logPath:n,traceLogPath:s?.trace?.outPath,requestId:a.meta?.requestId},d=async e=>await _(o,{command:"alert",action:e,appBundleId:s?.appBundleId},l);return await en(e,r,d)}async function en(e,t,a){if("wait"===t)return await es(e,a);let n="accept"===t||"dismiss"===t?t:"get";return"accept"===n||"dismiss"===n?await eo(e,n,a):er(e,await a("get"))}async function es(e,t){let a=D(e.req.positionals?.[1])??1e4,n=Date.now();for(;Date.now()-n<a;){try{return er(e,await t("get"))}catch{}await I(300)}return w("COMMAND_FAILED","alert wait timed out")}async function eo(e,t,a){var n,s;let o,i,l=Date.now();for(;Date.now()-l<2e3;){try{return er(e,await a(t))}catch(t){i=t;let e=String(t?.message??"").toLowerCase();if(!e.includes("alert not found")&&!e.includes("no alert"))break}await I(300)}throw(n=i)instanceof r&&(s=n,(o=String(s?.message??"").toLowerCase()).includes("alert not found")||o.includes("no alert"))?new r(n.code,n.message,{...n.details??{},hint:"If the permission sheet is visible in snapshot or screenshot but alert reports no alert, take a scoped snapshot around the visible button label and use press @ref."}):n}function er(e,t){return N(e.sessionStore,e.session,e.req,t),{ok:!0,data:t}}async function ei(e){let{req:t,logPath:a,sessionStore:n,session:s,device:o,parsed:r}=e,{setting:i,state:l,appBundleId:d,permissionTarget:p,latitude:u,longitude:c}=r;if(!g("settings",o))return w("UNSUPPORTED_OPERATION","settings is not supported on this device");if("macos"===o.platform&&!E(i))return w("INVALID_ARGS",K(i));let m=d??s?.appBundleId;if("clear-app-state"===i&&!m)return w("INVALID_ARGS","settings clear-app-state requires an app id when no app is bound to the session");let h="clear-app-state"===i?[i,l,m??""]:"permission"===i?[i,l,p??"",t.positionals?.[3]??"",m??""]:"location"===i&&"set"===l?[i,l,u??"",c??"",m??""]:[i,l,m??""],_=await f(o,"settings",h,t.flags?.out,{...S(a,t.flags,m,s?.trace?.outPath)});return N(n,s,t,_??{setting:i,state:l}),{ok:!0,data:_??{setting:i,state:l}}}async function el(e){var t,a;let n;if("snapshot"!==e.command||"android"!==e.device.platform)return;let s=o(e.error);if("COMMAND_FAILED"===(t=s).code&&(a=t,n=`${a.message}
2
+ ${a.hint??""}`,/Android UI hierarchy dump timed out/i.test(n)||/Stock UIAutomator fallback was skipped/i.test(n)||/Android accessibility snapshots can be blocked/i.test(n)||function(e){if(!e||"object"!=typeof e)return!1;let t=String(e.errorType??""),a=String(e.message??"");return/TimeoutException/i.test(t)||/timed out/i.test(a)}(t.details?.helper)||function(e){var t;if(!e)return!1;let a=e?.timeoutMs,n=e?.cmd,s=Array.isArray(t=e?.args)?t.map(String):"string"==typeof t?t.split(/\s+/):[];return"number"==typeof a&&"adb"===n&&s.includes("uiautomator")&&s.includes("dump")}(t.details)))return{ok:!1,error:{...s,details:{...s.details??{},androidSnapshotTimeoutScreenshot:await ed(e)}}}}async function ed(e){try{var s,r,i;let o=await t.mkdtemp(n.join(a.tmpdir(),"agent-device-android-snapshot-timeout-")),d=n.join(o,"snapshot-timeout-overlay-refs.png"),p=(s=await f(e.device,"screenshot",[d],void 0,{...S(e.logPath,{screenshotNoStabilize:!0},e.session?.appBundleId,e.session?.trace?.outPath),surface:e.session?.surface}),r=d,(i=s,"object"==typeof i&&null!==i&&"path"in i&&"string"==typeof i.path)?s.path:r);await t.access(p);let u=await ep(p,e.session);return l({level:"warn",phase:"android_snapshot_timeout_screenshot_captured",data:{path:p,overlayRefCount:"overlayRefCount"in u?u.overlayRefCount:void 0,overlayRefsAnnotated:"overlayRefsAnnotated"in u?u.overlayRefsAnnotated:void 0}}),u}catch(t){let e=o(t);return l({level:"warn",phase:"android_snapshot_timeout_screenshot_failed",data:{error:e.message}}),{captureFailed:!0,error:e.message}}}async function ep(e,t){if(!t?.snapshot)return{path:e,overlayRefsRequested:!0,overlayRefsAnnotated:!1,overlayRefSource:"unavailable",overlayRefCount:0};try{let a=await v({screenshotPath:e,snapshot:t.snapshot});return{path:e,overlayRefsRequested:!0,overlayRefsAnnotated:a.length>0,overlayRefCount:a.length,overlayRefSource:"session-snapshot",overlayRefs:a}}catch(a){let t=o(a);return l({level:"warn",phase:"android_snapshot_timeout_screenshot_overlay_failed",data:{path:e,error:t.message}}),{path:e,overlayRefsRequested:!0,overlayRefsAnnotated:!1,overlayRefSource:"session-snapshot",overlayRefCount:0,overlayAnnotationError:t.message}}}async function eu(e){return await ef({...e,command:"snapshot",unsupportedMessage:"snapshot is not supported on this device",execute:async({runtime:e,sessionName:t,req:a,snapshotScope:n})=>{let s=await e.capture.snapshot({session:t,interactiveOnly:a.flags?.snapshotInteractiveOnly,compact:a.flags?.snapshotCompact,depth:a.flags?.snapshotDepth,scope:n,raw:a.flags?.snapshotRaw,forceFull:a.flags?.snapshotForceFull});return{data:s,record:{kind:"snapshot",nodes:s.nodes.length,truncated:s.truncated}}}})}async function ec(e){return await ef({...e,command:"diff",unsupportedMessage:"diff is not supported on this device",execute:async({runtime:e,sessionName:t,req:a,snapshotScope:n})=>{let s=await e.capture.diffSnapshot({session:t,interactiveOnly:a.flags?.snapshotInteractiveOnly,compact:a.flags?.snapshotCompact,depth:a.flags?.snapshotDepth,scope:n,raw:a.flags?.snapshotRaw});return{data:s,record:{kind:"diff",mode:"snapshot",baselineInitialized:s.baselineInitialized,summary:s.summary}}}})}async function ef(e){let{req:t,sessionName:a,logPath:n,sessionStore:s}=e,{session:o,device:i}=await M(s,a,t.flags);if(!g(e.command,i))return w("UNSUPPORTED_OPERATION",e.unsupportedMessage);let l=m(t.flags?.snapshotScope,o);return l.ok?await P(o,i,async()=>{var d,p;let f,m,h=function(e){let{req:t,sessionName:a,logPath:n,sessionStore:s,session:o,device:i,snapshotScope:l}=e;return O({backend:function(e){let{req:t,logPath:a,session:n,device:s,snapshotScope:o}=e;return{platform:s.platform,captureSnapshot:async(e,r)=>{let i=await c({device:s,session:n,flags:t.flags,outPath:r?.outPath??t.flags?.out,logPath:a,snapshotScope:o});return{snapshot:i.snapshot,analysis:i.analysis,androidSnapshot:i.androidSnapshot,freshness:i.freshness,appName:n?.appBundleId?n.appName??n.appBundleId:void 0,appBundleId:n?.appBundleId}}}}({req:t,logPath:n,session:o,device:i,snapshotScope:l}),...R("snapshot"),sessions:u({sessionName:a,getSession:()=>s.get(a),recordOptions:{includeSnapshot:!0},setRecord:e=>{var n;let o=function(e){if(!e.snapshot)throw new r("UNKNOWN","snapshot runtime did not produce session state");return e}(e),l=s.get(a);s.set(a,function(e){var t,a;let{current:n,sessionName:s,device:o,record:r,refScopedSnapshot:i}=e,l=(t=n,a=r,i&&a.snapshot?.nodes.length===0&&t?.snapshot!==void 0),d=l?n.snapshot:r.snapshot,p=A({session:n,sessionName:s,device:o,snapshot:d,appBundleId:r.appBundleId});return p.snapshotScopeSource=function(e){let{current:t,keepCurrentSnapshot:a,refScopedSnapshot:n}=e;if(n)return a?t?.snapshotScopeSource:t?.snapshotScopeSource??t?.snapshot}({current:n,keepCurrentSnapshot:l,refScopedSnapshot:i}),r.appName&&(p.appName=r.appName),p}({current:l,sessionName:a,device:i,record:o,refScopedSnapshot:(n=t,n.flags?.snapshotScope?.trim().startsWith("@")===!0)}))}})})}({req:t,sessionName:a,logPath:n,sessionStore:s,session:o,device:i,snapshotScope:l.scope});try{m=await e.execute({runtime:h,sessionName:a,req:t,snapshotScope:l.scope})}catch(a){let t=await el({error:a,command:e.command,logPath:n,session:o,device:i});if(!t)throw a;return t}return(f=(d={req:t,sessionName:a,sessionStore:s,result:m.record}).sessionStore.get(d.sessionName))&&d.sessionStore.recordAction(f,{command:d.req.command,positionals:d.req.positionals??[],flags:d.req.flags??{},result:"snapshot"===(p=d.result).kind?{nodes:p.nodes,truncated:p.truncated}:{mode:p.mode,baselineInitialized:p.baselineInitialized,summary:p.summary}}),{ok:!0,data:m.data}}):l}let em=U.snapshot,eh={snapshot:async({req:e,sessionName:t,logPath:a,sessionStore:n})=>await eu({req:e,sessionName:t,logPath:a,sessionStore:n}),diff:async({req:e,sessionName:t,logPath:a,sessionStore:n})=>e.positionals?.[0]!=="snapshot"?w("INVALID_ARGS","diff currently supports only: diff snapshot"):await ec({req:e,sessionName:t,logPath:a,sessionStore:n}),wait:async({req:e,sessionName:t,logPath:a,sessionStore:n})=>await C({req:e,sessionName:t,logPath:a,sessionStore:n}),alert:async({req:e,sessionName:t,logPath:a,sessionStore:n})=>{let{session:s,device:o}=await M(n,t,e.flags);return await P(s,o,async()=>await ea({req:e,logPath:a,sessionStore:n,session:s,device:o}))},settings:async({req:e,sessionName:t,logPath:a,sessionStore:n})=>{let s,o,r,i=(s=e.positionals?.[0]?.toLowerCase(),o=e.positionals?.[1]?.toLowerCase(),r=e.positionals?.[2]?.toLowerCase(),"clear-app-state"===s?{ok:!0,parsed:{setting:s,state:"clear",appBundleId:"clear"===o?e.positionals?.[2]:e.positionals?.[1]}}:s&&o&&("permission"!==s||r)&&("location"!==s||"set"!==o||e.positionals?.[2]&&e.positionals?.[3])?{ok:!0,parsed:{setting:s,state:o,permissionTarget:r,latitude:e.positionals?.[2],longitude:e.positionals?.[3]}}:w("INVALID_ARGS",k));if(!i.ok)return i;let{session:l,device:d}=await M(n,t,e.flags);return await P(l,d,async()=>await ei({req:e,logPath:a,sessionStore:n,session:l,device:d,parsed:i.parsed}))}};async function ew(e){let t=e.req.command;if(!em.has(t))return null;let a=eh[t];return a?await a(e):w("COMMAND_FAILED",`Snapshot command has no handler: ${t}`)}export{x as snapshot_namespaceObject};
@@ -17,10 +17,10 @@ extension RunnerTests {
17
17
  switch outcome {
18
18
  case .performed:
19
19
  return nil
20
- case .unsupported(let message):
20
+ case .unsupported(let message, let hint):
21
21
  return Response(
22
22
  ok: false,
23
- error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: message)
23
+ error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: message, hint: hint)
24
24
  )
25
25
  }
26
26
  }
@@ -646,12 +646,26 @@ extension RunnerTests {
646
646
  scope: command.scope,
647
647
  raw: command.raw ?? false
648
648
  )
649
- if options.raw {
649
+ do {
650
+ let payload: DataPayload
651
+ if options.raw {
652
+ payload = try snapshotRaw(app: activeApp, options: options)
653
+ } else {
654
+ payload = try snapshotFast(app: activeApp, options: options)
655
+ }
650
656
  needsPostSnapshotInteractionDelay = true
651
- return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
657
+ return Response(ok: true, data: payload)
658
+ } catch let failure as SnapshotCaptureFailure {
659
+ // Other thrown errors fall through to executeOnMainSafely's generic error response.
660
+ return Response(
661
+ ok: false,
662
+ error: ErrorPayload(
663
+ code: failure.code,
664
+ message: failure.message,
665
+ hint: failure.hint
666
+ )
667
+ )
652
668
  }
653
- needsPostSnapshotInteractionDelay = true
654
- return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
655
669
  case .screenshot:
656
670
  let screenshot: XCUIScreenshot
657
671
  #if os(macOS)
@@ -283,6 +283,13 @@ extension RunnerTests {
283
283
 
284
284
  func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
285
285
  let point = CGPoint(x: x, y: y)
286
+ let textInputCandidates = textInputCandidatesAt(app: app, point: point)
287
+ for element in textInputCandidates where prefersExpandedTextRead(element) {
288
+ if let text = readableText(for: element) {
289
+ return text
290
+ }
291
+ }
292
+
286
293
  let candidates = app.descendants(matching: .any).allElementsBoundByIndex
287
294
  .filter { element in
288
295
  element.exists && !element.frame.isEmpty && element.frame.contains(point)
@@ -316,6 +323,18 @@ extension RunnerTests {
316
323
  }
317
324
 
318
325
  func clearTextInput(_ element: XCUIElement) {
326
+ // Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the
327
+ // field is empty. Why skip: the edge-tap computes a point from the element frame, which can
328
+ // be stale after the field repositions on focus (e.g. the Settings search bar jumps
329
+ // bottom->top and reveals a "Suggestions" list) — tapping there navigates away instead of
330
+ // clearing; and replacing into an already-empty field is a no-op anyway.
331
+ // editableTextValue returns nil for secure (and unknown) fields, where we CANNOT confirm
332
+ // emptiness — those must still be cleared, or replace would concatenate stale + new text.
333
+ // So distinguish nil (clear) from "" (skip).
334
+ if let existing = editableTextValue(for: element, treatingPlaceholderAsEmpty: true),
335
+ existing.isEmpty {
336
+ return
337
+ }
319
338
  #if !os(tvOS)
320
339
  moveCaretToEnd(element: element)
321
340
  #endif
@@ -325,20 +344,28 @@ extension RunnerTests {
325
344
  }
326
345
 
327
346
  func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
328
- let point = CGPoint(x: x, y: y)
329
- var matched: XCUIElement?
347
+ return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
348
+ }
349
+
350
+ private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
351
+ var candidates: [XCUIElement] = []
330
352
  let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
353
+ // Query the text-input element types directly instead of enumerating the entire tree
354
+ // (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
355
+ // slower — it dominated fill latency because resolveTextEntryElement re-runs this on
356
+ // each verify/repair poll once the focused field reference goes stale).
331
357
  // Prefer the smallest matching field so nested editable controls win over large containers.
332
- let candidates = app.descendants(matching: .any).allElementsBoundByIndex
358
+ candidates = [
359
+ app.textFields,
360
+ app.secureTextFields,
361
+ app.searchFields,
362
+ app.textViews,
363
+ ]
364
+ .flatMap { $0.allElementsBoundByIndex }
333
365
  .filter { element in
334
366
  guard element.exists else { return false }
335
- switch element.elementType {
336
- case .textField, .secureTextField, .searchField, .textView:
337
- let frame = element.frame
338
- return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
339
- default:
340
- return false
341
- }
367
+ let frame = element.frame
368
+ return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
342
369
  }
343
370
  .sorted { left, right in
344
371
  let leftArea = max(1, left.frame.width * left.frame.height)
@@ -354,16 +381,15 @@ extension RunnerTests {
354
381
  }
355
382
  return left.elementType.rawValue < right.elementType.rawValue
356
383
  }
357
- matched = candidates.first
358
384
  })
359
385
  if let exceptionMessage {
360
386
  NSLog(
361
387
  "AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@",
362
388
  exceptionMessage
363
389
  )
364
- return nil
390
+ return []
365
391
  }
366
- return matched
392
+ return candidates
367
393
  }
368
394
 
369
395
  private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
@@ -411,11 +437,17 @@ extension RunnerTests {
411
437
  return target
412
438
  #else
413
439
  let latest = target
440
+ let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
414
441
  let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
415
442
  while Date() < deadline {
416
443
  if let focused = focusedTextInput(app: app) {
417
444
  return focused
418
445
  }
446
+ // focusedTextInput is intentionally nil on iOS; treat the keyboard transitioning to
447
+ // visible after our tap as the focus-moved signal. Don't fast-path when it was already up.
448
+ if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
449
+ return latest
450
+ }
419
451
  sleepFor(TextEntryTiming.pollInterval)
420
452
  }
421
453
  return latest
@@ -866,6 +898,7 @@ extension RunnerTests {
866
898
  ) -> XCUIElement? {
867
899
  #if os(iOS)
868
900
  var latest = resolveTextEntryElement(app: app, target: target)
901
+ let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
869
902
  let deadline = Date().addingTimeInterval(timeout)
870
903
  let hardwareKeyboardFallback = Date().addingTimeInterval(
871
904
  min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
@@ -878,6 +911,14 @@ extension RunnerTests {
878
911
  return focused
879
912
  }
880
913
  }
914
+ // Fast-path on a keyboard hidden->visible transition: our tapped field gained focus, so
915
+ // return immediately instead of burning the full readinessTimeout (warmup-first-char echo
916
+ // + post-type verify/repair remain as drop safety nets). When the keyboard was ALREADY up
917
+ // (back-to-back fills), this isn't a focus signal — fall through to the settle/timeout so
918
+ // text isn't sent to the previously-focused field.
919
+ if keyboardBecameVisible(app: app, wasVisibleAtEntry: keyboardVisibleAtEntry) {
920
+ return latest
921
+ }
881
922
  sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
882
923
  if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
883
924
  return latest
@@ -934,6 +975,15 @@ extension RunnerTests {
934
975
  return visibleKeyboardFrame(app: app) != nil
935
976
  }
936
977
 
978
+ /// A focus-moved signal for iOS text entry, where `focusedTextInput` is intentionally nil.
979
+ /// The software keyboard TRANSITIONING from hidden (at entry) to visible means the field we
980
+ /// just tapped gained first-responder. If the keyboard was ALREADY up (e.g. back-to-back
981
+ /// fills into different fields), its visibility is not evidence focus moved to the new field,
982
+ /// so callers must keep waiting rather than typing into the previously-focused field.
983
+ private func keyboardBecameVisible(app: XCUIApplication, wasVisibleAtEntry: Bool) -> Bool {
984
+ return !wasVisibleAtEntry && isKeyboardVisible(app: app)
985
+ }
986
+
937
987
  private func keyboardElementExists(app: XCUIApplication) -> Bool {
938
988
  #if os(iOS)
939
989
  var exists = false
@@ -978,6 +1028,14 @@ extension RunnerTests {
978
1028
  return (wasVisible: true, dismissed: !visible, visible: visible)
979
1029
  }
980
1030
 
1031
+ if tapKeyboardReturnControl(app: app, allowCoordinateFallback: true) {
1032
+ sleepFor(0.2)
1033
+ let visible = isKeyboardVisible(app: app)
1034
+ if !visible {
1035
+ return (wasVisible: true, dismissed: true, visible: false)
1036
+ }
1037
+ }
1038
+
981
1039
  return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
982
1040
  #endif
983
1041
  }
@@ -1098,7 +1156,10 @@ extension RunnerTests {
1098
1156
  #endif
1099
1157
  }
1100
1158
 
1101
- private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
1159
+ private func tapKeyboardReturnControl(
1160
+ app: XCUIApplication,
1161
+ allowCoordinateFallback: Bool = false
1162
+ ) -> Bool {
1102
1163
  #if os(iOS)
1103
1164
  for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
1104
1165
  let candidates = [
@@ -1109,6 +1170,21 @@ extension RunnerTests {
1109
1170
  hittable.tap()
1110
1171
  return true
1111
1172
  }
1173
+ if allowCoordinateFallback,
1174
+ let keyboardFrame = visibleKeyboardFrame(app: app),
1175
+ let framed = candidates.first(where: {
1176
+ guard $0.exists else { return false }
1177
+ let frame = $0.frame
1178
+ return !frame.isEmpty && keyboardFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
1179
+ }) {
1180
+ let frame = framed.frame
1181
+ switch tapAt(app: app, x: frame.midX, y: frame.midY) {
1182
+ case .performed:
1183
+ return true
1184
+ case .unsupported:
1185
+ return false
1186
+ }
1187
+ }
1112
1188
  }
1113
1189
  #endif
1114
1190
  return false
@@ -1510,11 +1586,69 @@ extension RunnerTests {
1510
1586
  }
1511
1587
 
1512
1588
  func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
1513
- return performCoordinatePinch(app: app, scale: scale, x: x, y: y)
1589
+ #if os(iOS)
1590
+ // A coordinate tap+drag is a single-finger gesture: React Native reads it as a pan
1591
+ // and the pinch scale never changes (#629). Drive the two-finger XCTest synthesis
1592
+ // path (the same one transformGesture uses) with zero translation/rotation so RN's
1593
+ // pinch recognizer actually fires.
1594
+ let frame = interactionRoot(app: app).frame
1595
+ let centerX = x ?? Double(frame.midX)
1596
+ let centerY = y ?? Double(frame.midY)
1597
+ return transformGesture(
1598
+ app: app,
1599
+ x: centerX,
1600
+ y: centerY,
1601
+ dx: 0,
1602
+ dy: 0,
1603
+ scale: scale,
1604
+ degrees: 0,
1605
+ durationMs: 300
1606
+ )
1607
+ #elseif os(tvOS)
1608
+ return .unsupported(
1609
+ message: "pinch is not supported on tvOS",
1610
+ hint: "tvOS has no touch input; pinch requires a touchscreen (run on iOS)."
1611
+ )
1612
+ #else
1613
+ return .unsupported(
1614
+ message: "pinch is not supported on macOS",
1615
+ hint: "macOS automation has no multi-touch input; pinch requires a touchscreen (run on iOS)."
1616
+ )
1617
+ #endif
1514
1618
  }
1515
1619
 
1516
1620
  func rotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
1517
- return performCoordinateRotateGesture(app: app, degrees: degrees, x: x, y: y, velocity: velocity)
1621
+ #if os(iOS)
1622
+ // Drive the two-finger XCTest synthesis path (the same one pinch/transformGesture use, #634)
1623
+ // with zero translation/scale so React Native's rotation recognizer actually fires. The native
1624
+ // XCUIElement.rotate(withVelocity:) injects a single synthetic rotation that RN's gesture
1625
+ // handler does not read reliably — the same class of problem #629/#634 fixed for pinch.
1626
+ // velocity is unused on iOS (synthesis speed is governed by durationMs); the wire contract
1627
+ // keeps it for compatibility and direction is carried entirely by the sign of `degrees`.
1628
+ let frame = interactionRoot(app: app).frame
1629
+ let centerX = x ?? Double(frame.midX)
1630
+ let centerY = y ?? Double(frame.midY)
1631
+ return transformGesture(
1632
+ app: app,
1633
+ x: centerX,
1634
+ y: centerY,
1635
+ dx: 0,
1636
+ dy: 0,
1637
+ scale: 1,
1638
+ degrees: degrees,
1639
+ durationMs: 300
1640
+ )
1641
+ #elseif os(tvOS)
1642
+ return .unsupported(
1643
+ message: "rotate-gesture is not supported on tvOS",
1644
+ hint: "tvOS has no touch input; rotation gestures require a touchscreen (run on iOS)."
1645
+ )
1646
+ #else
1647
+ return .unsupported(
1648
+ message: "rotate-gesture is not supported on macOS",
1649
+ hint: "macOS automation has no multi-touch input; rotation gestures require a touchscreen (run on iOS)."
1650
+ )
1651
+ #endif
1518
1652
  }
1519
1653
 
1520
1654
  func transformGesture(
@@ -1540,13 +1674,22 @@ extension RunnerTests {
1540
1674
  radius: transformGestureRadius(frame: target.frame, scale: scale),
1541
1675
  durationMs: durationMs
1542
1676
  ) {
1543
- return .unsupported(message)
1677
+ return .unsupported(
1678
+ message: message,
1679
+ hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode (these APIs can change across Xcode versions)."
1680
+ )
1544
1681
  }
1545
1682
  return .performed
1546
1683
  #elseif os(tvOS)
1547
- return .unsupported("transformGesture is not supported on tvOS")
1684
+ return .unsupported(
1685
+ message: "transformGesture is not supported on tvOS",
1686
+ hint: "tvOS has no touch input; transform gestures require a touchscreen (run on iOS)."
1687
+ )
1548
1688
  #else
1549
- return .unsupported("transformGesture is not supported on macOS")
1689
+ return .unsupported(
1690
+ message: "transformGesture is not supported on macOS",
1691
+ hint: "macOS automation has no multi-touch input; transform gestures require a touchscreen (run on iOS)."
1692
+ )
1550
1693
  #endif
1551
1694
  }
1552
1695
 
@@ -1558,57 +1701,6 @@ extension RunnerTests {
1558
1701
  return min(max(scaleAdjustedRadius, 48.0), shorterSide * 0.35)
1559
1702
  }
1560
1703
 
1561
- private func performCoordinatePinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
1562
- #if os(tvOS)
1563
- return .unsupported("pinch is not supported on tvOS")
1564
- #else
1565
- let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
1566
-
1567
- // Use double-tap + drag gesture for reliable map zoom
1568
- // Zoom in (scale > 1): tap then drag UP
1569
- // Zoom out (scale < 1): tap then drag DOWN
1570
-
1571
- // Determine center point (use provided x/y or screen center)
1572
- let centerX = x.map { $0 / target.frame.width } ?? 0.5
1573
- let centerY = y.map { $0 / target.frame.height } ?? 0.5
1574
- let center = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: centerY))
1575
-
1576
- // Calculate drag distance based on scale (clamped to reasonable range)
1577
- // Larger scale = more drag distance
1578
- let dragAmount: CGFloat
1579
- if scale > 1.0 {
1580
- // Zoom in: drag up (negative Y direction in normalized coords)
1581
- dragAmount = min(0.4, CGFloat(scale - 1.0) * 0.2)
1582
- } else {
1583
- // Zoom out: drag down (positive Y direction)
1584
- dragAmount = min(0.4, CGFloat(1.0 - scale) * 0.4)
1585
- }
1586
-
1587
- let endY = scale > 1.0 ? (centerY - Double(dragAmount)) : (centerY + Double(dragAmount))
1588
- let endPoint = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: max(0.1, min(0.9, endY))))
1589
-
1590
- // Tap first (first tap of double-tap)
1591
- center.tap()
1592
-
1593
- // Immediately press and drag (second tap + drag)
1594
- center.press(forDuration: 0.05, thenDragTo: endPoint)
1595
- return .performed
1596
- #endif
1597
- }
1598
-
1599
- private func performCoordinateRotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
1600
- #if os(iOS)
1601
- let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
1602
- let radians = CGFloat(degrees * .pi / 180.0)
1603
- target.rotate(radians, withVelocity: CGFloat(velocity))
1604
- return .performed
1605
- #elseif os(tvOS)
1606
- return .unsupported("rotate-gesture is not supported on tvOS")
1607
- #else
1608
- return .unsupported("rotate-gesture is not supported on macOS")
1609
- #endif
1610
- }
1611
-
1612
1704
  private func interactionRoot(app: XCUIApplication) -> XCUIElement {
1613
1705
  let windows = app.windows.allElementsBoundByIndex
1614
1706
  if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
@@ -1619,7 +1711,10 @@ extension RunnerTests {
1619
1711
 
1620
1712
  private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
1621
1713
  #if os(tvOS)
1622
- return .unsupported("coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
1714
+ return .unsupported(
1715
+ message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
1716
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
1717
+ )
1623
1718
  #else
1624
1719
  interactionCoordinate(app: app, x: x, y: y).tap()
1625
1720
  return .performed
@@ -1628,7 +1723,10 @@ extension RunnerTests {
1628
1723
 
1629
1724
  private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
1630
1725
  #if os(tvOS)
1631
- return .unsupported("coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
1726
+ return .unsupported(
1727
+ message: "coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
1728
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
1729
+ )
1632
1730
  #else
1633
1731
  interactionCoordinate(app: app, x: x, y: y).doubleTap()
1634
1732
  return .performed
@@ -1637,7 +1735,10 @@ extension RunnerTests {
1637
1735
 
1638
1736
  private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
1639
1737
  #if os(tvOS)
1640
- return .unsupported("coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element")
1738
+ return .unsupported(
1739
+ message: "coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element",
1740
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then long-select it."
1741
+ )
1641
1742
  #else
1642
1743
  interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
1643
1744
  return .performed
@@ -1653,7 +1754,10 @@ extension RunnerTests {
1653
1754
  holdDuration: TimeInterval
1654
1755
  ) -> RunnerInteractionOutcome {
1655
1756
  #if os(tvOS)
1656
- return .unsupported("coordinate drag is not supported on tvOS")
1757
+ return .unsupported(
1758
+ message: "coordinate drag is not supported on tvOS",
1759
+ hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
1760
+ )
1657
1761
  #else
1658
1762
  let start = interactionCoordinate(app: app, x: x, y: y)
1659
1763
  let end = interactionCoordinate(app: app, x: x2, y: y2)
@@ -216,14 +216,14 @@ extension RunnerTests {
216
216
  // MARK: - Command Classification
217
217
 
218
218
  func isReadOnlyCommand(_ command: Command) -> Bool {
219
- switch command.command {
220
- case .interactionFrame, .findText, .readText, .snapshot, .screenshot:
219
+ switch command.command.traits.readOnly {
220
+ case .always:
221
221
  return true
222
- case .alert:
223
- let action = (command.action ?? "get").lowercased()
224
- return action == "get"
225
- default:
222
+ case .never:
226
223
  return false
224
+ case .conditional:
225
+ // Today only `alert` is conditional: read-only when getting, mutating otherwise.
226
+ return (command.action ?? "get").lowercased() == "get"
227
227
  }
228
228
  }
229
229
 
@@ -234,36 +234,11 @@ extension RunnerTests {
234
234
  }
235
235
 
236
236
  func isInteractionCommand(_ command: CommandType) -> Bool {
237
- switch command {
238
- case
239
- .tap,
240
- .longPress,
241
- .drag,
242
- .remotePress,
243
- .type,
244
- .swipe,
245
- .back,
246
- .backInApp,
247
- .backSystem,
248
- .rotate,
249
- .appSwitcher,
250
- .keyboardDismiss,
251
- .pinch,
252
- .rotateGesture,
253
- .transformGesture:
254
- return true
255
- default:
256
- return false
257
- }
237
+ return command.traits.isInteraction
258
238
  }
259
239
 
260
240
  func isRunnerLifecycleCommand(_ command: CommandType) -> Bool {
261
- switch command {
262
- case .shutdown, .recordStop, .screenshot, .uptime:
263
- return true
264
- default:
265
- return false
266
- }
241
+ return command.traits.isLifecycle
267
242
  }
268
243
 
269
244
  // MARK: - Interaction Stabilization