swipium 1.4.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 (541) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +175 -219
  3. package/THREAT_MODEL.md +223 -47
  4. package/dist/appMap/automationLink.js +39 -26
  5. package/dist/appMap/automationLink.js.map +1 -1
  6. package/dist/appMap/build.js +68 -20
  7. package/dist/appMap/build.js.map +1 -1
  8. package/dist/appMap/codeIndex.js +31 -5
  9. package/dist/appMap/codeIndex.js.map +1 -1
  10. package/dist/appMap/featureIndex.js +23 -5
  11. package/dist/appMap/featureIndex.js.map +1 -1
  12. package/dist/appMap/featureModel.js +44 -8
  13. package/dist/appMap/featureModel.js.map +1 -1
  14. package/dist/appMap/firstRunApply.js +24 -6
  15. package/dist/appMap/firstRunApply.js.map +1 -1
  16. package/dist/appMap/fsWalk.js +19 -2
  17. package/dist/appMap/fsWalk.js.map +1 -1
  18. package/dist/appMap/issues.js +10 -30
  19. package/dist/appMap/issues.js.map +1 -1
  20. package/dist/appMap/migrations.js +6 -6
  21. package/dist/appMap/migrations.js.map +1 -1
  22. package/dist/appMap/prelaunch.js +2 -2
  23. package/dist/appMap/prelaunch.js.map +1 -1
  24. package/dist/appMap/projectRegistry.js +83 -11
  25. package/dist/appMap/projectRegistry.js.map +1 -1
  26. package/dist/appMap/provenance.js +2 -2
  27. package/dist/appMap/provenance.js.map +1 -1
  28. package/dist/appMap/query.js +21 -5
  29. package/dist/appMap/query.js.map +1 -1
  30. package/dist/appMap/runtimeMerge.js +12 -5
  31. package/dist/appMap/runtimeMerge.js.map +1 -1
  32. package/dist/appMap/schema.js +1 -1
  33. package/dist/appMap/schema.js.map +1 -1
  34. package/dist/appMap/screenMatch.js +6 -2
  35. package/dist/appMap/screenMatch.js.map +1 -1
  36. package/dist/appMap/staticScan.js +151 -39
  37. package/dist/appMap/staticScan.js.map +1 -1
  38. package/dist/appMap/store.js +58 -85
  39. package/dist/appMap/store.js.map +1 -1
  40. package/dist/appMap/tsAstScan.js +34 -12
  41. package/dist/appMap/tsAstScan.js.map +1 -1
  42. package/dist/artifacts/bundletool.js +49 -12
  43. package/dist/artifacts/bundletool.js.map +1 -1
  44. package/dist/artifacts/resolve.js +41 -14
  45. package/dist/artifacts/resolve.js.map +1 -1
  46. package/dist/automation/capabilities.js +112 -30
  47. package/dist/automation/capabilities.js.map +1 -1
  48. package/dist/automation/gestures.js +6 -2
  49. package/dist/automation/gestures.js.map +1 -1
  50. package/dist/automation/plan.js +71 -23
  51. package/dist/automation/plan.js.map +1 -1
  52. package/dist/automation/report.js +15 -5
  53. package/dist/automation/report.js.map +1 -1
  54. package/dist/automation/selectors.js +22 -28
  55. package/dist/automation/selectors.js.map +1 -1
  56. package/dist/automation/types.js +1 -1
  57. package/dist/automation/types.js.map +1 -1
  58. package/dist/automation/waits.js +14 -8
  59. package/dist/automation/waits.js.map +1 -1
  60. package/dist/automation/webview.js +9 -3
  61. package/dist/automation/webview.js.map +1 -1
  62. package/dist/automationGen/appiumModel.js +16 -8
  63. package/dist/automationGen/appiumModel.js.map +1 -1
  64. package/dist/automationGen/ciEmitter.js +6 -6
  65. package/dist/automationGen/ciEmitter.js.map +1 -1
  66. package/dist/automationGen/identifiers.js +224 -0
  67. package/dist/automationGen/identifiers.js.map +1 -0
  68. package/dist/automationGen/jsEmitter.js +288 -121
  69. package/dist/automationGen/jsEmitter.js.map +1 -1
  70. package/dist/automationGen/packagePatch.js +2 -2
  71. package/dist/automationGen/packagePatch.js.map +1 -1
  72. package/dist/automationGen/platformResolve.js +36 -0
  73. package/dist/automationGen/platformResolve.js.map +1 -0
  74. package/dist/automationGen/projectProfile.js +76 -27
  75. package/dist/automationGen/projectProfile.js.map +1 -1
  76. package/dist/automationGen/pythonEmitter.js +278 -107
  77. package/dist/automationGen/pythonEmitter.js.map +1 -1
  78. package/dist/automationGen/readmeEmitter.js +7 -6
  79. package/dist/automationGen/readmeEmitter.js.map +1 -1
  80. package/dist/automationGen/run.js +388 -0
  81. package/dist/automationGen/run.js.map +1 -0
  82. package/dist/automationGen/suitePlan.js +17 -10
  83. package/dist/automationGen/suitePlan.js.map +1 -1
  84. package/dist/automationGen/validation.js +52 -12
  85. package/dist/automationGen/validation.js.map +1 -1
  86. package/dist/build/parseBuildLog.js +235 -31
  87. package/dist/build/parseBuildLog.js.map +1 -1
  88. package/dist/build/plan.js +39 -18
  89. package/dist/build/plan.js.map +1 -1
  90. package/dist/ci/preflight.js +13 -4
  91. package/dist/ci/preflight.js.map +1 -1
  92. package/dist/cli/gc.js +74 -0
  93. package/dist/cli/gc.js.map +1 -0
  94. package/dist/cli/init.js +217 -26
  95. package/dist/cli/init.js.map +1 -1
  96. package/dist/cli/main.js +50 -0
  97. package/dist/cli/main.js.map +1 -0
  98. package/dist/cli/report.js +218 -0
  99. package/dist/cli/report.js.map +1 -0
  100. package/dist/cli/scan.js +36 -15
  101. package/dist/cli/scan.js.map +1 -1
  102. package/dist/cli/suite.js +7 -7
  103. package/dist/cli/suite.js.map +1 -1
  104. package/dist/cli/verify.js +7 -7
  105. package/dist/cli/verify.js.map +1 -1
  106. package/dist/consent/consent.js +162 -5
  107. package/dist/consent/consent.js.map +1 -1
  108. package/dist/context/detect.js +55 -18
  109. package/dist/context/detect.js.map +1 -1
  110. package/dist/context/findApps.js +22 -8
  111. package/dist/context/findApps.js.map +1 -1
  112. package/dist/context/projectRoot.js +174 -19
  113. package/dist/context/projectRoot.js.map +1 -1
  114. package/dist/context/scan.js +1 -1
  115. package/dist/context/scan.js.map +1 -1
  116. package/dist/core/capabilityGroups.js +90 -0
  117. package/dist/core/capabilityGroups.js.map +1 -0
  118. package/dist/core/target.js +10 -8
  119. package/dist/core/target.js.map +1 -1
  120. package/dist/core/targetPlan.js +160 -39
  121. package/dist/core/targetPlan.js.map +1 -1
  122. package/dist/drivers/DirectDriver.js +317 -43
  123. package/dist/drivers/DirectDriver.js.map +1 -1
  124. package/dist/drivers/SimctlDriver.js +124 -8
  125. package/dist/drivers/SimctlDriver.js.map +1 -1
  126. package/dist/drivers/WdaDriver.js +365 -55
  127. package/dist/drivers/WdaDriver.js.map +1 -1
  128. package/dist/explore/candidates.js +7 -3
  129. package/dist/explore/candidates.js.map +1 -1
  130. package/dist/explore/graph.js +15 -6
  131. package/dist/explore/graph.js.map +1 -1
  132. package/dist/explore/memory.js +4 -1
  133. package/dist/explore/memory.js.map +1 -1
  134. package/dist/explore/planner.js +54 -9
  135. package/dist/explore/planner.js.map +1 -1
  136. package/dist/explore/policy.js +11 -6
  137. package/dist/explore/policy.js.map +1 -1
  138. package/dist/explore/runner.js +363 -190
  139. package/dist/explore/runner.js.map +1 -1
  140. package/dist/explore/signatures.js +4 -2
  141. package/dist/explore/signatures.js.map +1 -1
  142. package/dist/explore/suite.js +3 -1
  143. package/dist/explore/suite.js.map +1 -1
  144. package/dist/featureTesting/executionBootstrap.js +137 -29
  145. package/dist/featureTesting/executionBootstrap.js.map +1 -1
  146. package/dist/featureTesting/featureMap.js +6 -3
  147. package/dist/featureTesting/featureMap.js.map +1 -1
  148. package/dist/featureTesting/featureScope.js +101 -20
  149. package/dist/featureTesting/featureScope.js.map +1 -1
  150. package/dist/featureTesting/mapFeatureScope.js +44 -15
  151. package/dist/featureTesting/mapFeatureScope.js.map +1 -1
  152. package/dist/featureTesting/objectiveModel.js +15 -11
  153. package/dist/featureTesting/objectiveModel.js.map +1 -1
  154. package/dist/featureTesting/resultMerge.js +16 -8
  155. package/dist/featureTesting/resultMerge.js.map +1 -1
  156. package/dist/featureTesting/sources.js +8 -5
  157. package/dist/featureTesting/sources.js.map +1 -1
  158. package/dist/featureTesting/suiteBridge.js +8 -2
  159. package/dist/featureTesting/suiteBridge.js.map +1 -1
  160. package/dist/featureTesting/synonyms.js +32 -4
  161. package/dist/featureTesting/synonyms.js.map +1 -1
  162. package/dist/featureTesting/testCaseFactory.js +27 -15
  163. package/dist/featureTesting/testCaseFactory.js.map +1 -1
  164. package/dist/featureTesting/testPlan.js +34 -12
  165. package/dist/featureTesting/testPlan.js.map +1 -1
  166. package/dist/firstRun/authStateMachine.js +1 -1
  167. package/dist/firstRun/authStateMachine.js.map +1 -1
  168. package/dist/firstRun/classifyScreen.js +9 -6
  169. package/dist/firstRun/classifyScreen.js.map +1 -1
  170. package/dist/firstRun/firstRunPlanner.js +16 -13
  171. package/dist/firstRun/firstRunPlanner.js.map +1 -1
  172. package/dist/firstRun/firstRunRunner.js +92 -31
  173. package/dist/firstRun/firstRunRunner.js.map +1 -1
  174. package/dist/firstRun/generatedDataPolicy.js +25 -15
  175. package/dist/firstRun/generatedDataPolicy.js.map +1 -1
  176. package/dist/firstRun/inputPlanner.js +45 -11
  177. package/dist/firstRun/inputPlanner.js.map +1 -1
  178. package/dist/firstRun/onboardingStateMachine.js +1 -1
  179. package/dist/firstRun/onboardingStateMachine.js.map +1 -1
  180. package/dist/firstRun/paywallPolicy.js +3 -3
  181. package/dist/firstRun/paywallPolicy.js.map +1 -1
  182. package/dist/firstRun/types.js +1 -1
  183. package/dist/firstRun/types.js.map +1 -1
  184. package/dist/fixtures/catalog.js +72 -9
  185. package/dist/fixtures/catalog.js.map +1 -1
  186. package/dist/fixtures/load.js +50 -0
  187. package/dist/fixtures/load.js.map +1 -0
  188. package/dist/flows/discover.js +2 -2
  189. package/dist/flows/discover.js.map +1 -1
  190. package/dist/flows/generate.js +68 -17
  191. package/dist/flows/generate.js.map +1 -1
  192. package/dist/flows/lint.js +88 -14
  193. package/dist/flows/lint.js.map +1 -1
  194. package/dist/flows/pack.js +21 -5
  195. package/dist/flows/pack.js.map +1 -1
  196. package/dist/flows/paths.js +57 -0
  197. package/dist/flows/paths.js.map +1 -0
  198. package/dist/flows/repair.js +176 -37
  199. package/dist/flows/repair.js.map +1 -1
  200. package/dist/flows/run.js +308 -99
  201. package/dist/flows/run.js.map +1 -1
  202. package/dist/flows/schema.js +107 -25
  203. package/dist/flows/schema.js.map +1 -1
  204. package/dist/flows/seedExec.js +8 -4
  205. package/dist/flows/seedExec.js.map +1 -1
  206. package/dist/flows/templates.js +5 -5
  207. package/dist/index.js +50 -8
  208. package/dist/index.js.map +1 -1
  209. package/dist/ios/signing.js +32 -3
  210. package/dist/ios/signing.js.map +1 -1
  211. package/dist/issues/classify.js +28 -20
  212. package/dist/issues/classify.js.map +1 -1
  213. package/dist/issues/fingerprint.js +35 -9
  214. package/dist/issues/fingerprint.js.map +1 -1
  215. package/dist/issues/index.js +93 -46
  216. package/dist/issues/index.js.map +1 -1
  217. package/dist/issues/metrics.js +6 -5
  218. package/dist/issues/metrics.js.map +1 -1
  219. package/dist/issues/recurrence.js +35 -8
  220. package/dist/issues/recurrence.js.map +1 -1
  221. package/dist/issues/report.js +12 -8
  222. package/dist/issues/report.js.map +1 -1
  223. package/dist/issues/reportBridge.js +40 -9
  224. package/dist/issues/reportBridge.js.map +1 -1
  225. package/dist/issues/schema.js +2 -2
  226. package/dist/issues/schema.js.map +1 -1
  227. package/dist/issues/sourceRevision.js +2 -2
  228. package/dist/issues/sourceRevision.js.map +1 -1
  229. package/dist/issues/store.js +110 -32
  230. package/dist/issues/store.js.map +1 -1
  231. package/dist/lib/abortScope.js +48 -0
  232. package/dist/lib/abortScope.js.map +1 -0
  233. package/dist/lib/android.js +84 -8
  234. package/dist/lib/android.js.map +1 -1
  235. package/dist/lib/coordSpace.js +1 -1
  236. package/dist/lib/coordSpace.js.map +1 -1
  237. package/dist/lib/device.js +53 -12
  238. package/dist/lib/device.js.map +1 -1
  239. package/dist/lib/gestures.js +141 -0
  240. package/dist/lib/gestures.js.map +1 -0
  241. package/dist/lib/gitignore.js +4 -3
  242. package/dist/lib/gitignore.js.map +1 -1
  243. package/dist/lib/image.js +12 -3
  244. package/dist/lib/image.js.map +1 -1
  245. package/dist/lib/lockfile.js +399 -0
  246. package/dist/lib/lockfile.js.map +1 -0
  247. package/dist/lib/needsInput.js +9 -6
  248. package/dist/lib/needsInput.js.map +1 -1
  249. package/dist/lib/png.js +4 -3
  250. package/dist/lib/png.js.map +1 -1
  251. package/dist/lib/redact.js +170 -9
  252. package/dist/lib/redact.js.map +1 -1
  253. package/dist/lib/result.js +94 -10
  254. package/dist/lib/result.js.map +1 -1
  255. package/dist/lib/schemaHash.js +7 -5
  256. package/dist/lib/schemaHash.js.map +1 -1
  257. package/dist/lib/sensitive.js +6 -3
  258. package/dist/lib/sensitive.js.map +1 -1
  259. package/dist/lib/simctl.js +3 -3
  260. package/dist/lib/simctl.js.map +1 -1
  261. package/dist/lib/spawn.js +78 -17
  262. package/dist/lib/spawn.js.map +1 -1
  263. package/dist/lib/toolAnnotations.js +114 -0
  264. package/dist/lib/toolAnnotations.js.map +1 -0
  265. package/dist/lib/wda.js +321 -25
  266. package/dist/lib/wda.js.map +1 -1
  267. package/dist/lib/wdaConfig.js +12 -5
  268. package/dist/lib/wdaConfig.js.map +1 -1
  269. package/dist/lib/wdaTune.js +52 -23
  270. package/dist/lib/wdaTune.js.map +1 -1
  271. package/dist/mobileAudit/checks.js +150 -28
  272. package/dist/mobileAudit/checks.js.map +1 -1
  273. package/dist/mobileAudit/evidence.js +4 -4
  274. package/dist/mobileAudit/evidence.js.map +1 -1
  275. package/dist/mobileAudit/profiles.js +121 -20
  276. package/dist/mobileAudit/profiles.js.map +1 -1
  277. package/dist/mobileAudit/results.js +9 -4
  278. package/dist/mobileAudit/results.js.map +1 -1
  279. package/dist/mobileAudit/runner.js +214 -35
  280. package/dist/mobileAudit/runner.js.map +1 -1
  281. package/dist/oracle/auth.js +1 -1
  282. package/dist/oracle/auth.js.map +1 -1
  283. package/dist/oracle/failures.js +1085 -145
  284. package/dist/oracle/failures.js.map +1 -1
  285. package/dist/oracle/health.js +148 -26
  286. package/dist/oracle/health.js.map +1 -1
  287. package/dist/oracle/locator.js +148 -19
  288. package/dist/oracle/locator.js.map +1 -1
  289. package/dist/oracle/record.js +3 -3
  290. package/dist/oracle/record.js.map +1 -1
  291. package/dist/orchestration/envelope.js +2 -2
  292. package/dist/orchestration/envelope.js.map +1 -1
  293. package/dist/orchestration/goal.js +7 -13
  294. package/dist/orchestration/goal.js.map +1 -1
  295. package/dist/orchestration/testThis/execute.js +167 -0
  296. package/dist/orchestration/testThis/execute.js.map +1 -0
  297. package/dist/orchestration/testThis/pipeline.js +413 -0
  298. package/dist/orchestration/testThis/pipeline.js.map +1 -0
  299. package/dist/orchestration/testThis/plan.js +519 -0
  300. package/dist/orchestration/testThis/plan.js.map +1 -0
  301. package/dist/orchestration/testThis/sessionIntent.js +72 -0
  302. package/dist/orchestration/testThis/sessionIntent.js.map +1 -0
  303. package/dist/orchestration/testThis/terminal.js +136 -0
  304. package/dist/orchestration/testThis/terminal.js.map +1 -0
  305. package/dist/orchestration/testThis/types.js +12 -0
  306. package/dist/orchestration/testThis/types.js.map +1 -0
  307. package/dist/plan/plan.js +9 -9
  308. package/dist/plan/plan.js.map +1 -1
  309. package/dist/prompts/index.js +24 -24
  310. package/dist/prompts/index.js.map +1 -1
  311. package/dist/report/coverage.js +0 -1
  312. package/dist/report/coverage.js.map +1 -1
  313. package/dist/report/evidence.js.map +1 -1
  314. package/dist/report/export.js +449 -72
  315. package/dist/report/export.js.map +1 -1
  316. package/dist/report/findingsDedupe.js +41 -0
  317. package/dist/report/findingsDedupe.js.map +1 -0
  318. package/dist/report/flake.js +13 -14
  319. package/dist/report/flake.js.map +1 -1
  320. package/dist/report/history.js +72 -38
  321. package/dist/report/history.js.map +1 -1
  322. package/dist/report/policy.js +18 -7
  323. package/dist/report/policy.js.map +1 -1
  324. package/dist/report/qaLevel.js +10 -16
  325. package/dist/report/qaLevel.js.map +1 -1
  326. package/dist/report/readiness.js +42 -12
  327. package/dist/report/readiness.js.map +1 -1
  328. package/dist/report/sarifSources.js +160 -0
  329. package/dist/report/sarifSources.js.map +1 -0
  330. package/dist/report/summary.js +2 -2
  331. package/dist/report/summary.js.map +1 -1
  332. package/dist/report/testCatalog.js +4 -4
  333. package/dist/report/testCatalog.js.map +1 -1
  334. package/dist/report/timing.js +23 -23
  335. package/dist/report/timing.js.map +1 -1
  336. package/dist/report/toolHealth.js +124 -0
  337. package/dist/report/toolHealth.js.map +1 -0
  338. package/dist/server.js +501 -69
  339. package/dist/server.js.map +1 -1
  340. package/dist/services/automationGenerate.js +8 -2
  341. package/dist/services/automationGenerate.js.map +1 -1
  342. package/dist/services/build.js +10 -4
  343. package/dist/services/build.js.map +1 -1
  344. package/dist/services/flowGenerate.js +75 -0
  345. package/dist/services/flowGenerate.js.map +1 -0
  346. package/dist/services/preflight.js +40 -8
  347. package/dist/services/preflight.js.map +1 -1
  348. package/dist/services/prepareAndroid.js +92 -18
  349. package/dist/services/prepareAndroid.js.map +1 -1
  350. package/dist/services/prepareIos.js +94 -17
  351. package/dist/services/prepareIos.js.map +1 -1
  352. package/dist/services/report.js +271 -80
  353. package/dist/services/report.js.map +1 -1
  354. package/dist/services/smoke.js +68 -22
  355. package/dist/services/smoke.js.map +1 -1
  356. package/dist/services/suiteGenerate.js +43 -6
  357. package/dist/services/suiteGenerate.js.map +1 -1
  358. package/dist/services/testSuiteKnowledge.js +78 -33
  359. package/dist/services/testSuiteKnowledge.js.map +1 -1
  360. package/dist/session/attach.js +284 -4
  361. package/dist/session/attach.js.map +1 -1
  362. package/dist/session/processRegistry.js +433 -0
  363. package/dist/session/processRegistry.js.map +1 -0
  364. package/dist/session/progress.js +1 -1
  365. package/dist/session/progress.js.map +1 -1
  366. package/dist/session/retention.js +201 -0
  367. package/dist/session/retention.js.map +1 -0
  368. package/dist/session/store.js +459 -59
  369. package/dist/session/store.js.map +1 -1
  370. package/dist/snapshot/overlays.js +60 -10
  371. package/dist/snapshot/overlays.js.map +1 -1
  372. package/dist/snapshot/parse.js +30 -12
  373. package/dist/snapshot/parse.js.map +1 -1
  374. package/dist/snapshot/present.js +36 -6
  375. package/dist/snapshot/present.js.map +1 -1
  376. package/dist/snapshot/settle.js +39 -9
  377. package/dist/snapshot/settle.js.map +1 -1
  378. package/dist/state/consent.js +99 -0
  379. package/dist/state/consent.js.map +1 -0
  380. package/dist/state/profile.js +82 -11
  381. package/dist/state/profile.js.map +1 -1
  382. package/dist/suite/compile.js +15 -6
  383. package/dist/suite/compile.js.map +1 -1
  384. package/dist/suite/lint.js +22 -4
  385. package/dist/suite/lint.js.map +1 -1
  386. package/dist/suite/pom.js +75 -44
  387. package/dist/suite/pom.js.map +1 -1
  388. package/dist/suite/secretGuard.js +266 -0
  389. package/dist/suite/secretGuard.js.map +1 -0
  390. package/dist/suite/testcase.js +15 -11
  391. package/dist/suite/testcase.js.map +1 -1
  392. package/dist/testSuite/exporter.js +7 -3
  393. package/dist/testSuite/exporter.js.map +1 -1
  394. package/dist/testSuite/generator.js +43 -14
  395. package/dist/testSuite/generator.js.map +1 -1
  396. package/dist/testSuite/history.js +1 -1
  397. package/dist/testSuite/history.js.map +1 -1
  398. package/dist/testSuite/issueLinks.js +3 -3
  399. package/dist/testSuite/issueLinks.js.map +1 -1
  400. package/dist/testSuite/lint.js +4 -4
  401. package/dist/testSuite/lint.js.map +1 -1
  402. package/dist/testSuite/merge.js +13 -7
  403. package/dist/testSuite/merge.js.map +1 -1
  404. package/dist/testSuite/schema.js +21 -16
  405. package/dist/testSuite/schema.js.map +1 -1
  406. package/dist/testSuite/store.js +12 -4
  407. package/dist/testSuite/store.js.map +1 -1
  408. package/dist/testSuite/traceability.js +2 -2
  409. package/dist/testSuite/traceability.js.map +1 -1
  410. package/dist/tools/act.js +868 -174
  411. package/dist/tools/act.js.map +1 -1
  412. package/dist/tools/agent.js +521 -112
  413. package/dist/tools/agent.js.map +1 -1
  414. package/dist/tools/appControl.js +82 -19
  415. package/dist/tools/appControl.js.map +1 -1
  416. package/dist/tools/appMap.js +393 -189
  417. package/dist/tools/appMap.js.map +1 -1
  418. package/dist/tools/build.js +92 -51
  419. package/dist/tools/build.js.map +1 -1
  420. package/dist/tools/bundletool.js +141 -40
  421. package/dist/tools/bundletool.js.map +1 -1
  422. package/dist/tools/clearOverlay.js +98 -18
  423. package/dist/tools/clearOverlay.js.map +1 -1
  424. package/dist/tools/device.js +108 -19
  425. package/dist/tools/device.js.map +1 -1
  426. package/dist/tools/doctor.js +98 -35
  427. package/dist/tools/doctor.js.map +1 -1
  428. package/dist/tools/explore.js +110 -42
  429. package/dist/tools/explore.js.map +1 -1
  430. package/dist/tools/featureTesting.js +152 -130
  431. package/dist/tools/featureTesting.js.map +1 -1
  432. package/dist/tools/firstRun.js +94 -66
  433. package/dist/tools/firstRun.js.map +1 -1
  434. package/dist/tools/flow.js +347 -132
  435. package/dist/tools/flow.js.map +1 -1
  436. package/dist/tools/flowRepair.js +42 -10
  437. package/dist/tools/flowRepair.js.map +1 -1
  438. package/dist/tools/generate.js +199 -0
  439. package/dist/tools/generate.js.map +1 -0
  440. package/dist/tools/getArtifact.js +37 -8
  441. package/dist/tools/getArtifact.js.map +1 -1
  442. package/dist/tools/health.js +17 -12
  443. package/dist/tools/health.js.map +1 -1
  444. package/dist/tools/ios.js +148 -194
  445. package/dist/tools/ios.js.map +1 -1
  446. package/dist/tools/issues.js +256 -171
  447. package/dist/tools/issues.js.map +1 -1
  448. package/dist/tools/jobs.js +55 -11
  449. package/dist/tools/jobs.js.map +1 -1
  450. package/dist/tools/metro.js +84 -38
  451. package/dist/tools/metro.js.map +1 -1
  452. package/dist/tools/mobileAudit.js +83 -26
  453. package/dist/tools/mobileAudit.js.map +1 -1
  454. package/dist/tools/network.js +36 -14
  455. package/dist/tools/network.js.map +1 -1
  456. package/dist/tools/note.js +31 -17
  457. package/dist/tools/note.js.map +1 -1
  458. package/dist/tools/prepareIosTarget.js +31 -14
  459. package/dist/tools/prepareIosTarget.js.map +1 -1
  460. package/dist/tools/prepareTarget.js +193 -47
  461. package/dist/tools/prepareTarget.js.map +1 -1
  462. package/dist/tools/report.js +16 -8
  463. package/dist/tools/report.js.map +1 -1
  464. package/dist/tools/resolveArtifact.js +12 -12
  465. package/dist/tools/resolveArtifact.js.map +1 -1
  466. package/dist/tools/resolveTarget.js +81 -18
  467. package/dist/tools/resolveTarget.js.map +1 -1
  468. package/dist/tools/screenRecord.js +85 -20
  469. package/dist/tools/screenRecord.js.map +1 -1
  470. package/dist/tools/screenshot.js +42 -18
  471. package/dist/tools/screenshot.js.map +1 -1
  472. package/dist/tools/smoke.js +29 -12
  473. package/dist/tools/smoke.js.map +1 -1
  474. package/dist/tools/snapshot.js +157 -107
  475. package/dist/tools/snapshot.js.map +1 -1
  476. package/dist/tools/startSession.js +117 -84
  477. package/dist/tools/startSession.js.map +1 -1
  478. package/dist/tools/suite.js +391 -310
  479. package/dist/tools/suite.js.map +1 -1
  480. package/dist/tools/testSuite.js +112 -36
  481. package/dist/tools/testSuite.js.map +1 -1
  482. package/dist/tools/testThis.js +35 -802
  483. package/dist/tools/testThis.js.map +1 -1
  484. package/dist/tools/visual.js +698 -116
  485. package/dist/tools/visual.js.map +1 -1
  486. package/dist/tools/wait.js +9 -23
  487. package/dist/tools/wait.js.map +1 -1
  488. package/dist/tools/wda.js +312 -54
  489. package/dist/tools/wda.js.map +1 -1
  490. package/dist/version.js +37 -64
  491. package/dist/version.js.map +1 -1
  492. package/dist/visual/ocr.js +65 -8
  493. package/dist/visual/ocr.js.map +1 -1
  494. package/dist/visual/provider.js +87 -15
  495. package/dist/visual/provider.js.map +1 -1
  496. package/docs/README.md +24 -10
  497. package/docs/ci-reports.md +328 -0
  498. package/docs/concepts.md +227 -0
  499. package/docs/flows.md +153 -0
  500. package/docs/mcp-server.md +210 -104
  501. package/docs/physical-devices.md +84 -0
  502. package/docs/tools.md +808 -217
  503. package/package.json +16 -6
  504. package/dist/appMap/diff.js +0 -60
  505. package/dist/appMap/diff.js.map +0 -1
  506. package/dist/automation/maestroIr.js +0 -282
  507. package/dist/automation/maestroIr.js.map +0 -1
  508. package/dist/interop/maestro.js +0 -151
  509. package/dist/interop/maestro.js.map +0 -1
  510. package/dist/tools/assertVisual.js +0 -74
  511. package/dist/tools/assertVisual.js.map +0 -1
  512. package/dist/tools/automationGenerate.js +0 -394
  513. package/dist/tools/automationGenerate.js.map +0 -1
  514. package/dist/tools/capabilities.js +0 -209
  515. package/dist/tools/capabilities.js.map +0 -1
  516. package/dist/tools/detectContext.js +0 -41
  517. package/dist/tools/detectContext.js.map +0 -1
  518. package/dist/tools/flowGenerate.js +0 -55
  519. package/dist/tools/flowGenerate.js.map +0 -1
  520. package/dist/tools/history.js +0 -56
  521. package/dist/tools/history.js.map +0 -1
  522. package/dist/tools/idling.js +0 -104
  523. package/dist/tools/idling.js.map +0 -1
  524. package/dist/tools/inputCapabilities.js +0 -30
  525. package/dist/tools/inputCapabilities.js.map +0 -1
  526. package/dist/tools/locator.js +0 -57
  527. package/dist/tools/locator.js.map +0 -1
  528. package/dist/tools/maestro.js +0 -78
  529. package/dist/tools/maestro.js.map +0 -1
  530. package/dist/tools/permissions.js +0 -149
  531. package/dist/tools/permissions.js.map +0 -1
  532. package/dist/tools/plan.js +0 -49
  533. package/dist/tools/plan.js.map +0 -1
  534. package/dist/tools/screenInfo.js +0 -70
  535. package/dist/tools/screenInfo.js.map +0 -1
  536. package/dist/tools/seed.js +0 -101
  537. package/dist/tools/seed.js.map +0 -1
  538. package/dist/tools/state.js +0 -170
  539. package/dist/tools/state.js.map +0 -1
  540. package/dist/tools/visualText.js +0 -61
  541. package/dist/tools/visualText.js.map +0 -1
package/dist/tools/act.js CHANGED
@@ -1,26 +1,356 @@
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';
19
- function nativeSelectorFor(selector) {
20
- const m = selector?.match(/^(accessibility id|name|predicate string|class chain)\s*=\s*(.+)$/i);
21
- if (!m)
22
- return null;
23
- return { using: m[1].toLowerCase(), value: m[2] };
21
+ import { isAbortError, runWithSignal } from '../lib/abortScope.js';
22
+ import { SECRET_VAR_NAME } from '../flows/schema.js';
23
+ /** Drop the (already-handled) native selector before generic ref/text/id/coords resolution. */
24
+ function stripSelector(t) {
25
+ if (!t)
26
+ return undefined;
27
+ const { selector: _selector, ...rest } = t;
28
+ return rest;
29
+ }
30
+ /** Discriminated native-selector schema (1.5.0: replaces the free-form "strategy=value" string). */
31
+ const nativeSelectorSchema = z.object({
32
+ using: z
33
+ .enum(['accessibility id', 'name', 'predicate string', 'class chain'])
34
+ .describe('"accessibility id" = accessibilityIdentifier/testID; predicate string = NSPredicate; class chain = XCUITest.'),
35
+ value: z.string(),
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;
24
354
  }
25
355
  function screenTitleFromNodes(nodes) {
26
356
  if (!nodes.length)
@@ -41,7 +371,13 @@ function recordingScreenContext(session) {
41
371
  return { screen: screenTitleFromNodes(nodes), screenSig: structuredSignature(els) };
42
372
  }
43
373
  function recordableNativeTarget(session, native) {
44
- const selectorKind = native.using === 'accessibility id' ? 'accessibility_id' : native.using === 'name' ? 'name' : native.using === 'predicate string' ? 'predicate' : 'class_chain';
374
+ const selectorKind = native.using === 'accessibility id'
375
+ ? 'accessibility_id'
376
+ : native.using === 'name'
377
+ ? 'name'
378
+ : native.using === 'predicate string'
379
+ ? 'predicate'
380
+ : 'class_chain';
45
381
  return {
46
382
  selector: native.value,
47
383
  selectorKind,
@@ -52,16 +388,10 @@ function recordableNativeTarget(session, native) {
52
388
  export function registerAct(server, sessions) {
53
389
  server.registerTool('qa_act', {
54
390
  title: 'Act on the screen',
55
- description: 'Perform one synchronized UI action and observe the result. Actions & their fields:\n' +
56
- '- tap: target {ref|text|id|selector|x,y}\n' +
57
- '- type: target (a field) + text + mode:"replace"|"append" (default replace); optional submit:true\n' +
58
- '- clear: target (a field), focuses it and clears existing text\n' +
59
- '- swipe: direction up|down|left|right (optional target as start point)\n' +
60
- '- scroll: direction + optional untilVisible {text|id} + maxScrolls (default 8)\n' +
61
- '- press: key back|home|enter\n' +
62
- '- open_url: url (deep link)\n' +
63
- '- wait: for {settled:true | ref | text | id | selector} + timeoutMs\n' +
64
- '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.',
65
395
  inputSchema: {
66
396
  sessionId: z.string(),
67
397
  action: z.enum(['tap', 'type', 'clear', 'swipe', 'scroll', 'press', 'open_url', 'wait']),
@@ -70,44 +400,66 @@ export function registerAct(server, sessions) {
70
400
  ref: z.string().optional(),
71
401
  text: z.string().optional(),
72
402
  id: z.string().optional(),
73
- selector: z.string().optional().describe('Backend-native selector, e.g. "accessibility id=email", "name=Continue", "predicate string=label == \\"Continue\\"", or "class chain=**/XCUIElementTypeButton[...]".'),
403
+ selector: nativeSelectorSchema.optional(),
74
404
  index: z.number().optional(),
75
405
  x: z.number().optional(),
76
406
  y: z.number().optional(),
77
407
  })
78
- .optional(),
79
- text: z.string().optional(),
80
- mode: z.enum(['replace', 'append']).optional(),
81
- submit: z.boolean().optional(),
82
- direction: z.enum(['up', 'down', 'left', 'right']).optional(),
83
- untilVisible: z.object({ text: z.string().optional(), id: z.string().optional() }).optional(),
84
- maxScrolls: z.number().optional(),
85
- key: z.enum(['back', 'home', 'enter']).optional(),
86
- url: z.string().optional(),
87
- durationMs: z.number().optional().describe('tap press duration. Coordinate taps default to ~100ms (RN ignores instant taps); ref/selector taps are instant unless set.'),
88
- ignoreOverlay: z.boolean().optional().describe('tap even if an overlay obstructs the target (default false → returns blockedByOverlay).'),
408
+ .optional()
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() })
416
+ .optional()
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.'),
89
423
  for: z
90
424
  .object({
91
425
  settled: z.boolean().optional(),
92
426
  ref: z.string().optional(),
93
427
  text: z.string().optional(),
94
428
  id: z.string().optional(),
95
- selector: z.string().optional().describe('Backend-native selector, e.g. "accessibility id=email" or "name=Continue". Requires WDA/native selector support.'),
429
+ selector: nativeSelectorSchema.optional(),
96
430
  })
97
- .optional(),
98
- 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.'),
99
438
  },
100
- }, 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 () => {
101
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;
102
449
  const session = sessions.get(sessionId);
103
- const { driver: d } = session ? await getDriver(session) : { driver: undefined };
104
- if (!session || !d) {
105
- return qaError({
106
- what: 'No device attached to this session',
107
- changedState: false,
108
- retrySafe: true,
109
- nextSteps: ['Call qa_prepare_target first.'],
110
- });
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
+ }));
111
463
  }
112
464
  if (d.kind === 'simulator') {
113
465
  return qaError({
@@ -115,34 +467,43 @@ export function registerAct(server, sessions) {
115
467
  changedState: false,
116
468
  retrySafe: false,
117
469
  failureCode: 'BACKEND_UNSUPPORTED',
118
- nextSteps: ['Attach WebDriverAgent with qa_wda for structured tap/type/snapshot. Without WDA, navigate via qa_ios deep links and verify with qa_assert_visual.'],
470
+ nextSteps: [
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".',
472
+ ],
119
473
  });
120
474
  }
121
475
  // Budget gate (review §4.1 / Rec 4): refuse new work once the session budget is spent.
122
476
  // `wait` is exempt from the action/screenshot caps (it's synchronization, not an
123
- // 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
124
478
  // burn the clock indefinitely.
125
479
  const stopReason = sessions.budgetStop(session);
126
480
  if (stopReason && (action !== 'wait' || /time budget/.test(stopReason))) {
127
481
  return qaStop(stopReason, { counters: session.counters, mode: session.mode });
128
482
  }
129
- 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
+ });
130
490
  // ---- wait is its own path (no settle/health afterward) ----
131
491
  if (action === 'wait') {
132
492
  const timeoutMs = args.timeoutMs ?? 8000;
133
493
  if (args.for?.settled || !args.for) {
134
494
  const s = await settle(d, { timeoutMs });
135
495
  const post = parseSnapshot(s.xml);
136
- session.lastSnapshot = { fullByRef: post.fullByRef, signatures: new Set(post.elements.map(signature)), allNodes: post.allNodes };
137
- const { elements: shown, rendered } = presentElements(post.elements, makeRedactor(session.secrets));
138
- return qaOk({ action, settled: s.settled, quality: post.quality.verdict, elements: shown }, `wait(settled)=${s.settled}\n\n${rendered}`);
496
+ session.lastSnapshot = {
497
+ fullByRef: post.fullByRef,
498
+ signatures: new Set(post.elements.map(signature)),
499
+ allNodes: post.allNodes,
500
+ };
501
+ const { elements: shown, rendered, omitted } = presentElements(post.elements, makeRedactor(session.secrets));
502
+ return qaOk({ action, settled: s.settled, quality: post.quality.verdict, elementsOmitted: omitted, elements: shown }, `wait(settled)=${s.settled}\n\n${rendered}`, { textOmit: ['elements'] });
139
503
  }
140
504
  const deadline = Date.now() + timeoutMs;
141
505
  const want = args.for;
142
- const native = nativeSelectorFor(want?.selector);
143
- if (want?.selector && !native) {
144
- return qaError({ what: `Unsupported wait selector ${JSON.stringify(want.selector)}`, changedState: false, retrySafe: true, nextSteps: ['Use accessibility id=..., name=..., predicate string=..., class chain=..., or wait by text/id/ref.'] });
145
- }
506
+ const native = want?.selector ?? null;
146
507
  if (native) {
147
508
  if (!d.existsBySelector)
148
509
  return qaError({
@@ -154,37 +515,69 @@ export function registerAct(server, sessions) {
154
515
  });
155
516
  while (Date.now() < deadline) {
156
517
  if (await d.existsBySelector(native.using, native.value)) {
157
- return qaOk({ action, found: true, selector: want?.selector, via: 'native-selector' }, `wait: found ${want?.selector}`);
518
+ return qaOk({ action, found: true, selector: want?.selector, via: 'native-selector' }, `wait: found ${native.using}=${native.value}`);
158
519
  }
159
520
  await new Promise((r) => setTimeout(r, 400));
160
521
  }
161
- return qaError({ what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`, changedState: false, retrySafe: true, nextSteps: ['Re-check the native selector value, or run qa_snapshot to inspect the current screen.'] });
522
+ return qaError({
523
+ what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`,
524
+ changedState: false,
525
+ retrySafe: true,
526
+ failureCode: 'ELEMENT_NOT_FOUND',
527
+ nextSteps: ['Re-check the native selector value, or run qa_snapshot to inspect the current screen.'],
528
+ });
162
529
  }
163
530
  while (Date.now() < deadline) {
164
531
  const parsed = parseSnapshot(await d.dumpXml());
165
- session.lastSnapshot = { fullByRef: parsed.fullByRef, signatures: new Set(parsed.elements.map(signature)), allNodes: parsed.allNodes };
532
+ session.lastSnapshot = {
533
+ fullByRef: parsed.fullByRef,
534
+ signatures: new Set(parsed.elements.map(signature)),
535
+ allNodes: parsed.allNodes,
536
+ };
166
537
  const hit = parsed.elements.find((e) => (want.ref && e.ref === want.ref) ||
167
538
  (want.id && e.id === want.id) ||
168
- (want.text && (e.text?.toLowerCase().includes(want.text.toLowerCase()) || e.label?.toLowerCase().includes(want.text.toLowerCase()))));
539
+ (want.text &&
540
+ (e.text?.toLowerCase().includes(want.text.toLowerCase()) || e.label?.toLowerCase().includes(want.text.toLowerCase()))));
169
541
  if (hit)
170
542
  return qaOk({ action, found: true, ref: hit.ref }, `wait: found ${hit.ref}`);
171
543
  await new Promise((r) => setTimeout(r, 400));
172
544
  }
173
- return qaError({ what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`, changedState: false, retrySafe: true, nextSteps: ['Re-snapshot; the element may use different text/id.'] });
545
+ return qaError({
546
+ what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`,
547
+ changedState: false,
548
+ retrySafe: true,
549
+ failureCode: 'ELEMENT_NOT_FOUND',
550
+ nextSteps: ['Re-snapshot; the element may use different text/id.'],
551
+ });
174
552
  }
175
553
  // Phase timing (P1.6): mark the first real action so the report can split setup vs active.
176
554
  sessions.milestone(session, 'first_action');
177
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');
178
568
  let meta = {};
179
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).
180
571
  let tapRetry;
181
572
  // action-IR step to record once the action succeeds (built here while lastSnapshot is
182
573
  // still the PRE-navigation screen, so a tapped @ref still resolves to its label).
183
574
  let toRecord;
575
+ // Non-fatal caveats for the result (unknown keyboard area, recovered WDA session, …).
576
+ const warnings = [];
184
577
  try {
185
578
  switch (action) {
186
579
  case 'tap': {
187
- const native = nativeSelectorFor(args.target?.selector);
580
+ const native = args.target?.selector ?? null;
188
581
  if (native) {
189
582
  if (!d.tapBySelector)
190
583
  return qaError({
@@ -199,13 +592,31 @@ export function registerAct(server, sessions) {
199
592
  toRecord = { action: 'tap', ...recordableNativeTarget(session, native) };
200
593
  break;
201
594
  }
202
- const t = await resolveTarget(session, args.target);
203
- if ('error' in t)
204
- 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
+ }
205
616
  // Overlay obstruction check (CR4): if another element is drawn over the target
206
617
  // point, return a structured blockedByOverlay instead of tapping blindly.
207
618
  if (!args.ignoreOverlay && t.via !== 'coords' && session.lastSnapshot?.allNodes) {
208
- // 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
209
620
  const node = t.ref ? session.lastSnapshot.fullByRef.get(t.ref) : undefined;
210
621
  const obs = obstructionAt(session.lastSnapshot.allNodes, node, t.x, t.y);
211
622
  if (obs.obstructed) {
@@ -214,7 +625,9 @@ export function registerAct(server, sessions) {
214
625
  changedState: false,
215
626
  retrySafe: true,
216
627
  failureCode: 'OVERLAY_OBSTRUCTION',
217
- nextSteps: ['Call qa_clear_overlay (auto, or hide_keyboard/minimize_logbox), then retry — or pass ignoreOverlay:true to tap anyway.'],
628
+ nextSteps: [
629
+ 'Call qa_clear_overlay (auto, or hide_keyboard/minimize_logbox), then retry. Or pass ignoreOverlay:true to tap anyway.',
630
+ ],
218
631
  }, { blockedByOverlay: true, obstructedBy: obs.by });
219
632
  }
220
633
  }
@@ -226,15 +639,49 @@ export function registerAct(server, sessions) {
226
639
  await d.pressXY(t.x, t.y, durationMs);
227
640
  else
228
641
  await d.tapXY(t.x, t.y);
229
- tapRetry = { x: t.x, y: t.y, instant: !durationMs };
642
+ tapRetry = { x: t.x, y: t.y, instant: !durationMs, imeUp };
230
643
  meta = { tappedAt: [t.x, t.y], via: t.via, ...(durationMs ? { durationMs } : {}) };
231
- toRecord = { action: 'tap', ...recordableTap(session, args.target, t) };
644
+ toRecord = { action: 'tap', ...recordableTap(session, stripSelector(args.target), t) };
232
645
  break;
233
646
  }
234
647
  case 'type': {
235
- if (args.text == null)
236
- return fail('type requires `text`.', false);
237
- const native = nativeSelectorFor(args.target?.selector);
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);
684
+ const native = args.target?.selector ?? null;
238
685
  if (native) {
239
686
  if (!d.typeBySelector)
240
687
  return qaError({
@@ -244,61 +691,121 @@ export function registerAct(server, sessions) {
244
691
  failureCode: 'BACKEND_UNSUPPORTED',
245
692
  nextSteps: ['Use a WDA-backed iOS session, or target by ref/text/id/coordinates on this backend.'],
246
693
  });
247
- if ((args.mode ?? 'replace') === 'replace') {
248
- if (!d.clearBySelector)
249
- return qaError({
250
- what: `replace-mode typing by ${native.using} requires backend-native clear support`,
251
- changedState: false,
252
- retrySafe: false,
253
- failureCode: 'BACKEND_UNSUPPORTED',
254
- nextSteps: ['Use append mode, or attach a WDA backend that supports element clear.'],
255
- });
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');
721
+ }
722
+ if (replace)
256
723
  await d.clearBySelector(native.using, native.value);
724
+ await d.typeBySelector(native.using, native.value, text);
725
+ const recordSecret = secure || knownSecret;
726
+ meta = {
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 } : {}),
732
+ mode: args.mode ?? 'replace',
733
+ via: 'native-selector',
734
+ selector: args.target?.selector,
735
+ submit: !!args.submit,
736
+ };
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
+ };
257
748
  }
258
- await d.typeBySelector(native.using, native.value, args.text);
259
- meta = { typedChars: args.text.length, redacted: true, mode: args.mode ?? 'replace', via: 'native-selector', selector: args.target?.selector, submit: !!args.submit };
260
- toRecord = { action: 'type', ...recordableNativeTarget(session, native), text: args.text };
261
749
  if (args.submit)
262
750
  await d.pressKey('enter');
263
751
  break;
264
752
  }
265
- const t = await resolveTarget(session, args.target);
266
- if ('error' in t)
267
- return fail(t.error, false);
268
- // 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
269
767
  // record that a login was performed (auth-state reporting, P1.5).
270
768
  if (t.secure) {
271
- session.secrets.add(args.text);
769
+ session.secrets.add(text);
272
770
  sessions.markAuth(session, { loginPerformed: true, loginPerformedAt: Date.now() });
273
771
  sessions.milestone(session, 'login_performed');
274
772
  }
275
773
  await d.tapXY(t.x, t.y); // focus + raise IME (real touch)
276
- await new Promise((r) => setTimeout(r, 700));
774
+ await awaitIme(d, 700, g.imeUp ? IME_HOP_FLOOR_MS : 0);
277
775
  if ((args.mode ?? 'replace') === 'replace')
278
776
  await d.clearFocusedText(t.textLen);
279
- await d.inputText(args.text);
777
+ await d.inputText(text);
280
778
  if (args.submit)
281
779
  await d.pressKey('enter');
282
- // Never echo the typed value — it may be a password/OTP/email/token and would
283
- // leak into the agent transcript + artifacts (DESIGN §9.7 sensitive-mode).
284
- meta = { typedChars: args.text.length, redacted: true, mode: args.mode ?? 'replace', via: t.via, submit: !!args.submit };
285
- // 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.
286
792
  {
287
- const targetRecord = recordableTap(session, args.target, t);
793
+ const targetRecord = recordableTap(session, stripSelector(args.target), t);
288
794
  toRecord = {
289
795
  action: 'type',
290
796
  selector: targetRecord.selector,
291
797
  selectorKind: targetRecord.selectorKind,
292
- secret: !!t.secure,
293
- text: t.secure ? undefined : args.text,
294
- 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,
295
802
  provenance: targetRecord.provenance,
296
803
  };
297
804
  }
298
805
  break;
299
806
  }
300
807
  case 'clear': {
301
- const native = nativeSelectorFor(args.target?.selector);
808
+ const native = args.target?.selector ?? null;
302
809
  if (native) {
303
810
  if (!d.clearBySelector)
304
811
  return qaError({
@@ -313,97 +820,190 @@ export function registerAct(server, sessions) {
313
820
  toRecord = { action: 'clear', ...recordableNativeTarget(session, native) };
314
821
  break;
315
822
  }
316
- const t = await resolveTarget(session, args.target);
317
- if ('error' in t)
318
- return fail(t.error, false);
319
- await d.tapXY(t.x, t.y);
320
- 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);
321
836
  await d.clearFocusedText(t.textLen);
322
837
  meta = { cleared: true, via: t.via };
323
- toRecord = { action: 'clear', ...recordableTap(session, args.target, t) };
838
+ toRecord = { action: 'clear', ...recordableTap(session, stripSelector(args.target), t) };
324
839
  break;
325
840
  }
326
841
  case 'swipe': {
327
- if (!args.direction)
328
- return fail('swipe requires `direction`.', false);
329
- const start = args.target ? await resolveTarget(session, args.target) : null;
330
- const sx = start && !('error' in start) ? start.x : 0;
331
- const sy = start && !('error' in start) ? start.y : 0;
332
- const from = sx && sy ? { x: sx, y: sy } : { x: 540, y: 1200 };
333
- const dist = 800;
334
- const to = {
335
- up: { x: from.x, y: from.y - dist },
336
- down: { x: from.x, y: from.y + dist },
337
- left: { x: from.x - dist, y: from.y },
338
- right: { x: from.x + dist, y: from.y },
339
- }[args.direction];
340
- await d.swipe(from.x, from.y, to.x, to.y, 300);
341
- meta = { direction: args.direction };
342
- 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' };
343
863
  break;
344
864
  }
345
865
  case 'scroll': {
346
- if (!args.direction)
347
- return fail('scroll requires `direction`.', false);
348
866
  const max = args.maxScrolls ?? 8;
349
- // scroll down = swipe up; build a screen-relative gesture
350
- 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
+ };
351
914
  let found = false;
352
- for (let i = 0; i < max; i++) {
353
- const cx = 540;
354
- const cy = 1200;
355
- const dist = 900;
356
- const vec = {
357
- down: [cx, cy + dist / 2, cx, cy - dist / 2],
358
- up: [cx, cy - dist / 2, cx, cy + dist / 2],
359
- left: [cx + dist / 2, cy, cx - dist / 2, cy],
360
- right: [cx - dist / 2, cy, cx + dist / 2, cy],
361
- }[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();
362
929
  await d.swipe(vec[0], vec[1], vec[2], vec[3], 300);
363
- await new Promise((r) => setTimeout(r, 400));
364
- if (args.untilVisible) {
365
- const parsed = parseSnapshot(await d.dumpXml());
366
- const u = args.untilVisible;
367
- found = parsed.elements.some((e) => (u.id && e.id === u.id) || (u.text && (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;
368
936
  if (found)
369
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;
370
944
  }
371
945
  }
372
- meta = { direction: dir, untilVisibleFound: args.untilVisible ? found : undefined };
373
- toRecord = { action: 'scroll', direction: dir, selector: args.untilVisible?.text, exportability: args.untilVisible?.text ? 'semantic' : 'coordinate' };
946
+ meta = {
947
+ direction: dir,
948
+ swipes,
949
+ ...(swipes ? { anchoredIn } : {}),
950
+ untilVisibleFound: u ? found : undefined,
951
+ ...(endOfList ? { endOfList: true } : {}),
952
+ };
953
+ toRecord = {
954
+ action: 'scroll',
955
+ direction: dir,
956
+ selector: args.untilVisible?.text,
957
+ exportability: args.untilVisible?.text ? 'semantic' : 'coordinate',
958
+ };
374
959
  break;
375
960
  }
376
961
  case 'press': {
377
- if (!args.key)
378
- return fail('press requires `key`.', false);
379
- await d.pressKey(args.key);
380
- meta = { key: args.key };
381
- 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' };
382
972
  break;
383
973
  }
384
974
  case 'open_url': {
385
- if (!args.url)
386
- return fail('open_url requires `url`.', false);
387
- await d.openUrl(args.url);
388
- meta = { url: args.url };
389
- 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' };
390
979
  break;
391
980
  }
392
981
  }
393
982
  }
394
983
  catch (e) {
395
- 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));
396
994
  return qaError({
397
- what: `Action "${action}" failed: ${String(e)}`,
995
+ what: `Action "${action}" failed: ${redactErr(String(e)) ?? ''}`,
398
996
  changedState: true,
399
997
  retrySafe: !['WDA_SESSION_FAILED', 'UNKNOWN'].includes(failureCode),
400
998
  failureCode,
401
- nextSteps: failureCode === 'UNKNOWN' ? ['Confirm the device is online and re-snapshot.'] : ['Use the failureCode to choose recovery, then re-snapshot before retrying.'],
999
+ nextSteps: failureCode === 'UNKNOWN'
1000
+ ? ['Confirm the device is online and re-snapshot.']
1001
+ : ['Use the failureCode to choose recovery, then re-snapshot before retrying.'],
402
1002
  });
403
1003
  }
404
1004
  // count this as an action (wait already returned earlier)
405
1005
  sessions.bump(session, 'actions');
406
- // Record the action into the IR for qa_flow_generate (the action definitely happened here;
1006
+ // Record the action into the IR for qa_generate target:"flow" (the action definitely happened here;
407
1007
  // recorded now so a later post-snapshot failure doesn't lose the step).
408
1008
  if (toRecord) {
409
1009
  const sc = recordingScreenContext(session);
@@ -412,24 +1012,41 @@ export function registerAct(server, sessions) {
412
1012
  // Post-action observation is wrapped so a settle/dump/parse failure (e.g. the
413
1013
  // looping-animation case) returns a Swipium-shaped result, never a raw MCP error.
414
1014
  try {
415
- // settle → observe → health
416
- 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);
417
1022
  let post = parseSnapshot(s.xml);
418
1023
  let postSigs = new Set(post.elements.map(signature));
419
- 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;
420
1030
  // No-change retry (review §4.5/§4.7): an instant tap that did nothing is often the RN
421
- // 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.
422
1032
  let retriedAsPress = false;
423
- 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) {
424
1036
  await d.pressXY(tapRetry.x, tapRetry.y, 120);
425
1037
  retriedAsPress = true;
426
1038
  s = await settle(d, { timeoutMs: args.timeoutMs ?? 8000 });
427
1039
  post = parseSnapshot(s.xml);
428
1040
  postSigs = new Set(post.elements.map(signature));
429
- changed = !setsEqual(preSigs, postSigs);
1041
+ toggled = stateChangedRefs(preState, post.fullByRef);
1042
+ changed = !setsEqual(preSigs, postSigs) || movedContent() || toggled.length > 0;
430
1043
  }
431
1044
  session.lastSnapshot = { fullByRef: post.fullByRef, signatures: postSigs, allNodes: post.allNodes };
432
- 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);
433
1050
  // Track no-change actions for the budget / no-op-loop detector.
434
1051
  if (!changed && (action === 'tap' || action === 'swipe' || action === 'scroll' || action === 'press')) {
435
1052
  sessions.bump(session, 'noChangeActions');
@@ -437,18 +1054,85 @@ export function registerAct(server, sessions) {
437
1054
  const budgetReached = sessions.budgetStop(session);
438
1055
  // Sensitive-mode present: mask secure fields + scrub known secrets AND the just-typed
439
1056
  // value (covers non-secure fields like email for this immediate response).
440
- const redact = makeRedactor([...session.secrets, ...(action === 'type' && args.text ? [args.text] : [])]);
441
- const { elements: outElements, rendered } = presentElements(post.elements, redact);
1057
+ const redact = makeRedactor([...session.secrets, ...(action === 'type' && typedValue ? [typedValue] : [])]);
442
1058
  // Record non-info findings for qa_report (deterministic bug trail) + app-error screenshot.
443
1059
  await recordHealthFindings(sessions, session, health.findings, d, health.foreground);
444
- 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)' : ''} ` +
445
1061
  `settled=${s.settled} quality=${post.quality.verdict} native=${health.nativeHealthy ? 'ok' : health.nativeStatus} app=${health.appStatus}` +
446
- (budgetReached ? `\n⏹ budget reached: ${budgetReached} — call qa_report.` : '') +
447
- (!changed && retriedAsPress ? `\nNo change even after a press retry — likely wrong coords / disabled element / overlay / auth wall.` : '');
448
- const findings = health.findings.length ? '\n' + health.findings.map((f) => `[${f.severity}] ${f.layer ?? '?'}/${f.kind}: ${f.detail}${f.evidence ? ` — "${f.evidence}"` : ''}`).join('\n') : '';
1062
+ (budgetReached ? `\n⏹ budget reached: ${budgetReached}, call qa_report.` : '') +
1063
+ (modeRecovered ? '\nmode: structured again (a UI tree dump succeeded; visual-fallback cleared)' : '') +
1064
+ (!changed && retriedAsPress
1065
+ ? `\nNo change even after a press retry. Likely wrong coords / disabled element / overlay / auth wall.`
1066
+ : '');
1067
+ const findings = health.findings.length
1068
+ ? '\n' +
1069
+ health.findings
1070
+ .map((f) => `[${f.severity}] ${f.layer ?? '?'}/${f.kind}: ${f.detail}${f.evidence ? `: "${f.evidence}"` : ''}`)
1071
+ .join('\n')
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);
449
1131
  return qaOk({
450
1132
  action,
451
1133
  ...meta,
1134
+ ...(keyboardHidden ? { keyboardHidden: true } : {}),
1135
+ ...(modeRecovered ? { modeRecovered: true, mode: 'structured' } : {}),
452
1136
  changed,
453
1137
  retriedAsPress,
454
1138
  settled: s.settled,
@@ -456,23 +1140,33 @@ export function registerAct(server, sessions) {
456
1140
  health,
457
1141
  counters: session.counters,
458
1142
  ...(budgetReached ? { budgetReached } : {}),
459
- elements: outElements,
460
- }, `${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'] });
461
1148
  }
462
1149
  catch (e) {
1150
+ if (isAbortError(e))
1151
+ return cancelledResult(`Action "${action}" ran, but the call was cancelled before its result was observed`, true);
463
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.
464
1154
  const idle = /idle|dump|hierarchy/i.test(String(e));
465
1155
  if (idle)
466
1156
  sessions.setMode(session, 'visual-fallback');
1157
+ const redactErr = makeRedactor(errorSecrets(session, action === 'type' ? (typedValue ?? args.text) : undefined));
467
1158
  return qaError({
468
- what: `Action "${action}" ran, but observing the result failed: ${String(e)}`,
1159
+ what: `Action "${action}" ran, but observing the result failed: ${redactErr(String(e)) ?? ''}`,
469
1160
  changedState: true,
470
1161
  retrySafe: false,
1162
+ failureCode: classifyFlowDriverError(e, 'SNAPSHOT_FAILED'),
471
1163
  nextSteps: idle
472
- ? ['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
+ ]
473
1167
  : ['Re-check the device is online, then qa_screenshot / qa_check_health.'],
474
1168
  });
475
1169
  }
476
- });
1170
+ }));
477
1171
  }
478
1172
  //# sourceMappingURL=act.js.map