swipium 1.5.0 → 2.0.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 (498) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +173 -280
  3. package/THREAT_MODEL.md +223 -47
  4. package/dist/appMap/automationLink.js +30 -27
  5. package/dist/appMap/automationLink.js.map +1 -1
  6. package/dist/appMap/build.js +47 -21
  7. package/dist/appMap/build.js.map +1 -1
  8. package/dist/appMap/codeIndex.js +2 -2
  9. package/dist/appMap/codeIndex.js.map +1 -1
  10. package/dist/appMap/featureIndex.js +3 -3
  11. package/dist/appMap/featureIndex.js.map +1 -1
  12. package/dist/appMap/featureModel.js +2 -2
  13. package/dist/appMap/featureModel.js.map +1 -1
  14. package/dist/appMap/firstRunApply.js +2 -2
  15. package/dist/appMap/firstRunApply.js.map +1 -1
  16. package/dist/appMap/issues.js +10 -10
  17. package/dist/appMap/issues.js.map +1 -1
  18. package/dist/appMap/migrations.js +5 -5
  19. package/dist/appMap/migrations.js.map +1 -1
  20. package/dist/appMap/prelaunch.js +2 -2
  21. package/dist/appMap/prelaunch.js.map +1 -1
  22. package/dist/appMap/projectRegistry.js +77 -11
  23. package/dist/appMap/projectRegistry.js.map +1 -1
  24. package/dist/appMap/provenance.js +2 -2
  25. package/dist/appMap/provenance.js.map +1 -1
  26. package/dist/appMap/query.js +2 -2
  27. package/dist/appMap/query.js.map +1 -1
  28. package/dist/appMap/runtimeMerge.js +3 -3
  29. package/dist/appMap/runtimeMerge.js.map +1 -1
  30. package/dist/appMap/schema.js +1 -1
  31. package/dist/appMap/schema.js.map +1 -1
  32. package/dist/appMap/screenMatch.js +1 -1
  33. package/dist/appMap/screenMatch.js.map +1 -1
  34. package/dist/appMap/staticScan.js +17 -17
  35. package/dist/appMap/staticScan.js.map +1 -1
  36. package/dist/appMap/store.js +55 -7
  37. package/dist/appMap/store.js.map +1 -1
  38. package/dist/appMap/tsAstScan.js +3 -3
  39. package/dist/appMap/tsAstScan.js.map +1 -1
  40. package/dist/artifacts/bundletool.js +5 -5
  41. package/dist/artifacts/bundletool.js.map +1 -1
  42. package/dist/artifacts/resolve.js +10 -10
  43. package/dist/artifacts/resolve.js.map +1 -1
  44. package/dist/automation/capabilities.js +3 -3
  45. package/dist/automation/capabilities.js.map +1 -1
  46. package/dist/automation/gestures.js +1 -1
  47. package/dist/automation/gestures.js.map +1 -1
  48. package/dist/automation/plan.js +7 -7
  49. package/dist/automation/plan.js.map +1 -1
  50. package/dist/automation/report.js +3 -3
  51. package/dist/automation/report.js.map +1 -1
  52. package/dist/automation/selectors.js +14 -25
  53. package/dist/automation/selectors.js.map +1 -1
  54. package/dist/automation/types.js +1 -1
  55. package/dist/automation/types.js.map +1 -1
  56. package/dist/automation/waits.js +5 -5
  57. package/dist/automation/waits.js.map +1 -1
  58. package/dist/automation/webview.js +2 -2
  59. package/dist/automation/webview.js.map +1 -1
  60. package/dist/automationGen/appiumModel.js +13 -7
  61. package/dist/automationGen/appiumModel.js.map +1 -1
  62. package/dist/automationGen/ciEmitter.js +5 -5
  63. package/dist/automationGen/ciEmitter.js.map +1 -1
  64. package/dist/automationGen/identifiers.js +224 -0
  65. package/dist/automationGen/identifiers.js.map +1 -0
  66. package/dist/automationGen/jsEmitter.js +265 -106
  67. package/dist/automationGen/jsEmitter.js.map +1 -1
  68. package/dist/automationGen/packagePatch.js +2 -2
  69. package/dist/automationGen/packagePatch.js.map +1 -1
  70. package/dist/automationGen/platformResolve.js +36 -0
  71. package/dist/automationGen/platformResolve.js.map +1 -0
  72. package/dist/automationGen/projectProfile.js +71 -26
  73. package/dist/automationGen/projectProfile.js.map +1 -1
  74. package/dist/automationGen/pythonEmitter.js +276 -105
  75. package/dist/automationGen/pythonEmitter.js.map +1 -1
  76. package/dist/automationGen/readmeEmitter.js +7 -6
  77. package/dist/automationGen/readmeEmitter.js.map +1 -1
  78. package/dist/{tools/automationGenerate.js → automationGen/run.js} +72 -113
  79. package/dist/automationGen/run.js.map +1 -0
  80. package/dist/automationGen/suitePlan.js +6 -4
  81. package/dist/automationGen/suitePlan.js.map +1 -1
  82. package/dist/automationGen/validation.js +20 -8
  83. package/dist/automationGen/validation.js.map +1 -1
  84. package/dist/build/parseBuildLog.js +2 -2
  85. package/dist/build/parseBuildLog.js.map +1 -1
  86. package/dist/build/plan.js +7 -7
  87. package/dist/build/plan.js.map +1 -1
  88. package/dist/ci/preflight.js +13 -4
  89. package/dist/ci/preflight.js.map +1 -1
  90. package/dist/cli/gc.js +74 -0
  91. package/dist/cli/gc.js.map +1 -0
  92. package/dist/cli/init.js +217 -26
  93. package/dist/cli/init.js.map +1 -1
  94. package/dist/cli/main.js +50 -0
  95. package/dist/cli/main.js.map +1 -0
  96. package/dist/cli/report.js +218 -0
  97. package/dist/cli/report.js.map +1 -0
  98. package/dist/cli/scan.js +35 -14
  99. package/dist/cli/scan.js.map +1 -1
  100. package/dist/cli/suite.js +6 -6
  101. package/dist/cli/suite.js.map +1 -1
  102. package/dist/cli/verify.js +7 -7
  103. package/dist/cli/verify.js.map +1 -1
  104. package/dist/consent/consent.js +162 -5
  105. package/dist/consent/consent.js.map +1 -1
  106. package/dist/context/detect.js +45 -14
  107. package/dist/context/detect.js.map +1 -1
  108. package/dist/context/findApps.js +6 -6
  109. package/dist/context/findApps.js.map +1 -1
  110. package/dist/context/projectRoot.js +174 -19
  111. package/dist/context/projectRoot.js.map +1 -1
  112. package/dist/context/scan.js +1 -1
  113. package/dist/context/scan.js.map +1 -1
  114. package/dist/core/capabilityGroups.js +90 -0
  115. package/dist/core/capabilityGroups.js.map +1 -0
  116. package/dist/core/target.js +3 -3
  117. package/dist/core/target.js.map +1 -1
  118. package/dist/core/targetPlan.js +65 -62
  119. package/dist/core/targetPlan.js.map +1 -1
  120. package/dist/drivers/DirectDriver.js +300 -36
  121. package/dist/drivers/DirectDriver.js.map +1 -1
  122. package/dist/drivers/SimctlDriver.js +120 -7
  123. package/dist/drivers/SimctlDriver.js.map +1 -1
  124. package/dist/drivers/WdaDriver.js +350 -50
  125. package/dist/drivers/WdaDriver.js.map +1 -1
  126. package/dist/explore/candidates.js +2 -2
  127. package/dist/explore/candidates.js.map +1 -1
  128. package/dist/explore/graph.js +5 -5
  129. package/dist/explore/graph.js.map +1 -1
  130. package/dist/explore/policy.js +5 -5
  131. package/dist/explore/policy.js.map +1 -1
  132. package/dist/explore/runner.js +301 -284
  133. package/dist/explore/runner.js.map +1 -1
  134. package/dist/explore/signatures.js +1 -1
  135. package/dist/featureTesting/executionBootstrap.js +29 -30
  136. package/dist/featureTesting/executionBootstrap.js.map +1 -1
  137. package/dist/featureTesting/featureMap.js +2 -2
  138. package/dist/featureTesting/featureMap.js.map +1 -1
  139. package/dist/featureTesting/featureScope.js +12 -12
  140. package/dist/featureTesting/featureScope.js.map +1 -1
  141. package/dist/featureTesting/mapFeatureScope.js +7 -7
  142. package/dist/featureTesting/mapFeatureScope.js.map +1 -1
  143. package/dist/featureTesting/objectiveModel.js +10 -10
  144. package/dist/featureTesting/objectiveModel.js.map +1 -1
  145. package/dist/featureTesting/resultMerge.js +6 -6
  146. package/dist/featureTesting/resultMerge.js.map +1 -1
  147. package/dist/featureTesting/sources.js +4 -4
  148. package/dist/featureTesting/sources.js.map +1 -1
  149. package/dist/featureTesting/suiteBridge.js +1 -1
  150. package/dist/featureTesting/suiteBridge.js.map +1 -1
  151. package/dist/featureTesting/synonyms.js +1 -1
  152. package/dist/featureTesting/synonyms.js.map +1 -1
  153. package/dist/featureTesting/testCaseFactory.js +11 -11
  154. package/dist/featureTesting/testCaseFactory.js.map +1 -1
  155. package/dist/featureTesting/testPlan.js +3 -3
  156. package/dist/featureTesting/testPlan.js.map +1 -1
  157. package/dist/firstRun/authStateMachine.js +1 -1
  158. package/dist/firstRun/authStateMachine.js.map +1 -1
  159. package/dist/firstRun/classifyScreen.js +5 -5
  160. package/dist/firstRun/classifyScreen.js.map +1 -1
  161. package/dist/firstRun/firstRunPlanner.js +11 -11
  162. package/dist/firstRun/firstRunPlanner.js.map +1 -1
  163. package/dist/firstRun/firstRunRunner.js +8 -8
  164. package/dist/firstRun/firstRunRunner.js.map +1 -1
  165. package/dist/firstRun/generatedDataPolicy.js +9 -9
  166. package/dist/firstRun/generatedDataPolicy.js.map +1 -1
  167. package/dist/firstRun/inputPlanner.js +5 -5
  168. package/dist/firstRun/inputPlanner.js.map +1 -1
  169. package/dist/firstRun/onboardingStateMachine.js +1 -1
  170. package/dist/firstRun/onboardingStateMachine.js.map +1 -1
  171. package/dist/firstRun/paywallPolicy.js +3 -3
  172. package/dist/firstRun/paywallPolicy.js.map +1 -1
  173. package/dist/firstRun/types.js +1 -1
  174. package/dist/firstRun/types.js.map +1 -1
  175. package/dist/fixtures/catalog.js +33 -3
  176. package/dist/fixtures/catalog.js.map +1 -1
  177. package/dist/fixtures/load.js +50 -0
  178. package/dist/fixtures/load.js.map +1 -0
  179. package/dist/flows/discover.js +2 -2
  180. package/dist/flows/discover.js.map +1 -1
  181. package/dist/flows/generate.js +20 -10
  182. package/dist/flows/generate.js.map +1 -1
  183. package/dist/flows/pack.js +3 -3
  184. package/dist/flows/pack.js.map +1 -1
  185. package/dist/flows/paths.js +57 -0
  186. package/dist/flows/paths.js.map +1 -0
  187. package/dist/flows/repair.js +111 -23
  188. package/dist/flows/repair.js.map +1 -1
  189. package/dist/flows/run.js +153 -57
  190. package/dist/flows/run.js.map +1 -1
  191. package/dist/flows/schema.js +50 -5
  192. package/dist/flows/schema.js.map +1 -1
  193. package/dist/flows/seedExec.js +4 -4
  194. package/dist/flows/seedExec.js.map +1 -1
  195. package/dist/flows/templates.js +5 -5
  196. package/dist/index.js +50 -8
  197. package/dist/index.js.map +1 -1
  198. package/dist/issues/classify.js +16 -16
  199. package/dist/issues/classify.js.map +1 -1
  200. package/dist/issues/fingerprint.js +24 -4
  201. package/dist/issues/fingerprint.js.map +1 -1
  202. package/dist/issues/index.js +94 -31
  203. package/dist/issues/index.js.map +1 -1
  204. package/dist/issues/metrics.js +4 -4
  205. package/dist/issues/metrics.js.map +1 -1
  206. package/dist/issues/recurrence.js +35 -8
  207. package/dist/issues/recurrence.js.map +1 -1
  208. package/dist/issues/report.js +4 -4
  209. package/dist/issues/report.js.map +1 -1
  210. package/dist/issues/reportBridge.js +4 -4
  211. package/dist/issues/reportBridge.js.map +1 -1
  212. package/dist/issues/schema.js +2 -2
  213. package/dist/issues/schema.js.map +1 -1
  214. package/dist/issues/sourceRevision.js +2 -2
  215. package/dist/issues/sourceRevision.js.map +1 -1
  216. package/dist/issues/store.js +110 -32
  217. package/dist/issues/store.js.map +1 -1
  218. package/dist/lib/abortScope.js +48 -0
  219. package/dist/lib/abortScope.js.map +1 -0
  220. package/dist/lib/android.js +75 -7
  221. package/dist/lib/android.js.map +1 -1
  222. package/dist/lib/coordSpace.js +1 -1
  223. package/dist/lib/coordSpace.js.map +1 -1
  224. package/dist/lib/device.js +32 -7
  225. package/dist/lib/device.js.map +1 -1
  226. package/dist/lib/gestures.js +141 -0
  227. package/dist/lib/gestures.js.map +1 -0
  228. package/dist/lib/gitignore.js +4 -3
  229. package/dist/lib/gitignore.js.map +1 -1
  230. package/dist/lib/image.js +1 -1
  231. package/dist/lib/image.js.map +1 -1
  232. package/dist/lib/lockfile.js +370 -31
  233. package/dist/lib/lockfile.js.map +1 -1
  234. package/dist/lib/needsInput.js +3 -3
  235. package/dist/lib/needsInput.js.map +1 -1
  236. package/dist/lib/png.js +2 -2
  237. package/dist/lib/png.js.map +1 -1
  238. package/dist/lib/redact.js +170 -9
  239. package/dist/lib/redact.js.map +1 -1
  240. package/dist/lib/result.js +79 -10
  241. package/dist/lib/result.js.map +1 -1
  242. package/dist/lib/schemaHash.js +3 -3
  243. package/dist/lib/schemaHash.js.map +1 -1
  244. package/dist/lib/sensitive.js +3 -2
  245. package/dist/lib/sensitive.js.map +1 -1
  246. package/dist/lib/simctl.js +3 -3
  247. package/dist/lib/simctl.js.map +1 -1
  248. package/dist/lib/spawn.js +52 -10
  249. package/dist/lib/spawn.js.map +1 -1
  250. package/dist/lib/toolAnnotations.js +114 -0
  251. package/dist/lib/toolAnnotations.js.map +1 -0
  252. package/dist/lib/wda.js +294 -15
  253. package/dist/lib/wda.js.map +1 -1
  254. package/dist/mobileAudit/checks.js +12 -12
  255. package/dist/mobileAudit/checks.js.map +1 -1
  256. package/dist/mobileAudit/evidence.js +4 -4
  257. package/dist/mobileAudit/evidence.js.map +1 -1
  258. package/dist/mobileAudit/profiles.js +1 -1
  259. package/dist/mobileAudit/profiles.js.map +1 -1
  260. package/dist/mobileAudit/results.js +3 -3
  261. package/dist/mobileAudit/results.js.map +1 -1
  262. package/dist/mobileAudit/runner.js +72 -18
  263. package/dist/mobileAudit/runner.js.map +1 -1
  264. package/dist/oracle/auth.js +1 -1
  265. package/dist/oracle/auth.js.map +1 -1
  266. package/dist/oracle/failures.js +219 -36
  267. package/dist/oracle/failures.js.map +1 -1
  268. package/dist/oracle/health.js +63 -17
  269. package/dist/oracle/health.js.map +1 -1
  270. package/dist/oracle/locator.js +13 -13
  271. package/dist/oracle/locator.js.map +1 -1
  272. package/dist/oracle/record.js +3 -3
  273. package/dist/oracle/record.js.map +1 -1
  274. package/dist/orchestration/envelope.js +2 -2
  275. package/dist/orchestration/envelope.js.map +1 -1
  276. package/dist/orchestration/goal.js +4 -4
  277. package/dist/orchestration/goal.js.map +1 -1
  278. package/dist/orchestration/testThis/execute.js +62 -21
  279. package/dist/orchestration/testThis/execute.js.map +1 -1
  280. package/dist/orchestration/testThis/pipeline.js +119 -30
  281. package/dist/orchestration/testThis/pipeline.js.map +1 -1
  282. package/dist/orchestration/testThis/plan.js +162 -73
  283. package/dist/orchestration/testThis/plan.js.map +1 -1
  284. package/dist/orchestration/testThis/sessionIntent.js +72 -0
  285. package/dist/orchestration/testThis/sessionIntent.js.map +1 -0
  286. package/dist/orchestration/testThis/terminal.js +34 -16
  287. package/dist/orchestration/testThis/terminal.js.map +1 -1
  288. package/dist/orchestration/testThis/types.js +1 -1
  289. package/dist/orchestration/testThis/types.js.map +1 -1
  290. package/dist/plan/plan.js +9 -9
  291. package/dist/plan/plan.js.map +1 -1
  292. package/dist/prompts/index.js +22 -22
  293. package/dist/prompts/index.js.map +1 -1
  294. package/dist/report/export.js +392 -37
  295. package/dist/report/export.js.map +1 -1
  296. package/dist/report/findingsDedupe.js +41 -0
  297. package/dist/report/findingsDedupe.js.map +1 -0
  298. package/dist/report/policy.js +7 -4
  299. package/dist/report/policy.js.map +1 -1
  300. package/dist/report/qaLevel.js +8 -8
  301. package/dist/report/qaLevel.js.map +1 -1
  302. package/dist/report/sarifSources.js +160 -0
  303. package/dist/report/sarifSources.js.map +1 -0
  304. package/dist/report/summary.js +2 -2
  305. package/dist/report/summary.js.map +1 -1
  306. package/dist/report/testCatalog.js +4 -4
  307. package/dist/report/testCatalog.js.map +1 -1
  308. package/dist/report/toolHealth.js +124 -0
  309. package/dist/report/toolHealth.js.map +1 -0
  310. package/dist/server.js +472 -45
  311. package/dist/server.js.map +1 -1
  312. package/dist/services/automationGenerate.js +8 -2
  313. package/dist/services/automationGenerate.js.map +1 -1
  314. package/dist/services/build.js +2 -2
  315. package/dist/services/build.js.map +1 -1
  316. package/dist/{tools → services}/flowGenerate.js +29 -13
  317. package/dist/services/flowGenerate.js.map +1 -0
  318. package/dist/services/preflight.js +4 -4
  319. package/dist/services/preflight.js.map +1 -1
  320. package/dist/services/prepareAndroid.js +46 -8
  321. package/dist/services/prepareAndroid.js.map +1 -1
  322. package/dist/services/prepareIos.js +24 -9
  323. package/dist/services/prepareIos.js.map +1 -1
  324. package/dist/services/report.js +100 -45
  325. package/dist/services/report.js.map +1 -1
  326. package/dist/services/smoke.js +32 -16
  327. package/dist/services/smoke.js.map +1 -1
  328. package/dist/services/suiteGenerate.js +28 -3
  329. package/dist/services/suiteGenerate.js.map +1 -1
  330. package/dist/services/testSuiteKnowledge.js +40 -27
  331. package/dist/services/testSuiteKnowledge.js.map +1 -1
  332. package/dist/session/attach.js +270 -6
  333. package/dist/session/attach.js.map +1 -1
  334. package/dist/session/processRegistry.js +317 -48
  335. package/dist/session/processRegistry.js.map +1 -1
  336. package/dist/session/progress.js +1 -1
  337. package/dist/session/retention.js +201 -0
  338. package/dist/session/retention.js.map +1 -0
  339. package/dist/session/store.js +275 -48
  340. package/dist/session/store.js.map +1 -1
  341. package/dist/snapshot/overlays.js +53 -8
  342. package/dist/snapshot/overlays.js.map +1 -1
  343. package/dist/snapshot/parse.js +28 -4
  344. package/dist/snapshot/parse.js.map +1 -1
  345. package/dist/snapshot/present.js +11 -5
  346. package/dist/snapshot/present.js.map +1 -1
  347. package/dist/snapshot/settle.js +39 -9
  348. package/dist/snapshot/settle.js.map +1 -1
  349. package/dist/state/consent.js +99 -0
  350. package/dist/state/consent.js.map +1 -0
  351. package/dist/state/profile.js +46 -2
  352. package/dist/state/profile.js.map +1 -1
  353. package/dist/suite/compile.js +9 -5
  354. package/dist/suite/compile.js.map +1 -1
  355. package/dist/suite/lint.js +4 -4
  356. package/dist/suite/lint.js.map +1 -1
  357. package/dist/suite/pom.js +50 -41
  358. package/dist/suite/pom.js.map +1 -1
  359. package/dist/suite/secretGuard.js +266 -0
  360. package/dist/suite/secretGuard.js.map +1 -0
  361. package/dist/suite/testcase.js +9 -7
  362. package/dist/suite/testcase.js.map +1 -1
  363. package/dist/testSuite/exporter.js +4 -4
  364. package/dist/testSuite/exporter.js.map +1 -1
  365. package/dist/testSuite/generator.js +13 -9
  366. package/dist/testSuite/generator.js.map +1 -1
  367. package/dist/testSuite/history.js +1 -1
  368. package/dist/testSuite/history.js.map +1 -1
  369. package/dist/testSuite/issueLinks.js +3 -3
  370. package/dist/testSuite/issueLinks.js.map +1 -1
  371. package/dist/testSuite/lint.js +4 -4
  372. package/dist/testSuite/lint.js.map +1 -1
  373. package/dist/testSuite/merge.js +5 -5
  374. package/dist/testSuite/merge.js.map +1 -1
  375. package/dist/testSuite/schema.js +5 -5
  376. package/dist/testSuite/schema.js.map +1 -1
  377. package/dist/testSuite/store.js +3 -3
  378. package/dist/testSuite/store.js.map +1 -1
  379. package/dist/testSuite/traceability.js +2 -2
  380. package/dist/testSuite/traceability.js.map +1 -1
  381. package/dist/tools/act.js +794 -164
  382. package/dist/tools/act.js.map +1 -1
  383. package/dist/tools/agent.js +451 -128
  384. package/dist/tools/agent.js.map +1 -1
  385. package/dist/tools/appControl.js +69 -19
  386. package/dist/tools/appControl.js.map +1 -1
  387. package/dist/tools/appMap.js +188 -130
  388. package/dist/tools/appMap.js.map +1 -1
  389. package/dist/tools/build.js +32 -42
  390. package/dist/tools/build.js.map +1 -1
  391. package/dist/tools/bundletool.js +21 -31
  392. package/dist/tools/bundletool.js.map +1 -1
  393. package/dist/tools/clearOverlay.js +55 -12
  394. package/dist/tools/clearOverlay.js.map +1 -1
  395. package/dist/tools/device.js +76 -24
  396. package/dist/tools/device.js.map +1 -1
  397. package/dist/tools/doctor.js +68 -28
  398. package/dist/tools/doctor.js.map +1 -1
  399. package/dist/tools/explore.js +41 -47
  400. package/dist/tools/explore.js.map +1 -1
  401. package/dist/tools/featureTesting.js +31 -48
  402. package/dist/tools/featureTesting.js.map +1 -1
  403. package/dist/tools/firstRun.js +34 -41
  404. package/dist/tools/firstRun.js.map +1 -1
  405. package/dist/tools/flow.js +183 -103
  406. package/dist/tools/flow.js.map +1 -1
  407. package/dist/tools/flowRepair.js +25 -13
  408. package/dist/tools/flowRepair.js.map +1 -1
  409. package/dist/tools/generate.js +52 -89
  410. package/dist/tools/generate.js.map +1 -1
  411. package/dist/tools/getArtifact.js +16 -5
  412. package/dist/tools/getArtifact.js.map +1 -1
  413. package/dist/tools/health.js +15 -12
  414. package/dist/tools/health.js.map +1 -1
  415. package/dist/tools/ios.js +45 -223
  416. package/dist/tools/ios.js.map +1 -1
  417. package/dist/tools/issues.js +251 -35
  418. package/dist/tools/issues.js.map +1 -1
  419. package/dist/tools/jobs.js +41 -16
  420. package/dist/tools/jobs.js.map +1 -1
  421. package/dist/tools/metro.js +36 -35
  422. package/dist/tools/metro.js.map +1 -1
  423. package/dist/tools/mobileAudit.js +50 -45
  424. package/dist/tools/mobileAudit.js.map +1 -1
  425. package/dist/tools/network.js +31 -14
  426. package/dist/tools/network.js.map +1 -1
  427. package/dist/tools/note.js +26 -26
  428. package/dist/tools/note.js.map +1 -1
  429. package/dist/tools/prepareIosTarget.js +10 -16
  430. package/dist/tools/prepareIosTarget.js.map +1 -1
  431. package/dist/tools/prepareTarget.js +117 -50
  432. package/dist/tools/prepareTarget.js.map +1 -1
  433. package/dist/tools/report.js +11 -22
  434. package/dist/tools/report.js.map +1 -1
  435. package/dist/tools/resolveArtifact.js +11 -15
  436. package/dist/tools/resolveArtifact.js.map +1 -1
  437. package/dist/tools/resolveTarget.js +78 -24
  438. package/dist/tools/resolveTarget.js.map +1 -1
  439. package/dist/tools/screenRecord.js +23 -21
  440. package/dist/tools/screenRecord.js.map +1 -1
  441. package/dist/tools/screenshot.js +23 -16
  442. package/dist/tools/screenshot.js.map +1 -1
  443. package/dist/tools/smoke.js +20 -16
  444. package/dist/tools/smoke.js.map +1 -1
  445. package/dist/tools/snapshot.js +155 -118
  446. package/dist/tools/snapshot.js.map +1 -1
  447. package/dist/tools/startSession.js +104 -116
  448. package/dist/tools/startSession.js.map +1 -1
  449. package/dist/tools/suite.js +72 -43
  450. package/dist/tools/suite.js.map +1 -1
  451. package/dist/tools/testSuite.js +30 -20
  452. package/dist/tools/testSuite.js.map +1 -1
  453. package/dist/tools/testThis.js +24 -28
  454. package/dist/tools/testThis.js.map +1 -1
  455. package/dist/tools/visual.js +686 -210
  456. package/dist/tools/visual.js.map +1 -1
  457. package/dist/tools/wait.js +9 -38
  458. package/dist/tools/wait.js.map +1 -1
  459. package/dist/tools/wda.js +187 -57
  460. package/dist/tools/wda.js.map +1 -1
  461. package/dist/version.js +31 -22
  462. package/dist/version.js.map +1 -1
  463. package/dist/visual/ocr.js +53 -6
  464. package/dist/visual/ocr.js.map +1 -1
  465. package/dist/visual/provider.js +80 -12
  466. package/dist/visual/provider.js.map +1 -1
  467. package/docs/README.md +24 -10
  468. package/docs/ci-reports.md +328 -0
  469. package/docs/concepts.md +227 -0
  470. package/docs/flows.md +153 -0
  471. package/docs/mcp-server.md +194 -128
  472. package/docs/physical-devices.md +84 -0
  473. package/docs/tools.md +812 -152
  474. package/package.json +3 -2
  475. package/dist/tools/assertVisual.js +0 -84
  476. package/dist/tools/assertVisual.js.map +0 -1
  477. package/dist/tools/automationGenerate.js.map +0 -1
  478. package/dist/tools/capabilities.js +0 -179
  479. package/dist/tools/capabilities.js.map +0 -1
  480. package/dist/tools/detectContext.js +0 -41
  481. package/dist/tools/detectContext.js.map +0 -1
  482. package/dist/tools/flowGenerate.js.map +0 -1
  483. package/dist/tools/history.js +0 -86
  484. package/dist/tools/history.js.map +0 -1
  485. package/dist/tools/locator.js +0 -85
  486. package/dist/tools/locator.js.map +0 -1
  487. package/dist/tools/permissions.js +0 -174
  488. package/dist/tools/permissions.js.map +0 -1
  489. package/dist/tools/plan.js +0 -52
  490. package/dist/tools/plan.js.map +0 -1
  491. package/dist/tools/screenInfo.js +0 -76
  492. package/dist/tools/screenInfo.js.map +0 -1
  493. package/dist/tools/seed.js +0 -133
  494. package/dist/tools/seed.js.map +0 -1
  495. package/dist/tools/state.js +0 -217
  496. package/dist/tools/state.js.map +0 -1
  497. package/dist/tools/visualText.js +0 -106
  498. package/dist/tools/visualText.js.map +0 -1
package/dist/tools/act.js CHANGED
@@ -1,21 +1,25 @@
1
- // qa_act — the single consolidated action tool (DESIGN §3 locked schema).
1
+ // qa_act: the single consolidated action tool (locked schema).
2
2
  // Resolves a target (ref | selector | coords), performs the action, waits for the screen
3
3
  // to settle, then returns the post-action snapshot + a deterministic health check. So one
4
4
  // call both acts AND observes the result.
5
5
  import { z } from 'zod';
6
- import { qaOk, qaError, qaStop } from '../lib/result.js';
6
+ import { qaOk, qaError, qaStop, unknownSessionError, cancelledResult } from '../lib/result.js';
7
7
  import { parseSnapshot, signature } from '../snapshot/parse.js';
8
8
  import { presentElements } from '../snapshot/present.js';
9
9
  import { obstructionAt } from '../snapshot/overlays.js';
10
10
  import { settle } from '../snapshot/settle.js';
11
11
  import { checkHealth } from '../oracle/health.js';
12
12
  import { recordHealthFindings } from '../oracle/record.js';
13
- import { makeRedactor } from '../lib/redact.js';
14
- import { getDriver } from '../session/attach.js';
15
- import { resolveTarget, setsEqual } from '../core/target.js';
13
+ import { isSecureNode, makeRedactor } from '../lib/redact.js';
14
+ import { blockedDeviceResult, getDriver } from '../session/attach.js';
15
+ import { adbInputTextChunks, escapeAdbInputText } from '../drivers/DirectDriver.js';
16
+ import { GESTURE_EDGE_INSET, SCROLL_CONTAINER_INSET, largestScrollableRect, swipeFromPoint, swipeInRect, swipeVector, } from '../lib/gestures.js';
17
+ import { center, resolveTarget, setsEqual } from '../core/target.js';
16
18
  import { recordableNativeSelector, recordableTap } from '../flows/generate.js';
17
19
  import { classifyFlowDriverError } from '../flows/run.js';
18
20
  import { structuredSignature } from '../explore/signatures.js';
21
+ import { isAbortError, runWithSignal } from '../lib/abortScope.js';
22
+ import { SECRET_VAR_NAME } from '../flows/schema.js';
19
23
  /** Drop the (already-handled) native selector before generic ref/text/id/coords resolution. */
20
24
  function stripSelector(t) {
21
25
  if (!t)
@@ -27,9 +31,327 @@ function stripSelector(t) {
27
31
  const nativeSelectorSchema = z.object({
28
32
  using: z
29
33
  .enum(['accessibility id', 'name', 'predicate string', 'class chain'])
30
- .describe('Locator strategy: "accessibility id" (accessibilityIdentifier/testID), "name", "predicate string" (NSPredicate, e.g. label == "Continue"), or "class chain" (XCUITest class chain, e.g. **/XCUIElementTypeButton[`label == "Continue"`]).'),
31
- value: z.string().describe('The locator value for the chosen strategy.'),
34
+ .describe('"accessibility id" = accessibilityIdentifier/testID; predicate string = NSPredicate; class chain = XCUITest.'),
35
+ value: z.string(),
32
36
  });
37
+ /** After the focus tap, wait for the soft keyboard instead of a fixed sleep. Poll
38
+ * imeShown() in ~100ms steps up to capMs (the old sleep), proceeding as soon as it's up.
39
+ * The tap stays a REAL touch (RN ignores synthetic focus, so tap-then-type is required);
40
+ * backends whose imeShown() throws get the original fixed sleep. */
41
+ async function awaitIme(d, capMs, floorMs = 0) {
42
+ const started = Date.now();
43
+ const deadline = started + capMs;
44
+ try {
45
+ while (Date.now() < deadline) {
46
+ if (await d.imeShown()) {
47
+ // Field hop: the IME was ALREADY up before the tap, so "shown" says nothing about the
48
+ // new field having focus yet, so give RN a floor to move focus before typing.
49
+ const rest = floorMs - (Date.now() - started);
50
+ if (rest > 0)
51
+ await new Promise((r) => setTimeout(r, rest));
52
+ return;
53
+ }
54
+ await new Promise((r) => setTimeout(r, Math.min(100, Math.max(1, deadline - Date.now()))));
55
+ }
56
+ }
57
+ catch {
58
+ // imeShown unsupported here: keep the original fixed-sleep behavior
59
+ await new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())));
60
+ }
61
+ }
62
+ /** Values to scrub from driver error text: session secrets + the value just typed, in raw AND
63
+ * adb-escaped form (escaping would otherwise defeat plain-substring redaction, H2). */
64
+ function errorSecrets(session, typed) {
65
+ const out = [...session.secrets];
66
+ for (const v of [...session.secrets, ...(typed ? [typed] : [])]) {
67
+ out.push(v);
68
+ for (const chunk of adbInputTextChunks(v))
69
+ out.push(escapeAdbInputText(chunk));
70
+ }
71
+ return out;
72
+ }
73
+ /** A typed value that IS (or contains) a value already registered as a session secret (typed
74
+ * into a secure field earlier, or provided as a secret input) must be recorded exactly like a
75
+ * secure-field value: `secret:true`, no plaintext (> a ${SECRET_n} variable at generate time),
76
+ * even when the field it goes into is an ordinary text field. Containment needs >= 3 chars so a
77
+ * 1-2 char secret can't mark every value that happens to include it. */
78
+ export function matchesSessionSecret(secrets, text) {
79
+ if (!text)
80
+ return false;
81
+ for (const s of secrets) {
82
+ if (!s)
83
+ continue;
84
+ if (text === s || (s.length >= 3 && text.includes(s)))
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+ /** How many elements (DFS order) enter the positional change fingerprint. */
90
+ const POSITION_FINGERPRINT_ELEMENTS = 80;
91
+ /** Positional fingerprint of a screen: identity + bounds of its first N surfaced elements. The
92
+ * presence-only signature set misses a scroll that moved content without adding/removing any
93
+ * element (WDA keeps off-screen cells in its tree). Bounds catch it. */
94
+ export function positionalFingerprint(byRef) {
95
+ if (!byRef?.size)
96
+ return undefined;
97
+ return [...byRef.values()]
98
+ .slice(0, POSITION_FINGERPRINT_ELEMENTS)
99
+ .map((n) => `${n.cls}|${n.id}|${n.desc}|${n.text}@${n.bounds.join(',')}`)
100
+ .join('\n');
101
+ }
102
+ /** Per-node toggle/selection state (Android checked/selected, a `value` attr where a backend
103
+ * exposes one), keyed by identity (class|id|desc|text, with a #n suffix for repeats). */
104
+ export function stateByIdentity(byRef) {
105
+ const out = new Map();
106
+ if (!byRef)
107
+ return out;
108
+ const seen = new Map();
109
+ for (const n of byRef.values()) {
110
+ const base = `${n.cls}|${n.id}|${n.desc}|${n.text}`;
111
+ const k = (seen.get(base) ?? 0) + 1;
112
+ seen.set(base, k);
113
+ out.set(k > 1 ? `${base}#${k}` : base, `${n.attrs.checked ?? ''}/${n.attrs.selected ?? ''}/${n.attrs.value ?? ''}`);
114
+ }
115
+ return out;
116
+ }
117
+ /** Refs (in `post`) whose checked/selected/value state differs from `pre` for the SAME identity:
118
+ * a Switch/Checkbox toggle, a tab selection. The presence-only signature misses these, so a real
119
+ * toggle looked like "no change" and the press retry toggled it straight back. */
120
+ export function stateChangedRefs(pre, postByRef) {
121
+ if (!pre.size)
122
+ return [];
123
+ const post = stateByIdentity(postByRef);
124
+ const keys = [...post.keys()];
125
+ const refs = [...postByRef.keys()];
126
+ const out = [];
127
+ keys.forEach((k, i) => {
128
+ const before = pre.get(k);
129
+ if (before !== undefined && before !== post.get(k))
130
+ out.push(refs[i]);
131
+ });
132
+ return out;
133
+ }
134
+ /** Human form of a node's toggle state for the diff line. */
135
+ function stateLabel(n) {
136
+ const parts = [];
137
+ if (n.attrs.checkable === 'true' || n.attrs.checked === 'true')
138
+ parts.push(`checked=${n.attrs.checked === 'true'}`);
139
+ if (n.attrs.selected === 'true' || !parts.length)
140
+ parts.push(`selected=${n.attrs.selected === 'true'}`);
141
+ if (n.attrs.value)
142
+ parts.push(`value=${JSON.stringify(n.attrs.value)}`);
143
+ return parts.join(' ');
144
+ }
145
+ /** Share of post-action elements that are new before observe:"diff" falls back to the full list
146
+ * (after a navigation "diff" = every new element + every old one listed as removed, larger
147
+ * than "full"). */
148
+ export const DIFF_FULL_FALLBACK_RATIO = 0.5;
149
+ /** Only `${SWIPIUM_*}` placeholders are expanded in typed text (anything else stays literal). */
150
+ const INPUT_PLACEHOLDER_RE = /\$\{(SWIPIUM_[A-Z0-9_]+)\}/g;
151
+ /** Env-sourced placeholder values whose NAME looks secret join the redaction set. Uses the shared
152
+ * SECRET_VAR_NAME (flows/schema.ts), so `code` (SWIPIUM_VERIFICATION_CODE) counts here too. */
153
+ const SECRET_VAR_NAME_RE = SECRET_VAR_NAME;
154
+ /** Expand `${SWIPIUM_*}` placeholders from session inputs (qa_continue_from_blocker values), else the server
155
+ * env. Returns the expanded text, the variable names used, which of them are secret, and any
156
+ * that could not be resolved. PURE apart from reading `env`. */
157
+ export function expandInputPlaceholders(text, inputs, env = process.env) {
158
+ const vars = [];
159
+ const missing = [];
160
+ const secretValues = [];
161
+ const out = text.replace(INPUT_PLACEHOLDER_RE, (whole, name) => {
162
+ const fromSession = inputs.values.get(name);
163
+ const value = fromSession ?? env[name];
164
+ if (value === undefined) {
165
+ missing.push(name);
166
+ return whole;
167
+ }
168
+ if (!vars.includes(name))
169
+ vars.push(name);
170
+ if (fromSession !== undefined ? inputs.secretVars.has(name) : SECRET_VAR_NAME_RE.test(name))
171
+ secretValues.push(value);
172
+ return value;
173
+ });
174
+ return { text: out, vars, secretValues, missing };
175
+ }
176
+ /** What to RECORD for a typed value: the placeholder template when the agent typed one, else
177
+ * `${VAR}` when the value equals a stored session input (email/username included), else the
178
+ * literal. Keeps provided test data out of generated flows as literals. */
179
+ export function recordableTypedText(template, typed, vars, inputs) {
180
+ if (vars.length)
181
+ return template;
182
+ if (typed)
183
+ for (const [name, value] of inputs)
184
+ if (value && value === typed)
185
+ return `\${${name}}`;
186
+ return typed;
187
+ }
188
+ /** Typed failure code for a resolveTarget() error message. */
189
+ function targetErrorCode(error) {
190
+ if (/^AMBIGUOUS_SELECTOR/.test(error))
191
+ return 'AMBIGUOUS_SELECTOR';
192
+ if (/^No target provided/.test(error))
193
+ return 'INVALID_ARGUMENT';
194
+ if (/^No @e\d+ in the latest snapshot|no longer on screen/.test(error))
195
+ return 'STALE_REF';
196
+ if (/^No element matched/.test(error))
197
+ return 'ELEMENT_NOT_FOUND';
198
+ return 'UNKNOWN';
199
+ }
200
+ /** Minimum wait after a focus tap when the keyboard was already shown (field hop). */
201
+ export const IME_HOP_FLOOR_MS = 250;
202
+ /** A reported IME frame taller than this fraction of the screen is not a keyboard rect (older
203
+ * builds size the IME window near full-screen; accessory bars/suggestion strips can inflate it),
204
+ * so it is treated as UNKNOWN rather than as "the keyboard covers everything". */
205
+ export const IME_MAX_HEIGHT_FRACTION = 0.55;
206
+ /** Warning attached when the keyboard is up but its area could not be determined. */
207
+ export const IME_UNKNOWN_AREA_WARNING = 'keyboard is up; could not determine its area; tapped without hiding it';
208
+ /** Warning attached to a qa_act result when the WDA session had to be re-created (after an
209
+ * invalid-session error). The recovery sends forceAppLaunch:false + shouldTerminateApp:false, but
210
+ * WDA can still have lost the app (e.g. it crashed / WDA restarted), so this does not promise it. */
211
+ export const WDA_SESSION_RECOVERED_WARNING = 'WDA session was re-created (requested without relaunching the app); verify the screen state';
212
+ /** Keyboard state for the obstruction check: shown? and, when KNOWN, where. `rect` is absent
213
+ * when the backend cannot report a plausible frame. Callers must not guess (a bottom-40%
214
+ * guess hid the keyboard for targets that were actually above it: accessory bars, chips). */
215
+ async function keyboardArea(d) {
216
+ let frame;
217
+ if (d.imeState) {
218
+ // One round trip for shown + frame (Android: one adb shell; WDA: one keyboard lookup).
219
+ const st = await d.imeState().catch(() => ({ shown: false, frame: null }));
220
+ if (!st.shown)
221
+ return { shown: false };
222
+ frame = st.frame;
223
+ }
224
+ else {
225
+ if (!(await d.imeShown().catch(() => false)))
226
+ return { shown: false };
227
+ frame = d.imeFrame ? await d.imeFrame().catch(() => null) : null;
228
+ }
229
+ if (!frame)
230
+ return { shown: true };
231
+ const size = await d.screenSize().catch(() => null);
232
+ if (size && frame[3] - frame[1] > size.height * IME_MAX_HEIGHT_FRACTION)
233
+ return { shown: true };
234
+ return { shown: true, rect: frame };
235
+ }
236
+ function inRect(r, x, y) {
237
+ return !!r && x >= r[0] && x < r[2] && y >= r[1] && y < r[3];
238
+ }
239
+ /** After the keyboard hid, the layout may shift and refs renumber: re-find the SAME node
240
+ * (class/id/text/desc, nearest to its old position) in a fresh dump. */
241
+ async function reresolveAfterKeyboard(session, d, t, target) {
242
+ if (!t.ref || !target?.ref)
243
+ return resolveTarget(session, target); // text/id re-dump + re-match
244
+ const old = session.lastSnapshot?.fullByRef.get(t.ref);
245
+ const parsed = parseSnapshot(await d.dumpXml());
246
+ session.lastSnapshot = { fullByRef: parsed.fullByRef, signatures: new Set(parsed.elements.map(signature)), allNodes: parsed.allNodes };
247
+ if (!old)
248
+ return { error: `${t.ref} is no longer on screen after hiding the keyboard; re-run qa_snapshot.` };
249
+ let best;
250
+ for (const [ref, node] of parsed.fullByRef) {
251
+ if (node.cls !== old.cls || node.id !== old.id || node.text !== old.text || node.desc !== old.desc)
252
+ continue;
253
+ const c = center(node.bounds);
254
+ const dist = Math.abs(c.x - t.x) + Math.abs(c.y - t.y);
255
+ if (!best || dist < best.dist)
256
+ best = { ref, node, dist };
257
+ }
258
+ if (!best)
259
+ return { error: `${t.ref} is no longer on screen after hiding the keyboard; re-run qa_snapshot.` };
260
+ return { ...center(best.node.bounds), via: best.ref, ref: best.ref, textLen: best.node.text.length, secure: isSecureNode(best.node) };
261
+ }
262
+ /** H5: a tap on a point under the soft keyboard lands on a KEY (typing a stray character).
263
+ * Only when a KNOWN keyboard frame contains the target's center: hide the keyboard (never a
264
+ * blind BACK), re-resolve the target, and proceed when it is uncovered, else
265
+ * KEYBOARD_OBSTRUCTION. When the frame is UNKNOWN (no/implausible frame) the keyboard is NOT
266
+ * hidden: the tap proceeds with a warning. Returns the (possibly re-resolved) point, whether the
267
+ * IME is up at tap time, and an optional warning for the result. */
268
+ async function guardKeyboard(session, d, t, target) {
269
+ const kb = await keyboardArea(d);
270
+ if (!kb.shown)
271
+ return { t, imeUp: false };
272
+ if (!kb.rect)
273
+ return { t, imeUp: true, warning: IME_UNKNOWN_AREA_WARNING };
274
+ if (!inRect(kb.rect, t.x, t.y))
275
+ return { t, imeUp: true };
276
+ const hid = d.hideKeyboard ? await d.hideKeyboard().catch(() => false) : false;
277
+ if (!hid) {
278
+ return {
279
+ result: qaError({
280
+ what: `Target at (${t.x},${t.y}) is covered by the soft keyboard and this backend could not hide it. Nothing was tapped.`,
281
+ changedState: false,
282
+ retrySafe: true,
283
+ failureCode: 'KEYBOARD_OBSTRUCTION',
284
+ nextSteps: [
285
+ 'Dismiss the keyboard (qa_clear_overlay strategy:"hide_keyboard", or press enter to submit), scroll the target above it, then retry.',
286
+ ],
287
+ }, { blockedByOverlay: true, obstructedBy: { type: 'keyboard', bounds: kb.rect } }),
288
+ };
289
+ }
290
+ await new Promise((r) => setTimeout(r, 300)); // let the layout reflow after the IME hides
291
+ const again = await reresolveAfterKeyboard(session, d, t, target);
292
+ if ('error' in again)
293
+ return {
294
+ result: qaError({
295
+ what: `The soft keyboard was hidden (it covered the target), but ${again.error} Nothing was tapped.`,
296
+ changedState: true,
297
+ retrySafe: true,
298
+ failureCode: 'KEYBOARD_OBSTRUCTION',
299
+ nextSteps: ['Run qa_snapshot to see the screen with the keyboard hidden, then retry.'],
300
+ }, { keyboardHidden: true }),
301
+ };
302
+ const after = await keyboardArea(d);
303
+ if (after.shown && after.rect && inRect(after.rect, again.x, again.y)) {
304
+ return {
305
+ result: qaError({
306
+ what: `The soft keyboard was hidden, but the target at (${again.x},${again.y}) is still covered by it. Nothing was tapped.`,
307
+ changedState: true,
308
+ retrySafe: true,
309
+ failureCode: 'KEYBOARD_OBSTRUCTION',
310
+ nextSteps: [
311
+ 'Dismiss the keyboard (qa_clear_overlay strategy:"hide_keyboard", or press enter to submit), scroll the target above it, then retry.',
312
+ ],
313
+ }, { blockedByOverlay: true, keyboardHidden: true, obstructedBy: { type: 'keyboard', bounds: after.rect } }),
314
+ };
315
+ }
316
+ return {
317
+ t: again,
318
+ imeUp: after.shown,
319
+ hidKeyboard: true,
320
+ ...(after.shown && !after.rect ? { warning: IME_UNKNOWN_AREA_WARNING } : {}),
321
+ };
322
+ }
323
+ // Per-action schema contract feasibility note: SDK ^1.19 registerTool DOES accept an
324
+ // arbitrary ZodType (AnySchema) as inputSchema, and validateToolInput parses unions correctly,
325
+ // but the tools/list serializer goes through normalizeObjectSchema(), which only understands
326
+ // plain object schemas / raw shapes and silently falls back to an EMPTY object schema for a
327
+ // root z.discriminatedUnion (verified empirically: the tool lists as {"type":"object",
328
+ // "properties":{}}, so the model would see NO fields at all). The strongest contract the SDK
329
+ // permits is therefore a flat ZodRawShape whose field descriptions name the action(s) they
330
+ // belong to, with the cross-field requirements enforced by this single table + validator
331
+ // (replacing the scattered per-case fail('X requires Y') calls).
332
+ const REQUIRED_BY_ACTION = {
333
+ type: ['text'],
334
+ swipe: ['direction'],
335
+ scroll: ['direction'],
336
+ press: ['key'],
337
+ open_url: ['url'],
338
+ };
339
+ /** The ONE cross-field validation layer. Names both the action and the missing field. */
340
+ function missingRequiredField(action, args) {
341
+ for (const field of REQUIRED_BY_ACTION[action] ?? []) {
342
+ // `text` may legitimately be '' (typing nothing in replace mode); the other fields may not.
343
+ if (args[field] == null || (field !== 'text' && args[field] === '')) {
344
+ return qaError({
345
+ what: `Action "${action}" requires \`${field}\`.`,
346
+ changedState: false,
347
+ retrySafe: true,
348
+ failureCode: 'INVALID_ARGUMENT',
349
+ nextSteps: [`Re-call qa_act with action:"${action}" and \`${field}\` set (see the ${field} parameter description).`],
350
+ });
351
+ }
352
+ }
353
+ return undefined;
354
+ }
33
355
  function screenTitleFromNodes(nodes) {
34
356
  if (!nodes.length)
35
357
  return undefined;
@@ -66,16 +388,10 @@ function recordableNativeTarget(session, native) {
66
388
  export function registerAct(server, sessions) {
67
389
  server.registerTool('qa_act', {
68
390
  title: 'Act on the screen',
69
- description: 'Perform one synchronized UI action and observe the result. Actions & their fields:\n' +
70
- '- tap: target {ref|text|id|selector:{using,value}|x,y}\n' +
71
- '- type: target (a field) + text + mode:"replace"|"append" (default replace); optional submit:true\n' +
72
- '- clear: target (a field), focuses it and clears existing text\n' +
73
- '- swipe: direction up|down|left|right (optional target as start point)\n' +
74
- '- scroll: direction + optional untilVisible {text|id} + maxScrolls (default 8)\n' +
75
- '- press: key back|home|enter\n' +
76
- '- open_url: url (deep link)\n' +
77
- '- wait: for {settled:true | ref | text | id | selector:{using,value}} + timeoutMs\n' +
78
- 'Every non-wait action auto-waits for the screen to settle, then returns the post snapshot (quality + @eN elements), whether the screen changed, and a health check.',
391
+ description: 'Perform one UI action, wait for the screen to settle, and observe: changed/settled, snapshot quality, a health check, and ' +
392
+ 'post-action elements per observe. action: tap, type, clear, swipe, scroll, press, open_url, wait (each param names its ' +
393
+ 'actions). Target @eN refs from qa_snapshot (invalid after navigation), text, id, a native selector (WDA iOS), or x/y. A field ' +
394
+ 'hidden under the keyboard is handled (hide + re-resolve; KEYBOARD_OBSTRUCTION if still covered). See docs/tools.md#qa_act.',
79
395
  inputSchema: {
80
396
  sessionId: z.string(),
81
397
  action: z.enum(['tap', 'type', 'clear', 'swipe', 'scroll', 'press', 'open_url', 'wait']),
@@ -84,54 +400,66 @@ export function registerAct(server, sessions) {
84
400
  ref: z.string().optional(),
85
401
  text: z.string().optional(),
86
402
  id: z.string().optional(),
87
- selector: nativeSelectorSchema
88
- .optional()
89
- .describe('Backend-native selector object, e.g. { using: "accessibility id", value: "email" }. Requires native selector support (WDA-backed iOS); on other backends target by ref/text/id/coordinates.'),
403
+ selector: nativeSelectorSchema.optional(),
90
404
  index: z.number().optional(),
91
405
  x: z.number().optional(),
92
406
  y: z.number().optional(),
93
407
  })
94
- .optional(),
95
- text: z.string().optional(),
96
- mode: z.enum(['replace', 'append']).optional(),
97
- submit: z.boolean().optional(),
98
- direction: z.enum(['up', 'down', 'left', 'right']).optional(),
99
- untilVisible: z.object({ text: z.string().optional(), id: z.string().optional() }).optional(),
100
- maxScrolls: z.number().optional(),
101
- key: z.enum(['back', 'home', 'enter']).optional(),
102
- url: z.string().optional(),
103
- durationMs: z
104
- .number()
105
408
  .optional()
106
- .describe('tap press duration. Coordinate taps default to ~100ms (RN ignores instant taps); ref/selector taps are instant unless set.'),
107
- ignoreOverlay: z
108
- .boolean()
409
+ .describe('tap/type/clear (required); swipe: optional start point.'),
410
+ text: z.string().optional().describe('type (required)'),
411
+ mode: z.enum(['replace', 'append']).optional().describe('type: replace (default) clears first.'),
412
+ submit: z.boolean().optional().describe('type: press enter after.'),
413
+ direction: z.enum(['up', 'down', 'left', 'right']).optional().describe('swipe/scroll (required)'),
414
+ untilVisible: z
415
+ .object({ text: z.string().optional(), id: z.string().optional() })
109
416
  .optional()
110
- .describe('tap even if an overlay obstructs the target (default false → returns blockedByOverlay).'),
417
+ .describe('scroll: stop once visible (checked before the first swipe).'),
418
+ maxScrolls: z.number().optional().describe('scroll: max swipes (default 8).'),
419
+ key: z.enum(['back', 'home', 'enter']).optional().describe('press (required)'),
420
+ url: z.string().optional().describe('open_url (required)'),
421
+ durationMs: z.number().optional().describe('tap: press duration (coordinate taps default ~100ms).'),
422
+ ignoreOverlay: z.boolean().optional().describe('tap: tap even if an overlay/keyboard covers the target.'),
111
423
  for: z
112
424
  .object({
113
425
  settled: z.boolean().optional(),
114
426
  ref: z.string().optional(),
115
427
  text: z.string().optional(),
116
428
  id: z.string().optional(),
117
- selector: nativeSelectorSchema
118
- .optional()
119
- .describe('Backend-native selector object, e.g. { using: "accessibility id", value: "email" }. Requires WDA/native selector support.'),
429
+ selector: nativeSelectorSchema.optional(),
120
430
  })
121
- .optional(),
122
- timeoutMs: z.number().optional(),
431
+ .optional()
432
+ .describe('wait: {settled:true} (default) or an element.'),
433
+ timeoutMs: z.number().optional().describe('wait: max ms (default 8000); others: settle-wait cap.'),
434
+ observe: z
435
+ .enum(['diff', 'full', 'none'])
436
+ .optional()
437
+ .describe('diff (default after a snapshot): elements added/removed; full: capped list; none: verdicts only.'),
123
438
  },
124
- }, async (args) => {
439
+ },
440
+ // Cancellation (MCP notifications/cancelled): the call's signal is scoped to THIS call
441
+ // (abortScope): an in-flight adb child / WDA request is aborted without touching a
442
+ // concurrently running job's own cancellation.
443
+ async (args, extra) => runWithSignal(extra?.signal, async () => {
125
444
  const { sessionId, action } = args;
445
+ // Single validation layer for the per-action field contract (see REQUIRED_BY_ACTION).
446
+ const invalid = missingRequiredField(action, args);
447
+ if (invalid)
448
+ return invalid;
126
449
  const session = sessions.get(sessionId);
127
- const { driver: d } = session ? await getDriver(session) : { driver: undefined };
128
- if (!session || !d) {
129
- return qaError({
130
- what: 'No device attached to this session',
131
- changedState: false,
132
- retrySafe: true,
133
- nextSteps: ['Call qa_prepare_target first.'],
134
- });
450
+ if (!session)
451
+ return unknownSessionError(sessionId);
452
+ const { driver: d, blocked } = await getDriver(session);
453
+ if (!d) {
454
+ // H6: a device is online but refused (physical) or not ready (still booting).
455
+ return (blockedDeviceResult(blocked) ??
456
+ qaError({
457
+ what: 'No device attached to this session',
458
+ changedState: false,
459
+ retrySafe: true,
460
+ failureCode: 'NO_DEVICE',
461
+ nextSteps: ['Call qa_prepare_target first.'],
462
+ }));
135
463
  }
136
464
  if (d.kind === 'simulator') {
137
465
  return qaError({
@@ -140,28 +468,38 @@ export function registerAct(server, sessions) {
140
468
  retrySafe: false,
141
469
  failureCode: 'BACKEND_UNSUPPORTED',
142
470
  nextSteps: [
143
- 'Attach WebDriverAgent with qa_wda for structured tap/type/snapshot. Without WDA, navigate via qa_ios deep links and verify with qa_assert_visual.',
471
+ 'Attach WebDriverAgent with qa_wda for structured tap/type/snapshot. Without WDA, locate targets with qa_visual (mode:"find_text" OCR or mode:"find_image" return tappable device coordinates; tap:true taps via idb when installed), navigate via qa_ios deep links, and verify with qa_visual mode:"assert" or mode:"diff".',
144
472
  ],
145
473
  });
146
474
  }
147
475
  // Budget gate (review §4.1 / Rec 4): refuse new work once the session budget is spent.
148
476
  // `wait` is exempt from the action/screenshot caps (it's synchronization, not an
149
- // action), but it is NOT exempt from the TIME budget — otherwise repeated waits could
477
+ // action), but it is NOT exempt from the TIME budget, otherwise repeated waits could
150
478
  // burn the clock indefinitely.
151
479
  const stopReason = sessions.budgetStop(session);
152
480
  if (stopReason && (action !== 'wait' || /time budget/.test(stopReason))) {
153
481
  return qaStop(stopReason, { counters: session.counters, mode: session.mode });
154
482
  }
155
- const fail = (what, changedState) => qaError({ what, changedState, retrySafe: true, nextSteps: ['Run qa_snapshot to see the current screen, then retry.'] });
483
+ const fail = (what, changedState) => qaError({
484
+ what,
485
+ changedState,
486
+ retrySafe: true,
487
+ failureCode: targetErrorCode(what),
488
+ nextSteps: ['Run qa_snapshot to see the current screen, then retry.'],
489
+ });
156
490
  // ---- wait is its own path (no settle/health afterward) ----
157
491
  if (action === 'wait') {
158
492
  const timeoutMs = args.timeoutMs ?? 8000;
159
493
  if (args.for?.settled || !args.for) {
160
494
  const s = await settle(d, { timeoutMs });
161
495
  const post = parseSnapshot(s.xml);
162
- session.lastSnapshot = { fullByRef: post.fullByRef, signatures: new Set(post.elements.map(signature)), allNodes: post.allNodes };
496
+ session.lastSnapshot = {
497
+ fullByRef: post.fullByRef,
498
+ signatures: new Set(post.elements.map(signature)),
499
+ allNodes: post.allNodes,
500
+ };
163
501
  const { elements: shown, rendered, omitted } = presentElements(post.elements, makeRedactor(session.secrets));
164
- return qaOk({ action, settled: s.settled, quality: post.quality.verdict, elementsOmitted: omitted, elements: shown }, `wait(settled)=${s.settled}\n\n${rendered}`);
502
+ return qaOk({ action, settled: s.settled, quality: post.quality.verdict, elementsOmitted: omitted, elements: shown }, `wait(settled)=${s.settled}\n\n${rendered}`, { textOmit: ['elements'] });
165
503
  }
166
504
  const deadline = Date.now() + timeoutMs;
167
505
  const want = args.for;
@@ -185,6 +523,7 @@ export function registerAct(server, sessions) {
185
523
  what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`,
186
524
  changedState: false,
187
525
  retrySafe: true,
526
+ failureCode: 'ELEMENT_NOT_FOUND',
188
527
  nextSteps: ['Re-check the native selector value, or run qa_snapshot to inspect the current screen.'],
189
528
  });
190
529
  }
@@ -207,18 +546,34 @@ export function registerAct(server, sessions) {
207
546
  what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`,
208
547
  changedState: false,
209
548
  retrySafe: true,
549
+ failureCode: 'ELEMENT_NOT_FOUND',
210
550
  nextSteps: ['Re-snapshot; the element may use different text/id.'],
211
551
  });
212
552
  }
213
553
  // Phase timing (P1.6): mark the first real action so the report can split setup vs active.
214
554
  sessions.milestone(session, 'first_action');
215
555
  const preSigs = session.lastSnapshot?.signatures ?? new Set();
556
+ // F: positions too. A scroll/swipe can move content without adding/removing elements.
557
+ const prePositions = positionalFingerprint(session.lastSnapshot?.fullByRef);
558
+ // Toggle/selection state of the pre-action screen (a Switch flip changes no signature).
559
+ const preState = stateByIdentity(session.lastSnapshot?.fullByRef);
560
+ // The value actually typed (after ${SWIPIUM_*} expansion), redacted from results/errors.
561
+ let typedValue;
562
+ // untilVisible's last probe dump. Seeds the post-action settle (no redundant dump).
563
+ let settleSeed;
564
+ // I: set when the soft keyboard was hidden because it covered the target.
565
+ let keyboardHidden = false;
566
+ // Observation mode is fixed at action start; diff needs a pre-action baseline.
567
+ const observe = args.observe ?? (session.lastSnapshot ? 'diff' : 'full');
216
568
  let meta = {};
217
569
  // remembered so a no-change tap can be retried as a longer press (RN tap quirk)
570
+ // imeUp: the soft keyboard was shown at tap time, so a re-press could hit a key (H5).
218
571
  let tapRetry;
219
572
  // action-IR step to record once the action succeeds (built here while lastSnapshot is
220
573
  // still the PRE-navigation screen, so a tapped @ref still resolves to its label).
221
574
  let toRecord;
575
+ // Non-fatal caveats for the result (unknown keyboard area, recovered WDA session, …).
576
+ const warnings = [];
222
577
  try {
223
578
  switch (action) {
224
579
  case 'tap': {
@@ -237,13 +592,31 @@ export function registerAct(server, sessions) {
237
592
  toRecord = { action: 'tap', ...recordableNativeTarget(session, native) };
238
593
  break;
239
594
  }
240
- const t = await resolveTarget(session, stripSelector(args.target));
241
- if ('error' in t)
242
- return fail(t.error, false);
595
+ const resolved = await resolveTarget(session, stripSelector(args.target));
596
+ if ('error' in resolved)
597
+ return fail(resolved.error, false);
598
+ let t = resolved;
599
+ // H5: keyboard obstruction (the IME window is not in the app's UI dump, so the
600
+ // overlay check below can't see it). Coordinate taps are taken as deliberate.
601
+ let imeUp = false;
602
+ if (!args.ignoreOverlay && t.via !== 'coords') {
603
+ const g = await guardKeyboard(session, d, t, stripSelector(args.target));
604
+ if ('result' in g)
605
+ return g.result;
606
+ t = g.t;
607
+ imeUp = g.imeUp;
608
+ if (g.warning)
609
+ warnings.push(g.warning);
610
+ if (g.hidKeyboard)
611
+ keyboardHidden = true;
612
+ }
613
+ else {
614
+ imeUp = await d.imeShown().catch(() => false);
615
+ }
243
616
  // Overlay obstruction check (CR4): if another element is drawn over the target
244
617
  // point, return a structured blockedByOverlay instead of tapping blindly.
245
618
  if (!args.ignoreOverlay && t.via !== 'coords' && session.lastSnapshot?.allNodes) {
246
- // works for ref AND selector taps — t.ref is the resolved @eN in either case
619
+ // works for ref AND selector taps: t.ref is the resolved @eN in either case
247
620
  const node = t.ref ? session.lastSnapshot.fullByRef.get(t.ref) : undefined;
248
621
  const obs = obstructionAt(session.lastSnapshot.allNodes, node, t.x, t.y);
249
622
  if (obs.obstructed) {
@@ -253,7 +626,7 @@ export function registerAct(server, sessions) {
253
626
  retrySafe: true,
254
627
  failureCode: 'OVERLAY_OBSTRUCTION',
255
628
  nextSteps: [
256
- 'Call qa_clear_overlay (auto, or hide_keyboard/minimize_logbox), then retry — or pass ignoreOverlay:true to tap anyway.',
629
+ 'Call qa_clear_overlay (auto, or hide_keyboard/minimize_logbox), then retry. Or pass ignoreOverlay:true to tap anyway.',
257
630
  ],
258
631
  }, { blockedByOverlay: true, obstructedBy: obs.by });
259
632
  }
@@ -266,14 +639,48 @@ export function registerAct(server, sessions) {
266
639
  await d.pressXY(t.x, t.y, durationMs);
267
640
  else
268
641
  await d.tapXY(t.x, t.y);
269
- tapRetry = { x: t.x, y: t.y, instant: !durationMs };
642
+ tapRetry = { x: t.x, y: t.y, instant: !durationMs, imeUp };
270
643
  meta = { tappedAt: [t.x, t.y], via: t.via, ...(durationMs ? { durationMs } : {}) };
271
644
  toRecord = { action: 'tap', ...recordableTap(session, stripSelector(args.target), t) };
272
645
  break;
273
646
  }
274
647
  case 'type': {
275
- if (args.text == null)
276
- return fail('type requires `text`.', false);
648
+ // presence enforced by missingRequiredField
649
+ // P1: `${SWIPIUM_*}` placeholders expand from session inputs (qa_continue_from_blocker) or the env,
650
+ // so credentials never have to pass through the agent transcript.
651
+ const secretVars = new Set(session.inputs.filter((i) => i.secret).map((i) => i.varName));
652
+ const expanded = expandInputPlaceholders(args.text, { values: session.inputValues, secretVars });
653
+ if (expanded.missing.length) {
654
+ return qaError({
655
+ what: `No value for ${expanded.missing.map((v) => `\${${v}}`).join(', ')}. Nothing was typed.`,
656
+ changedState: false,
657
+ retrySafe: true,
658
+ failureCode: 'MISSING_TEST_DATA',
659
+ nextSteps: [
660
+ 'Provide it via qa_continue_from_blocker (needs_input credentials) or set the env var for the Swipium server, then retry. Only ${SWIPIUM_*} placeholders are expanded.',
661
+ ],
662
+ });
663
+ }
664
+ for (const v of expanded.secretValues)
665
+ session.secrets.add(v);
666
+ const text = expanded.text;
667
+ typedValue = text;
668
+ const recordText = recordableTypedText(args.text, text, expanded.vars, session.inputValues);
669
+ // D: validate deliverability BEFORE any focus tap / clear. A refused value must
670
+ // leave the field (and the device) exactly as it was.
671
+ const deliverable = d.canDeliverText?.(text);
672
+ if (deliverable && !deliverable.ok) {
673
+ return qaError({
674
+ what: `${deliverable.reason} Nothing was tapped or cleared.`,
675
+ changedState: false,
676
+ retrySafe: false,
677
+ failureCode: 'TEXT_INPUT_UNSUPPORTED',
678
+ nextSteps: ['Use ASCII-safe text on this backend (adb `input text`), or type it on a Unicode-safe backend.'],
679
+ });
680
+ }
681
+ // A: a value that is/contains a registered secret is recorded as secret even when the
682
+ // target field is not a secure one (checked before this call can register it).
683
+ const knownSecret = matchesSessionSecret(session.secrets, text);
277
684
  const native = args.target?.selector ?? null;
278
685
  if (native) {
279
686
  if (!d.typeBySelector)
@@ -284,61 +691,114 @@ export function registerAct(server, sessions) {
284
691
  failureCode: 'BACKEND_UNSUPPORTED',
285
692
  nextSteps: ['Use a WDA-backed iOS session, or target by ref/text/id/coordinates on this backend.'],
286
693
  });
287
- if ((args.mode ?? 'replace') === 'replace') {
288
- if (!d.clearBySelector)
289
- return qaError({
290
- what: `replace-mode typing by ${native.using} requires backend-native clear support`,
291
- changedState: false,
292
- retrySafe: false,
293
- failureCode: 'BACKEND_UNSUPPORTED',
294
- nextSteps: ['Use append mode, or attach a WDA backend that supports element clear.'],
295
- });
296
- await d.clearBySelector(native.using, native.value);
694
+ const replace = (args.mode ?? 'replace') === 'replace';
695
+ if (replace && !d.clearBySelector)
696
+ return qaError({
697
+ what: `replace-mode typing by ${native.using} requires backend-native clear support`,
698
+ changedState: false,
699
+ retrySafe: false,
700
+ failureCode: 'BACKEND_UNSUPPORTED',
701
+ nextSteps: ['Use append mode, or attach a WDA backend that supports element clear.'],
702
+ });
703
+ // This path must capture secrets exactly like the generic path below, or a
704
+ // password typed by native selector is recorded VERBATIM into generated flows.
705
+ // Heuristic first (selector value looks secret, same SECRET_RE the generic
706
+ // resolver applies to ids), then the real signal: probe the resolved element's
707
+ // type (XCUIElementTypeSecureTextField). A failing probe keeps the heuristic verdict.
708
+ let secure = isSecureNode({ id: native.value, desc: '', attrs: {} });
709
+ if (!secure && d.isSecureBySelector) {
710
+ try {
711
+ secure = await d.isSecureBySelector(native.using, native.value);
712
+ }
713
+ catch {
714
+ // probe unavailable (older WDA / element churn): heuristic alone decides
715
+ }
716
+ }
717
+ if (secure) {
718
+ session.secrets.add(text);
719
+ sessions.markAuth(session, { loginPerformed: true, loginPerformedAt: Date.now() });
720
+ sessions.milestone(session, 'login_performed');
297
721
  }
298
- await d.typeBySelector(native.using, native.value, args.text);
722
+ if (replace)
723
+ await d.clearBySelector(native.using, native.value);
724
+ await d.typeBySelector(native.using, native.value, text);
725
+ const recordSecret = secure || knownSecret;
299
726
  meta = {
300
- typedChars: args.text.length,
301
- redacted: true,
727
+ typedChars: text.length,
728
+ // The response never echoes the value; `redacted`/`secret` flag a value treated as
729
+ // secret (registered for scrubbing, recorded only as a placeholder).
730
+ ...(recordSecret ? { redacted: true, secret: true } : {}),
731
+ ...(expanded.vars.length ? { placeholders: expanded.vars } : {}),
302
732
  mode: args.mode ?? 'replace',
303
733
  via: 'native-selector',
304
734
  selector: args.target?.selector,
305
735
  submit: !!args.submit,
306
736
  };
307
- toRecord = { action: 'type', ...recordableNativeTarget(session, native), text: args.text };
737
+ // Never store a secret's value in the IR; secrets become a ${VAR} at generate time.
738
+ {
739
+ const nativeTarget = recordableNativeTarget(session, native);
740
+ toRecord = {
741
+ action: 'type',
742
+ ...nativeTarget,
743
+ secret: recordSecret,
744
+ // a secret is recorded only as its ${VAR} placeholder (never the value)
745
+ text: recordSecret ? (recordText !== text ? recordText : undefined) : recordText,
746
+ exportability: recordSecret ? 'needs-human-data' : nativeTarget.exportability,
747
+ };
748
+ }
308
749
  if (args.submit)
309
750
  await d.pressKey('enter');
310
751
  break;
311
752
  }
312
- const t = await resolveTarget(session, stripSelector(args.target));
313
- if ('error' in t)
314
- return fail(t.error, false);
315
- // Typing into a secure field → remember the value so it's scrubbed everywhere, and
753
+ const resolved = await resolveTarget(session, stripSelector(args.target));
754
+ if ('error' in resolved)
755
+ return fail(resolved.error, false);
756
+ let t = resolved;
757
+ // H5: focusing a field hidden under the keyboard would type a stray key character.
758
+ const g = await guardKeyboard(session, d, t, stripSelector(args.target));
759
+ if ('result' in g)
760
+ return g.result;
761
+ t = g.t;
762
+ if (g.warning)
763
+ warnings.push(g.warning);
764
+ if (g.hidKeyboard)
765
+ keyboardHidden = true;
766
+ // Typing into a secure field > remember the value so it's scrubbed everywhere, and
316
767
  // record that a login was performed (auth-state reporting, P1.5).
317
768
  if (t.secure) {
318
- session.secrets.add(args.text);
769
+ session.secrets.add(text);
319
770
  sessions.markAuth(session, { loginPerformed: true, loginPerformedAt: Date.now() });
320
771
  sessions.milestone(session, 'login_performed');
321
772
  }
322
773
  await d.tapXY(t.x, t.y); // focus + raise IME (real touch)
323
- await new Promise((r) => setTimeout(r, 700));
774
+ await awaitIme(d, 700, g.imeUp ? IME_HOP_FLOOR_MS : 0);
324
775
  if ((args.mode ?? 'replace') === 'replace')
325
776
  await d.clearFocusedText(t.textLen);
326
- await d.inputText(args.text);
777
+ await d.inputText(text);
327
778
  if (args.submit)
328
779
  await d.pressKey('enter');
329
- // Never echo the typed value — it may be a password/OTP/email/token and would
330
- // leak into the agent transcript + artifacts (DESIGN §9.7 sensitive-mode).
331
- meta = { typedChars: args.text.length, redacted: true, mode: args.mode ?? 'replace', via: t.via, submit: !!args.submit };
332
- // Never store a secret's value in the IR — secrets become a ${VAR} at generate time.
780
+ // Never echo the typed value. It may be a password/OTP/email/token and would
781
+ // leak into the agent transcript + artifacts (sensitive-mode).
782
+ const recordSecret = !!t.secure || knownSecret;
783
+ meta = {
784
+ typedChars: text.length,
785
+ ...(recordSecret ? { redacted: true, secret: true } : {}),
786
+ ...(expanded.vars.length ? { placeholders: expanded.vars } : {}),
787
+ mode: args.mode ?? 'replace',
788
+ via: t.via,
789
+ submit: !!args.submit,
790
+ };
791
+ // Never store a secret's value in the IR; secrets become a ${VAR} at generate time.
333
792
  {
334
793
  const targetRecord = recordableTap(session, stripSelector(args.target), t);
335
794
  toRecord = {
336
795
  action: 'type',
337
796
  selector: targetRecord.selector,
338
797
  selectorKind: targetRecord.selectorKind,
339
- secret: !!t.secure,
340
- text: t.secure ? undefined : args.text,
341
- exportability: t.secure ? 'needs-human-data' : targetRecord.exportability,
798
+ secret: recordSecret,
799
+ // a secret is recorded only as its ${VAR} placeholder (never the value)
800
+ text: recordSecret ? (recordText !== text ? recordText : undefined) : recordText,
801
+ exportability: recordSecret ? 'needs-human-data' : targetRecord.exportability,
342
802
  provenance: targetRecord.provenance,
343
803
  };
344
804
  }
@@ -360,65 +820,136 @@ export function registerAct(server, sessions) {
360
820
  toRecord = { action: 'clear', ...recordableNativeTarget(session, native) };
361
821
  break;
362
822
  }
363
- const t = await resolveTarget(session, stripSelector(args.target));
364
- if ('error' in t)
365
- return fail(t.error, false);
366
- await d.tapXY(t.x, t.y);
367
- await new Promise((r) => setTimeout(r, 400));
823
+ const resolved = await resolveTarget(session, stripSelector(args.target));
824
+ if ('error' in resolved)
825
+ return fail(resolved.error, false);
826
+ const g = await guardKeyboard(session, d, resolved, stripSelector(args.target));
827
+ if ('result' in g)
828
+ return g.result;
829
+ const t = g.t;
830
+ if (g.warning)
831
+ warnings.push(g.warning);
832
+ if (g.hidKeyboard)
833
+ keyboardHidden = true;
834
+ await d.tapXY(t.x, t.y); // focus + raise IME (real touch)
835
+ await awaitIme(d, 400, g.imeUp ? IME_HOP_FLOOR_MS : 0);
368
836
  await d.clearFocusedText(t.textLen);
369
837
  meta = { cleared: true, via: t.via };
370
838
  toRecord = { action: 'clear', ...recordableTap(session, stripSelector(args.target), t) };
371
839
  break;
372
840
  }
373
841
  case 'swipe': {
374
- if (!args.direction)
375
- return fail('swipe requires `direction`.', false);
376
- const start = args.target ? await resolveTarget(session, stripSelector(args.target)) : null;
377
- const sx = start && !('error' in start) ? start.x : 0;
378
- const sy = start && !('error' in start) ? start.y : 0;
379
- const from = sx && sy ? { x: sx, y: sy } : { x: 540, y: 1200 };
380
- const dist = 800;
381
- const to = {
382
- up: { x: from.x, y: from.y - dist },
383
- down: { x: from.x, y: from.y + dist },
384
- left: { x: from.x - dist, y: from.y },
385
- right: { x: from.x + dist, y: from.y },
386
- }[args.direction];
387
- await d.swipe(from.x, from.y, to.x, to.y, 300);
388
- meta = { direction: args.direction };
389
- toRecord = { action: 'swipe', direction: args.direction, exportability: 'coordinate' };
842
+ const direction = args.direction; // presence enforced by missingRequiredField
843
+ // Derive the gesture from the real screen size. WDA swipes are in POINTS
844
+ // (≤~440pt wide), so the old fixed 540/1200 constants were off-screen on iOS; the
845
+ // legacy constants survive only inside the shared fallback for screenSize()===null.
846
+ // A supplied target that fails to resolve is a structured error (not a
847
+ // silent default swipe), and a resolved point is used verbatim (0 is a legitimate
848
+ // coordinate). Endpoints keep an ~8% inset clear of iOS system-gesture zones.
849
+ const size = await d.screenSize().catch(() => null);
850
+ let vec;
851
+ if (args.target) {
852
+ const start = await resolveTarget(session, stripSelector(args.target));
853
+ if ('error' in start)
854
+ return fail(start.error, false);
855
+ vec = swipeFromPoint(size, { x: start.x, y: start.y }, direction, 0.5, GESTURE_EDGE_INSET);
856
+ }
857
+ else {
858
+ vec = swipeVector(size, direction, 'center', 0.5, GESTURE_EDGE_INSET);
859
+ }
860
+ await d.swipe(vec[0], vec[1], vec[2], vec[3], 300);
861
+ meta = { direction };
862
+ toRecord = { action: 'swipe', direction, exportability: 'coordinate' };
390
863
  break;
391
864
  }
392
865
  case 'scroll': {
393
- if (!args.direction)
394
- return fail('scroll requires `direction`.', false);
395
866
  const max = args.maxScrolls ?? 8;
396
- // scroll down = swipe up; build a screen-relative gesture
397
- const dir = args.direction;
867
+ // scroll down = FINGER swipes up (same for the other directions where finger ==
868
+ // content axis). Screen-relative + clamped vector, size fetched once.
869
+ const finger = { down: 'up', up: 'down', left: 'left', right: 'right' };
870
+ const size = await d.screenSize().catch(() => null);
871
+ const dir = args.direction; // presence enforced by missingRequiredField
872
+ const u = args.untilVisible;
873
+ // A match only counts as FOUND when its center is on screen and not under the soft
874
+ // keyboard: a row just crossing the bottom edge is "in the tree" but a center tap on it
875
+ // would land off-screen / on a key, so keep swiping instead.
876
+ const kb = u ? await keyboardArea(d) : undefined;
877
+ const probe = async () => {
878
+ const xml = await d.dumpXml();
879
+ const at = Date.now();
880
+ const parsed = parseSnapshot(xml);
881
+ const [sw, sh] = size ? [size.width, size.height] : parsed.screen;
882
+ const hit = parsed.elements.some((e) => {
883
+ const match = (u?.id && e.id === u.id) ||
884
+ (u?.text &&
885
+ (e.text?.toLowerCase().includes(u.text.toLowerCase()) || e.label?.toLowerCase().includes(u.text.toLowerCase())));
886
+ if (!match)
887
+ return false;
888
+ const c = center(e.bounds);
889
+ const onScreen = !(sw > 0 && sh > 0) || (c.x >= 0 && c.y >= 0 && c.x < sw && c.y < sh);
890
+ return onScreen && !inRect(kb?.rect, c.x, c.y);
891
+ });
892
+ // Positions are part of the signature: a list that moved but still shows the same
893
+ // labels is not at its end (the label-only signature reported endOfList mid-list).
894
+ const sig = parsed.elements
895
+ .map((e) => `${signature(e)}@${e.bounds.join(',')}`)
896
+ .sort()
897
+ .join('\n');
898
+ settleSeed = { xml, at };
899
+ return { hit, sig, nodes: parsed.allNodes };
900
+ };
901
+ // B: anchor the swipe INSIDE the largest scrollable container (a swipe that starts on
902
+ // a sticky app bar moves nothing); screen center only when no container is known.
903
+ let anchorNodes = session.lastSnapshot?.allNodes;
904
+ if (!anchorNodes && !u)
905
+ anchorNodes = await d.dumpXml().then((x) => parseSnapshot(x).allNodes, () => undefined);
906
+ let anchoredIn = 'screen';
907
+ const nextVec = () => {
908
+ const rect = largestScrollableRect(anchorNodes, size);
909
+ anchoredIn = rect ? 'scrollable' : 'screen';
910
+ return rect
911
+ ? swipeInRect(rect, finger[dir], size, 0.6, SCROLL_CONTAINER_INSET, GESTURE_EDGE_INSET)
912
+ : swipeVector(size, finger[dir], 'center', 0.6, GESTURE_EDGE_INSET);
913
+ };
398
914
  let found = false;
399
- for (let i = 0; i < max; i++) {
400
- const cx = 540;
401
- const cy = 1200;
402
- const dist = 900;
403
- const vec = {
404
- down: [cx, cy + dist / 2, cx, cy - dist / 2],
405
- up: [cx, cy - dist / 2, cx, cy + dist / 2],
406
- left: [cx + dist / 2, cy, cx - dist / 2, cy],
407
- right: [cx - dist / 2, cy, cx + dist / 2, cy],
408
- }[dir];
915
+ let endOfList = false;
916
+ let swipes = 0;
917
+ let prevSig;
918
+ // Already visible? Then don't swipe at all (it could scroll the target away).
919
+ if (u) {
920
+ const first = await probe();
921
+ found = first.hit;
922
+ prevSig = first.sig;
923
+ anchorNodes = first.nodes;
924
+ }
925
+ // C: a plain scroll is exactly ONE swipe; only untilVisible loops (up to maxScrolls).
926
+ const limit = u ? max : 1;
927
+ for (let i = 0; i < limit && !found; i++) {
928
+ const vec = nextVec();
409
929
  await d.swipe(vec[0], vec[1], vec[2], vec[3], 300);
410
- await new Promise((r) => setTimeout(r, 400));
411
- if (args.untilVisible) {
412
- const parsed = parseSnapshot(await d.dumpXml());
413
- const u = args.untilVisible;
414
- found = parsed.elements.some((e) => (u.id && e.id === u.id) ||
415
- (u.text &&
416
- (e.text?.toLowerCase().includes(u.text.toLowerCase()) || e.label?.toLowerCase().includes(u.text.toLowerCase()))));
930
+ swipes++;
931
+ if (u) {
932
+ await new Promise((r) => setTimeout(r, 400));
933
+ const now = await probe();
934
+ anchorNodes = now.nodes;
935
+ found = now.hit;
417
936
  if (found)
418
937
  break;
938
+ // Screen identical after a swipe > end of the list; more swipes can't help.
939
+ if (now.sig === prevSig) {
940
+ endOfList = true;
941
+ break;
942
+ }
943
+ prevSig = now.sig;
419
944
  }
420
945
  }
421
- meta = { direction: dir, untilVisibleFound: args.untilVisible ? found : undefined };
946
+ meta = {
947
+ direction: dir,
948
+ swipes,
949
+ ...(swipes ? { anchoredIn } : {}),
950
+ untilVisibleFound: u ? found : undefined,
951
+ ...(endOfList ? { endOfList: true } : {}),
952
+ };
422
953
  toRecord = {
423
954
  action: 'scroll',
424
955
  direction: dir,
@@ -428,27 +959,40 @@ export function registerAct(server, sessions) {
428
959
  break;
429
960
  }
430
961
  case 'press': {
431
- if (!args.key)
432
- return fail('press requires `key`.', false);
433
- await d.pressKey(args.key);
434
- meta = { key: args.key };
435
- toRecord = { action: 'press', key: args.key, exportability: 'semantic' };
962
+ const key = args.key; // presence enforced by missingRequiredField
963
+ await d.pressKey(key);
964
+ meta = { key };
965
+ // iOS has no back key: WdaDriver taps the nav-bar back button or edge-swipes. Say which.
966
+ if (key === 'back' && d.kind === 'wda') {
967
+ const via = d.lastBackVia;
968
+ if (via)
969
+ meta.backVia = via;
970
+ }
971
+ toRecord = { action: 'press', key, exportability: 'semantic' };
436
972
  break;
437
973
  }
438
974
  case 'open_url': {
439
- if (!args.url)
440
- return fail('open_url requires `url`.', false);
441
- await d.openUrl(args.url);
442
- meta = { url: args.url };
443
- toRecord = { action: 'open_url', url: args.url, exportability: 'semantic' };
975
+ const url = args.url; // presence enforced by missingRequiredField
976
+ await d.openUrl(url);
977
+ meta = { url };
978
+ toRecord = { action: 'open_url', url, exportability: 'semantic' };
444
979
  break;
445
980
  }
446
981
  }
447
982
  }
448
983
  catch (e) {
449
- const failureCode = classifyFlowDriverError(e);
984
+ // Cancelled mid-action: not a driver failure (no WDA_UNREACHABLE / SNAPSHOT_FAILED tool
985
+ // error, no finding). The action may have partly run, so changedState stays true.
986
+ if (isAbortError(e))
987
+ return cancelledResult(`Action "${action}" cancelled: the call was aborted before it finished`, true);
988
+ const msg = String(e?.message ?? e);
989
+ const failureCode = /BACKEND_UNSUPPORTED|not supported by the WDA backend/.test(msg)
990
+ ? 'BACKEND_UNSUPPORTED'
991
+ : classifyFlowDriverError(e);
992
+ // H2: driver errors can echo argv/stderr. Scrub known secrets AND the value just typed.
993
+ const redactErr = makeRedactor(errorSecrets(session, action === 'type' ? (typedValue ?? args.text) : undefined));
450
994
  return qaError({
451
- what: `Action "${action}" failed: ${String(e)}`,
995
+ what: `Action "${action}" failed: ${redactErr(String(e)) ?? ''}`,
452
996
  changedState: true,
453
997
  retrySafe: !['WDA_SESSION_FAILED', 'UNKNOWN'].includes(failureCode),
454
998
  failureCode,
@@ -468,24 +1012,41 @@ export function registerAct(server, sessions) {
468
1012
  // Post-action observation is wrapped so a settle/dump/parse failure (e.g. the
469
1013
  // looping-animation case) returns a Swipium-shaped result, never a raw MCP error.
470
1014
  try {
471
- // settle → observe → health
472
- let s = await settle(d, { timeoutMs: args.timeoutMs ?? 8000 });
1015
+ // settle > observe > health (seeded with untilVisible's last probe; it was taken after the
1016
+ // last swipe, so re-dumping it first would be pure latency)
1017
+ let s = await settle(d, { timeoutMs: args.timeoutMs ?? 8000, ...(settleSeed ? { seed: settleSeed } : {}) });
1018
+ // Cancelled while observing: an empty/aborted dump is not evidence (no WDA_UNREACHABLE
1019
+ // health finding, no visual-fallback switch).
1020
+ if (isAbortError(undefined))
1021
+ return cancelledResult(`Action "${action}" ran, but the call was cancelled before its result was observed`, true);
473
1022
  let post = parseSnapshot(s.xml);
474
1023
  let postSigs = new Set(post.elements.map(signature));
475
- let changed = !setsEqual(preSigs, postSigs);
1024
+ // F: for gestures that move content, a bounds shift counts as a change too.
1025
+ const positional = action === 'scroll' || action === 'swipe';
1026
+ const movedContent = () => positional && prePositions !== undefined && prePositions !== positionalFingerprint(post.fullByRef);
1027
+ // Toggle state counts: a Switch/Checkbox flip keeps every signature (role|label|id|text).
1028
+ let toggled = stateChangedRefs(preState, post.fullByRef);
1029
+ let changed = !setsEqual(preSigs, postSigs) || movedContent() || toggled.length > 0;
476
1030
  // No-change retry (review §4.5/§4.7): an instant tap that did nothing is often the RN
477
- // tap quirk — retry ONCE as a longer press before believing it's blocked.
1031
+ // tap quirk. Retry ONCE as a longer press before believing it's blocked.
478
1032
  let retriedAsPress = false;
479
- if (!changed && action === 'tap' && tapRetry?.instant) {
1033
+ // Never when the keyboard was up at tap time: the retry would re-press a point the IME
1034
+ // may own and type a second stray character (H5).
1035
+ if (!changed && action === 'tap' && tapRetry?.instant && !tapRetry.imeUp) {
480
1036
  await d.pressXY(tapRetry.x, tapRetry.y, 120);
481
1037
  retriedAsPress = true;
482
1038
  s = await settle(d, { timeoutMs: args.timeoutMs ?? 8000 });
483
1039
  post = parseSnapshot(s.xml);
484
1040
  postSigs = new Set(post.elements.map(signature));
485
- changed = !setsEqual(preSigs, postSigs);
1041
+ toggled = stateChangedRefs(preState, post.fullByRef);
1042
+ changed = !setsEqual(preSigs, postSigs) || movedContent() || toggled.length > 0;
486
1043
  }
487
1044
  session.lastSnapshot = { fullByRef: post.fullByRef, signatures: postSigs, allNodes: post.allNodes };
488
- const health = await checkHealth(d, session.appId, s.xml);
1045
+ // A structured dump succeeded > a visual-fallback session is structured again (per-screen).
1046
+ const modeRecovered = s.xml ? sessions.noteStructuredDump(session) : false;
1047
+ const health = await checkHealth(d, session.appId, s.xml, { nodes: post.allNodes });
1048
+ if (health.cancelled)
1049
+ return cancelledResult(`Action "${action}" ran, but the call was cancelled before its result was observed`, true);
489
1050
  // Track no-change actions for the budget / no-op-loop detector.
490
1051
  if (!changed && (action === 'tap' || action === 'swipe' || action === 'scroll' || action === 'press')) {
491
1052
  sessions.bump(session, 'noChangeActions');
@@ -493,25 +1054,85 @@ export function registerAct(server, sessions) {
493
1054
  const budgetReached = sessions.budgetStop(session);
494
1055
  // Sensitive-mode present: mask secure fields + scrub known secrets AND the just-typed
495
1056
  // value (covers non-secure fields like email for this immediate response).
496
- const redact = makeRedactor([...session.secrets, ...(action === 'type' && args.text ? [args.text] : [])]);
497
- const { elements: outElements, rendered, omitted } = presentElements(post.elements, redact);
1057
+ const redact = makeRedactor([...session.secrets, ...(action === 'type' && typedValue ? [typedValue] : [])]);
498
1058
  // Record non-info findings for qa_report (deterministic bug trail) + app-error screenshot.
499
1059
  await recordHealthFindings(sessions, session, health.findings, d, health.foreground);
500
- const banner = `${action} ${JSON.stringify(meta)} → changed=${changed}${retriedAsPress ? ' (retried as press)' : ''} ` +
1060
+ const banner = `${action} ${JSON.stringify(meta)} > changed=${changed}${retriedAsPress ? ' (retried as press)' : ''} ` +
501
1061
  `settled=${s.settled} quality=${post.quality.verdict} native=${health.nativeHealthy ? 'ok' : health.nativeStatus} app=${health.appStatus}` +
502
- (budgetReached ? `\n⏹ budget reached: ${budgetReached} — call qa_report.` : '') +
1062
+ (budgetReached ? `\n⏹ budget reached: ${budgetReached}, call qa_report.` : '') +
1063
+ (modeRecovered ? '\nmode: structured again (a UI tree dump succeeded; visual-fallback cleared)' : '') +
503
1064
  (!changed && retriedAsPress
504
- ? `\nNo change even after a press retry — likely wrong coords / disabled element / overlay / auth wall.`
1065
+ ? `\nNo change even after a press retry. Likely wrong coords / disabled element / overlay / auth wall.`
505
1066
  : '');
506
1067
  const findings = health.findings.length
507
1068
  ? '\n' +
508
1069
  health.findings
509
- .map((f) => `[${f.severity}] ${f.layer ?? '?'}/${f.kind}: ${f.detail}${f.evidence ? ` — "${f.evidence}"` : ''}`)
1070
+ .map((f) => `[${f.severity}] ${f.layer ?? '?'}/${f.kind}: ${f.detail}${f.evidence ? `: "${f.evidence}"` : ''}`)
510
1071
  .join('\n')
511
1072
  : '';
1073
+ // `observe` changes ONLY the element presentation below. Everything above
1074
+ // (lastSnapshot bookkeeping, press retry, health recording, counters, budget lines)
1075
+ // is identical in all modes.
1076
+ let elementPayload = {};
1077
+ let elementsText = '';
1078
+ const added = observe === 'diff' ? post.elements.filter((e) => !preSigs.has(signature(e))) : [];
1079
+ // Mostly-new screen (navigation): a diff would list every new element AND every old one
1080
+ // as removed, bigger than the full list. Return the (capped) full list + removedCount.
1081
+ const diffAsFull = observe === 'diff' && post.elements.length > 0 && added.length > post.elements.length * DIFF_FULL_FALLBACK_RATIO;
1082
+ if (observe === 'diff' && !diffAsFull) {
1083
+ const removed = [...preSigs].filter((sig) => !postSigs.has(sig)).map((sig) => redact(sig) ?? sig);
1084
+ const { elements: addedShown, rendered: renderedAdded, omitted } = presentElements(added, redact);
1085
+ const unchangedElements = post.elements.length - added.length;
1086
+ const hint = `${unchangedElements} unchanged element(s) not shown; pass observe:"full" to see the whole screen.`;
1087
+ elementPayload = { elementsOmitted: omitted, elements: addedShown, removed, unchangedElements, hint };
1088
+ elementsText =
1089
+ `\n\nDIFF vs pre-action: +${added.length} / -${removed.length}` +
1090
+ (added.length ? `\n${renderedAdded}` : '') +
1091
+ (removed.length ? `\nremoved: ${removed.join(' | ')}` : '') +
1092
+ `\n${hint}`;
1093
+ }
1094
+ else if (diffAsFull) {
1095
+ const removedCount = [...preSigs].filter((sig) => !postSigs.has(sig)).length;
1096
+ const { elements: outElements, rendered, omitted } = presentElements(post.elements, redact);
1097
+ elementPayload = {
1098
+ diffAsFull: true,
1099
+ elementsOmitted: omitted,
1100
+ elements: outElements,
1101
+ addedCount: added.length,
1102
+ removedCount,
1103
+ };
1104
+ elementsText =
1105
+ `\n\nNEW SCREEN (${added.length}/${post.elements.length} elements new, ${removedCount} previous gone), full list:` +
1106
+ `\n${rendered}`;
1107
+ }
1108
+ else if (observe === 'full') {
1109
+ const { elements: outElements, rendered, omitted } = presentElements(post.elements, redact);
1110
+ elementPayload = { elementsOmitted: omitted, elements: outElements };
1111
+ elementsText = `\n\n${rendered}`;
1112
+ }
1113
+ else {
1114
+ const hint = `${post.elements.length} element(s) not shown (observe:"none"); pass observe:"full" or run qa_snapshot to see the screen.`;
1115
+ elementPayload = { hint };
1116
+ elementsText = `\n\n${hint}`;
1117
+ }
1118
+ if (toggled.length && observe !== 'none') {
1119
+ const lines = toggled.map((ref) => {
1120
+ const n = post.fullByRef.get(ref);
1121
+ const name = isSecureNode(n) ? '«secure»' : (redact(n.desc || n.text) ?? '');
1122
+ return `${ref} ${JSON.stringify(name)} ${stateLabel(n)}`;
1123
+ });
1124
+ elementPayload = { ...elementPayload, stateChanged: lines };
1125
+ elementsText += `\nstate changed: ${lines.join(' | ')}`;
1126
+ }
1127
+ // The WDA driver transparently re-creates a reaped session (without relaunching the app);
1128
+ // the agent must know the screen may not be where it left it.
1129
+ if (d.consumeSessionRecovered?.())
1130
+ warnings.push(WDA_SESSION_RECOVERED_WARNING);
512
1131
  return qaOk({
513
1132
  action,
514
1133
  ...meta,
1134
+ ...(keyboardHidden ? { keyboardHidden: true } : {}),
1135
+ ...(modeRecovered ? { modeRecovered: true, mode: 'structured' } : {}),
515
1136
  changed,
516
1137
  retriedAsPress,
517
1138
  settled: s.settled,
@@ -519,24 +1140,33 @@ export function registerAct(server, sessions) {
519
1140
  health,
520
1141
  counters: session.counters,
521
1142
  ...(budgetReached ? { budgetReached } : {}),
522
- elementsOmitted: omitted,
523
- elements: outElements,
524
- }, `${banner}${findings}\n\n${rendered}`);
1143
+ observe,
1144
+ ...elementPayload,
1145
+ ...(warnings.length ? { warnings } : {}),
1146
+ }, `${banner}${keyboardHidden ? '\nNote: the soft keyboard covered the target and was hidden first (keyboardHidden:true).' : ''}` +
1147
+ `${findings}${warnings.map((w) => `\n⚠ ${w}`).join('')}${elementsText}`, { textOmit: ['elements', 'removed', 'hint', 'stateChanged'] });
525
1148
  }
526
1149
  catch (e) {
1150
+ if (isAbortError(e))
1151
+ return cancelledResult(`Action "${action}" ran, but the call was cancelled before its result was observed`, true);
527
1152
  // The action ran; observing the result failed (often a UI that never reaches idle).
1153
+ // visual-fallback is per-screen: the next successful qa_snapshot / qa_act dump clears it.
528
1154
  const idle = /idle|dump|hierarchy/i.test(String(e));
529
1155
  if (idle)
530
1156
  sessions.setMode(session, 'visual-fallback');
1157
+ const redactErr = makeRedactor(errorSecrets(session, action === 'type' ? (typedValue ?? args.text) : undefined));
531
1158
  return qaError({
532
- what: `Action "${action}" ran, but observing the result failed: ${String(e)}`,
1159
+ what: `Action "${action}" ran, but observing the result failed: ${redactErr(String(e)) ?? ''}`,
533
1160
  changedState: true,
534
1161
  retrySafe: false,
1162
+ failureCode: classifyFlowDriverError(e, 'SNAPSHOT_FAILED'),
535
1163
  nextSteps: idle
536
- ? ['Switched to visual-fallback. Use qa_screenshot; qa_check_health still works.']
1164
+ ? [
1165
+ 'Switched to visual-fallback for this screen (cleared by the next successful qa_snapshot / qa_act dump). Use qa_screenshot; qa_check_health still works.',
1166
+ ]
537
1167
  : ['Re-check the device is online, then qa_screenshot / qa_check_health.'],
538
1168
  });
539
1169
  }
540
- });
1170
+ }));
541
1171
  }
542
1172
  //# sourceMappingURL=act.js.map