agent-device 0.16.6 → 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.
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.6.apk → agent-device-android-multitouch-helper-0.16.8.apk} +0 -0
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.8.apk.sha256 +1 -0
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.6.manifest.json → agent-device-android-multitouch-helper-0.16.8.manifest.json} +4 -4
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.6.apk → agent-device-android-snapshot-helper-0.16.8.apk} +0 -0
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.8.apk.sha256 +1 -0
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.6.manifest.json → agent-device-android-snapshot-helper-0.16.8.manifest.json} +6 -6
- package/dist/src/1352.js +1 -1
- package/dist/src/2415.js +29 -29
- package/dist/src/2805.js +1 -1
- package/dist/src/6232.js +1 -0
- package/dist/src/7455.js +1 -0
- package/dist/src/8699.js +1 -1
- package/dist/src/940.js +1 -1
- package/dist/src/9471.js +1 -1
- package/dist/src/9533.js +1 -1
- package/dist/src/9542.js +1 -1
- package/dist/src/9818.js +1 -1
- package/dist/src/android-adb.d.ts +2 -0
- package/dist/src/android-snapshot-helper.d.ts +2 -0
- package/dist/src/args.js +5 -4
- package/dist/src/cli.js +6 -6
- package/dist/src/command-metadata.js +1 -1
- package/dist/src/find.js +1 -1
- package/dist/src/generic.js +10 -7
- package/dist/src/interaction.js +1 -1
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-trace.js +3 -3
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/session.js +9 -9
- package/dist/src/snapshot.js +2 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +20 -6
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +178 -74
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -33
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +71 -1
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +80 -10
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +34 -6
- package/package.json +4 -6
- package/server.json +2 -2
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.6.apk.sha256 +0 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.6.apk.sha256 +0 -1
- package/dist/src/5186.js +0 -1
package/dist/src/snapshot.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{__webpack_require__ as e}from"./rslib-runtime.js";import{promises as t}from"node:fs";import
|
|
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 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};
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
|
390
|
+
return []
|
|
365
391
|
}
|
|
366
|
-
return
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 .
|
|
219
|
+
switch command.command.traits.readOnly {
|
|
220
|
+
case .always:
|
|
221
221
|
return true
|
|
222
|
-
case .
|
|
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
|
-
|
|
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
|
-
|
|
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
|