swipium 1.5.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +173 -280
  3. package/THREAT_MODEL.md +223 -47
  4. package/dist/appMap/automationLink.js +30 -27
  5. package/dist/appMap/automationLink.js.map +1 -1
  6. package/dist/appMap/build.js +47 -21
  7. package/dist/appMap/build.js.map +1 -1
  8. package/dist/appMap/codeIndex.js +2 -2
  9. package/dist/appMap/codeIndex.js.map +1 -1
  10. package/dist/appMap/featureIndex.js +3 -3
  11. package/dist/appMap/featureIndex.js.map +1 -1
  12. package/dist/appMap/featureModel.js +2 -2
  13. package/dist/appMap/featureModel.js.map +1 -1
  14. package/dist/appMap/firstRunApply.js +2 -2
  15. package/dist/appMap/firstRunApply.js.map +1 -1
  16. package/dist/appMap/issues.js +10 -10
  17. package/dist/appMap/issues.js.map +1 -1
  18. package/dist/appMap/migrations.js +5 -5
  19. package/dist/appMap/migrations.js.map +1 -1
  20. package/dist/appMap/prelaunch.js +2 -2
  21. package/dist/appMap/prelaunch.js.map +1 -1
  22. package/dist/appMap/projectRegistry.js +77 -11
  23. package/dist/appMap/projectRegistry.js.map +1 -1
  24. package/dist/appMap/provenance.js +2 -2
  25. package/dist/appMap/provenance.js.map +1 -1
  26. package/dist/appMap/query.js +2 -2
  27. package/dist/appMap/query.js.map +1 -1
  28. package/dist/appMap/runtimeMerge.js +3 -3
  29. package/dist/appMap/runtimeMerge.js.map +1 -1
  30. package/dist/appMap/schema.js +1 -1
  31. package/dist/appMap/schema.js.map +1 -1
  32. package/dist/appMap/screenMatch.js +1 -1
  33. package/dist/appMap/screenMatch.js.map +1 -1
  34. package/dist/appMap/staticScan.js +17 -17
  35. package/dist/appMap/staticScan.js.map +1 -1
  36. package/dist/appMap/store.js +55 -7
  37. package/dist/appMap/store.js.map +1 -1
  38. package/dist/appMap/tsAstScan.js +3 -3
  39. package/dist/appMap/tsAstScan.js.map +1 -1
  40. package/dist/artifacts/bundletool.js +5 -5
  41. package/dist/artifacts/bundletool.js.map +1 -1
  42. package/dist/artifacts/resolve.js +10 -10
  43. package/dist/artifacts/resolve.js.map +1 -1
  44. package/dist/automation/capabilities.js +3 -3
  45. package/dist/automation/capabilities.js.map +1 -1
  46. package/dist/automation/gestures.js +1 -1
  47. package/dist/automation/gestures.js.map +1 -1
  48. package/dist/automation/plan.js +7 -7
  49. package/dist/automation/plan.js.map +1 -1
  50. package/dist/automation/report.js +3 -3
  51. package/dist/automation/report.js.map +1 -1
  52. package/dist/automation/selectors.js +14 -25
  53. package/dist/automation/selectors.js.map +1 -1
  54. package/dist/automation/types.js +1 -1
  55. package/dist/automation/types.js.map +1 -1
  56. package/dist/automation/waits.js +5 -5
  57. package/dist/automation/waits.js.map +1 -1
  58. package/dist/automation/webview.js +2 -2
  59. package/dist/automation/webview.js.map +1 -1
  60. package/dist/automationGen/appiumModel.js +13 -7
  61. package/dist/automationGen/appiumModel.js.map +1 -1
  62. package/dist/automationGen/ciEmitter.js +5 -5
  63. package/dist/automationGen/ciEmitter.js.map +1 -1
  64. package/dist/automationGen/identifiers.js +224 -0
  65. package/dist/automationGen/identifiers.js.map +1 -0
  66. package/dist/automationGen/jsEmitter.js +265 -106
  67. package/dist/automationGen/jsEmitter.js.map +1 -1
  68. package/dist/automationGen/packagePatch.js +2 -2
  69. package/dist/automationGen/packagePatch.js.map +1 -1
  70. package/dist/automationGen/platformResolve.js +36 -0
  71. package/dist/automationGen/platformResolve.js.map +1 -0
  72. package/dist/automationGen/projectProfile.js +71 -26
  73. package/dist/automationGen/projectProfile.js.map +1 -1
  74. package/dist/automationGen/pythonEmitter.js +276 -105
  75. package/dist/automationGen/pythonEmitter.js.map +1 -1
  76. package/dist/automationGen/readmeEmitter.js +7 -6
  77. package/dist/automationGen/readmeEmitter.js.map +1 -1
  78. package/dist/{tools/automationGenerate.js → automationGen/run.js} +72 -113
  79. package/dist/automationGen/run.js.map +1 -0
  80. package/dist/automationGen/suitePlan.js +6 -4
  81. package/dist/automationGen/suitePlan.js.map +1 -1
  82. package/dist/automationGen/validation.js +20 -8
  83. package/dist/automationGen/validation.js.map +1 -1
  84. package/dist/build/parseBuildLog.js +2 -2
  85. package/dist/build/parseBuildLog.js.map +1 -1
  86. package/dist/build/plan.js +7 -7
  87. package/dist/build/plan.js.map +1 -1
  88. package/dist/ci/preflight.js +13 -4
  89. package/dist/ci/preflight.js.map +1 -1
  90. package/dist/cli/gc.js +74 -0
  91. package/dist/cli/gc.js.map +1 -0
  92. package/dist/cli/init.js +217 -26
  93. package/dist/cli/init.js.map +1 -1
  94. package/dist/cli/main.js +50 -0
  95. package/dist/cli/main.js.map +1 -0
  96. package/dist/cli/report.js +218 -0
  97. package/dist/cli/report.js.map +1 -0
  98. package/dist/cli/scan.js +35 -14
  99. package/dist/cli/scan.js.map +1 -1
  100. package/dist/cli/suite.js +6 -6
  101. package/dist/cli/suite.js.map +1 -1
  102. package/dist/cli/verify.js +7 -7
  103. package/dist/cli/verify.js.map +1 -1
  104. package/dist/consent/consent.js +162 -5
  105. package/dist/consent/consent.js.map +1 -1
  106. package/dist/context/detect.js +45 -14
  107. package/dist/context/detect.js.map +1 -1
  108. package/dist/context/findApps.js +6 -6
  109. package/dist/context/findApps.js.map +1 -1
  110. package/dist/context/projectRoot.js +174 -19
  111. package/dist/context/projectRoot.js.map +1 -1
  112. package/dist/context/scan.js +1 -1
  113. package/dist/context/scan.js.map +1 -1
  114. package/dist/core/capabilityGroups.js +90 -0
  115. package/dist/core/capabilityGroups.js.map +1 -0
  116. package/dist/core/target.js +3 -3
  117. package/dist/core/target.js.map +1 -1
  118. package/dist/core/targetPlan.js +65 -62
  119. package/dist/core/targetPlan.js.map +1 -1
  120. package/dist/drivers/DirectDriver.js +300 -36
  121. package/dist/drivers/DirectDriver.js.map +1 -1
  122. package/dist/drivers/SimctlDriver.js +120 -7
  123. package/dist/drivers/SimctlDriver.js.map +1 -1
  124. package/dist/drivers/WdaDriver.js +350 -50
  125. package/dist/drivers/WdaDriver.js.map +1 -1
  126. package/dist/explore/candidates.js +2 -2
  127. package/dist/explore/candidates.js.map +1 -1
  128. package/dist/explore/graph.js +5 -5
  129. package/dist/explore/graph.js.map +1 -1
  130. package/dist/explore/policy.js +5 -5
  131. package/dist/explore/policy.js.map +1 -1
  132. package/dist/explore/runner.js +301 -284
  133. package/dist/explore/runner.js.map +1 -1
  134. package/dist/explore/signatures.js +1 -1
  135. package/dist/featureTesting/executionBootstrap.js +29 -30
  136. package/dist/featureTesting/executionBootstrap.js.map +1 -1
  137. package/dist/featureTesting/featureMap.js +2 -2
  138. package/dist/featureTesting/featureMap.js.map +1 -1
  139. package/dist/featureTesting/featureScope.js +12 -12
  140. package/dist/featureTesting/featureScope.js.map +1 -1
  141. package/dist/featureTesting/mapFeatureScope.js +7 -7
  142. package/dist/featureTesting/mapFeatureScope.js.map +1 -1
  143. package/dist/featureTesting/objectiveModel.js +10 -10
  144. package/dist/featureTesting/objectiveModel.js.map +1 -1
  145. package/dist/featureTesting/resultMerge.js +6 -6
  146. package/dist/featureTesting/resultMerge.js.map +1 -1
  147. package/dist/featureTesting/sources.js +4 -4
  148. package/dist/featureTesting/sources.js.map +1 -1
  149. package/dist/featureTesting/suiteBridge.js +1 -1
  150. package/dist/featureTesting/suiteBridge.js.map +1 -1
  151. package/dist/featureTesting/synonyms.js +1 -1
  152. package/dist/featureTesting/synonyms.js.map +1 -1
  153. package/dist/featureTesting/testCaseFactory.js +11 -11
  154. package/dist/featureTesting/testCaseFactory.js.map +1 -1
  155. package/dist/featureTesting/testPlan.js +3 -3
  156. package/dist/featureTesting/testPlan.js.map +1 -1
  157. package/dist/firstRun/authStateMachine.js +1 -1
  158. package/dist/firstRun/authStateMachine.js.map +1 -1
  159. package/dist/firstRun/classifyScreen.js +5 -5
  160. package/dist/firstRun/classifyScreen.js.map +1 -1
  161. package/dist/firstRun/firstRunPlanner.js +11 -11
  162. package/dist/firstRun/firstRunPlanner.js.map +1 -1
  163. package/dist/firstRun/firstRunRunner.js +8 -8
  164. package/dist/firstRun/firstRunRunner.js.map +1 -1
  165. package/dist/firstRun/generatedDataPolicy.js +9 -9
  166. package/dist/firstRun/generatedDataPolicy.js.map +1 -1
  167. package/dist/firstRun/inputPlanner.js +5 -5
  168. package/dist/firstRun/inputPlanner.js.map +1 -1
  169. package/dist/firstRun/onboardingStateMachine.js +1 -1
  170. package/dist/firstRun/onboardingStateMachine.js.map +1 -1
  171. package/dist/firstRun/paywallPolicy.js +3 -3
  172. package/dist/firstRun/paywallPolicy.js.map +1 -1
  173. package/dist/firstRun/types.js +1 -1
  174. package/dist/firstRun/types.js.map +1 -1
  175. package/dist/fixtures/catalog.js +33 -3
  176. package/dist/fixtures/catalog.js.map +1 -1
  177. package/dist/fixtures/load.js +50 -0
  178. package/dist/fixtures/load.js.map +1 -0
  179. package/dist/flows/discover.js +2 -2
  180. package/dist/flows/discover.js.map +1 -1
  181. package/dist/flows/generate.js +20 -10
  182. package/dist/flows/generate.js.map +1 -1
  183. package/dist/flows/pack.js +3 -3
  184. package/dist/flows/pack.js.map +1 -1
  185. package/dist/flows/paths.js +57 -0
  186. package/dist/flows/paths.js.map +1 -0
  187. package/dist/flows/repair.js +111 -23
  188. package/dist/flows/repair.js.map +1 -1
  189. package/dist/flows/run.js +153 -57
  190. package/dist/flows/run.js.map +1 -1
  191. package/dist/flows/schema.js +50 -5
  192. package/dist/flows/schema.js.map +1 -1
  193. package/dist/flows/seedExec.js +4 -4
  194. package/dist/flows/seedExec.js.map +1 -1
  195. package/dist/flows/templates.js +5 -5
  196. package/dist/index.js +50 -8
  197. package/dist/index.js.map +1 -1
  198. package/dist/issues/classify.js +16 -16
  199. package/dist/issues/classify.js.map +1 -1
  200. package/dist/issues/fingerprint.js +24 -4
  201. package/dist/issues/fingerprint.js.map +1 -1
  202. package/dist/issues/index.js +94 -31
  203. package/dist/issues/index.js.map +1 -1
  204. package/dist/issues/metrics.js +4 -4
  205. package/dist/issues/metrics.js.map +1 -1
  206. package/dist/issues/recurrence.js +35 -8
  207. package/dist/issues/recurrence.js.map +1 -1
  208. package/dist/issues/report.js +4 -4
  209. package/dist/issues/report.js.map +1 -1
  210. package/dist/issues/reportBridge.js +4 -4
  211. package/dist/issues/reportBridge.js.map +1 -1
  212. package/dist/issues/schema.js +2 -2
  213. package/dist/issues/schema.js.map +1 -1
  214. package/dist/issues/sourceRevision.js +2 -2
  215. package/dist/issues/sourceRevision.js.map +1 -1
  216. package/dist/issues/store.js +110 -32
  217. package/dist/issues/store.js.map +1 -1
  218. package/dist/lib/abortScope.js +48 -0
  219. package/dist/lib/abortScope.js.map +1 -0
  220. package/dist/lib/android.js +75 -7
  221. package/dist/lib/android.js.map +1 -1
  222. package/dist/lib/coordSpace.js +1 -1
  223. package/dist/lib/coordSpace.js.map +1 -1
  224. package/dist/lib/device.js +32 -7
  225. package/dist/lib/device.js.map +1 -1
  226. package/dist/lib/gestures.js +141 -0
  227. package/dist/lib/gestures.js.map +1 -0
  228. package/dist/lib/gitignore.js +4 -3
  229. package/dist/lib/gitignore.js.map +1 -1
  230. package/dist/lib/image.js +1 -1
  231. package/dist/lib/image.js.map +1 -1
  232. package/dist/lib/lockfile.js +370 -31
  233. package/dist/lib/lockfile.js.map +1 -1
  234. package/dist/lib/needsInput.js +3 -3
  235. package/dist/lib/needsInput.js.map +1 -1
  236. package/dist/lib/png.js +2 -2
  237. package/dist/lib/png.js.map +1 -1
  238. package/dist/lib/redact.js +170 -9
  239. package/dist/lib/redact.js.map +1 -1
  240. package/dist/lib/result.js +79 -10
  241. package/dist/lib/result.js.map +1 -1
  242. package/dist/lib/schemaHash.js +3 -3
  243. package/dist/lib/schemaHash.js.map +1 -1
  244. package/dist/lib/sensitive.js +3 -2
  245. package/dist/lib/sensitive.js.map +1 -1
  246. package/dist/lib/simctl.js +3 -3
  247. package/dist/lib/simctl.js.map +1 -1
  248. package/dist/lib/spawn.js +52 -10
  249. package/dist/lib/spawn.js.map +1 -1
  250. package/dist/lib/toolAnnotations.js +114 -0
  251. package/dist/lib/toolAnnotations.js.map +1 -0
  252. package/dist/lib/wda.js +294 -15
  253. package/dist/lib/wda.js.map +1 -1
  254. package/dist/mobileAudit/checks.js +12 -12
  255. package/dist/mobileAudit/checks.js.map +1 -1
  256. package/dist/mobileAudit/evidence.js +4 -4
  257. package/dist/mobileAudit/evidence.js.map +1 -1
  258. package/dist/mobileAudit/profiles.js +1 -1
  259. package/dist/mobileAudit/profiles.js.map +1 -1
  260. package/dist/mobileAudit/results.js +3 -3
  261. package/dist/mobileAudit/results.js.map +1 -1
  262. package/dist/mobileAudit/runner.js +72 -18
  263. package/dist/mobileAudit/runner.js.map +1 -1
  264. package/dist/oracle/auth.js +1 -1
  265. package/dist/oracle/auth.js.map +1 -1
  266. package/dist/oracle/failures.js +219 -36
  267. package/dist/oracle/failures.js.map +1 -1
  268. package/dist/oracle/health.js +63 -17
  269. package/dist/oracle/health.js.map +1 -1
  270. package/dist/oracle/locator.js +13 -13
  271. package/dist/oracle/locator.js.map +1 -1
  272. package/dist/oracle/record.js +3 -3
  273. package/dist/oracle/record.js.map +1 -1
  274. package/dist/orchestration/envelope.js +2 -2
  275. package/dist/orchestration/envelope.js.map +1 -1
  276. package/dist/orchestration/goal.js +4 -4
  277. package/dist/orchestration/goal.js.map +1 -1
  278. package/dist/orchestration/testThis/execute.js +62 -21
  279. package/dist/orchestration/testThis/execute.js.map +1 -1
  280. package/dist/orchestration/testThis/pipeline.js +119 -30
  281. package/dist/orchestration/testThis/pipeline.js.map +1 -1
  282. package/dist/orchestration/testThis/plan.js +162 -73
  283. package/dist/orchestration/testThis/plan.js.map +1 -1
  284. package/dist/orchestration/testThis/sessionIntent.js +72 -0
  285. package/dist/orchestration/testThis/sessionIntent.js.map +1 -0
  286. package/dist/orchestration/testThis/terminal.js +34 -16
  287. package/dist/orchestration/testThis/terminal.js.map +1 -1
  288. package/dist/orchestration/testThis/types.js +1 -1
  289. package/dist/orchestration/testThis/types.js.map +1 -1
  290. package/dist/plan/plan.js +9 -9
  291. package/dist/plan/plan.js.map +1 -1
  292. package/dist/prompts/index.js +22 -22
  293. package/dist/prompts/index.js.map +1 -1
  294. package/dist/report/export.js +392 -37
  295. package/dist/report/export.js.map +1 -1
  296. package/dist/report/findingsDedupe.js +41 -0
  297. package/dist/report/findingsDedupe.js.map +1 -0
  298. package/dist/report/policy.js +7 -4
  299. package/dist/report/policy.js.map +1 -1
  300. package/dist/report/qaLevel.js +8 -8
  301. package/dist/report/qaLevel.js.map +1 -1
  302. package/dist/report/sarifSources.js +160 -0
  303. package/dist/report/sarifSources.js.map +1 -0
  304. package/dist/report/summary.js +2 -2
  305. package/dist/report/summary.js.map +1 -1
  306. package/dist/report/testCatalog.js +4 -4
  307. package/dist/report/testCatalog.js.map +1 -1
  308. package/dist/report/toolHealth.js +124 -0
  309. package/dist/report/toolHealth.js.map +1 -0
  310. package/dist/server.js +472 -45
  311. package/dist/server.js.map +1 -1
  312. package/dist/services/automationGenerate.js +8 -2
  313. package/dist/services/automationGenerate.js.map +1 -1
  314. package/dist/services/build.js +2 -2
  315. package/dist/services/build.js.map +1 -1
  316. package/dist/{tools → services}/flowGenerate.js +29 -13
  317. package/dist/services/flowGenerate.js.map +1 -0
  318. package/dist/services/preflight.js +4 -4
  319. package/dist/services/preflight.js.map +1 -1
  320. package/dist/services/prepareAndroid.js +46 -8
  321. package/dist/services/prepareAndroid.js.map +1 -1
  322. package/dist/services/prepareIos.js +24 -9
  323. package/dist/services/prepareIos.js.map +1 -1
  324. package/dist/services/report.js +100 -45
  325. package/dist/services/report.js.map +1 -1
  326. package/dist/services/smoke.js +32 -16
  327. package/dist/services/smoke.js.map +1 -1
  328. package/dist/services/suiteGenerate.js +28 -3
  329. package/dist/services/suiteGenerate.js.map +1 -1
  330. package/dist/services/testSuiteKnowledge.js +40 -27
  331. package/dist/services/testSuiteKnowledge.js.map +1 -1
  332. package/dist/session/attach.js +270 -6
  333. package/dist/session/attach.js.map +1 -1
  334. package/dist/session/processRegistry.js +317 -48
  335. package/dist/session/processRegistry.js.map +1 -1
  336. package/dist/session/progress.js +1 -1
  337. package/dist/session/retention.js +201 -0
  338. package/dist/session/retention.js.map +1 -0
  339. package/dist/session/store.js +275 -48
  340. package/dist/session/store.js.map +1 -1
  341. package/dist/snapshot/overlays.js +53 -8
  342. package/dist/snapshot/overlays.js.map +1 -1
  343. package/dist/snapshot/parse.js +28 -4
  344. package/dist/snapshot/parse.js.map +1 -1
  345. package/dist/snapshot/present.js +11 -5
  346. package/dist/snapshot/present.js.map +1 -1
  347. package/dist/snapshot/settle.js +39 -9
  348. package/dist/snapshot/settle.js.map +1 -1
  349. package/dist/state/consent.js +99 -0
  350. package/dist/state/consent.js.map +1 -0
  351. package/dist/state/profile.js +46 -2
  352. package/dist/state/profile.js.map +1 -1
  353. package/dist/suite/compile.js +9 -5
  354. package/dist/suite/compile.js.map +1 -1
  355. package/dist/suite/lint.js +4 -4
  356. package/dist/suite/lint.js.map +1 -1
  357. package/dist/suite/pom.js +50 -41
  358. package/dist/suite/pom.js.map +1 -1
  359. package/dist/suite/secretGuard.js +266 -0
  360. package/dist/suite/secretGuard.js.map +1 -0
  361. package/dist/suite/testcase.js +9 -7
  362. package/dist/suite/testcase.js.map +1 -1
  363. package/dist/testSuite/exporter.js +4 -4
  364. package/dist/testSuite/exporter.js.map +1 -1
  365. package/dist/testSuite/generator.js +13 -9
  366. package/dist/testSuite/generator.js.map +1 -1
  367. package/dist/testSuite/history.js +1 -1
  368. package/dist/testSuite/history.js.map +1 -1
  369. package/dist/testSuite/issueLinks.js +3 -3
  370. package/dist/testSuite/issueLinks.js.map +1 -1
  371. package/dist/testSuite/lint.js +4 -4
  372. package/dist/testSuite/lint.js.map +1 -1
  373. package/dist/testSuite/merge.js +5 -5
  374. package/dist/testSuite/merge.js.map +1 -1
  375. package/dist/testSuite/schema.js +5 -5
  376. package/dist/testSuite/schema.js.map +1 -1
  377. package/dist/testSuite/store.js +3 -3
  378. package/dist/testSuite/store.js.map +1 -1
  379. package/dist/testSuite/traceability.js +2 -2
  380. package/dist/testSuite/traceability.js.map +1 -1
  381. package/dist/tools/act.js +794 -164
  382. package/dist/tools/act.js.map +1 -1
  383. package/dist/tools/agent.js +451 -128
  384. package/dist/tools/agent.js.map +1 -1
  385. package/dist/tools/appControl.js +69 -19
  386. package/dist/tools/appControl.js.map +1 -1
  387. package/dist/tools/appMap.js +188 -130
  388. package/dist/tools/appMap.js.map +1 -1
  389. package/dist/tools/build.js +32 -42
  390. package/dist/tools/build.js.map +1 -1
  391. package/dist/tools/bundletool.js +21 -31
  392. package/dist/tools/bundletool.js.map +1 -1
  393. package/dist/tools/clearOverlay.js +55 -12
  394. package/dist/tools/clearOverlay.js.map +1 -1
  395. package/dist/tools/device.js +76 -24
  396. package/dist/tools/device.js.map +1 -1
  397. package/dist/tools/doctor.js +68 -28
  398. package/dist/tools/doctor.js.map +1 -1
  399. package/dist/tools/explore.js +41 -47
  400. package/dist/tools/explore.js.map +1 -1
  401. package/dist/tools/featureTesting.js +31 -48
  402. package/dist/tools/featureTesting.js.map +1 -1
  403. package/dist/tools/firstRun.js +34 -41
  404. package/dist/tools/firstRun.js.map +1 -1
  405. package/dist/tools/flow.js +183 -103
  406. package/dist/tools/flow.js.map +1 -1
  407. package/dist/tools/flowRepair.js +25 -13
  408. package/dist/tools/flowRepair.js.map +1 -1
  409. package/dist/tools/generate.js +52 -89
  410. package/dist/tools/generate.js.map +1 -1
  411. package/dist/tools/getArtifact.js +16 -5
  412. package/dist/tools/getArtifact.js.map +1 -1
  413. package/dist/tools/health.js +15 -12
  414. package/dist/tools/health.js.map +1 -1
  415. package/dist/tools/ios.js +45 -223
  416. package/dist/tools/ios.js.map +1 -1
  417. package/dist/tools/issues.js +251 -35
  418. package/dist/tools/issues.js.map +1 -1
  419. package/dist/tools/jobs.js +41 -16
  420. package/dist/tools/jobs.js.map +1 -1
  421. package/dist/tools/metro.js +36 -35
  422. package/dist/tools/metro.js.map +1 -1
  423. package/dist/tools/mobileAudit.js +50 -45
  424. package/dist/tools/mobileAudit.js.map +1 -1
  425. package/dist/tools/network.js +31 -14
  426. package/dist/tools/network.js.map +1 -1
  427. package/dist/tools/note.js +26 -26
  428. package/dist/tools/note.js.map +1 -1
  429. package/dist/tools/prepareIosTarget.js +10 -16
  430. package/dist/tools/prepareIosTarget.js.map +1 -1
  431. package/dist/tools/prepareTarget.js +117 -50
  432. package/dist/tools/prepareTarget.js.map +1 -1
  433. package/dist/tools/report.js +11 -22
  434. package/dist/tools/report.js.map +1 -1
  435. package/dist/tools/resolveArtifact.js +11 -15
  436. package/dist/tools/resolveArtifact.js.map +1 -1
  437. package/dist/tools/resolveTarget.js +78 -24
  438. package/dist/tools/resolveTarget.js.map +1 -1
  439. package/dist/tools/screenRecord.js +23 -21
  440. package/dist/tools/screenRecord.js.map +1 -1
  441. package/dist/tools/screenshot.js +23 -16
  442. package/dist/tools/screenshot.js.map +1 -1
  443. package/dist/tools/smoke.js +20 -16
  444. package/dist/tools/smoke.js.map +1 -1
  445. package/dist/tools/snapshot.js +155 -118
  446. package/dist/tools/snapshot.js.map +1 -1
  447. package/dist/tools/startSession.js +104 -116
  448. package/dist/tools/startSession.js.map +1 -1
  449. package/dist/tools/suite.js +72 -43
  450. package/dist/tools/suite.js.map +1 -1
  451. package/dist/tools/testSuite.js +30 -20
  452. package/dist/tools/testSuite.js.map +1 -1
  453. package/dist/tools/testThis.js +24 -28
  454. package/dist/tools/testThis.js.map +1 -1
  455. package/dist/tools/visual.js +686 -210
  456. package/dist/tools/visual.js.map +1 -1
  457. package/dist/tools/wait.js +9 -38
  458. package/dist/tools/wait.js.map +1 -1
  459. package/dist/tools/wda.js +187 -57
  460. package/dist/tools/wda.js.map +1 -1
  461. package/dist/version.js +31 -22
  462. package/dist/version.js.map +1 -1
  463. package/dist/visual/ocr.js +53 -6
  464. package/dist/visual/ocr.js.map +1 -1
  465. package/dist/visual/provider.js +80 -12
  466. package/dist/visual/provider.js.map +1 -1
  467. package/docs/README.md +24 -10
  468. package/docs/ci-reports.md +328 -0
  469. package/docs/concepts.md +227 -0
  470. package/docs/flows.md +153 -0
  471. package/docs/mcp-server.md +194 -128
  472. package/docs/physical-devices.md +84 -0
  473. package/docs/tools.md +812 -152
  474. package/package.json +3 -2
  475. package/dist/tools/assertVisual.js +0 -84
  476. package/dist/tools/assertVisual.js.map +0 -1
  477. package/dist/tools/automationGenerate.js.map +0 -1
  478. package/dist/tools/capabilities.js +0 -179
  479. package/dist/tools/capabilities.js.map +0 -1
  480. package/dist/tools/detectContext.js +0 -41
  481. package/dist/tools/detectContext.js.map +0 -1
  482. package/dist/tools/flowGenerate.js.map +0 -1
  483. package/dist/tools/history.js +0 -86
  484. package/dist/tools/history.js.map +0 -1
  485. package/dist/tools/locator.js +0 -85
  486. package/dist/tools/locator.js.map +0 -1
  487. package/dist/tools/permissions.js +0 -174
  488. package/dist/tools/permissions.js.map +0 -1
  489. package/dist/tools/plan.js +0 -52
  490. package/dist/tools/plan.js.map +0 -1
  491. package/dist/tools/screenInfo.js +0 -76
  492. package/dist/tools/screenInfo.js.map +0 -1
  493. package/dist/tools/seed.js +0 -133
  494. package/dist/tools/seed.js.map +0 -1
  495. package/dist/tools/state.js +0 -217
  496. package/dist/tools/state.js.map +0 -1
  497. package/dist/tools/visualText.js +0 -106
  498. package/dist/tools/visualText.js.map +0 -1
@@ -1,245 +1,721 @@
1
- // qa_visual (PHASE3-PLAN §8.2) — local, deterministic visual intelligence for screens with no
2
- // usable UI tree (maps/canvases/games), plus visual regression. Actions:
3
- // baseline — save the current screen as a named baseline (.swipium/baselines/<name>.png)
4
- // diff — compare the current screen to a baseline → changed-ratio + changed region
5
- // find_image — locate a reference image in the current screen → tappable coordinates
6
- // ocr — OPTIONAL, consent-gated: run a locally-configured OCR command (none bundled)
7
- // Every result carries the coordinate space so screenshot-pixel hits convert to tap pixels.
1
+ // qa_visual: consolidated local visual intelligence for screens with no usable UI
2
+ // tree (maps/canvases/games) and for visual regression. Its whole point is that it stays
3
+ // backend-neutral: only driver.screenshot() is required, so it works in visual-only iOS
4
+ // simulator mode (no WDA) where qa_act / qa_snapshot are rejected. Modes:
5
+ // baseline : save the current screen as a named baseline (<repo>/.swipium/baselines/<name>.png)
6
+ // diff : compare the current screen to a baseline > changed-ratio + changed region
7
+ // find_text : consent-gated OCR (locally-configured provider, none bundled) > text matches
8
+ // find_image : locate a reference PNG in the current screen > tappable coordinates
9
+ // assert : record a visual pass/fail with screenshot evidence (a qa_note with
10
+ // verifiedVisually=true; a pass is also recorded as a semantic IR step)
11
+ // Every result declares its coordinateSpace so screenshot-pixel hits convert
12
+ // honestly to device (tap) coordinates: POINTS on iOS (WDA and the idb fallback), pixels on
13
+ // Android. Policy matches qa_screenshot: refuse in sensitive mode, and
14
+ // withhold capture when a secure field is (or may be) on screen unless force:true
15
+ // (THREAT_MODEL "Sensitive-screen capture": pixels are not redactable).
16
+ //
17
+ // Each mode is one handler in MODE_HANDLERS sharing a VisualContext, so adding a mode is one
18
+ // enum value + one handler.
8
19
  import { z } from 'zod';
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
- import { isAbsolute, join } from 'node:path';
11
- import { qaOk, qaError } from '../lib/result.js';
20
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
21
+ import { isAbsolute, join, resolve, sep } from 'node:path';
22
+ import { qaOk, qaError, qaStop, unknownSessionError } from '../lib/result.js';
12
23
  import { isSecureNode, makeRedactor } from '../lib/redact.js';
24
+ import { parseSnapshot } from '../snapshot/parse.js';
13
25
  import { displayArgv } from '../lib/commandTemplate.js';
14
- import { GitScopeForbiddenError } from '../lib/spawn.js';
26
+ import { GitScopeForbiddenError, run } from '../lib/spawn.js';
27
+ import { which } from '../lib/android.js';
15
28
  import { sensitiveRefusal } from '../lib/sensitive.js';
16
29
  import { imageDiff, findTemplate } from '../lib/image.js';
17
30
  import { captureCoordinateSpace, toDevicePoint } from '../lib/coordSpace.js';
18
- import { configuredOcrCommand, runOcr } from '../visual/ocr.js';
19
- import { boundedText, resolveMaskProvider, resolveVisualProvider } from '../visual/provider.js';
31
+ import { configuredOcrCommand, ocrCommandSource, findOcrRegion, runOcr, OCR_PROVIDER_CONTRACT, TESSERACT_OCR_EXAMPLE, } from '../visual/ocr.js';
32
+ import { boundedText, maskCommandSource, providerSourceLabel, resolveMaskProvider, resolveVisualProvider, VisualProviderFailedError, } from '../visual/provider.js';
20
33
  import { requireConsent, consumeConsent } from '../consent/consent.js';
21
- import { getDriver } from '../session/attach.js';
22
- export function registerVisual(server, sessions) {
23
- server.registerTool('qa_visual', {
24
- title: 'Visual matching',
25
- description: 'Local, deterministic visual ops for screens without a usable UI tree, and visual regression. action: ' +
26
- 'baseline (save current screen as a named reference), diff (compare current vs a baseline → changed ratio + region; pass if within threshold), ' +
27
- 'find_image (locate a reference PNG in the current screen → tappable device coordinates), ocr (OPTIONAL, consent-gated; runs a locally-configured OCR command — none is bundled). ' +
28
- 'Results include coordinateSpace so screenshot-pixel hits convert to qa_act {x,y} device pixels.',
29
- inputSchema: {
30
- sessionId: z.string(),
31
- action: z.enum(['baseline', 'diff', 'find_image', 'ocr']),
32
- name: z.string().optional().describe('Baseline name (for baseline/diff).'),
33
- template: z.string().optional().describe('Path to a reference PNG (for find_image); absolute or relative to the project root.'),
34
- threshold: z.number().optional().describe('diff: max changed fraction to still PASS (default 0.02).'),
35
- minScore: z.number().optional().describe('find_image: min match confidence 0..1 (default 0.85).'),
36
- force: z.boolean().optional().describe('Capture even if a secure field is on screen (pixels are not redactable).'),
37
- consentId: z.string().optional(),
38
- approve: z.boolean().optional(),
39
- },
40
- }, async ({ sessionId, action, name, template, threshold, minScore, force, consentId, approve }) => {
41
- const session = sessions.get(sessionId);
42
- const { driver } = session ? await getDriver(session) : { driver: undefined };
43
- if (!session || !driver) {
44
- return qaError({
45
- what: 'No device attached to this session',
46
- changedState: false,
47
- retrySafe: true,
48
- nextSteps: ['Call qa_prepare_target first.'],
49
- });
50
- }
51
- if (session.sensitive)
52
- return sensitiveRefusal('Visual capture');
53
- // Secure-screen guard (same as qa_screenshot): never persist password/OTP pixels by default.
54
- const hasSecure = session.lastSnapshot ? [...session.lastSnapshot.fullByRef.values()].some((n) => isSecureNode(n)) : false;
55
- if (hasSecure && !force) {
56
- return qaError({
57
- what: 'Withheld — a secure field (password/OTP) is on screen',
58
- changedState: false,
59
- retrySafe: true,
60
- nextSteps: ['Pass force:true to proceed (pixels are NOT redactable), or use a non-sensitive screen.'],
61
- });
34
+ import { blockedDeviceResult, getDriver } from '../session/attach.js';
35
+ import { recordableTap } from '../flows/generate.js';
36
+ export const VISUAL_MODES = ['assert', 'baseline', 'diff', 'find_text', 'find_image'];
37
+ // ---------------------------------------------------------------------------------------------
38
+ // B5: path containment for baselines and templates.
39
+ /** Baseline names are plain file stems: 1-64 of [A-Za-z0-9._-], never starting with a dot. */
40
+ const BASELINE_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
41
+ export function baselinesDirFor(root) {
42
+ return join(root, '.swipium', 'baselines');
43
+ }
44
+ function pathRefused(what, nextStep) {
45
+ return qaError({ what, changedState: false, retrySafe: false, failureCode: 'VISUAL_PATH_REFUSED', nextSteps: [nextStep] });
46
+ }
47
+ /** Resolve `<root>/.swipium/baselines/<name>.png`, refusing any name that could escape it. */
48
+ export function resolveBaselinePath(root, name) {
49
+ if (!BASELINE_NAME_RE.test(name) || name.startsWith('.')) {
50
+ return { error: `Invalid baseline name ${JSON.stringify(name)}. Use 1-64 of [A-Za-z0-9._-], not starting with "."` };
51
+ }
52
+ const dir = resolve(baselinesDirFor(root));
53
+ const path = resolve(dir, `${name}.png`);
54
+ if (!path.startsWith(dir + sep))
55
+ return { error: `Baseline name ${JSON.stringify(name)} resolves outside .swipium/baselines` };
56
+ // A symlinked baseline file could still redirect the write/read elsewhere, so refuse it.
57
+ try {
58
+ if (lstatSync(path).isSymbolicLink())
59
+ return { error: `Baseline ${JSON.stringify(name)} is a symlink, refusing to follow it` };
60
+ }
61
+ catch {
62
+ // does not exist yet, fine
63
+ }
64
+ return { path };
65
+ }
66
+ function realOrResolved(p) {
67
+ try {
68
+ return realpathSync(p);
69
+ }
70
+ catch {
71
+ return resolve(p);
72
+ }
73
+ }
74
+ /** A find_image template must be a PNG inside the project root (symlinks resolved) or a
75
+ * swipium:// artifact of this session's project, never an arbitrary absolute path. */
76
+ export function resolveTemplatePath(sessions, session, template) {
77
+ if (template.startsWith('swipium://')) {
78
+ const found = sessions.findArtifact(template);
79
+ if (!found || found.session.root !== session.root)
80
+ return { error: `Unknown artifact ${template} for this project`, missing: true };
81
+ return { path: found.rec.path };
82
+ }
83
+ const root = realOrResolved(session.root);
84
+ const candidate = isAbsolute(template) ? resolve(template) : resolve(session.root, template);
85
+ if (!existsSync(candidate))
86
+ return { error: `Template not found: ${candidate}`, missing: true };
87
+ const real = realOrResolved(candidate);
88
+ if (real !== root && !real.startsWith(root + sep)) {
89
+ return {
90
+ error: `Template ${JSON.stringify(template)} is outside the project root. Only project files or swipium:// artifacts are allowed`,
91
+ };
92
+ }
93
+ return { path: real };
94
+ }
95
+ // ---------------------------------------------------------------------------------------------
96
+ // B8: secure-screen detection.
97
+ /** Snapshots taken BEFORE a qa_visual tap describe a screen that may no longer be showing. */
98
+ const staleSnapshots = new WeakSet();
99
+ /** secure/clear from a FRESH UI tree; unknown when there is none (WDA-less simulator, no
100
+ * qa_snapshot yet) or it predates a visual tap. */
101
+ export function secureScreenState(session) {
102
+ const snap = session.lastSnapshot;
103
+ if (!snap || staleSnapshots.has(snap))
104
+ return 'unknown';
105
+ return [...snap.fullByRef.values()].some((n) => isSecureNode(n)) ? 'secure' : 'clear';
106
+ }
107
+ /** When the cached tree is missing or stale (e.g. after a qa_visual tap), a structured backend
108
+ * (Android adb / WDA) can simply re-dump it: a qa_visual tap must not, by itself, turn the next
109
+ * qa_visual call into an "unverified" one there. The fresh dump only informs the secure gate:
110
+ * session.lastSnapshot (the agent's @ref numbering) is left untouched. WDA-less simulators
111
+ * cannot dump a tree, so they stay 'unknown'. */
112
+ async function refreshSecureState(session, driver) {
113
+ const cached = secureScreenState(session);
114
+ if (cached !== 'unknown' || driver.kind === 'simulator')
115
+ return cached;
116
+ try {
117
+ const parsed = parseSnapshot(await driver.dumpXml());
118
+ if (parsed.allNodes.length === 0)
119
+ return 'unknown';
120
+ return parsed.allNodes.some((n) => isSecureNode(n)) ? 'secure' : 'clear';
121
+ }
122
+ catch {
123
+ return 'unknown';
124
+ }
125
+ }
126
+ /** The session has handled credentials (typed secrets, secret inputs, a login, or a
127
+ * rehydrated session whose secrets were dropped). An unverified screen may be sensitive. */
128
+ function hasSecretContext(session) {
129
+ return (session.secrets.size > 0 ||
130
+ !!session.redactionDegraded ||
131
+ (session.inputs ?? []).some((i) => i.secret) ||
132
+ !!session.auth?.loginPerformed);
133
+ }
134
+ /** OCR text that reads like a password/OTP/payment screen. */
135
+ const SECURE_TEXT_RE = /password|passcode|one[- ]?time|\bOTP\b|verification code|security code|\bCVV\b|\bCVC\b|card number|\bPIN\b/i;
136
+ function redactRegion(r, redact) {
137
+ return { ...r, text: redact(r.text) ?? '' };
138
+ }
139
+ // ---------------------------------------------------------------------------------------------
140
+ // Tapping (B6 units, B7 recording/budget).
141
+ /** Tap a found device-space point. On the WDA-less simulator the driver cannot inject input,
142
+ * so fall back to Meta's `idb` when it is on PATH (`idb ui tap` takes POINTS, which is what
143
+ * SimctlDriver.screenSize() reports, so devicePoint is already in points); otherwise return a
144
+ * typed refusal that still carries the coordinates (the locate itself succeeded). */
145
+ async function tapFoundPoint(driver, point, foundPayload) {
146
+ if (driver.kind !== 'simulator') {
147
+ try {
148
+ await driver.tapXY(point.x, point.y);
149
+ return { via: 'driver' };
62
150
  }
63
- const baselinesDir = join(session.root, '.swipium', 'baselines');
64
- // ---- ocr: optional + consent-gated; only if a command is configured ----
65
- if (action === 'ocr') {
66
- const ocrCommand = configuredOcrCommand(session.root);
67
- if (!ocrCommand) {
68
- return qaError({
69
- what: 'OCR is not configured',
70
- changedState: false,
71
- retrySafe: false,
72
- failureCode: 'VISUAL_ONLY_SCREEN',
73
- nextSteps: [
74
- 'Set an OCR command in .swipium/config.json ("ocrCommand": ["your-ocr", "{image}"]) or SWIPIUM_OCR_CMD env, then retry. Or use find_image / qa_assert_visual.',
75
- ],
76
- });
77
- }
78
- let preview;
79
- let maskPreview;
80
- try {
81
- preview = resolveVisualProvider(ocrCommand, { image: '<screenshot>' }, 30000);
82
- maskPreview = resolveMaskProvider(session.root);
83
- }
84
- catch (e) {
85
- return qaError({
86
- what: e instanceof GitScopeForbiddenError ? e.message : `Invalid OCR command template: ${String(e)}`,
87
- changedState: false,
88
- retrySafe: !(e instanceof GitScopeForbiddenError),
89
- failureCode: e instanceof GitScopeForbiddenError ? 'GIT_SCOPE_FORBIDDEN' : 'INVALID_FLOW',
90
- nextSteps: e instanceof GitScopeForbiddenError
91
- ? ['Run Git yourself outside Swipium; configure ocrCommand to use a non-Git executable.']
92
- : ['Use an argv array in .swipium/config.json, e.g. ["node","ocr.js","{image}"].'],
93
- });
94
- }
95
- const maskConfigured = !!maskPreview;
96
- const gate = consumeConsent(consentId, approve, {
97
- action: 'ocr_run',
98
- affects: { argv: preview.argv, io: preview.io, maskConfigured },
99
- });
100
- if (!gate.approved) {
101
- return requireConsent({
102
- action: 'ocr_run',
103
- risk: 'medium',
104
- exactCommand: displayArgv(preview.argv),
105
- affects: { argv: preview.argv, io: preview.io, maskConfigured },
106
- explain: 'Run the configured OCR command on a screenshot of the current screen? The screen image is passed to that local program or via the Swipium JSON provider contract. If visualMaskCommand is configured, Swipium runs it first and sends the masked image.',
107
- });
108
- }
109
- try {
110
- const ocr = await runOcr(driver, session.root, ocrCommand);
111
- const bounded = boundedText(ocr.text.trim(), makeRedactor(session.secrets), 8000);
112
- return qaOk({
113
- action,
114
- method: 'ocr',
115
- evidenceKind: 'ocr_text',
116
- text: bounded.text,
117
- truncated: bounded.truncated,
118
- regions: ocr.regions,
119
- coordinateSpace: ocr.coordinateSpace,
120
- provider: ocr.provider,
121
- masking: ocr.masking,
122
- }, `OCR text (${bounded.text.length}${bounded.truncated ? '+ truncated' : ''} chars):\n${bounded.text.slice(0, 2000)}`);
123
- }
124
- catch (e) {
125
- return qaError({
126
- what: `OCR command failed: ${String(e)}`,
151
+ catch (e) {
152
+ return {
153
+ error: qaError({
154
+ what: `Found the target but the tap failed: ${String(e)}`,
127
155
  changedState: false,
128
156
  retrySafe: true,
129
- nextSteps: ['Check the configured OCR command runs standalone on a PNG.'],
130
- });
131
- }
132
- }
133
- // capture once for the pixel actions
134
- let png;
135
- try {
136
- png = await driver.screenshot();
157
+ nextSteps: ['Tap the returned devicePoint with qa_act { action:"tap", target:{ x, y } }.'],
158
+ }, foundPayload),
159
+ };
137
160
  }
138
- catch (e) {
139
- return qaError({
161
+ }
162
+ const udid = driver.currentDevice();
163
+ if (udid && (await which('idb'))) {
164
+ const r = await run('idb', ['ui', 'tap', String(point.x), String(point.y), '--udid', udid], { timeoutMs: 15000 }).catch((e) => ({
165
+ code: -1,
166
+ stdout: '',
167
+ stderr: String(e),
168
+ timedOut: false,
169
+ }));
170
+ if (r.code === 0)
171
+ return { via: 'idb' };
172
+ return {
173
+ error: qaError({
174
+ what: `Found the target but \`idb ui tap\` failed: ${r.stderr.trim() || `exit ${r.code}`}`,
175
+ changedState: false,
176
+ retrySafe: true,
177
+ failureCode: 'BACKEND_UNSUPPORTED',
178
+ nextSteps: ['Attach WebDriverAgent with qa_wda, then tap the returned devicePoint via qa_act { action:"tap", target:{ x, y } }.'],
179
+ }, foundPayload),
180
+ };
181
+ }
182
+ return {
183
+ error: qaError({
184
+ what: 'Found the target, but tapping is not supported on the WDA-less iOS simulator backend (no idb on PATH)',
185
+ changedState: false,
186
+ retrySafe: true,
187
+ failureCode: 'BACKEND_UNSUPPORTED',
188
+ nextSteps: [
189
+ 'Attach WebDriverAgent with qa_wda, then tap the returned devicePoint via qa_act { action:"tap", target:{ x, y } }.',
190
+ "Or install Meta's idb (`brew tap facebook/fb && brew install idb-companion`, then `pipx install fb-idb`) so qa_visual can tap here.",
191
+ ],
192
+ }, foundPayload),
193
+ };
194
+ }
195
+ /** Tap + record it like qa_act does: bump the action counter, append a coordinate-kind action
196
+ * to the IR (so qa_generate / next-best-action see WDA-less progress), mark the UI tree stale,
197
+ * and surface budget exhaustion. */
198
+ async function tapAndRecord(ctx, devicePoint, payload, visual, label) {
199
+ const { sessions, session, driver } = ctx;
200
+ const tapped = await tapFoundPoint(driver, devicePoint, payload);
201
+ if ('error' in tapped)
202
+ return tapped.error;
203
+ sessions.bump(session, 'actions');
204
+ if (session.lastSnapshot)
205
+ staleSnapshots.add(session.lastSnapshot);
206
+ const point = { x: devicePoint.x, y: devicePoint.y, via: 'coords' };
207
+ const rec = recordableTap(session, { x: devicePoint.x, y: devicePoint.y }, point);
208
+ sessions.addRecordedAction(session, {
209
+ at: Date.now(),
210
+ action: 'tap',
211
+ ...rec,
212
+ warning: `Located visually (${visual.ocrText ? `OCR "${visual.ocrText}"` : 'template match'}). Coordinate-only replay; add an accessibilityIdentifier/testID for a durable selector`,
213
+ provenance: {
214
+ ...(rec.provenance ?? {}),
215
+ selectorKind: 'coords',
216
+ visual: {
217
+ screenshotCrop: visual.screenshotCrop,
218
+ ocrText: visual.ocrText,
219
+ confidence: visual.confidence,
220
+ density: visual.coordinateSpace.density,
221
+ orientation: visual.coordinateSpace.orientation,
222
+ },
223
+ },
224
+ });
225
+ const budgetReached = sessions.budgetStop(session);
226
+ return qaOk({ ...payload, tapped: true, tapVia: tapped.via, recorded: true, ...(budgetReached ? { budgetReached } : {}) }, `${label} > tapped device (${devicePoint.x}, ${devicePoint.y}) via ${tapped.via}` +
227
+ (budgetReached ? `\n⏹ budget reached: ${budgetReached}. Call qa_report.` : ''));
228
+ }
229
+ /** One capture for the pixel modes; counts against the screenshot budget. */
230
+ async function capture(ctx) {
231
+ let png;
232
+ try {
233
+ png = await ctx.driver.screenshot();
234
+ }
235
+ catch (e) {
236
+ return {
237
+ error: qaError({
140
238
  what: `Screenshot failed: ${String(e)}`,
141
239
  changedState: false,
142
240
  retrySafe: true,
143
241
  nextSteps: ['Confirm the device is online.'],
144
- });
242
+ }),
243
+ };
244
+ }
245
+ ctx.sessions.bump(ctx.session, 'screenshots');
246
+ return { png, coordinateSpace: await captureCoordinateSpace(ctx.driver, png) };
247
+ }
248
+ /** Pixel outputs carry an explicit warning when the secure-field check could not run.
249
+ * `ocrChecked`: the OCR text of this very capture was screened for password/OTP wording (the
250
+ * only signal without a UI tree), reported as secureFieldCheck:"ocr". */
251
+ function unverifiedNote(ctx, opts = {}) {
252
+ if (ctx.secureState !== 'unknown' || ctx.args.force)
253
+ return { secureFieldCheck: ctx.secureState === 'unknown' ? 'forced' : ctx.secureState };
254
+ const withheld = ctx.withholdCapture ? { captureWithheld: true } : {};
255
+ return opts.ocrChecked
256
+ ? {
257
+ secureFieldCheck: 'ocr',
258
+ warning: 'No fresh UI tree. The secure-field check was OCR-text only (no password/OTP wording found).',
259
+ ...withheld,
145
260
  }
146
- const coordinateSpace = await captureCoordinateSpace(driver, png);
147
- if (action === 'baseline') {
148
- if (!name)
149
- return qaError({
150
- what: 'baseline requires a name',
151
- changedState: false,
152
- retrySafe: true,
153
- nextSteps: ['Pass name="home-screen".'],
154
- });
155
- mkdirSync(baselinesDir, { recursive: true });
156
- writeFileSync(join(baselinesDir, `${name}.png`), png);
157
- const uri = sessions.saveArtifact(session, 'baseline', `${name}.png`, png, 'image/png', `visual baseline: ${name}`);
158
- return qaOk({ action, name, uri, coordinateSpace }, `saved baseline "${name}" (${png.length} bytes) → ${uri}`);
261
+ : {
262
+ secureFieldCheck: 'unverified',
263
+ warning: 'No fresh UI tree. Could not verify that no password/OTP field is on screen.',
264
+ ...withheld,
265
+ };
266
+ }
267
+ /** Persist a capture as a session artifact, unless the screen is unverified in a
268
+ * credential-handling session (then nothing is written and the result says so). */
269
+ function saveCapture(ctx, name, png, label) {
270
+ if (ctx.withholdCapture)
271
+ return undefined;
272
+ return ctx.sessions.saveArtifact(ctx.session, 'screenshot', name, png, 'image/png', label);
273
+ }
274
+ const WITHHELD_EVIDENCE = 'evidence: withheld (no fresh UI tree to rule out a password/OTP field in a credential-handling session. Pass force:true to keep the screenshot)';
275
+ // ---------------------------------------------------------------------------------------------
276
+ // Mode handlers.
277
+ async function findText(ctx) {
278
+ const { session, driver, args, redact } = ctx;
279
+ const { query, minConfidence, consentId, approve, tap, force } = args;
280
+ if (!query)
281
+ return qaError({
282
+ what: 'find_text requires a query',
283
+ changedState: false,
284
+ retrySafe: true,
285
+ failureCode: 'INVALID_ARGUMENT',
286
+ nextSteps: ['Pass query="Log in".'],
287
+ });
288
+ const command = configuredOcrCommand(session.root);
289
+ if (!command) {
290
+ return qaError({
291
+ what: 'OCR is not configured. find_text needs a local OCR provider (none is bundled)',
292
+ changedState: false,
293
+ retrySafe: false,
294
+ failureCode: 'OCR_NOT_CONFIGURED',
295
+ nextSteps: [
296
+ `Configure a provider: ${OCR_PROVIDER_CONTRACT}`,
297
+ 'Tesseract example: save the exampleProvider script from this result as .swipium/ocr_tesseract.py (needs `brew install tesseract`), then set "ocrCommand": ["python3", ".swipium/ocr_tesseract.py", "{image}"] in .swipium/config.json.',
298
+ 'Or locate by image with qa_visual mode:"find_image" (no provider needed).',
299
+ ],
300
+ }, {
301
+ exampleProvider: {
302
+ path: '.swipium/ocr_tesseract.py',
303
+ ocrCommand: ['python3', '.swipium/ocr_tesseract.py', '{image}'],
304
+ script: TESSERACT_OCR_EXAMPLE,
305
+ },
306
+ });
307
+ }
308
+ let preview;
309
+ let maskPreview;
310
+ try {
311
+ preview = resolveVisualProvider(command, { image: '<screenshot>' }, 30000);
312
+ maskPreview = resolveMaskProvider(session.root);
313
+ }
314
+ catch (e) {
315
+ return qaError({
316
+ what: e instanceof GitScopeForbiddenError ? e.message : `Invalid OCR command template: ${String(e)}`,
317
+ changedState: false,
318
+ retrySafe: !(e instanceof GitScopeForbiddenError),
319
+ failureCode: e instanceof GitScopeForbiddenError ? 'GIT_SCOPE_FORBIDDEN' : 'INVALID_FLOW',
320
+ nextSteps: e instanceof GitScopeForbiddenError
321
+ ? ['Run Git yourself outside Swipium; configure ocrCommand to use a non-Git executable.']
322
+ : ['Use an argv array in .swipium/config.json, e.g. ["node","ocr.js","{image}"].'],
323
+ });
324
+ }
325
+ const maskConfigured = !!maskPreview;
326
+ // BOTH commands that will run are disclosed (argv + provenance). A repo config could otherwise
327
+ // pair a harmless-looking ocrCommand with an arbitrary visualMaskCommand the user never sees.
328
+ const ocrSource = providerSourceLabel(ocrCommandSource(session.root) ?? 'environment', 'SWIPIUM_OCR_CMD');
329
+ const maskSource = maskPreview ? providerSourceLabel(maskCommandSource(session.root) ?? 'environment', 'SWIPIUM_VISUAL_MASK_CMD') : null;
330
+ const affects = {
331
+ argv: preview.argv,
332
+ io: preview.io,
333
+ ocrCommandSource: ocrSource,
334
+ query,
335
+ maskConfigured,
336
+ maskArgv: maskPreview?.argv ?? null,
337
+ maskIo: maskPreview?.io ?? null,
338
+ maskCommandSource: maskSource,
339
+ };
340
+ const gate = consumeConsent(consentId, approve, { action: 'ocr_run', affects });
341
+ if (!gate.approved) {
342
+ const exactCommand = maskPreview
343
+ ? `1) mask [${maskSource}]: ${displayArgv(maskPreview.argv)}\n2) OCR [${ocrSource}]: ${displayArgv(preview.argv)}`
344
+ : `OCR [${ocrSource}]: ${displayArgv(preview.argv)}`;
345
+ return requireConsent({
346
+ action: 'ocr_run',
347
+ risk: 'medium',
348
+ exactCommand,
349
+ affects,
350
+ explain: maskPreview
351
+ ? `Run TWO local programs on the current screenshot to find "${query}": first the visualMaskCommand (${maskSource}), then the OCR command (${ocrSource}) on its masked output. The screen image is passed to both.`
352
+ : `Run the OCR command (${ocrSource}) on the current screenshot to find "${query}"? The screen image is passed to that local program.`,
353
+ });
354
+ }
355
+ let ocr;
356
+ try {
357
+ ocr = await runOcr(driver, session.root, command);
358
+ }
359
+ catch (e) {
360
+ if (e instanceof VisualProviderFailedError) {
361
+ // A crashing provider must not read as "text not on screen" (real-device smoke 2.0.0).
362
+ const stderr = boundedText(e.stderr, redact, 1200);
363
+ return qaError({
364
+ what: `${e.provider === 'ocr' ? 'OCR' : 'Visual mask'} provider failed (${e.timedOut ? 'timed out' : `exit code ${e.exitCode}`})${stderr.text ? `: ${stderr.text.split('\n').pop()}` : ''}`,
365
+ changedState: false,
366
+ retrySafe: false,
367
+ failureCode: 'OCR_PROVIDER_FAILED',
368
+ nextSteps: [
369
+ `Run the provider standalone from the project root (${session.root}) on a PNG and fix the error in stderr.`,
370
+ 'Relative paths in ocrCommand resolve against the project root; the tesseract example needs `brew install tesseract`.',
371
+ ],
372
+ }, { provider: e.provider, exitCode: e.exitCode, timedOut: e.timedOut, stderr: stderr.text, stderrTruncated: stderr.truncated });
159
373
  }
160
- if (action === 'diff') {
161
- if (!name)
162
- return qaError({
163
- what: 'diff requires a baseline name',
164
- changedState: false,
165
- retrySafe: true,
166
- nextSteps: ['Pass the name used with action:"baseline".'],
167
- });
168
- const basePath = join(baselinesDir, `${name}.png`);
169
- if (!existsSync(basePath)) {
170
- return qaError({
171
- what: `No baseline "${name}" — capture one first`,
172
- changedState: false,
173
- retrySafe: true,
174
- nextSteps: [`Call qa_visual { action: "baseline", name: "${name}" } on the reference screen.`],
175
- });
176
- }
177
- const result = imageDiff(readFileSync(basePath), png);
178
- const tol = threshold ?? 0.02;
179
- const pass = result.comparable && result.ratio <= tol;
180
- const currentUri = sessions.saveArtifact(session, 'screenshot', `diff-${name}-${Date.now()}.png`, png, 'image/png', `diff vs baseline ${name}`);
181
- const deviceBox = result.box
182
- ? {
183
- x: toDevicePoint(coordinateSpace, result.box.x, result.box.y).x,
184
- y: toDevicePoint(coordinateSpace, result.box.x, result.box.y).y,
185
- width: Math.round(result.box.width / (coordinateSpace.scale ?? 1)),
186
- height: Math.round(result.box.height / (coordinateSpace.scale ?? 1)),
187
- }
188
- : null;
189
- return qaOk({
190
- action,
191
- name,
192
- method: 'visual',
193
- comparable: result.comparable,
194
- reason: result.reason,
195
- changedRatio: Math.round(result.ratio * 10000) / 10000,
196
- threshold: tol,
197
- pass,
198
- changedBox: result.box,
199
- changedBoxDevice: deviceBox,
200
- currentUri,
201
- coordinateSpace,
202
- }, `diff vs "${name}": ${result.comparable ? `${(result.ratio * 100).toFixed(2)}% changed (threshold ${(tol * 100).toFixed(1)}%) → ${pass ? '✅ PASS' : '❌ FAIL'}` : `not comparable: ${result.reason}`}\nevidence: ${currentUri}`);
374
+ return qaError({
375
+ what: `OCR command failed: ${redact(String(e)) ?? ''}`,
376
+ changedState: false,
377
+ retrySafe: true,
378
+ failureCode: 'OCR_PROVIDER_FAILED',
379
+ nextSteps: ['Check the configured OCR command runs standalone on a PNG.'],
380
+ });
381
+ }
382
+ ctx.sessions.bump(session, 'screenshots'); // runOcr captured the screen
383
+ // B8: no fresh UI tree, so the OCR text itself is the only signal; a password/OTP screen is
384
+ // withheld (no text, no regions, no tap) unless force:true.
385
+ if (ctx.secureState === 'unknown' && !force && (SECURE_TEXT_RE.test(ocr.text) || ocr.regions.some((r) => SECURE_TEXT_RE.test(r.text)))) {
386
+ return qaError({
387
+ what: 'Withheld: the screen reads like a password/OTP/payment screen and no fresh UI tree could confirm otherwise',
388
+ changedState: false,
389
+ retrySafe: true,
390
+ failureCode: 'CAPTURE_WITHHELD_SECURE',
391
+ nextSteps: [
392
+ 'Pass force:true to return OCR results for this screen anyway (text is still secret-redacted), or navigate to a non-sensitive screen.',
393
+ ],
394
+ });
395
+ }
396
+ const regions = ocr.regions.map((r) => redactRegion(r, redact));
397
+ const hit = findOcrRegion(ocr, query, minConfidence ?? 0.8);
398
+ if (!hit) {
399
+ const bounded = boundedText(ocr.text, redact, 8000);
400
+ return qaOk({
401
+ mode: 'find_text',
402
+ found: false,
403
+ query,
404
+ text: bounded.text,
405
+ truncated: bounded.truncated,
406
+ regions,
407
+ coordinateSpace: ocr.coordinateSpace,
408
+ provider: ocr.provider,
409
+ masking: ocr.masking,
410
+ method: 'ocr',
411
+ evidenceKind: 'ocr_text',
412
+ ...unverifiedNote(ctx, { ocrChecked: true }),
413
+ }, `OCR did not find "${query}" at confidence >= ${minConfidence ?? 0.8}.`);
414
+ }
415
+ const region = redactRegion(hit.region, redact);
416
+ const payload = {
417
+ mode: 'find_text',
418
+ found: true,
419
+ query,
420
+ region,
421
+ devicePoint: hit.devicePoint,
422
+ coordinateSpace: ocr.coordinateSpace,
423
+ method: 'ocr',
424
+ locatorStrategy: 'ocr_text',
425
+ evidenceKind: 'ocr_text',
426
+ provider: ocr.provider,
427
+ masking: ocr.masking,
428
+ ...unverifiedNote(ctx, { ocrChecked: true }),
429
+ };
430
+ const label = `found "${region.text}" (${region.confidence})`;
431
+ if (tap)
432
+ return tapAndRecord(ctx, hit.devicePoint, payload, { screenshotCrop: hit.region.bbox, ocrText: region.text, confidence: region.confidence, coordinateSpace: ocr.coordinateSpace }, label);
433
+ return qaOk(payload, `${label} > tap device (${hit.devicePoint.x}, ${hit.devicePoint.y}) via qa_act { action:"tap", target:{ x:${hit.devicePoint.x}, y:${hit.devicePoint.y} } }`);
434
+ }
435
+ async function baseline(ctx) {
436
+ const { sessions, session, args } = ctx;
437
+ const name = args.name;
438
+ if (!name)
439
+ return qaError({
440
+ what: 'baseline requires a name',
441
+ changedState: false,
442
+ retrySafe: true,
443
+ failureCode: 'INVALID_ARGUMENT',
444
+ nextSteps: ['Pass name="home-screen".'],
445
+ });
446
+ const target = resolveBaselinePath(session.root, name);
447
+ if ('error' in target)
448
+ return pathRefused(target.error, 'Use a plain name such as "home-screen" or "checkout.step-2".');
449
+ const shot = await capture(ctx);
450
+ if ('error' in shot)
451
+ return shot.error;
452
+ const { png, coordinateSpace } = shot;
453
+ mkdirSync(baselinesDirFor(session.root), { recursive: true });
454
+ writeFileSync(target.path, png);
455
+ const uri = sessions.saveArtifact(session, 'baseline', `${name}.png`, png, 'image/png', `visual baseline: ${name}`);
456
+ return qaOk({ mode: 'baseline', name, uri, path: target.path, coordinateSpace, ...unverifiedNote(ctx) }, `saved baseline "${name}" (${png.length} bytes) > .swipium/baselines/${name}.png + ${uri}`);
457
+ }
458
+ async function diff(ctx) {
459
+ const { session, args } = ctx;
460
+ const name = args.name;
461
+ if (!name)
462
+ return qaError({
463
+ what: 'diff requires a baseline name',
464
+ changedState: false,
465
+ retrySafe: true,
466
+ failureCode: 'INVALID_ARGUMENT',
467
+ nextSteps: ['Pass the name used with mode:"baseline".'],
468
+ });
469
+ const target = resolveBaselinePath(session.root, name);
470
+ if ('error' in target)
471
+ return pathRefused(target.error, 'Use the plain name you passed to mode:"baseline".');
472
+ if (!existsSync(target.path)) {
473
+ return qaError({
474
+ what: `No baseline "${name}". Capture one first`,
475
+ changedState: false,
476
+ retrySafe: true,
477
+ nextSteps: [`Call qa_visual { mode: "baseline", name: "${name}" } on the reference screen.`],
478
+ });
479
+ }
480
+ const shot = await capture(ctx);
481
+ if ('error' in shot)
482
+ return shot.error;
483
+ const { png, coordinateSpace } = shot;
484
+ const result = imageDiff(readFileSync(target.path), png);
485
+ const tol = args.threshold ?? 0.02;
486
+ const pass = result.comparable && result.ratio <= tol;
487
+ const currentUri = saveCapture(ctx, `diff-${name}-${Date.now()}.png`, png, `diff vs baseline ${name}`);
488
+ const deviceBox = result.box
489
+ ? {
490
+ x: toDevicePoint(coordinateSpace, result.box.x, result.box.y).x,
491
+ y: toDevicePoint(coordinateSpace, result.box.x, result.box.y).y,
492
+ width: Math.round(result.box.width / (coordinateSpace.scale ?? 1)),
493
+ height: Math.round(result.box.height / (coordinateSpace.scale ?? 1)),
203
494
  }
204
- // action === 'find_image'
205
- if (!template)
495
+ : null;
496
+ return qaOk({
497
+ mode: 'diff',
498
+ name,
499
+ method: 'visual',
500
+ comparable: result.comparable,
501
+ reason: result.reason,
502
+ changedRatio: Math.round(result.ratio * 10000) / 10000,
503
+ threshold: tol,
504
+ pass,
505
+ changedBox: result.box,
506
+ changedBoxDevice: deviceBox,
507
+ currentUri: currentUri ?? null,
508
+ coordinateSpace,
509
+ ...unverifiedNote(ctx),
510
+ }, `diff vs "${name}": ${result.comparable ? `${(result.ratio * 100).toFixed(2)}% changed (threshold ${(tol * 100).toFixed(1)}%) > ${pass ? '✅ PASS' : '❌ FAIL'}` : `not comparable: ${result.reason}`}\n${currentUri ? `evidence: ${currentUri}` : WITHHELD_EVIDENCE}`);
511
+ }
512
+ async function findImage(ctx) {
513
+ const { sessions, session, args } = ctx;
514
+ const { template, minScore, tap } = args;
515
+ if (!template)
516
+ return qaError({
517
+ what: 'find_image requires a template path',
518
+ changedState: false,
519
+ retrySafe: true,
520
+ failureCode: 'INVALID_ARGUMENT',
521
+ nextSteps: ['Pass template="assets/reference.png" (inside the project root) or a swipium:// artifact URI.'],
522
+ });
523
+ const tpl = resolveTemplatePath(sessions, session, template);
524
+ if ('error' in tpl) {
525
+ if (tpl.missing)
206
526
  return qaError({
207
- what: 'find_image requires a template path',
527
+ what: tpl.error,
208
528
  changedState: false,
209
529
  retrySafe: true,
210
- nextSteps: ['Pass template="/abs/or/project-relative/reference.png".'],
530
+ nextSteps: ['Provide an existing PNG inside the project root, or a swipium:// artifact URI from this project.'],
211
531
  });
212
- const tplPath = isAbsolute(template) ? template : join(session.root, template);
213
- if (!existsSync(tplPath))
532
+ return pathRefused(tpl.error, 'Copy the reference PNG into the project (e.g. .swipium/templates/) and pass its project-relative path.');
533
+ }
534
+ const shot = await capture(ctx);
535
+ if ('error' in shot)
536
+ return shot.error;
537
+ const { png, coordinateSpace } = shot;
538
+ let match;
539
+ try {
540
+ match = findTemplate(png, readFileSync(tpl.path), minScore ?? 0.85);
541
+ }
542
+ catch (e) {
543
+ return qaError({
544
+ what: `Image match failed: ${String(e)}`,
545
+ changedState: false,
546
+ retrySafe: true,
547
+ nextSteps: ['Ensure the template is an 8-bit PNG smaller than the screen.'],
548
+ });
549
+ }
550
+ const devicePoint = match.found ? toDevicePoint(coordinateSpace, match.x, match.y) : null;
551
+ const payload = {
552
+ mode: 'find_image',
553
+ found: match.found,
554
+ score: match.score,
555
+ screenshotPoint: match.found ? { x: match.x, y: match.y } : null,
556
+ devicePoint,
557
+ coordinateSpace,
558
+ method: 'visual',
559
+ evidenceKind: 'visual_match',
560
+ ...unverifiedNote(ctx),
561
+ };
562
+ if (match.found && tap)
563
+ return tapAndRecord(ctx, devicePoint, payload, { confidence: match.score, coordinateSpace }, `found (score ${match.score})`);
564
+ return qaOk(payload, match.found
565
+ ? `found (score ${match.score}) at screenshot (${match.x},${match.y}) > tap device (${devicePoint.x},${devicePoint.y}) via qa_act { action:"tap", target:{ x:${devicePoint.x}, y:${devicePoint.y} } }`
566
+ : `not found (best score ${match.score} < ${minScore ?? 0.85})`);
567
+ }
568
+ /** Record a visual assertion in one call: screenshot evidence + a qa_note(verifiedVisually) pass/fail
569
+ * (the former qa_assert_visual). For screens with no usable UI tree where the agent confirms a
570
+ * rendered result by eye. A pass is also recorded as a semantic `assert_visual` IR step so
571
+ * generated suites keep the check. */
572
+ async function assertVisual(ctx) {
573
+ const { sessions, session, args } = ctx;
574
+ const assertion = args.assertion?.trim();
575
+ if (!assertion)
576
+ return qaError({
577
+ what: 'assert requires an assertion',
578
+ changedState: false,
579
+ retrySafe: true,
580
+ failureCode: 'INVALID_ARGUMENT',
581
+ nextSteps: ['Pass assertion="Live Map rendered with the route polyline" (and pass:false if it is NOT visible).'],
582
+ });
583
+ const pass = args.pass ?? true;
584
+ const shot = await capture(ctx);
585
+ if ('error' in shot)
586
+ return shot.error;
587
+ const { png, coordinateSpace } = shot;
588
+ const uri = saveCapture(ctx, `visual-${Date.now()}.png`, png, `visual assertion: ${assertion}`);
589
+ sessions.addNote(session, {
590
+ at: Date.now(),
591
+ workflow: assertion,
592
+ outcome: pass ? 'pass' : 'fail',
593
+ reason: args.reason,
594
+ method: 'visual',
595
+ evidenceKind: 'visual_match',
596
+ artifactUris: uri ? [uri] : [],
597
+ verifiedVisually: true,
598
+ });
599
+ if (pass) {
600
+ const screenshotCrop = coordinateSpace.screenshot
601
+ ? { x: 0, y: 0, width: coordinateSpace.screenshot.width, height: coordinateSpace.screenshot.height }
602
+ : undefined;
603
+ sessions.addRecordedAction(session, {
604
+ at: Date.now(),
605
+ action: 'assert_visual',
606
+ assertion,
607
+ exportability: 'semantic',
608
+ provenance: {
609
+ ...(uri ? { screenshotUri: uri } : {}),
610
+ selectorKind: 'visual_region',
611
+ selectorValue: assertion,
612
+ visual: { screenshotCrop, confidence: 1, density: coordinateSpace.density ?? null, orientation: coordinateSpace.orientation },
613
+ },
614
+ });
615
+ }
616
+ return qaOk({
617
+ mode: 'assert',
618
+ assertion,
619
+ pass,
620
+ verifiedVisually: true,
621
+ method: 'visual',
622
+ evidenceKind: 'visual_match',
623
+ screenshotUri: uri ?? null,
624
+ coordinateSpace,
625
+ ...unverifiedNote(ctx),
626
+ }, `visual assertion ${pass ? '✅ PASS' : '❌ FAIL'}: "${assertion}"\n${uri ? `evidence: ${uri}` : WITHHELD_EVIDENCE}`);
627
+ }
628
+ const MODE_HANDLERS = {
629
+ assert: assertVisual,
630
+ baseline,
631
+ diff,
632
+ find_text: findText,
633
+ find_image: findImage,
634
+ };
635
+ export function registerVisual(server, sessions) {
636
+ server.registerTool('qa_visual', {
637
+ title: 'Visual assert / baseline / diff / find text / find image',
638
+ description: 'Screenshot-based checks for screens without a usable UI tree (maps, canvases, WDA-less iOS). mode: assert (pass/fail ' +
639
+ 'with screenshot evidence; pass:false if NOT visible), baseline (save .swipium/baselines/<name>.png in the repo), diff ' +
640
+ '(vs a baseline; pass within threshold), find_text (OCR via a locally configured provider; consent-gated), find_image ' +
641
+ '(template-match a project PNG). Finds return device coordinates + coordinateSpace; tap:true taps the hit (recorded + ' +
642
+ 'budgeted). Withheld when a password/OTP field is on screen unless force:true; on an unverified screen in a credential ' +
643
+ 'session only baseline needs force (diff/assert evidence is not saved). Details: docs/tools.md#qa_visual.',
644
+ inputSchema: {
645
+ sessionId: z.string(),
646
+ mode: z.enum(VISUAL_MODES),
647
+ assertion: z.string().optional().describe('assert: what you visually confirmed, e.g. "Live Map shows the route".'),
648
+ pass: z.boolean().optional().describe('assert: default true; false if the expected result is NOT visible.'),
649
+ reason: z.string().optional().describe('assert: extra detail (what you saw / why it failed).'),
650
+ name: z.string().optional().describe('baseline/diff: baseline name, [A-Za-z0-9._-]{1,64}.'),
651
+ query: z.string().optional().describe('find_text: text to find.'),
652
+ template: z.string().optional().describe('find_image: PNG inside the project root, or a swipium:// artifact URI.'),
653
+ threshold: z.number().optional().describe('diff: max changed fraction to pass (default 0.02).'),
654
+ minScore: z.number().optional().describe('find_image: min match score 0..1 (default 0.85).'),
655
+ minConfidence: z.number().optional().describe('find_text: min OCR confidence 0..1 (default 0.8).'),
656
+ tap: z.boolean().optional().describe('find_text/find_image: tap the found point.'),
657
+ force: z.boolean().optional().describe('Capture even if a secure field may be visible.'),
658
+ consentId: z.string().optional(),
659
+ approve: z.boolean().optional(),
660
+ },
661
+ }, async (args) => {
662
+ const session = sessions.get(args.sessionId);
663
+ if (!session)
664
+ return unknownSessionError(args.sessionId);
665
+ const { driver, blocked } = await getDriver(session);
666
+ if (!session || !driver) {
667
+ return (blockedDeviceResult(blocked) ??
668
+ qaError({
669
+ what: 'No device attached to this session',
670
+ changedState: false,
671
+ retrySafe: true,
672
+ nextSteps: ['Call qa_prepare_target (Android) or qa_prepare_ios_target (iOS) first.'],
673
+ }));
674
+ }
675
+ if (session.sensitive)
676
+ return sensitiveRefusal('Visual capture');
677
+ // Budget gate (B7): same rule as qa_act / qa_screenshot: no new capture or tap once spent.
678
+ const stopReason = sessions.budgetStop(session);
679
+ if (stopReason)
680
+ return qaStop(stopReason, { counters: session.counters, mode: session.mode });
681
+ // Secure-screen guard (same policy as qa_screenshot, THREAT_MODEL "Sensitive-screen
682
+ // capture"): never persist or OCR password/OTP pixels by default. Without a fresh UI tree
683
+ // (B8) the screen is unverified; in a session that has handled credentials only the
684
+ // mode that persists a capture into the repo (baseline) requires force:true; the others
685
+ // run, but never persist the capture (diff/assert evidence withheld), and find_text
686
+ // screens the OCR text itself for password/OTP wording (withheld on a hit).
687
+ const secureState = await refreshSecureState(session, driver);
688
+ if (!args.force && secureState === 'secure') {
214
689
  return qaError({
215
- what: `Template not found: ${tplPath}`,
690
+ what: 'Withheld: a secure field (password/OTP) is on screen',
216
691
  changedState: false,
217
692
  retrySafe: true,
218
- nextSteps: ['Provide an existing PNG path.'],
693
+ failureCode: 'CAPTURE_WITHHELD_SECURE',
694
+ nextSteps: ['Pass force:true to proceed (pixels are NOT redactable), or use a non-sensitive screen.'],
219
695
  });
220
- let match;
221
- try {
222
- match = findTemplate(png, readFileSync(tplPath), minScore ?? 0.85);
223
696
  }
224
- catch (e) {
697
+ const unverifiedCredentialScreen = !args.force && secureState === 'unknown' && hasSecretContext(session);
698
+ if (unverifiedCredentialScreen && args.mode === 'baseline') {
225
699
  return qaError({
226
- what: `Image match failed: ${String(e)}`,
700
+ what: 'Withheld: a baseline persists the screenshot, but no fresh UI tree can verify the screen has no password/OTP field, and this session has handled credentials',
227
701
  changedState: false,
228
702
  retrySafe: true,
229
- nextSteps: ['Ensure the template is an 8-bit PNG smaller than the screen.'],
703
+ failureCode: 'CAPTURE_WITHHELD_SECURE',
704
+ nextSteps: [
705
+ 'Run qa_snapshot to refresh the UI tree (when a structured backend is attached), or pass force:true to proceed (pixels are NOT redactable).',
706
+ ],
230
707
  });
231
708
  }
232
- const devicePoint = match.found ? toDevicePoint(coordinateSpace, match.x, match.y) : null;
233
- return qaOk({
234
- action,
235
- found: match.found,
236
- score: match.score,
237
- screenshotPoint: match.found ? { x: match.x, y: match.y } : null,
238
- devicePoint,
239
- coordinateSpace,
240
- }, match.found
241
- ? `found (score ${match.score}) at screenshot (${match.x},${match.y}) → tap device (${devicePoint.x},${devicePoint.y}) via qa_act { action:"tap", target:{ x:${devicePoint.x}, y:${devicePoint.y} } }`
242
- : `not found (best score ${match.score} < ${minScore ?? 0.85})`);
709
+ const ctx = {
710
+ sessions,
711
+ session,
712
+ driver,
713
+ args,
714
+ redact: makeRedactor(session.secrets),
715
+ secureState,
716
+ withholdCapture: unverifiedCredentialScreen,
717
+ };
718
+ return MODE_HANDLERS[args.mode](ctx);
243
719
  });
244
720
  }
245
721
  //# sourceMappingURL=visual.js.map