@xenon-device-management/xenon 2.14.0 → 2.15.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 (259) hide show
  1. package/README.md +7 -4
  2. package/lib/package.json +5 -5
  3. package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-Btq3dZRI.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-C9OoAgg_.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-DBC_xczB.js +2 -0
  6. package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-DqWU2-vM.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-CveQ3xrA.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-BfKDSfHz.js} +1 -1
  9. package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-CtdCybzA.js} +1 -1
  10. package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-Bsvj1tm2.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CRofOiyx.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-jNQHb2IR.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C8HtTha-.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-D8K1LZNo.js} +1 -1
  15. package/lib/public/assets/{Table-BcTm5xV2.js → Table-zNTX92Pn.js} +1 -1
  16. package/lib/public/assets/{activity-DOVdsfMQ.js → activity-CJ8LwZq9.js} +1 -1
  17. package/lib/public/assets/ai-settings-CgxQZqrc.js +16 -0
  18. package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-ECpo-zTo.js} +1 -1
  19. package/lib/public/assets/{apps-D0SC6uw6.js → apps-BjGymyf3.js} +1 -1
  20. package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BWfCbeHP.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-DC7UGf_n.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-BOjwybDk.js} +1 -1
  23. package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CxMTo9aH.js} +2 -2
  24. package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-DOCm4HkA.js} +1 -1
  25. package/lib/public/assets/{button-Dda4irn_.js → button-Cg08-EMT.js} +1 -1
  26. package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-CA1ikLJs.js} +1 -1
  27. package/lib/public/assets/{check-CZ9iw9-e.js → check-BMTaH72W.js} +1 -1
  28. package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-B2QOv9Nx.js} +1 -1
  29. package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CYXBoUqT.js} +1 -1
  30. package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-DTwnSAnl.js} +1 -1
  31. package/lib/public/assets/{clock-CpuDKysU.js → clock-C8_HViQP.js} +1 -1
  32. package/lib/public/assets/{copy-CAQydNFe.js → copy-Be7LNMXV.js} +1 -1
  33. package/lib/public/assets/derive-UnLRp4r3.js +1 -0
  34. package/lib/public/assets/device-explorer-BOqtfGXz.js +282 -0
  35. package/lib/public/assets/{download-C9XG6lLv.js → download-B56lwxWP.js} +1 -1
  36. package/lib/public/assets/forgot-password-CCU6bzLn.js +1 -0
  37. package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-C-OtmIuk.js} +6 -6
  38. package/lib/public/assets/index-B9KQ6LsY.js +15 -0
  39. package/lib/public/assets/{index-BbKEXUFH.js → index-BOxe43p1.js} +2 -2
  40. package/lib/public/assets/{index-DJGc1M2f.js → index-CfxsUslp.js} +1 -1
  41. package/lib/public/assets/{input-Cm0tmSoc.js → input-CZHFjbSg.js} +1 -1
  42. package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-Gl_Dz3X_.js} +1 -1
  43. package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-CCgjeyGY.js} +1 -1
  44. package/lib/public/assets/{lock-CCJv9m_I.js → lock-CkOwgDcx.js} +1 -1
  45. package/lib/public/assets/{login-f8vOVW-U.js → login-D5cc4w7M.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-t7qr3fvX.js} +1 -1
  47. package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-DEJixKQO.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CtOrk4M8.js} +1 -1
  49. package/lib/public/assets/{network-ebT86qoi.js → network-Dii9qdvk.js} +1 -1
  50. package/lib/public/assets/{overview-C0aykVrd.js → overview-rolAACrO.js} +1 -1
  51. package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-COQZxJfR.js} +1 -1
  52. package/lib/public/assets/{play-DLG9t8FU.js → play-DrN1Se_R.js} +1 -1
  53. package/lib/public/assets/{plus-nBFSMu_X.js → plus-5aq-9uRx.js} +1 -1
  54. package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-6i5kY6kF.js} +1 -1
  55. package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-sp2IBrc7.js} +1 -1
  56. package/lib/public/assets/runbook-page-DJDjeopU.js +253 -0
  57. package/lib/public/assets/{select-88jHcl-Z.js → select-Bpyn7rWO.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BH74vmKv.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-Cw_g218H.js} +1 -1
  60. package/lib/public/assets/session-detail-page-AZJE6XXE.js +4 -0
  61. package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-C3uyGhyc.js} +1 -1
  62. package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
  63. package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-xVqAKIlI.js} +1 -1
  64. package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BxUScAeO.js} +1 -1
  65. package/lib/public/assets/{teams-B4-jrr-1.js → teams-B5ljGkY2.js} +1 -1
  66. package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-B701i8sy.js} +1 -1
  67. package/lib/public/assets/{upload-BG7_rMXK.js → upload-C2gr44RS.js} +1 -1
  68. package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-NeQOFEoy.js} +1 -1
  69. package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-DEzJq5ef.js} +1 -1
  70. package/lib/public/assets/{users-CFba5TSy.js → users-D_vv_yz4.js} +1 -1
  71. package/lib/public/assets/{users-Cp0beJm3.js → users-MY6eNe6s.js} +1 -1
  72. package/lib/public/assets/{video-off-DDsEISjV.js → video-off-OK1lNZQn.js} +1 -1
  73. package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-BU-C9lBf.js} +1 -1
  74. package/lib/public/assets/{zap-l4VaXzq8.js → zap-DO9ry1iz.js} +1 -1
  75. package/lib/public/index.html +1 -1
  76. package/lib/schema.json +5 -5
  77. package/lib/src/app/dashboardPluginLink.js +117 -0
  78. package/lib/src/app/index.js +13 -34
  79. package/lib/src/app/openapi/control.yaml +6 -4
  80. package/lib/src/app/openapi/grid.yaml +8 -2
  81. package/lib/src/app/openapi/identity.yaml +103 -31
  82. package/lib/src/app/openapi/platform.yaml +33 -15
  83. package/lib/src/app/openapi/sessions.yaml +10 -3
  84. package/lib/src/app/routers/apikeys.js +14 -2
  85. package/lib/src/app/routers/auth.js +29 -5
  86. package/lib/src/app/routers/control.js +13 -5
  87. package/lib/src/app/routers/dashboard.js +42 -27
  88. package/lib/src/app/routers/grid.js +5 -3
  89. package/lib/src/app/routers/profile.js +21 -0
  90. package/lib/src/app/routers/users.js +10 -5
  91. package/lib/src/app/swagger.js +2 -0
  92. package/lib/src/app/ws/h264StreamWs.js +10 -1
  93. package/lib/src/config.js +1 -0
  94. package/lib/src/dashboard/commands.js +5 -5
  95. package/lib/src/dashboard/event-manager.js +49 -61
  96. package/lib/src/dashboard/services/failure-analysis-service.js +136 -45
  97. package/lib/src/dashboard/sessionCommandSummary.js +39 -0
  98. package/lib/src/data-service/device-store.js +14 -1
  99. package/lib/src/data-service/prisma-store.js +19 -7
  100. package/lib/src/data-service/web-config-service.js +7 -0
  101. package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
  102. package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
  103. package/lib/src/device-utils.js +20 -2
  104. package/lib/src/gateway/defaultGateway.js +1 -1
  105. package/lib/src/gateway/forwardToNode.js +2 -0
  106. package/lib/src/gateway/healReport.js +87 -0
  107. package/lib/src/gateway/sessionGateway.js +12 -2
  108. package/lib/src/generated/client/edge.js +4 -3
  109. package/lib/src/generated/client/index-browser.js +1 -0
  110. package/lib/src/generated/client/index.d.ts +32 -0
  111. package/lib/src/generated/client/index.js +4 -3
  112. package/lib/src/generated/client/package.json +1 -1
  113. package/lib/src/generated/client/schema.prisma +3 -0
  114. package/lib/src/generated/client/wasm.js +1 -0
  115. package/lib/src/interceptors/CommandInterceptor.js +243 -181
  116. package/lib/src/middleware/authMiddleware.js +15 -3
  117. package/lib/src/middleware/commandCaller.js +3 -3
  118. package/lib/src/middleware/verifyCredential.js +58 -1
  119. package/lib/src/plugin.js +8 -3
  120. package/lib/src/services/AICommandService.js +9 -2
  121. package/lib/src/services/AIService.js +192 -42
  122. package/lib/src/services/EmailService.js +29 -0
  123. package/lib/src/services/OrphanSweeper.js +2 -0
  124. package/lib/src/services/SelectorVerificationJob.js +15 -4
  125. package/lib/src/services/ServerManager.js +11 -1
  126. package/lib/src/services/SessionLifecycleService.js +86 -26
  127. package/lib/src/services/SocketServer.js +19 -3
  128. package/lib/src/services/bug-report/video-slice.js +4 -1
  129. package/lib/src/services/device-access/leaseOverride.js +7 -5
  130. package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
  131. package/lib/src/services/healing/HealEtalonService.js +3 -2
  132. package/lib/src/services/healing/HealingOrchestrator.js +55 -72
  133. package/lib/src/services/healing/OcrHealingProvider.js +41 -64
  134. package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
  135. package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
  136. package/lib/src/services/healing/fingerprintIdentity.js +126 -0
  137. package/lib/src/services/healing/resilioPath.js +274 -0
  138. package/lib/src/services/logcat/SessionDeviceLogs.js +271 -0
  139. package/lib/src/services/logcat/deviceLogBook.js +163 -0
  140. package/lib/src/services/ocr/ocrData.js +118 -0
  141. package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
  142. package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
  143. package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
  144. package/lib/src/services/passwordResetLink.js +50 -9
  145. package/lib/src/services/passwordResetMode.js +3 -1
  146. package/lib/src/services/publicUrl.js +45 -0
  147. package/lib/src/services/session/sessionIdentity.js +5 -3
  148. package/lib/src/services/session/shutdownReason.js +26 -0
  149. package/lib/src/services/settings/aiEngineSettings.js +219 -0
  150. package/lib/src/services/token/mintedLifetime.js +51 -0
  151. package/lib/src/sessions/SessionManager.js +8 -2
  152. package/lib/src/sessions/sessionMemory.js +25 -0
  153. package/lib/test/helpers/fake-ai-provider.js +188 -0
  154. package/lib/test/helpers/loki-stores.js +33 -0
  155. package/lib/test/helpers/page-sources.js +164 -0
  156. package/lib/test/helpers/scratch-database.js +57 -13
  157. package/lib/test/integration/audit-ingest.spec.js +10 -10
  158. package/lib/test/integration/auth-flow.spec.js +9 -14
  159. package/lib/test/integration/auth-rate-limit.spec.js +6 -3
  160. package/lib/test/integration/bug-report-route.spec.js +22 -2
  161. package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
  162. package/lib/test/integration/forgot-password.spec.js +17 -18
  163. package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
  164. package/lib/test/integration/migration.spec.js +6 -1
  165. package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
  166. package/lib/test/integration/ports-router.spec.js +4 -4
  167. package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
  168. package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
  169. package/lib/test/integration/profile-tokens.spec.js +10 -16
  170. package/lib/test/integration/recordings-router.spec.js +16 -16
  171. package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
  172. package/lib/test/integration/role-matrix.spec.js +4 -9
  173. package/lib/test/integration/sdk-leases-router.spec.js +9 -9
  174. package/lib/test/integration/sdk-version.spec.js +2 -2
  175. package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
  176. package/lib/test/integration/team-membership-migration.spec.js +73 -12
  177. package/lib/test/integration/team-visibility-apps.spec.js +15 -22
  178. package/lib/test/integration/team-visibility-control.spec.js +96 -69
  179. package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
  180. package/lib/test/integration/team-visibility-grid.spec.js +16 -15
  181. package/lib/test/integration/team-visibility-leases.spec.js +8 -11
  182. package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
  183. package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
  184. package/lib/test/integration/users-flow.spec.js +7 -12
  185. package/lib/test/integration/users-lockout.spec.js +5 -3
  186. package/lib/test/unit/EmailService.test.js +66 -0
  187. package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
  188. package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
  189. package/lib/test/unit/ai-engine-settings.spec.js +338 -0
  190. package/lib/test/unit/ai-provider-switch.spec.js +130 -0
  191. package/lib/test/unit/ai-rate-limit.spec.js +126 -0
  192. package/lib/test/unit/android-h264-viewers.spec.js +272 -0
  193. package/lib/test/unit/android-preview-hold.spec.js +3 -2
  194. package/lib/test/unit/apikeys-router.spec.js +84 -0
  195. package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
  196. package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
  197. package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
  198. package/lib/test/unit/dashboard-events-device-logs.spec.js +207 -0
  199. package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
  200. package/lib/test/unit/device-log-book.spec.js +180 -0
  201. package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
  202. package/lib/test/unit/event-log-session-data.spec.js +244 -0
  203. package/lib/test/unit/failure-analysis.spec.js +182 -0
  204. package/lib/test/unit/failure-categories.spec.js +128 -0
  205. package/lib/test/unit/fingerprint-identity.spec.js +433 -0
  206. package/lib/test/unit/h264-multiplexer.spec.js +46 -0
  207. package/lib/test/unit/heal-report.spec.js +71 -0
  208. package/lib/test/unit/healing-instrumentation.spec.js +6 -1
  209. package/lib/test/unit/healing-tier-gate.spec.js +73 -29
  210. package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
  211. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +171 -0
  212. package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
  213. package/lib/test/unit/lease/lease-override.spec.js +23 -6
  214. package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
  215. package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
  216. package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
  217. package/lib/test/unit/ocr-offline.spec.js +213 -0
  218. package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
  219. package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
  220. package/lib/test/unit/omni-vision.spec.js +5 -7
  221. package/lib/test/unit/package-engines.spec.js +27 -0
  222. package/lib/test/unit/passwordResetMode.test.js +10 -2
  223. package/lib/test/unit/profile-router.test.js +122 -1
  224. package/lib/test/unit/queue-requester.spec.js +1 -1
  225. package/lib/test/unit/reset-link-address.spec.js +266 -0
  226. package/lib/test/unit/reset-link.test.js +10 -4
  227. package/lib/test/unit/resilio-path-healing.spec.js +270 -0
  228. package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
  229. package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
  230. package/lib/test/unit/selector-verification-job.spec.js +13 -13
  231. package/lib/test/unit/self-healing-boot.spec.js +10 -5
  232. package/lib/test/unit/self-healing-setting.spec.js +7 -6
  233. package/lib/test/unit/session-app-resolution.spec.js +5 -1
  234. package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
  235. package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
  236. package/lib/test/unit/session-credential-strip.spec.js +1 -1
  237. package/lib/test/unit/session-device-logs.spec.js +347 -0
  238. package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
  239. package/lib/test/unit/session-log-indexes.spec.js +64 -3
  240. package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
  241. package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
  242. package/lib/test/unit/token-mint-limits.spec.js +165 -0
  243. package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
  244. package/lib/test/unit/verify-credential.spec.js +33 -0
  245. package/lib/test/unit/vision-assertion.spec.js +3 -2
  246. package/lib/test/unit/web-config-service.spec.js +19 -0
  247. package/lib/tsconfig.tsbuildinfo +1 -1
  248. package/package.json +5 -5
  249. package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
  250. package/prisma/schema.prisma +3 -0
  251. package/schema.json +5 -5
  252. package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
  253. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
  254. package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
  255. package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
  256. package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
  257. package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
  258. package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
  259. /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const typedi_1 = require("typedi");
19
+ const CommandInterceptor_1 = require("../../src/interceptors/CommandInterceptor");
20
+ const HealingOrchestrator_1 = require("../../src/services/healing/HealingOrchestrator");
21
+ const types_1 = require("../../src/services/healing/types");
22
+ const OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
23
+ const SelfHealingSwitch_1 = require("../../src/services/settings/SelfHealingSwitch");
24
+ const event_manager_1 = require("../../src/dashboard/event-manager");
25
+ const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
26
+ const container_registration_1 = require("../helpers/container-registration");
27
+ const scratch_database_1 = require("../helpers/scratch-database");
28
+ /**
29
+ * Elements Xenon found in a screenshot (Omni-Vision's `-custom:ai-*` locators,
30
+ * and self-healing's OCR and Visual AI tiers) are boxes on the screen with an
31
+ * id of Xenon's own (`omni_*`, `healed_ocr*`, `healed_visual*`). The
32
+ * interceptor answers their commands itself. Through 2.14:
33
+ *
34
+ * - a `-custom:ai-*` findElement that matched nothing answered `unknown error`
35
+ * ("NoSuchElement: AI Vision failed…"), and self-healing then ran on it;
36
+ * - a healed OCR or Visual AI element was tapped during the find, and the
37
+ * test's own click tapped it again;
38
+ * - `getText` on a healed element answered Xenon's note about the match;
39
+ * - `setValue` never reached the element: Appium passes it (text, elementId),
40
+ * and the interceptor read the text as the element id.
41
+ *
42
+ * No AI provider, OCR or phone: healing, OCR and the driver are stubbed.
43
+ */
44
+ describe('Elements Xenon found in a screenshot', () => {
45
+ (0, scratch_database_1.useScratchDatabase)();
46
+ const SESSION = 'visual-session';
47
+ let pluginArgs;
48
+ let restoreContainer;
49
+ let attemptHealing;
50
+ let driver;
51
+ let omni;
52
+ beforeEach(() => {
53
+ pluginArgs = Object.assign({}, IPluginArgs_1.DefaultPluginArgs);
54
+ restoreContainer = (0, container_registration_1.saveRegistrations)(HealingOrchestrator_1.HealingOrchestrator, SelfHealingSwitch_1.SelfHealingSwitch);
55
+ typedi_1.Container.set(SelfHealingSwitch_1.SelfHealingSwitch, new SelfHealingSwitch_1.SelfHealingSwitch());
56
+ attemptHealing = sinon_1.default.stub().resolves(null);
57
+ typedi_1.Container.set(HealingOrchestrator_1.HealingOrchestrator, { attemptHealing });
58
+ sinon_1.default.stub(event_manager_1.DASHBORD_EVENT_MANAGER, 'afterSessionCommand').resolves();
59
+ omni = typedi_1.Container.get(OmniVisionService_1.OmniVisionService);
60
+ driver = {
61
+ sessionId: SESSION,
62
+ caps: { platformName: 'Android' },
63
+ performActions: sinon_1.default.stub().resolves(),
64
+ releaseActions: sinon_1.default.stub().resolves(),
65
+ findElement: sinon_1.default.stub().rejects(new Error('unexpected find')),
66
+ active: sinon_1.default.stub().resolves({ 'element-6066-11e4-a52e-4f735466cecf': 'field-1' }),
67
+ setValue: sinon_1.default.stub().resolves(null),
68
+ elementEnabled: sinon_1.default.stub().resolves(true),
69
+ };
70
+ });
71
+ afterEach(() => {
72
+ restoreContainer();
73
+ sinon_1.default.restore();
74
+ });
75
+ const run = (command, args, next = () => undefined) => typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor).handle(() => __awaiter(void 0, void 0, void 0, function* () { return next(); }), driver, command, [...args, SESSION], pluginArgs, false);
76
+ const failure = (promise) => __awaiter(void 0, void 0, void 0, function* () {
77
+ try {
78
+ yield promise;
79
+ }
80
+ catch (err) {
81
+ return err;
82
+ }
83
+ throw new Error('expected the command to fail');
84
+ });
85
+ const tapsAt = () => driver.performActions.getCalls().map((c) => {
86
+ const move = c.args[0][0].actions[0];
87
+ return { x: move.x, y: move.y };
88
+ });
89
+ describe('a -custom:ai-* find that matches nothing', () => {
90
+ it('fails with the standard "no such element" error for ai-text', () => __awaiter(void 0, void 0, void 0, function* () {
91
+ sinon_1.default.stub(omni, 'findByText').resolves([]);
92
+ const err = yield failure(run('findElement', ['-custom:ai-text', 'Checkout']));
93
+ (0, chai_1.expect)(err.error).to.equal('no such element');
94
+ (0, chai_1.expect)(err.w3cStatus).to.equal(404);
95
+ }));
96
+ it('fails with the standard "no such element" error for ai-icon', () => __awaiter(void 0, void 0, void 0, function* () {
97
+ sinon_1.default.stub(omni, 'findByIcon').resolves(null);
98
+ const err = yield failure(run('findElement', ['-custom:ai-icon', 'a cart icon']));
99
+ (0, chai_1.expect)(err.error).to.equal('no such element');
100
+ }));
101
+ it('is not handed to self-healing', () => __awaiter(void 0, void 0, void 0, function* () {
102
+ sinon_1.default.stub(omni, 'findByText').resolves([]);
103
+ yield failure(run('findElement', ['-custom:ai-text', 'Checkout']));
104
+ (0, chai_1.expect)(attemptHealing.called).to.equal(false);
105
+ }));
106
+ it('still answers an empty list to findElements', () => __awaiter(void 0, void 0, void 0, function* () {
107
+ sinon_1.default.stub(omni, 'findByText').resolves([]);
108
+ (0, chai_1.expect)(yield run('findElements', ['-custom:ai-text', 'Checkout'])).to.deep.equal([]);
109
+ }));
110
+ });
111
+ describe('a healed OCR or Visual AI element', () => {
112
+ const healedBy = (id, tier, text) => (Object.assign({ id,
113
+ tier, confidence: 0.9, originalSelector: "//*[@text='Login']", originalStrategy: 'xpath', recommendedSelector: 'ocr:text="Login"', recommendedStrategy: 'xenon:visual', message: 'Found text "Login" via local OCR (90% confidence)', rect: { x: 100, y: 400, width: 80, height: 24 } }, (text !== undefined ? { text } : {})));
114
+ const findHealed = (healed) => __awaiter(void 0, void 0, void 0, function* () {
115
+ attemptHealing.resolves(healed);
116
+ return run('findElement', ['xpath', "//*[@text='Login']"], () => {
117
+ throw Object.assign(new Error('NoSuchElement: gone'), { name: 'NoSuchElementError' });
118
+ });
119
+ });
120
+ it('is not tapped during the find', () => __awaiter(void 0, void 0, void 0, function* () {
121
+ const found = yield findHealed(healedBy('healed_ocr_1', types_1.HealingTier.TIER_3_LOCAL_OCR, 'Login'));
122
+ (0, chai_1.expect)(found).to.deep.include({ 'element-6066-11e4-a52e-4f735466cecf': 'healed_ocr_1' });
123
+ (0, chai_1.expect)(driver.performActions.called, 'tapped during the find').to.equal(false);
124
+ }));
125
+ it("is tapped once, by the test's own click", () => __awaiter(void 0, void 0, void 0, function* () {
126
+ yield findHealed(healedBy('healed_visual_1', types_1.HealingTier.TIER_4_VISUAL_AI));
127
+ yield run('click', ['healed_visual_1']);
128
+ (0, chai_1.expect)(tapsAt()).to.deep.equal([{ x: 140, y: 412 }]);
129
+ }));
130
+ it('answers getText with the text OCR read, not a note about the match', () => __awaiter(void 0, void 0, void 0, function* () {
131
+ yield findHealed(healedBy('healed_ocr_2', types_1.HealingTier.TIER_3_LOCAL_OCR, 'Login'));
132
+ (0, chai_1.expect)(yield run('getText', ['healed_ocr_2'])).to.equal('Login');
133
+ }));
134
+ it('refuses getText when Visual AI found it, since no text was read', () => __awaiter(void 0, void 0, void 0, function* () {
135
+ yield findHealed(healedBy('healed_visual_2', types_1.HealingTier.TIER_4_VISUAL_AI));
136
+ const err = yield failure(run('getText', ['healed_visual_2']));
137
+ (0, chai_1.expect)(err.error).to.equal('unsupported operation');
138
+ (0, chai_1.expect)(err.message).to.match(/text/i);
139
+ }));
140
+ });
141
+ describe('setValue on an element found in a screenshot', () => {
142
+ beforeEach(() => {
143
+ omni.remember(SESSION, {
144
+ id: 'omni_ocr_field',
145
+ text: 'Email',
146
+ rect: { x: 10, y: 20, width: 100, height: 40 },
147
+ confidence: 0.9,
148
+ });
149
+ });
150
+ it('taps it, then types into the field that has the keyboard focus', () => __awaiter(void 0, void 0, void 0, function* () {
151
+ yield run('setValue', ['me@example.com', 'omni_ocr_field']);
152
+ (0, chai_1.expect)(tapsAt()).to.deep.equal([{ x: 60, y: 40 }]);
153
+ (0, chai_1.expect)(driver.active.calledAfter(driver.performActions)).to.equal(true);
154
+ (0, chai_1.expect)(driver.setValue.firstCall.args.slice(0, 2)).to.deep.equal([
155
+ 'me@example.com',
156
+ 'field-1',
157
+ ]);
158
+ }));
159
+ it('refuses before tapping when the driver cannot tell which field has the focus', () => __awaiter(void 0, void 0, void 0, function* () {
160
+ delete driver.active;
161
+ const err = yield failure(run('setValue', ['me@example.com', 'omni_ocr_field']));
162
+ (0, chai_1.expect)(err.error).to.equal('unsupported operation');
163
+ (0, chai_1.expect)(driver.performActions.called, 'tapped').to.equal(false);
164
+ }));
165
+ it('says so when no field took the focus after the tap', () => __awaiter(void 0, void 0, void 0, function* () {
166
+ driver.active.rejects(new Error('no element has focus'));
167
+ const err = yield failure(run('setValue', ['me@example.com', 'omni_ocr_field']));
168
+ (0, chai_1.expect)(err.error).to.equal('element not interactable');
169
+ (0, chai_1.expect)(driver.setValue.called).to.equal(false);
170
+ }));
171
+ });
172
+ describe('other commands on an element found in a screenshot', () => {
173
+ it("refuses them clearly instead of sending Xenon's id to the driver", () => __awaiter(void 0, void 0, void 0, function* () {
174
+ omni.remember(SESSION, {
175
+ id: 'omni_ai_cart',
176
+ rect: { x: 0, y: 0, width: 40, height: 40 },
177
+ confidence: 0.85,
178
+ });
179
+ const next = sinon_1.default.stub();
180
+ const err = yield failure(run('getAttribute', ['content-desc', 'omni_ai_cart'], next));
181
+ (0, chai_1.expect)(err.error).to.equal('unsupported operation');
182
+ (0, chai_1.expect)(next.called, 'sent to the driver').to.equal(false);
183
+ }));
184
+ });
185
+ describe('autowait before setValue on a real element', () => {
186
+ it('waits for the element, not for an element named after the text', () => __awaiter(void 0, void 0, void 0, function* () {
187
+ pluginArgs = Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { autowait: Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs.autowait), { enabled: true }) });
188
+ yield run('setValue', ['hello', 'el-1']);
189
+ (0, chai_1.expect)(driver.elementEnabled.firstCall.args[0]).to.equal('el-1');
190
+ }));
191
+ });
192
+ });
@@ -26,14 +26,31 @@ describe('canOverrideLease — who may use a lease they did not create', () => {
26
26
  (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'api-key', scopes: 'admin', user: null })).to.equal(false);
27
27
  });
28
28
  });
29
- describe('a session token (the user themselves, like a dashboard cookie)', () => {
30
- it('is allowed for an ADMIN or SUPER_ADMIN', () => {
31
- (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', user: user('ADMIN') })).to.equal(true);
32
- (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', user: user('SUPER_ADMIN') })).to.equal(true);
29
+ // A session token carries the scopes of the credential that minted it, and
30
+ // is judged by them like a key. By role alone, an ADMIN's key without the
31
+ // admin scope minted itself a token that could override.
32
+ describe("a session token (judged by its scopes and its user's role now)", () => {
33
+ // resolveActor's rule (a SUPER_ADMIN, or a credential with `admin`), on
34
+ // the user's role now: the scope is fixed at mint, so an ADMIN's token
35
+ // also needs the role to still be ADMIN, which notices a demotion.
36
+ it("is allowed for an ADMIN's or SUPER_ADMIN's token carrying the admin scope", () => {
37
+ for (const role of ['ADMIN', 'SUPER_ADMIN']) {
38
+ (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', scopes: 'admin,sessions', user: user(role) })).to.equal(true);
39
+ }
40
+ });
41
+ // As their key pair overrides, and their Bearer token in per-command auth.
42
+ it("is allowed for a SUPER_ADMIN's token without the admin scope", () => {
43
+ (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', scopes: 'sessions', user: user('SUPER_ADMIN') })).to.equal(true);
44
+ });
45
+ it('is refused for a token with the admin scope whose user is now a member', () => {
46
+ (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', scopes: 'admin,sessions', user: user('MEMBER') })).to.equal(false);
47
+ });
48
+ it("is refused for an ADMIN's token without the admin scope", () => {
49
+ (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', scopes: 'sessions', user: user('ADMIN') })).to.equal(false);
33
50
  });
34
51
  it('is refused for a member, or a user who is not active', () => {
35
- (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', user: user('MEMBER') })).to.equal(false);
36
- (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', user: null })).to.equal(false);
52
+ (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', scopes: 'sessions', user: user('MEMBER') })).to.equal(false);
53
+ (0, chai_1.expect)((0, leaseOverride_1.canOverrideLease)({ kind: 'session-token', scopes: 'admin', user: null })).to.equal(false);
37
54
  });
38
55
  });
39
56
  it('is allowed with auth disabled, where every caller is a SUPER_ADMIN', () => {
@@ -83,8 +83,11 @@ const sessionCaps = (xenonOptions, extra = {}) => ({
83
83
  const key = (accessKey) => ({ accessKey, token: 'tk' });
84
84
  const ownerKey = key('ak_owner');
85
85
  const otherKey = key('ak_other');
86
- // A session token names its user in `sub`; the fake verifier echoes it back.
87
- const sessionToken = (sub) => ({ sessionToken: `jwt:${sub}` });
86
+ // A session token names its user in `sub` and carries the scopes of the
87
+ // credential that minted it; the fake verifier echoes both back.
88
+ const sessionToken = (sub, scopes = 'sessions') => ({
89
+ sessionToken: `jwt:${sub}|${scopes}`,
90
+ });
88
91
  const keys = {
89
92
  ak_owner: { id: 'key_owner', userId: 'usr_owner', scopes: 'sessions', teamId: null },
90
93
  ak_other: { id: 'key_other', userId: 'usr_other', scopes: 'sessions', teamId: null },
@@ -174,7 +177,10 @@ describe('createSession — a lease-bound session proves it holds the lease', ()
174
177
  findById: (id) => __awaiter(void 0, void 0, void 0, function* () { return roles[id] ? { id, role: roles[id], status: 'ACTIVE' } : null; }),
175
178
  });
176
179
  typedi_1.Container.set(JwtKeyService_1.JwtKeyService, {
177
- verify: (t) => __awaiter(void 0, void 0, void 0, function* () { return ({ sub: t.replace(/^jwt:/, ''), teamId: null }); }),
180
+ verify: (t) => __awaiter(void 0, void 0, void 0, function* () {
181
+ const [sub, scopes] = t.replace(/^jwt:/, '').split('|');
182
+ return { sub, teamId: null, scopes };
183
+ }),
178
184
  });
179
185
  teamRows = sinon_1.default.stub(prisma_1.prisma.teamMember, 'findMany').resolves([]);
180
186
  sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
@@ -331,8 +337,14 @@ describe('createSession — a lease-bound session proves it holds the lease', ()
331
337
  it("is allowed to a SUPER_ADMIN's key", () => __awaiter(void 0, void 0, void 0, function* () {
332
338
  (0, chai_1.expect)(yield allowed(sessionCaps(key('ak_super')))).to.equal(true);
333
339
  }));
340
+ // A session token minted from the dashboard carries the admin's admin
341
+ // scope; one minted with an ADMIN's narrower key doesn't, and overrides
342
+ // no more than that key does.
334
343
  it('is allowed to an ADMIN presenting a session token, as on the dashboard', () => __awaiter(void 0, void 0, void 0, function* () {
335
- (0, chai_1.expect)(yield allowed(sessionCaps(sessionToken('usr_admin')))).to.equal(true);
344
+ (0, chai_1.expect)(yield allowed(sessionCaps(sessionToken('usr_admin', 'admin,sessions')))).to.equal(true);
345
+ }));
346
+ it("is refused to an ADMIN's session token minted without the admin scope", () => __awaiter(void 0, void 0, void 0, function* () {
347
+ (0, chai_1.expect)(yield refusal(sessionCaps(sessionToken('usr_admin', 'sessions')))).to.equal(REFUSED);
336
348
  }));
337
349
  it('is refused to a member presenting a session token', () => __awaiter(void 0, void 0, void 0, function* () {
338
350
  (0, chai_1.expect)(yield refusal(sessionCaps(sessionToken('usr_other')))).to.equal(REFUSED);
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sharp_1 = __importDefault(require("sharp"));
18
+ const typedi_1 = require("typedi");
19
+ const OcrHealingProvider_1 = require("../../src/services/healing/OcrHealingProvider");
20
+ const OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
21
+ const container_registration_1 = require("../helpers/container-registration");
22
+ /**
23
+ * The OCR healing tier, on a real screenshot read by a real Tesseract worker
24
+ * (the language data the plugin ships), with nothing stubbed between them.
25
+ *
26
+ * Through 2.14 the tier read `result.data.words` from tesseract.js, which
27
+ * since tesseract.js 6 returns text only unless asked: `words` was undefined,
28
+ * and the tier healed nothing, ever. Its tests stubbed the OCR with words, so
29
+ * none of them could see it. ocr-healing-tier.spec.ts covers the matching
30
+ * rules on stubbed words; this checks the whole tier reads a screen.
31
+ */
32
+ describe('The OCR healing tier, reading a real screenshot', () => {
33
+ let restoreContainer;
34
+ let screenshot;
35
+ // A phone-like screen: four lines, 200 px apart, each at x 60. "Log out"
36
+ // and "Check in" come first, so a tier that took a lone "Log" for "Login",
37
+ // or a lone "in" for "Sign in", answers with the wrong line.
38
+ const WIDTH = 720;
39
+ const LINES = { 'Log out': 150, 'Check in': 350, 'Sign in': 550, Login: 750 };
40
+ const FONT_SIZE = 56;
41
+ /** Where a line's text is: from its cap height above the baseline to just below it. */
42
+ const band = (line) => ({
43
+ top: LINES[line] - FONT_SIZE,
44
+ bottom: LINES[line] + FONT_SIZE / 4,
45
+ });
46
+ before(() => __awaiter(void 0, void 0, void 0, function* () {
47
+ const text = Object.entries(LINES)
48
+ .map(([words, y]) => `<text x="60" y="${y}" font-family="Helvetica, Arial, sans-serif" font-size="${FONT_SIZE}" fill="#111">${words}</text>`)
49
+ .join('');
50
+ const png = yield (0, sharp_1.default)({
51
+ create: { width: WIDTH, height: 900, channels: 3, background: '#ffffff' },
52
+ })
53
+ .composite([
54
+ {
55
+ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="900">${text}</svg>`),
56
+ },
57
+ ])
58
+ .png()
59
+ .toBuffer();
60
+ screenshot = png.toString('base64');
61
+ // An Omni-Vision of its own, whose worker this suite starts and ends.
62
+ restoreContainer = (0, container_registration_1.saveRegistrations)(OmniVisionService_1.OmniVisionService);
63
+ typedi_1.Container.set(OmniVisionService_1.OmniVisionService, new OmniVisionService_1.OmniVisionService());
64
+ }));
65
+ after(() => __awaiter(void 0, void 0, void 0, function* () {
66
+ const worker = typedi_1.Container.get(OmniVisionService_1.OmniVisionService).sharedWorker;
67
+ yield (worker === null || worker === void 0 ? void 0 : worker.terminate());
68
+ restoreContainer();
69
+ }));
70
+ /** An Android driver: no element answers an iOS predicate, so the tier says where the text is. */
71
+ const android = { findElement: () => __awaiter(void 0, void 0, void 0, function* () { return Promise.reject(new Error('unsupported locator')); }) };
72
+ const heal = (selector) => new OcrHealingProvider_1.OcrHealingProvider().heal({
73
+ sessionId: 'real-ocr-session',
74
+ driver: android,
75
+ strategy: 'xpath',
76
+ selector,
77
+ screenshotBase64: screenshot,
78
+ });
79
+ /** The healed element's box lies on `line`, starting where its text starts. */
80
+ const expectOnLine = (rect, line) => {
81
+ const { top, bottom } = band(line);
82
+ (0, chai_1.expect)(rect, 'rect').to.be.an('object');
83
+ (0, chai_1.expect)(rect.y, `top of the box, on "${line}"`).to.be.within(top, bottom);
84
+ (0, chai_1.expect)(rect.y + rect.height, `bottom of the box, on "${line}"`).to.be.within(top, bottom);
85
+ (0, chai_1.expect)(rect.x, 'left of the box').to.be.within(40, 80);
86
+ };
87
+ it('finds a two-word text on its line, not a lone word of it read earlier', () => __awaiter(void 0, void 0, void 0, function* () {
88
+ const healed = yield heal("//*[@text='Sign in']");
89
+ (0, chai_1.expect)(healed, 'healed').to.not.equal(null);
90
+ (0, chai_1.expect)(healed.id).to.match(/^healed_ocr_/);
91
+ (0, chai_1.expect)(healed.recommendedSelector).to.equal('ocr:text="Sign in"');
92
+ (0, chai_1.expect)(healed.text).to.equal('Sign in');
93
+ expectOnLine(healed.rect, 'Sign in');
94
+ // Both words: wider than "Sign" alone at this size.
95
+ (0, chai_1.expect)(healed.rect.width).to.be.above(150);
96
+ (0, chai_1.expect)(healed.confidence).to.be.within(0.6, 1);
97
+ }));
98
+ it('does not take a word that is only part of the text', () => __awaiter(void 0, void 0, void 0, function* () {
99
+ const healed = yield heal("//*[@text='Login']");
100
+ (0, chai_1.expect)(healed, 'healed').to.not.equal(null);
101
+ (0, chai_1.expect)(healed.text).to.equal('Login');
102
+ expectOnLine(healed.rect, 'Login');
103
+ }));
104
+ it('reads the text from the attribute the selector names, whatever its case', () => __awaiter(void 0, void 0, void 0, function* () {
105
+ const healed = yield heal("//android.widget.Button[@content-desc='check IN']");
106
+ (0, chai_1.expect)(healed, 'healed').to.not.equal(null);
107
+ (0, chai_1.expect)(healed.text).to.equal('Check in');
108
+ expectOnLine(healed.rect, 'Check in');
109
+ }));
110
+ it('heals nothing when the text is not on the screen', () => __awaiter(void 0, void 0, void 0, function* () {
111
+ (0, chai_1.expect)(yield heal("//*[@text='Sign up']")).to.equal(null);
112
+ }));
113
+ });
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const typedi_1 = require("typedi");
19
+ const OcrHealingProvider_1 = require("../../src/services/healing/OcrHealingProvider");
20
+ const OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
21
+ const VisualAiHealingProvider_1 = require("../../src/services/healing/VisualAiHealingProvider");
22
+ const AIService_1 = require("../../src/services/AIService");
23
+ /**
24
+ * Self-healing's OCR tier took the first word OCR read that contained the
25
+ * text, or was part of it: for `Sign in` that was any lone "in" or "Sign" on
26
+ * the screen, and for `Login` a "Log". It now matches as Omni-Vision's
27
+ * `-custom:ai-text` does (ocrTextMatch.ts): the text across neighbouring words
28
+ * on one line, the first match in reading order.
29
+ *
30
+ * OCR is stubbed with the words it would read, in screenshot pixels: no
31
+ * Tesseract runs. ocr-healing-tier-real-ocr.spec.ts runs the tier on a real
32
+ * screenshot, which a stub can't stand in for: tesseract.js's own output is
33
+ * what hid the tier's never healing.
34
+ */
35
+ describe('The OCR healing tier matches text as Omni-Vision does', () => {
36
+ const word = (text, x0, y0, x1, y1) => ({
37
+ text,
38
+ confidence: 90,
39
+ bbox: { x0, y0, x1, y1 },
40
+ });
41
+ // A heading with "Log" and a lone "in" read first; then "Sign in", far
42
+ // from "Help" on its line; then "Login".
43
+ const WORDS = [
44
+ word('Log', 40, 60, 90, 84),
45
+ word('in', 300, 60, 320, 84),
46
+ word('Sign', 100, 200, 150, 224),
47
+ word('in', 158, 200, 180, 224),
48
+ word('Help', 600, 200, 650, 224),
49
+ word('Login', 100, 400, 180, 424),
50
+ ];
51
+ let driver;
52
+ beforeEach(() => {
53
+ // OCR reads these words: the tier asks Omni-Vision's OCR.
54
+ sinon_1.default
55
+ .stub(typedi_1.Container.get(OmniVisionService_1.OmniVisionService), 'performOcr')
56
+ .resolves({ text: '', words: WORDS });
57
+ // Android: no element is found by an iOS predicate, so the tier answers
58
+ // with where the text is.
59
+ driver = { findElement: sinon_1.default.stub().rejects(new Error('unsupported locator strategy')) };
60
+ });
61
+ afterEach(() => sinon_1.default.restore());
62
+ const heal = (selector) => new OcrHealingProvider_1.OcrHealingProvider().heal({
63
+ sessionId: 'ocr-session',
64
+ driver,
65
+ strategy: 'xpath',
66
+ selector,
67
+ screenshotBase64: 'aGVsbG8=',
68
+ });
69
+ it('finds a two-word text across its words, not a lone word of it', () => __awaiter(void 0, void 0, void 0, function* () {
70
+ const healed = yield heal("//*[@text='Sign in']");
71
+ (0, chai_1.expect)(healed, 'healed').to.not.equal(null);
72
+ (0, chai_1.expect)(healed.recommendedSelector).to.equal('ocr:text="Sign in"');
73
+ (0, chai_1.expect)(healed.rect).to.deep.equal({ x: 100, y: 200, width: 80, height: 24 });
74
+ }));
75
+ it('hands on the text it read, for getText on the element it returns', () => __awaiter(void 0, void 0, void 0, function* () {
76
+ const healed = yield heal("//*[@text='sign IN']");
77
+ (0, chai_1.expect)(healed.text).to.equal('Sign in');
78
+ (0, chai_1.expect)(healed.message).to.not.equal(healed.text);
79
+ }));
80
+ it('does not take a word that is only part of the text', () => __awaiter(void 0, void 0, void 0, function* () {
81
+ const healed = yield heal("//*[@text='Login']");
82
+ (0, chai_1.expect)(healed, 'healed').to.not.equal(null);
83
+ (0, chai_1.expect)(healed.recommendedSelector).to.equal('ocr:text="Login"');
84
+ (0, chai_1.expect)(healed.rect).to.deep.equal({ x: 100, y: 400, width: 80, height: 24 });
85
+ }));
86
+ it('finds nothing when the text is not on the screen', () => __awaiter(void 0, void 0, void 0, function* () {
87
+ (0, chai_1.expect)(yield heal("//*[@text='Sign up']")).to.equal(null);
88
+ }));
89
+ });
90
+ /**
91
+ * A healed element's id names it in the server's memory of virtual elements,
92
+ * which every session shares. `healed_ocr_<ms>` was the same for two heals in
93
+ * one millisecond, so the second overwrote the first.
94
+ */
95
+ describe('Elements healed by OCR or Visual AI', () => {
96
+ afterEach(() => sinon_1.default.restore());
97
+ const context = {
98
+ sessionId: 'ids-session',
99
+ driver: { findElement: () => __awaiter(void 0, void 0, void 0, function* () { return Promise.reject(new Error('no predicate')); }) },
100
+ strategy: 'xpath',
101
+ selector: "//*[@text='Login']",
102
+ screenshotBase64: 'aGVsbG8=',
103
+ };
104
+ it('get ids of their own when healed in the same millisecond', () => __awaiter(void 0, void 0, void 0, function* () {
105
+ var _a, _b, _c, _d;
106
+ sinon_1.default.stub(Date, 'now').returns(1700000000000);
107
+ sinon_1.default.stub(typedi_1.Container.get(OmniVisionService_1.OmniVisionService), 'performOcr').resolves({
108
+ text: '',
109
+ words: [{ text: 'Login', confidence: 90, bbox: { x0: 1, y0: 1, x1: 50, y1: 20 } }],
110
+ });
111
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'isEnabled').returns(true);
112
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'visualFind').resolves({ x: 100, y: 100 });
113
+ const ids = [
114
+ (_a = (yield new OcrHealingProvider_1.OcrHealingProvider().heal(context))) === null || _a === void 0 ? void 0 : _a.id,
115
+ (_b = (yield new OcrHealingProvider_1.OcrHealingProvider().heal(context))) === null || _b === void 0 ? void 0 : _b.id,
116
+ (_c = (yield new VisualAiHealingProvider_1.VisualAiHealingProvider().heal(context))) === null || _c === void 0 ? void 0 : _c.id,
117
+ (_d = (yield new VisualAiHealingProvider_1.VisualAiHealingProvider().heal(context))) === null || _d === void 0 ? void 0 : _d.id,
118
+ ];
119
+ (0, chai_1.expect)(ids.every(Boolean), String(ids)).to.equal(true);
120
+ (0, chai_1.expect)(new Set(ids).size, String(ids)).to.equal(4);
121
+ }));
122
+ });