agent-device 0.16.13 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.13.apk → agent-device-android-multitouch-helper-0.17.0.apk} +0 -0
  2. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.0.apk.sha256 +1 -0
  3. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.13.manifest.json → agent-device-android-multitouch-helper-0.17.0.manifest.json} +4 -4
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.13.apk → agent-device-android-snapshot-helper-0.17.0.apk} +0 -0
  5. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.0.apk.sha256 +1 -0
  6. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.13.manifest.json → agent-device-android-snapshot-helper-0.17.0.manifest.json} +6 -6
  7. package/dist/src/1352.js +1 -1
  8. package/dist/src/221.js +4 -4
  9. package/dist/src/2415.js +29 -29
  10. package/dist/src/2805.js +1 -1
  11. package/dist/src/5792.js +1 -1
  12. package/dist/src/6232.js +1 -1
  13. package/dist/src/7599.js +4 -3
  14. package/dist/src/8020.js +1 -0
  15. package/dist/src/8699.js +1 -1
  16. package/dist/src/940.js +1 -1
  17. package/dist/src/9533.js +1 -1
  18. package/dist/src/android-snapshot-helper.d.ts +1 -0
  19. package/dist/src/apple.js +1 -1
  20. package/dist/src/args.js +20 -14
  21. package/dist/src/cli.js +9 -9
  22. package/dist/src/command-metadata.js +1 -1
  23. package/dist/src/contracts.d.ts +1 -0
  24. package/dist/src/find.js +1 -1
  25. package/dist/src/finders.d.ts +1 -0
  26. package/dist/src/generic.js +9 -9
  27. package/dist/src/index.d.ts +19 -1
  28. package/dist/src/interaction.js +1 -1
  29. package/dist/src/lease.js +1 -1
  30. package/dist/src/react-native.js +1 -1
  31. package/dist/src/record-trace.js +3 -3
  32. package/dist/src/selectors.d.ts +1 -0
  33. package/dist/src/session.js +11 -11
  34. package/dist/src/snapshot.js +2 -2
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +4 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +71 -0
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +41 -7
  38. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +154 -11
  39. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +11 -0
  40. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +12 -4
  41. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +26 -0
  42. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -0
  43. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +7 -1
  44. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +571 -56
  45. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +21 -0
  46. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +11 -0
  47. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +13 -2
  48. package/ios-runner/README.md +13 -0
  49. package/package.json +1 -1
  50. package/server.json +2 -2
  51. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.13.apk.sha256 +0 -1
  52. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.13.apk.sha256 +0 -1
@@ -1,2 +1,2 @@
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 D}from"./selector-runtime.js";import{parseTimeout as P}from"./6085.js";import{SETTINGS_INVALID_ARGS_MESSAGE as k,isMacOsSettingSupported as E,getUnsupportedMacOsSettingMessage as O}from"./1352.js";import{createAgentDevice as K}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,B=/^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)||B.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=>$.test(e.identifier??"")),r=s.length?[...s,...o]:(a=(t=o).some(e=>B.test(e.identifier??"")),n=t.some(e=>!B.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=P(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=P(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",O(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){var t,a;let{req:n,sessionName:s,logPath:o,sessionStore:i}=e,{session:l,device:d}=await M(i,s,n.flags);if(!g(e.command,d))return w("UNSUPPORTED_OPERATION",e.unsupportedMessage);let p=m(n.flags?.snapshotScope,l);if(!p.ok)return p;let f=(t=e.command,a=l,"ios"!==d.platform||a?.appBundleId?null:w("SESSION_NOT_FOUND",`iOS ${t} requires an active app session on the target device. Run open first (for example: open --session ${a?.name??"sim"} --platform ios --device "<name>" <app>).`));return f||await D(l,d,async()=>{var t,a;let f,m,h=function(e){let{req:t,sessionName:a,logPath:n,sessionStore:s,session:o,device:i,snapshotScope:l}=e;return K({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:n,sessionName:s,logPath:o,sessionStore:i,session:l,device:d,snapshotScope:p.scope});try{m=await e.execute({runtime:h,sessionName:s,req:n,snapshotScope:p.scope})}catch(a){let t=await el({error:a,command:e.command,logPath:o,session:l,device:d});if(!t)throw a;return t}return(f=(t={req:n,sessionName:s,sessionStore:i,result:m.record}).sessionStore.get(t.sessionName))&&t.sessionStore.recordAction(f,{command:t.req.command,positionals:t.req.positionals??[],flags:t.req.flags??{},result:"snapshot"===(a=t.result).kind?{nodes:a.nodes,truncated:a.truncated}:{mode:a.mode,baselineInitialized:a.baselineInitialized,summary:a.summary}}),{ok:!0,data:m.data}})}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 D(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 D(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};
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 g,isCommandSupportedOnDevice as y,snapshotAndroid as _,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 R,createDaemonRuntimePolicy as C,recordIfSession as N,resolveSessionDevice as M,withSessionlessRunnerCleanup as P}from"./selector-runtime.js";import{parseTimeout as k}from"./6085.js";import{SETTINGS_INVALID_ARGS_MESSAGE as D,isMacOsSettingSupported as E,getUnsupportedMacOsSettingMessage as K}from"./1352.js";import{createAgentDevice as O}from"./9533.js";var U={};e.r(U),e.d(U,{handleSnapshotCommands:()=>em});let x=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,H=/^android:id\/button[123]$/i,$=/(?:^|:)id\/permission_/i,q=/\b(?:is(?:n't| not) responding|keeps stopping|has stopped|close app|app info)\b/i,T=/^(?:ok|allow|allow all|while using the app|only this time|yes|continue|save|confirm|turn on|open settings)$/i,F=/^(?:cancel|deny|don.t allow|don’t allow|not now|no|dismiss|close|close app|later|skip)$/i;function j(e,t){let a=t[0];if(!a)return;let n=z(e,a.index);for(let a of t.slice(1)){let t=new Set(z(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 z(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 G(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 V(e){let t=e.type??"",a=e.identifier??"";return!!(e.hittable||/\bbutton\b/i.test(t)||H.test(a)||/(?:^|:)id\/permission_(?:allow|deny)/i.test(a))}function W(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 J(e,t,a={}){if("wait"===t)return await Q(e,a.timeoutMs??1e4);if("get"===t){var n;let t=await Z(e);return{kind:"alertStatus",platform:"android",action:"get",alert:n=t?.alert??null,...n?b("Alert visible"):b("No alert visible")}}return await X(e,t)}async function Q(e,t){let a=Date.now(),n=await Y(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 X(e,t){var a;let n,s,o=await Y(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),ee(t,o.alert,i.label);if("dismiss"===t)return await p(e),ee(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 Y(e,t){let a=Date.now();for(;Date.now()-a<t;){let t=await Z(e);if(t)return t;await I(300)}return null}async function Z(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??"",x.has(a)||$.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(W(t))});if(0===t.length)return[];let a=j(e,t);return void 0===a?t:G(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=>H.test(e.identifier??"")),n=t.some(e=>!H.test(e.identifier??"")),a&&n?t:[]);if(0===r.length)return[];let i=j(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,a=[];for(let n of e){let e=W(n);if(!e||!n.rect||!V(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||(T.test(t.trim())?"accept":F.test(t.trim())?"dismiss":"neutral")}(n,e)})}return a}(l),p=l.filter(e=>W(e)&&!V(e)),u=W((t=p).find(e=>/(?:^|:)id\/(?:alertTitle|permission_message)$/i.test(e.identifier??"")))||W(t[0]),c=(a=p,n=u,(r=a.map(e=>W(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 _(e,{helperWaitForIdleTimeoutMs:0,includeHiddenContentHints:!1}),{backend:"android",purpose:"alert"})).nodes)}function ee(e,t,a){return{kind:"alertHandled",platform:"android",action:e,handled:!0,alert:t,button:a,...b(`Alert ${e}ed`)}}async function et(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(!y("alert",o))return w("UNSUPPORTED_OPERATION","alert is not supported on this device");if("android"===o.platform){let t=k(a.positionals?.[1])??1e4;return eo(e,await J(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:a.flags?.verbose,logPath:n,traceLogPath:s?.trace?.outPath,requestId:a.meta?.requestId},d=async e=>await g(o,{command:"alert",action:e,appBundleId:s?.appBundleId},l);return await ea(e,r,d)}async function ea(e,t,a){if("wait"===t)return await en(e,a);let n="accept"===t||"dismiss"===t?t:"get";return"accept"===n||"dismiss"===n?await es(e,n,a):eo(e,await a("get"))}async function en(e,t){let a=k(e.req.positionals?.[1])??1e4,n=Date.now();for(;Date.now()-n<a;){try{return eo(e,await t("get"))}catch{}await I(300)}return w("COMMAND_FAILED","alert wait timed out")}async function es(e,t,a){var n,s;let o,i,l=Date.now();for(;Date.now()-l<2e3;){try{return eo(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 eo(e,t){return N(e.sessionStore,e.session,e.req,t),{ok:!0,data:t}}async function er(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(!y("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??""],g=await f(o,"settings",h,t.flags?.out,{...S(a,t.flags,m,s?.trace?.outPath)});return N(n,s,t,g??{setting:i,state:l}),{ok:!0,data:g??{setting:i,state:l}}}async function ei(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 el(e)}}}}async function el(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 ed(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 ed(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 ep(e){return await ec({...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 eu(e){return await ec({...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 ec(e){var t,a;let{req:n,sessionName:s,logPath:o,sessionStore:i}=e,{session:l,device:d}=await M(i,s,n.flags);if(!y(e.command,d))return w("UNSUPPORTED_OPERATION",e.unsupportedMessage);let p=m(n.flags?.snapshotScope,l);if(!p.ok)return p;let f=(t=e.command,a=l,"ios"!==d.platform||a?.appBundleId?null:w("SESSION_NOT_FOUND",`iOS ${t} requires an active app session on the target device. Run open first (for example: open --session ${a?.name??"sim"} --platform ios --device "<name>" <app>).`));return f||await P(l,d,async()=>{var t,a;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}),...C("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:n,sessionName:s,logPath:o,sessionStore:i,session:l,device:d,snapshotScope:p.scope});try{m=await e.execute({runtime:h,sessionName:s,req:n,snapshotScope:p.scope})}catch(a){let t=await ei({error:a,command:e.command,logPath:o,session:l,device:d});if(!t)throw a;return t}return(f=(t={req:n,sessionName:s,sessionStore:i,result:m.record}).sessionStore.get(t.sessionName))&&t.sessionStore.recordAction(f,{command:t.req.command,positionals:t.req.positionals??[],flags:t.req.flags??{},result:"snapshot"===(a=t.result).kind?{nodes:a.nodes,truncated:a.truncated}:{mode:a.mode,baselineInitialized:a.baselineInitialized,summary:a.summary}}),{ok:!0,data:m.data}})}let ef={snapshot:async({req:e,sessionName:t,logPath:a,sessionStore:n})=>await ep({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 eu({req:e,sessionName:t,logPath:a,sessionStore:n}),wait:async({req:e,sessionName:t,logPath:a,sessionStore:n})=>await R({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 et({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",D));if(!i.ok)return i;let{session:l,device:d}=await M(n,t,e.flags);return await P(l,d,async()=>await er({req:e,logPath:a,sessionStore:n,session:l,device:d,parsed:i.parsed}))}};async function em(e){let t=ef[e.req.command];return t?await t(e):null}export{U as snapshot_namespaceObject};
@@ -21,6 +21,10 @@ NS_ASSUME_NONNULL_BEGIN
21
21
  y2:(double)y2
22
22
  durationMs:(double)durationMs;
23
23
 
24
+ + (NSString * _Nullable)synthesizeTapWithApplication:(id)application
25
+ x:(double)x
26
+ y:(double)y;
27
+
24
28
  @end
25
29
 
26
30
  NS_ASSUME_NONNULL_END
@@ -53,6 +53,10 @@ static id RunnerSwipePointerPath(
53
53
  CGPoint end,
54
54
  double durationMs
55
55
  );
56
+ static id RunnerTapPointerPath(
57
+ const RunnerXCTestEventBridge *bridge,
58
+ CGPoint point
59
+ );
56
60
  static CGPoint RunnerPointerPointAt(
57
61
  double x,
58
62
  double y,
@@ -115,6 +119,18 @@ static double RunnerSmoothStep(double t);
115
119
  }
116
120
  }
117
121
 
122
+ + (NSString * _Nullable)synthesizeTapWithApplication:(id)application
123
+ x:(double)x
124
+ y:(double)y {
125
+ @try {
126
+ return [self trySynthesizeTapWithApplication:application x:x y:y];
127
+ } @catch (NSException *exception) {
128
+ NSString *name = exception.name ?: @"NSException";
129
+ NSString *reason = exception.reason ?: @"private XCTest event synthesis failed";
130
+ return [NSString stringWithFormat:@"%@: %@", name, reason];
131
+ }
132
+ }
133
+
118
134
  + (NSString * _Nullable)trySynthesizeTransformWithApplication:(id)application
119
135
  x:(double)x
120
136
  y:(double)y
@@ -224,6 +240,48 @@ static double RunnerSmoothStep(double t);
224
240
  return nil;
225
241
  }
226
242
 
243
+ + (NSString * _Nullable)trySynthesizeTapWithApplication:(id)application
244
+ x:(double)x
245
+ y:(double)y {
246
+ RunnerXCTestEventBridge bridge;
247
+ NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge);
248
+ if (missing != nil) {
249
+ return missing;
250
+ }
251
+
252
+ NSInteger interfaceOrientation =
253
+ ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector);
254
+ NSInteger targetProcessID = ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector);
255
+ if (targetProcessID <= 0) {
256
+ return @"private XCTest event synthesis unavailable: could not resolve target process ID";
257
+ }
258
+
259
+ id record = ((RunnerMsgSendInitRecord)objc_msgSend)(
260
+ [bridge.recordClass alloc],
261
+ bridge.initRecordSelector,
262
+ @"agent-device-tap",
263
+ interfaceOrientation
264
+ );
265
+ if (record == nil) {
266
+ return @"private XCTest event synthesis failed: could not create event record";
267
+ }
268
+ ((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID);
269
+
270
+ id path = RunnerTapPointerPath(&bridge, CGPointMake(x, y));
271
+ if (path == nil) {
272
+ return @"private XCTest event synthesis failed: could not create pointer path";
273
+ }
274
+ ((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path);
275
+
276
+ NSError *error = nil;
277
+ BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error);
278
+ if (!ok) {
279
+ NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
280
+ return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
281
+ }
282
+ return nil;
283
+ }
284
+
227
285
  static NSString * _Nullable RunnerResolveXCTestEventBridge(
228
286
  id application,
229
287
  RunnerXCTestEventBridge *bridge
@@ -368,6 +426,19 @@ static id RunnerSwipePointerPath(
368
426
  return path;
369
427
  }
370
428
 
429
+ static id RunnerTapPointerPath(
430
+ const RunnerXCTestEventBridge *bridge,
431
+ CGPoint point
432
+ ) {
433
+ id path =
434
+ ((RunnerMsgSendInitPath)objc_msgSend)([bridge->pathClass alloc], bridge->initPathSelector, point, 0.0);
435
+ if (path == nil) {
436
+ return nil;
437
+ }
438
+ ((RunnerMsgSendPathOffset)objc_msgSend)(path, bridge->liftSelector, 0.05);
439
+ return path;
440
+ }
441
+
371
442
  static CGPoint RunnerPointerPointAt(
372
443
  double x,
373
444
  double y,
@@ -8,20 +8,18 @@ extension RunnerTests {
8
8
  }
9
9
 
10
10
  func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
11
+ #if !os(macOS)
12
+ if let systemModal = firstBlockingSystemModal(in: springboard) {
13
+ return runnerAlert(root: systemModal, ownerApp: springboard)
14
+ }
15
+ #endif
11
16
  if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
12
17
  return runnerAlert(root: alert, ownerApp: activeApp)
13
18
  }
14
19
  if let popup = firstDismissPopupWindow(in: activeApp) {
15
20
  return runnerAlert(root: popup, ownerApp: activeApp)
16
21
  }
17
- #if os(macOS)
18
- return nil
19
- #else
20
- if let systemModal = firstBlockingSystemModal(in: springboard) {
21
- return runnerAlert(root: systemModal, ownerApp: springboard)
22
- }
23
22
  return nil
24
- #endif
25
23
  }
26
24
 
27
25
  func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
@@ -33,6 +31,27 @@ extension RunnerTests {
33
31
  if let response = unsupportedResponse(for: outcome) {
34
32
  return response
35
33
  }
34
+ sleepFor(0.2)
35
+ if alertStillVisible(alert, actionButtonLabel: button.label) {
36
+ let frame = button.frame
37
+ if !frame.isNull && !frame.isEmpty {
38
+ let coordinateOutcome = tapAt(app: alert.ownerApp, x: frame.midX, y: frame.midY)
39
+ if let response = unsupportedResponse(for: coordinateOutcome) {
40
+ return response
41
+ }
42
+ sleepFor(0.2)
43
+ }
44
+ }
45
+ if alertStillVisible(alert, actionButtonLabel: button.label) {
46
+ return Response(
47
+ ok: false,
48
+ error: ErrorPayload(
49
+ code: "INTERACTION_FAILED",
50
+ message: "alert \(action) did not dismiss the visible alert",
51
+ hint: "The alert button was found but the system still reports the alert after tapping it."
52
+ )
53
+ )
54
+ }
36
55
  return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
37
56
  }
38
57
 
@@ -53,6 +72,21 @@ extension RunnerTests {
53
72
  return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
54
73
  }
55
74
 
75
+ private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool {
76
+ guard let current = resolveAlert(app: alert.ownerApp) else {
77
+ return false
78
+ }
79
+ let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
80
+ let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
81
+ if previousTitle == currentTitle {
82
+ return true
83
+ }
84
+ let normalizedActionLabel = actionButtonLabel.trimmingCharacters(in: .whitespacesAndNewlines)
85
+ return current.buttons.contains { button in
86
+ button.label.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedActionLabel
87
+ }
88
+ }
89
+
56
90
  private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
57
91
  elements.first { isVisibleElement($0) }
58
92
  }
@@ -147,10 +147,64 @@ extension RunnerTests {
147
147
  return Response(ok: true, data: data)
148
148
  }
149
149
 
150
+ func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
151
+ let response = gestureResponse(
152
+ message: "tapped",
153
+ timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
154
+ fallback: GestureFallback(
155
+ strategy: "xctest-coordinate-tap",
156
+ message: "Runner synthesized coordinate tap is unavailable",
157
+ hint: "Using XCTest coordinate tap fallback."
158
+ )
159
+ )
160
+
161
+ XCTAssertEqual(response.ok, true)
162
+ XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-tap")
163
+ XCTAssertEqual(
164
+ response.data?.gestureFallbackMessage,
165
+ "Runner synthesized coordinate tap is unavailable"
166
+ )
167
+ XCTAssertEqual(response.data?.gestureFallbackHint, "Using XCTest coordinate tap fallback.")
168
+ }
169
+
170
+ func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
171
+ let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
172
+ let response = Response(ok: true, data: DataPayload(message: "tapped"))
173
+
174
+ let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
175
+
176
+ XCTAssertEqual(failureResponse?.ok, false)
177
+ XCTAssertEqual(failureResponse?.error?.code, "XCTEST_RECORDED_FAILURE")
178
+ XCTAssertEqual(
179
+ failureResponse?.error?.message,
180
+ "XCTest recorded a failure while executing tap; the action may not have been performed."
181
+ )
182
+ }
183
+
184
+ func testXCTestRecordedFailureResponseDoesNotWrapReadOnlyOrRunnerFatalResponses() throws {
185
+ let snapshotCommand = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-1"}"#)
186
+ let tapCommand = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
187
+ let runnerFatalResponse = Response(
188
+ ok: true,
189
+ data: DataPayload(runnerFatal: true, runnerFatalReason: "ax_snapshot_unavailable")
190
+ )
191
+
192
+ XCTAssertNil(
193
+ xctestRecordedFailureResponse(
194
+ command: snapshotCommand,
195
+ response: Response(ok: true, data: DataPayload(nodes: [], truncated: false))
196
+ )
197
+ )
198
+ XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse))
199
+ }
200
+
150
201
  func execute(command: Command) throws -> Response {
151
202
  if command.command == .status {
152
203
  return executeStatus(command: command)
153
204
  }
205
+ if command.command == .uptime {
206
+ return executeUptime()
207
+ }
154
208
  commandJournal.accept(command: command)
155
209
  return try executeAccepted(command: command)
156
210
  }
@@ -185,6 +239,13 @@ extension RunnerTests {
185
239
  return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
186
240
  }
187
241
 
242
+ func executeUptime() -> Response {
243
+ Response(
244
+ ok: true,
245
+ data: DataPayload(currentUptimeMs: currentUptimeMs())
246
+ )
247
+ }
248
+
188
249
  private func executeDispatched(command: Command) throws -> Response {
189
250
  if Thread.isMainThread {
190
251
  return try executeOnMainSafely(command: command)
@@ -229,6 +290,7 @@ extension RunnerTests {
229
290
  while true {
230
291
  var response: Response?
231
292
  var swiftError: Error?
293
+ let failureCountBefore = currentXCTestFailureCount()
232
294
  let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
233
295
  do {
234
296
  response = try self.executeOnMain(command: command)
@@ -238,8 +300,7 @@ extension RunnerTests {
238
300
  })
239
301
 
240
302
  if let exceptionMessage {
241
- currentApp = nil
242
- currentBundleId = nil
303
+ invalidateCachedTarget(reason: "objc_exception")
243
304
  if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
244
305
  NSLog(
245
306
  "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
@@ -265,14 +326,19 @@ extension RunnerTests {
265
326
  userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
266
327
  )
267
328
  }
329
+ if didRecordXCTestFailure(since: failureCountBefore),
330
+ let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
331
+ {
332
+ invalidateCachedTarget(reason: "xctest_recorded_failure")
333
+ return failureResponse
334
+ }
268
335
  if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
269
336
  NSLog(
270
337
  "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
271
338
  command.command.rawValue
272
339
  )
273
340
  hasRetried = true
274
- currentApp = nil
275
- currentBundleId = nil
341
+ invalidateCachedTarget(reason: "response_unavailable")
276
342
  sleepFor(retryCooldown)
277
343
  continue
278
344
  }
@@ -282,7 +348,9 @@ extension RunnerTests {
282
348
 
283
349
  private func executeOnMain(command: Command) throws -> Response {
284
350
  var activeApp = currentApp ?? app
285
- if !isRunnerLifecycleCommand(command.command) {
351
+ if shouldSkipAppActivationPreflight(command) {
352
+ activeApp = resolveAppWithoutActivation(command: command)
353
+ } else if !isRunnerLifecycleCommand(command.command) {
286
354
  let normalizedBundleId = command.appBundleId?
287
355
  .trimmingCharacters(in: .whitespacesAndNewlines)
288
356
  let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
@@ -408,10 +476,7 @@ extension RunnerTests {
408
476
  return Response(ok: false, error: ErrorPayload(message: "failed to stop recording: \(error.localizedDescription)"))
409
477
  }
410
478
  case .uptime:
411
- return Response(
412
- ok: true,
413
- data: DataPayload(currentUptimeMs: currentUptimeMs())
414
- )
479
+ return executeUptime()
415
480
  case .tap:
416
481
  if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
417
482
  let match = findElement(
@@ -425,6 +490,7 @@ extension RunnerTests {
425
490
  }
426
491
  if let element = match.element {
427
492
  let frame = element.frame
493
+ let isTextEntry = isTextEntryElement(element)
428
494
  let touchFrame = frame.isEmpty
429
495
  ? nil
430
496
  : resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
@@ -440,7 +506,9 @@ extension RunnerTests {
440
506
  if let response = unsupportedResponse(for: outcome) {
441
507
  return response
442
508
  }
443
- waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
509
+ if isTextEntry {
510
+ waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
511
+ }
444
512
  return gestureResponse(
445
513
  message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
446
514
  timing: timing,
@@ -462,12 +530,27 @@ extension RunnerTests {
462
530
  return Response(ok: false, error: ErrorPayload(message: "element not found"))
463
531
  }
464
532
  if let x = command.x, let y = command.y {
533
+ var fallback: GestureFallback?
534
+ if command.synthesized == true {
535
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
536
+ synthesizedTapAt(app: activeApp, x: x, y: y)
537
+ }
538
+ if case .performed = outcome {
539
+ return gestureResponse(message: "tapped", timing: timing)
540
+ }
541
+ fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
542
+ }
465
543
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
466
544
  let (timing, outcome) = performGesture(activeApp) { tapAt(app: activeApp, x: x, y: y) }
467
545
  if let response = unsupportedResponse(for: outcome) {
468
546
  return response
469
547
  }
470
- return gestureResponse(message: "tapped", timing: timing, frame: .touch(touchFrame))
548
+ return gestureResponse(
549
+ message: "tapped",
550
+ timing: timing,
551
+ frame: .touch(touchFrame),
552
+ fallback: fallback
553
+ )
471
554
  }
472
555
  return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
473
556
  case .mouseClick:
@@ -736,6 +819,7 @@ extension RunnerTests {
736
819
  needsPostSnapshotInteractionDelay = true
737
820
  return Response(ok: true, data: payload)
738
821
  } catch let failure as SnapshotCaptureFailure {
822
+ invalidateCachedTarget(reason: "ax_snapshot_failure")
739
823
  // Other thrown errors fall through to executeOnMainSafely's generic error response.
740
824
  return Response(
741
825
  ok: false,
@@ -935,6 +1019,65 @@ extension RunnerTests {
935
1019
  }
936
1020
  }
937
1021
 
1022
+ private func currentXCTestFailureCount() -> Int {
1023
+ return testRun?.failureCount ?? 0
1024
+ }
1025
+
1026
+ private func didRecordXCTestFailure(since failureCountBefore: Int) -> Bool {
1027
+ return currentXCTestFailureCount() > failureCountBefore
1028
+ }
1029
+
1030
+ private func xctestRecordedFailureResponse(command: Command, response: Response) -> Response? {
1031
+ guard response.ok else { return nil }
1032
+ if response.data?.runnerFatal == true {
1033
+ return nil
1034
+ }
1035
+ guard !isReadOnlyCommand(command), !isRunnerLifecycleCommand(command.command) else {
1036
+ return nil
1037
+ }
1038
+ return Response(
1039
+ ok: false,
1040
+ error: ErrorPayload(
1041
+ code: "XCTEST_RECORDED_FAILURE",
1042
+ message: "XCTest recorded a failure while executing \(command.command.rawValue); the action may not have been performed.",
1043
+ hint: "The iOS runner session will be restarted. Retry after a fresh snapshot, or use screenshot plus coordinate commands when the accessibility tree is unavailable."
1044
+ )
1045
+ )
1046
+ }
1047
+
1048
+ private func runnerCommandFixture(_ json: String) throws -> Command {
1049
+ try JSONDecoder().decode(Command.self, from: Data(json.utf8))
1050
+ }
1051
+
1052
+ private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
1053
+ #if os(iOS)
1054
+ // Coordinate-only synthesized taps can run after an AX-fatal screen because they do not need
1055
+ // app activation, window lookup, keyboard lookup, or element resolution. Selector/text taps
1056
+ // intentionally stay on the normal AX path because they need an element query.
1057
+ return command.command == .tap
1058
+ && command.synthesized == true
1059
+ && command.x != nil
1060
+ && command.y != nil
1061
+ && command.text == nil
1062
+ && command.selectorKey == nil
1063
+ #else
1064
+ return false
1065
+ #endif
1066
+ }
1067
+
1068
+ private func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
1069
+ guard let bundleId = command.appBundleId?
1070
+ .trimmingCharacters(in: .whitespacesAndNewlines),
1071
+ !bundleId.isEmpty
1072
+ else {
1073
+ return currentApp ?? app
1074
+ }
1075
+ if currentBundleId == bundleId, let currentApp {
1076
+ return currentApp
1077
+ }
1078
+ return XCUIApplication(bundleIdentifier: bundleId)
1079
+ }
1080
+
938
1081
  private func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response {
939
1082
  guard let text = command.text else {
940
1083
  return Response(ok: false, error: ErrorPayload(message: "type requires text"))
@@ -154,6 +154,17 @@ final class RunnerCommandJournal {
154
154
  }
155
155
 
156
156
  extension RunnerTests {
157
+ func testUptimeBypassesCommandJournal() throws {
158
+ let command = runnerJournalCommand("uptime", id: "uptime-probe")
159
+
160
+ let response = try execute(command: command)
161
+ let status = commandJournal.status(commandId: "uptime-probe")
162
+
163
+ XCTAssertEqual(response.ok, true)
164
+ XCTAssertNotNil(response.data?.currentUptimeMs)
165
+ XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.notAccepted.rawValue)
166
+ }
167
+
157
168
  func testCommandJournalRetentionPolicy() throws {
158
169
  let journal = RunnerCommandJournal()
159
170
 
@@ -10,10 +10,7 @@ extension RunnerTests {
10
10
  /// exception telemetry later. `RunnerObjCExceptionCatcher.catchException` takes a non-escaping
11
11
  /// block, so `block` may capture `inout` state.
12
12
  func safely<T>(_ tag: String, _ fallback: T, _ block: () -> T) -> T {
13
- var result = fallback
14
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
15
- result = block()
16
- })
13
+ let (result, exceptionMessage) = catchingObjCException(fallback: fallback, block)
17
14
  if let exceptionMessage {
18
15
  NSLog("AGENT_DEVICE_RUNNER_%@_IGNORED_EXCEPTION=%@", tag, exceptionMessage)
19
16
  return fallback
@@ -21,6 +18,17 @@ extension RunnerTests {
21
18
  return result
22
19
  }
23
20
 
21
+ func catchingObjCException<T>(
22
+ fallback: T,
23
+ _ block: () -> T
24
+ ) -> (result: T, exceptionMessage: String?) {
25
+ var result = fallback
26
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
27
+ result = block()
28
+ })
29
+ return (result, exceptionMessage)
30
+ }
31
+
24
32
  /// Optional-returning convenience: returns `nil` on exception (matching the common
25
33
  /// `var x: T?` + catch-and-return-nil shape).
26
34
  func safely<T>(_ tag: String, _ block: () -> T?) -> T? {
@@ -670,6 +670,32 @@ extension RunnerTests {
670
670
  #endif
671
671
  }
672
672
 
673
+ func synthesizedTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
674
+ #if os(iOS)
675
+ if let message = RunnerSynthesizedGesture.synthesizeTap(
676
+ withApplication: app,
677
+ x: x,
678
+ y: y
679
+ ) {
680
+ return .unsupported(
681
+ message: message,
682
+ hint: "Falling back to XCTest coordinate tap may be slower and can still need a healthy accessibility tree."
683
+ )
684
+ }
685
+ return .performed
686
+ #elseif os(tvOS)
687
+ return .unsupported(
688
+ message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
689
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
690
+ )
691
+ #else
692
+ return .unsupported(
693
+ message: "synthesized coordinate tap is not supported on macOS",
694
+ hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
695
+ )
696
+ #endif
697
+ }
698
+
673
699
  func keyboardAvoidingDragPoints(
674
700
  app: XCUIApplication,
675
701
  x: Double,
@@ -87,6 +87,14 @@ extension RunnerTests {
87
87
  currentBundleId = nil
88
88
  }
89
89
 
90
+ func invalidateCachedTarget(reason: String) {
91
+ if currentApp != nil || currentBundleId != nil {
92
+ NSLog("AGENT_DEVICE_RUNNER_TARGET_CACHE_INVALIDATE reason=%@", reason)
93
+ }
94
+ currentApp = nil
95
+ currentBundleId = nil
96
+ }
97
+
90
98
  func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
91
99
  let state = target.state
92
100
  #if os(macOS)
@@ -193,6 +193,8 @@ struct DataPayload: Codable {
193
193
  let gestureFallback: String?
194
194
  let gestureFallbackMessage: String?
195
195
  let gestureFallbackHint: String?
196
+ let runnerFatal: Bool?
197
+ let runnerFatalReason: String?
196
198
 
197
199
  init(
198
200
  message: String? = nil,
@@ -224,7 +226,9 @@ struct DataPayload: Codable {
224
226
  orientation: String? = nil,
225
227
  gestureFallback: String? = nil,
226
228
  gestureFallbackMessage: String? = nil,
227
- gestureFallbackHint: String? = nil
229
+ gestureFallbackHint: String? = nil,
230
+ runnerFatal: Bool? = nil,
231
+ runnerFatalReason: String? = nil
228
232
  ) {
229
233
  self.message = message
230
234
  self.text = text
@@ -256,6 +260,8 @@ struct DataPayload: Codable {
256
260
  self.gestureFallback = gestureFallback
257
261
  self.gestureFallbackMessage = gestureFallbackMessage
258
262
  self.gestureFallbackHint = gestureFallbackHint
263
+ self.runnerFatal = runnerFatal
264
+ self.runnerFatalReason = runnerFatalReason
259
265
  }
260
266
  }
261
267