@xenon-device-management/xenon 2.13.2 → 2.14.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 (209) hide show
  1. package/README.md +11 -5
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-BXruClDk.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-qTS4LPLC.js → ApiKeyGate-BwuaMjkX.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-BoGhlI7F.js → BugReportButton-BAt0_pzq.js} +1 -1
  6. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
  7. package/lib/public/assets/{EmptyState-Cr17PdDv.js → EmptyState-BSjhk_IV.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-Bvn08svv.js → FieldGroup-Cb5QZsE1.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-DDSO8aDf.js → FilterMenu-BAaSlWbW.js} +1 -1
  10. package/lib/public/assets/{Menu-B-Qn2X3T.js → Menu-JGGYrSQQ.js} +1 -1
  11. package/lib/public/assets/{Modal-Cg9V5JYU.js → Modal-CFCp3n9d.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-BTUs9VTW.js → RecordingPage-CbJj_EBT.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-DYcfxITK.js → RecordingsPage-BJeYnygR.js} +2 -2
  14. package/lib/public/assets/{SegmentedControl-Zy7suhT5.js → SegmentedControl-Bg42Atjy.js} +1 -1
  15. package/lib/public/assets/{SettingCard-CWYmycoe.js → SettingCard-Njz1pUIJ.js} +1 -1
  16. package/lib/public/assets/{Table-io9fwa98.js → Table-BcTm5xV2.js} +1 -1
  17. package/lib/public/assets/{activity-Bdf_uIWl.js → activity-DOVdsfMQ.js} +1 -1
  18. package/lib/public/assets/{ai-settings-Bce5SJob.js → ai-settings-CsuJroRf.js} +2 -2
  19. package/lib/public/assets/{api-keys-CPqtVpm3.js → api-keys-BKnNSTw9.js} +1 -1
  20. package/lib/public/assets/{apps-BgZan0H9.js → apps-D0SC6uw6.js} +2 -2
  21. package/lib/public/assets/{arrow-left-B27ckB31.js → arrow-left-MMbTmgoE.js} +1 -1
  22. package/lib/public/assets/{arrow-right-DOF8ITCz.js → arrow-right-BMfnGmxW.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-eFG5KTFS.js → arrow-up-right-DuAftMWB.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DlC3_27j.js → auth-shell-nZC2JNDO.js} +2 -2
  25. package/lib/public/assets/{builds-page-BuXpPfKo.js → builds-page-hDCgTwkS.js} +1 -1
  26. package/lib/public/assets/{button-fIW8owMI.js → button-Dda4irn_.js} +1 -1
  27. package/lib/public/assets/{calendar-WNjmrF-a.js → calendar-BMr0Q62v.js} +1 -1
  28. package/lib/public/assets/{check-BdTh1Sq7.js → check-CZ9iw9-e.js} +1 -1
  29. package/lib/public/assets/{chevron-right-B4BKlsqk.js → chevron-right-BRYJJVC9.js} +1 -1
  30. package/lib/public/assets/{circle-check-zJU80_EF.js → circle-check-CqAJjQ5O.js} +1 -1
  31. package/lib/public/assets/{circle-x-5Uwquo0h.js → circle-x-DlDJtnVz.js} +1 -1
  32. package/lib/public/assets/{clock-BtUprDsZ.js → clock-CpuDKysU.js} +1 -1
  33. package/lib/public/assets/{copy-DGSbZI3u.js → copy-CAQydNFe.js} +1 -1
  34. package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
  35. package/lib/public/assets/{download-WG6l7BgD.js → download-C9XG6lLv.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DnudqFFo.js → forgot-password-CGnkRgE9.js} +1 -1
  37. package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
  38. package/lib/public/assets/{index-DI1fGIyj.js → index-BbKEXUFH.js} +5 -5
  39. package/lib/public/assets/{index-HDQVe1gi.js → index-DJGc1M2f.js} +1 -1
  40. package/lib/public/assets/{input-BjDRKCea.js → input-Cm0tmSoc.js} +1 -1
  41. package/lib/public/assets/{line-chart-C0zdgd0v.js → line-chart-D2DyJCml.js} +1 -1
  42. package/lib/public/assets/{list-checks-BdW5CjM8.js → list-checks-UWf1GY99.js} +1 -1
  43. package/lib/public/assets/{lock-Dy3L4I8E.js → lock-CCJv9m_I.js} +1 -1
  44. package/lib/public/assets/{login-BHbY9CLG.js → login-f8vOVW-U.js} +1 -1
  45. package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
  46. package/lib/public/assets/{monitor-8xjkeHHi.js → monitor-B2jDgwm7.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CXdKMXQ8.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
  48. package/lib/public/assets/{network-Cj8JOKON.js → network-ebT86qoi.js} +1 -1
  49. package/lib/public/assets/{overview-CTg7F0im.js → overview-C0aykVrd.js} +1 -1
  50. package/lib/public/assets/{page-header--aTyJXq6.js → page-header-BjzgWz-T.js} +1 -1
  51. package/lib/public/assets/{play-B-FodgHV.js → play-DLG9t8FU.js} +1 -1
  52. package/lib/public/assets/{plus-CsIk10Cq.js → plus-nBFSMu_X.js} +1 -1
  53. package/lib/public/assets/{profile-page-DkFKkMfl.js → profile-page-CSl0UYlv.js} +1 -1
  54. package/lib/public/assets/{reset-password-I2-zqLlI.js → reset-password-LeOuHqmB.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJTVCJX7.js → runbook-page-CMMeQml9.js} +1 -1
  56. package/lib/public/assets/{select-DvCSv25z.js → select-88jHcl-Z.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BLVfMJs_.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-oqadRokn.js → selector-health-page-B4rA1Xkv.js} +2 -2
  59. package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
  60. package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
  61. package/lib/public/assets/{stat-tile-DV9Ix-4O.js → stat-tile-BV98e_Sv.js} +1 -1
  62. package/lib/public/assets/{tablet-6b_SEN2X.js → tablet-BiAsnFDj.js} +1 -1
  63. package/lib/public/assets/{teams-BjZyfuHh.js → teams-B4-jrr-1.js} +1 -1
  64. package/lib/public/assets/{trash-2-CTZpPSdd.js → trash-2-C5RSuX0e.js} +1 -1
  65. package/lib/public/assets/{upload-P3XzwQUb.js → upload-BG7_rMXK.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-1cOCMf-z.js → use-builds-data-J-riQYyC.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DgF9w088.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
  68. package/lib/public/assets/{users-DMF9Mk5H.js → users-CFba5TSy.js} +1 -1
  69. package/lib/public/assets/{users-CiNTKUiz.js → users-Cp0beJm3.js} +1 -1
  70. package/lib/public/assets/{video-off-CHp1KU_T.js → video-off-DDsEISjV.js} +1 -1
  71. package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
  72. package/lib/public/assets/{zap-DRz0tDq5.js → zap-l4VaXzq8.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +19 -21
  75. package/lib/src/XenonCapabilityManager.js +22 -5
  76. package/lib/src/app/openapi/control.yaml +71 -20
  77. package/lib/src/app/openapi/grid.yaml +23 -5
  78. package/lib/src/app/openapi/platform.yaml +136 -28
  79. package/lib/src/app/routers/control.js +12 -2
  80. package/lib/src/app/routers/dashboard.js +29 -1
  81. package/lib/src/app/routers/grid.js +32 -10
  82. package/lib/src/app/routers/streamType.js +7 -2
  83. package/lib/src/app/routers/webhook.js +8 -4
  84. package/lib/src/config.js +50 -5
  85. package/lib/src/dashboard/commands.js +185 -141
  86. package/lib/src/dashboard/event-manager.js +39 -6
  87. package/lib/src/data-service/appleFamily.js +43 -0
  88. package/lib/src/data-service/db.js +11 -0
  89. package/lib/src/data-service/device-service.js +6 -1
  90. package/lib/src/data-service/device-store.js +62 -2
  91. package/lib/src/data-service/deviceClaims.js +37 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +25 -11
  93. package/lib/src/data-service/deviceSettings.js +79 -0
  94. package/lib/src/data-service/prisma-store.js +150 -8
  95. package/lib/src/data-service/web-config-service.js +43 -92
  96. package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
  97. package/lib/src/device-managers/ios/WDAClient.js +44 -43
  98. package/lib/src/device-managers/shellCommands.js +96 -0
  99. package/lib/src/device-utils.js +96 -40
  100. package/lib/src/generated/client/edge.js +17 -3
  101. package/lib/src/generated/client/index-browser.js +14 -0
  102. package/lib/src/generated/client/index.d.ts +2054 -379
  103. package/lib/src/generated/client/index.js +17 -3
  104. package/lib/src/generated/client/package.json +1 -1
  105. package/lib/src/generated/client/schema.prisma +35 -7
  106. package/lib/src/generated/client/wasm.js +14 -0
  107. package/lib/src/interceptors/CommandInterceptor.js +29 -8
  108. package/lib/src/interceptors/xenonScripts.js +55 -0
  109. package/lib/src/interfaces/IPluginArgs.js +1 -2
  110. package/lib/src/plugin.js +15 -1
  111. package/lib/src/prisma.js +1 -1
  112. package/lib/src/scripts/database-check.js +124 -0
  113. package/lib/src/services/AIService.js +99 -0
  114. package/lib/src/services/CleanupService.js +6 -1
  115. package/lib/src/services/InterceptorService.js +70 -13
  116. package/lib/src/services/NetworkConditioningService.js +85 -88
  117. package/lib/src/services/NotificationService.js +36 -29
  118. package/lib/src/services/OrphanSweeper.js +5 -0
  119. package/lib/src/services/PortAllocator.js +5 -0
  120. package/lib/src/services/ServerManager.js +94 -8
  121. package/lib/src/services/SessionLifecycleService.js +47 -49
  122. package/lib/src/services/ShutdownCoordinator.js +33 -0
  123. package/lib/src/services/TeamService.js +20 -3
  124. package/lib/src/services/healing/HealingOrchestrator.js +32 -1
  125. package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
  126. package/lib/src/services/interceptor/interceptorOptions.js +44 -0
  127. package/lib/src/services/network/AndroidRadios.js +71 -0
  128. package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
  129. package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
  130. package/lib/src/services/network/adbForPhone.js +60 -0
  131. package/lib/src/services/network/phoneNetworkLock.js +20 -0
  132. package/lib/src/services/network/xenonProxy.js +107 -0
  133. package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
  134. package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
  135. package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
  136. package/lib/src/services/omni-vision/screenScale.js +68 -0
  137. package/lib/src/services/recording/concurrency-gate.js +12 -3
  138. package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
  139. package/lib/src/services/settings/labSettings.js +149 -0
  140. package/lib/src/services/webhookEvents.js +173 -0
  141. package/lib/test/helpers/fake-adb.js +58 -0
  142. package/lib/test/helpers/fake-device-table.js +63 -24
  143. package/lib/test/helpers/scratch-database.js +1 -0
  144. package/lib/test/unit/TeamService.test.js +3 -0
  145. package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
  146. package/lib/test/unit/apple-family.spec.js +36 -0
  147. package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
  148. package/lib/test/unit/cleanup-settings.spec.js +244 -0
  149. package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
  150. package/lib/test/unit/dashboard-commands.spec.js +249 -0
  151. package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
  152. package/lib/test/unit/database-check.spec.js +108 -0
  153. package/lib/test/unit/default-plugin-args.spec.js +33 -0
  154. package/lib/test/unit/device-offline-webhook.spec.js +81 -0
  155. package/lib/test/unit/device-setting-columns.spec.js +66 -0
  156. package/lib/test/unit/device-settings-loki.spec.js +87 -0
  157. package/lib/test/unit/device-settings-survive.spec.js +555 -0
  158. package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
  159. package/lib/test/unit/emulator-boot.spec.js +91 -0
  160. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
  161. package/lib/test/unit/hub-node-same-host.spec.js +5 -0
  162. package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
  163. package/lib/test/unit/json-logging-option.spec.js +74 -0
  164. package/lib/test/unit/lab-settings.spec.js +218 -0
  165. package/lib/test/unit/max-sessions.spec.js +240 -0
  166. package/lib/test/unit/ocr-text-match.spec.js +44 -0
  167. package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
  168. package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
  169. package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
  170. package/lib/test/unit/omni-vision.spec.js +1 -1
  171. package/lib/test/unit/partial-config-file.spec.js +14 -0
  172. package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
  173. package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
  174. package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
  175. package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
  176. package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
  177. package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
  178. package/lib/test/unit/phone-network/session-start.spec.js +110 -0
  179. package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
  180. package/lib/test/unit/plugin.spec.js +3 -3
  181. package/lib/test/unit/recording-options.spec.js +181 -0
  182. package/lib/test/unit/self-healing-boot.spec.js +75 -0
  183. package/lib/test/unit/self-healing-setting.spec.js +272 -0
  184. package/lib/test/unit/session-failed-webhook.spec.js +170 -0
  185. package/lib/test/unit/shell-commands.spec.js +133 -0
  186. package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
  187. package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
  188. package/lib/test/unit/stream-status-type.spec.js +4 -0
  189. package/lib/test/unit/vision-assertion.spec.js +118 -0
  190. package/lib/test/unit/wda-client-shell.spec.js +133 -0
  191. package/lib/test/unit/web-config-service.spec.js +79 -0
  192. package/lib/test/unit/webhook-delivery.spec.js +24 -0
  193. package/lib/test/unit/webhook-payloads.spec.js +273 -0
  194. package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
  195. package/lib/tsconfig.tsbuildinfo +1 -1
  196. package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
  197. package/package.json +1 -1
  198. package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
  199. package/prisma/schema.prisma +35 -7
  200. package/schema.json +19 -21
  201. package/scripts/generate-types-from-schema.js +1 -2
  202. package/scripts/lib/always-present-plugin-args.js +4 -1
  203. package/lib/public/assets/DeviceMosaicView-8J0oYqMM.js +0 -2
  204. package/lib/public/assets/device-explorer-C-KnhMHI.js +0 -279
  205. package/lib/public/assets/maintenance-settings-zDVlOMVK.js +0 -6
  206. package/lib/public/assets/recording-group-store-WVM3hoXn.js +0 -26
  207. package/lib/public/assets/session-detail-page-CGn55Pvl.js +0 -4
  208. package/lib/public/assets/settings-MsVU2ukE.js +0 -6
  209. package/lib/public/assets/webhook-settings-DiS8geIm.js +0 -1
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ require("reflect-metadata");
49
+ const chai_1 = __importDefault(require("chai"));
50
+ const sinon_1 = __importDefault(require("sinon"));
51
+ const DeviceUtils = __importStar(require("../../src/device-utils"));
52
+ const db_1 = require("../../src/data-service/db");
53
+ const device_service_1 = require("../../src/data-service/device-service");
54
+ const deviceClaims_1 = require("../../src/data-service/deviceClaims");
55
+ const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
56
+ const test_container_1 = require("../helpers/test-container");
57
+ const loki_stores_1 = require("../helpers/loki-stores");
58
+ const scratch_database_1 = require("../helpers/scratch-database");
59
+ const expect = chai_1.default.expect;
60
+ /**
61
+ * `maxSessions` holds new sessions back once that many Appium sessions run.
62
+ *
63
+ * It compared the number of busy phones with `===`, so once the count was past
64
+ * the limit nothing was held back any more, and it counted every busy phone: a
65
+ * live preview, a recording, or an SDK lease nobody had started a session on
66
+ * used up a slot that no Appium session was using. What it counts now is the
67
+ * phones running a session, or being given one.
68
+ */
69
+ describe('maxSessions', () => {
70
+ describe('which phones count', () => {
71
+ const base = { busy: true };
72
+ it('counts a session being created (a claim with no session id yet)', () => {
73
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { claimedAt: Date.now() }))).to.equal(true);
74
+ });
75
+ it('counts a session that is running', () => {
76
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { claimedAt: 1, claimSessionId: 'sess-1', session_id: 'sess-1' }))).to.equal(true);
77
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { session_id: 'sess-1' }))).to.equal(true);
78
+ });
79
+ it('does not count a live preview or a recording', () => {
80
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { session_id: 'manual_u1_phone-1' }))).to.equal(false);
81
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { session_id: 'manual_phone-1' }))).to.equal(false);
82
+ });
83
+ it('does not count a phone an SDK lease holds that has no session on it', () => {
84
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign({}, base))).to.equal(false);
85
+ });
86
+ it('counts a session started on a leased phone', () => {
87
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { claimSessionId: 'sess-2', claimedAt: 1 }))).to.equal(true);
88
+ });
89
+ it('does not count a phone that is not busy', () => {
90
+ expect((0, deviceClaims_1.countsTowardMaxSessions)({ busy: false })).to.equal(false);
91
+ expect((0, deviceClaims_1.countsTowardMaxSessions)({ busy: false, session_id: 'sess-1' })).to.equal(false);
92
+ });
93
+ it("counts a node's phone the node reports busy, unless the node says it is a preview", () => {
94
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { nodeBusy: true }))).to.equal(true);
95
+ expect((0, deviceClaims_1.countsTowardMaxSessions)(Object.assign(Object.assign({}, base), { nodeBusy: true, nodeHold: 'manual_u1_phone-1' }))).to.equal(false);
96
+ });
97
+ });
98
+ describe('the limit itself', () => {
99
+ it('is the setting when it is a positive number', () => {
100
+ expect((0, deviceClaims_1.sessionCap)(8)).to.equal(8);
101
+ expect((0, deviceClaims_1.sessionCap)(1)).to.equal(1);
102
+ });
103
+ it('is no limit when it is unset, zero, negative or not a number', () => {
104
+ for (const value of [undefined, null, 0, -1, NaN, Infinity, '8']) {
105
+ expect((0, deviceClaims_1.sessionCap)(value), String(value)).to.equal(undefined);
106
+ }
107
+ });
108
+ });
109
+ describe('allocation', () => {
110
+ (0, loki_stores_1.useLokiStores)();
111
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
112
+ const sandbox = sinon_1.default.createSandbox();
113
+ const HOST = 'http://127.0.0.1:4723';
114
+ const pluginArgs = Object.assign({}, IPluginArgs_1.DefaultPluginArgs, {
115
+ iosDeviceType: 'both',
116
+ androidDeviceType: 'both',
117
+ });
118
+ const phone = (udid, over = {}) => (Object.assign({ udid, name: udid, platform: 'android', sdk: '13', host: HOST, busy: false, offline: false, userBlocked: false, state: 'device', deviceType: 'emulator', realDevice: false, systemPort: 57000, totalUtilizationTimeMilliSec: 0, sessionStartTime: 0 }, over));
119
+ // New each time: allocation pins the phone it picked into the caps it is given.
120
+ const caps = () => ({
121
+ alwaysMatch: { platformName: 'android' },
122
+ firstMatch: [{}],
123
+ });
124
+ const row = (udid) => __awaiter(void 0, void 0, void 0, function* () { return (yield db_1.XenonDatabase.DeviceModel).findOne({ udid }); });
125
+ /** Asks for a phone, waiting `ms` at most. Resolves to the udid, or null when held back. */
126
+ const ask = (ms = 400) => DeviceUtils.allocateDeviceForSession(caps(), ms, 50, pluginArgs).then((d) => d.udid, () => null);
127
+ const withCap = (maxSessions) => (0, test_container_1.createTestXenonManager)(Object.assign({}, pluginArgs, { maxSessions, platform: 'android' }));
128
+ before(() => __awaiter(void 0, void 0, void 0, function* () {
129
+ yield (0, test_container_1.resetTestContainer)();
130
+ yield (0, test_container_1.setupTestContainer)();
131
+ }));
132
+ after(() => (0, test_container_1.resetTestContainer)());
133
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
134
+ (yield db_1.XenonDatabase.DeviceModel).removeDataOnly();
135
+ yield scratch.db.lease.deleteMany({});
136
+ sandbox.stub(DeviceUtils, 'setUtilizationTime').resolves();
137
+ }));
138
+ afterEach(() => sandbox.restore());
139
+ const running = (n) => Array.from({ length: n }, (_, i) => phone(`running-${i}`, {
140
+ busy: true,
141
+ session_id: `sess-${i}`,
142
+ claimSessionId: `sess-${i}`,
143
+ claimedAt: 1,
144
+ }));
145
+ it('allocates while fewer sessions run than the limit', () => __awaiter(void 0, void 0, void 0, function* () {
146
+ withCap(2);
147
+ yield (0, device_service_1.addNewDevice)([...running(1), phone('free-1')]);
148
+ expect(yield ask()).to.equal('free-1');
149
+ }));
150
+ it('holds a session back at the limit', () => __awaiter(void 0, void 0, void 0, function* () {
151
+ withCap(2);
152
+ yield (0, device_service_1.addNewDevice)([...running(2), phone('free-1')]);
153
+ expect(yield ask()).to.equal(null);
154
+ expect((yield row('free-1')).busy).to.equal(false);
155
+ }));
156
+ it('still holds it back when more sessions run than the limit, which `===` let through', () => __awaiter(void 0, void 0, void 0, function* () {
157
+ withCap(2);
158
+ yield (0, device_service_1.addNewDevice)([...running(3), phone('free-1')]);
159
+ expect(yield ask()).to.equal(null);
160
+ expect((yield row('free-1')).busy).to.equal(false);
161
+ }));
162
+ it('gives the phone once a session ends', () => __awaiter(void 0, void 0, void 0, function* () {
163
+ withCap(2);
164
+ yield (0, device_service_1.addNewDevice)([...running(2), phone('free-1')]);
165
+ const waiting = ask(2000);
166
+ yield new Promise((r) => setTimeout(r, 150));
167
+ (yield db_1.XenonDatabase.DeviceModel)
168
+ .chain()
169
+ .find({ udid: 'running-0' })
170
+ .update((d) => Object.assign(d, {
171
+ busy: false,
172
+ session_id: null,
173
+ claimSessionId: null,
174
+ claimedAt: null,
175
+ }));
176
+ expect(yield waiting).to.be.oneOf(['free-1', 'running-0']);
177
+ }));
178
+ it('does not count live previews: they are not sessions', () => __awaiter(void 0, void 0, void 0, function* () {
179
+ withCap(2);
180
+ yield (0, device_service_1.addNewDevice)([
181
+ phone('preview-1', { busy: true, session_id: 'manual_u1_preview-1' }),
182
+ phone('preview-2', { busy: true, session_id: 'manual_u1_preview-2' }),
183
+ phone('free-1'),
184
+ ]);
185
+ expect(yield ask()).to.equal('free-1');
186
+ }));
187
+ it('does not count a leased phone no session is running on', () => __awaiter(void 0, void 0, void 0, function* () {
188
+ withCap(1);
189
+ yield (0, device_service_1.addNewDevice)([phone('leased-1', { busy: true }), phone('free-1')]);
190
+ yield scratch.db.lease.create({
191
+ data: {
192
+ tokenHash: 'h',
193
+ deviceUdid: 'leased-1',
194
+ deviceHost: HOST,
195
+ actorId: 'key_owner',
196
+ status: 'active',
197
+ expiresAt: Date.now() + 600000,
198
+ lastHeartbeatAt: Date.now(),
199
+ allocatedPorts: '{}',
200
+ capabilityBag: '{}',
201
+ },
202
+ });
203
+ expect(yield ask()).to.equal('free-1');
204
+ }));
205
+ it('counts a session started on a leased phone', () => __awaiter(void 0, void 0, void 0, function* () {
206
+ withCap(1);
207
+ yield (0, device_service_1.addNewDevice)([
208
+ phone('leased-1', {
209
+ busy: true,
210
+ session_id: 'sess-l',
211
+ claimSessionId: 'sess-l',
212
+ claimedAt: 1,
213
+ }),
214
+ phone('free-1'),
215
+ ]);
216
+ expect(yield ask()).to.equal(null);
217
+ }));
218
+ it('counts a session still being created', () => __awaiter(void 0, void 0, void 0, function* () {
219
+ withCap(1);
220
+ yield (0, device_service_1.addNewDevice)([
221
+ phone('pending-1', { busy: true, claimedAt: Date.now() }),
222
+ phone('free-1'),
223
+ ]);
224
+ expect(yield ask()).to.equal(null);
225
+ }));
226
+ it('holds back exactly the sessions over the limit when many ask at once', () => __awaiter(void 0, void 0, void 0, function* () {
227
+ withCap(2);
228
+ yield (0, device_service_1.addNewDevice)(Array.from({ length: 6 }, (_, i) => phone(`free-${i}`)));
229
+ const got = yield Promise.all(Array.from({ length: 5 }, () => ask(300)));
230
+ const given = got.filter((udid) => udid !== null);
231
+ expect(given, JSON.stringify(got)).to.have.length(2);
232
+ expect(new Set(given).size).to.equal(2);
233
+ }));
234
+ it('has no limit when the setting is not a positive number', () => __awaiter(void 0, void 0, void 0, function* () {
235
+ withCap(0);
236
+ yield (0, device_service_1.addNewDevice)([...running(3), phone('free-1')]);
237
+ expect(yield ask()).to.equal('free-1');
238
+ }));
239
+ });
240
+ });
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chai_1 = require("chai");
4
+ const ocrTextMatch_1 = require("../../src/services/omni-vision/ocrTextMatch");
5
+ describe('ocrTextMatch', () => {
6
+ const word = (text, x0, y0, x1, y1, confidence = 90) => ({ text, x0, y0, x1, y1, confidence });
7
+ it('groups words into lines and runs, in reading order, despite a slightly uneven baseline', () => {
8
+ const runs = (0, ocrTextMatch_1.wordRuns)([
9
+ word('Done', 900, 40, 980, 70),
10
+ word('in', 158, 202, 180, 226),
11
+ word('Cancel', 20, 42, 120, 70),
12
+ word('Sign', 100, 200, 150, 224),
13
+ ]);
14
+ (0, chai_1.expect)(runs.map((r) => r.map((w) => w.text))).to.deep.equal([
15
+ ['Cancel'],
16
+ ['Done'],
17
+ ['Sign', 'in'],
18
+ ]);
19
+ });
20
+ it('finds every occurrence, once per set of words, with the lowest confidence of the words', () => {
21
+ const words = [
22
+ word('Add', 10, 10, 50, 30, 95),
23
+ word('to', 56, 10, 70, 30, 70),
24
+ word('cart', 76, 10, 120, 30, 88),
25
+ word('Add', 10, 100, 50, 120),
26
+ word('to', 56, 100, 70, 120),
27
+ word('cart', 76, 100, 120, 120),
28
+ ];
29
+ const found = (0, ocrTextMatch_1.findText)(words, ' add TO cart ');
30
+ (0, chai_1.expect)(found.map((m) => [m.text, m.y0, m.confidence])).to.deep.equal([
31
+ ['Add to cart', 10, 70],
32
+ ['Add to cart', 100, 90],
33
+ ]);
34
+ (0, chai_1.expect)(found[0]).to.include({ x0: 10, x1: 120, y1: 30 });
35
+ });
36
+ it('matches a one-word text inside a word once, however often it appears there', () => {
37
+ (0, chai_1.expect)((0, ocrTextMatch_1.findText)([word('banana', 0, 0, 60, 20)], 'an').map((m) => m.text)).to.deep.equal([
38
+ 'banana',
39
+ ]);
40
+ });
41
+ it('matches nothing for an empty text', () => {
42
+ (0, chai_1.expect)((0, ocrTextMatch_1.findText)([word('Sign', 0, 0, 40, 20)], ' ')).to.deep.equal([]);
43
+ });
44
+ });
@@ -0,0 +1,72 @@
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 OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
19
+ const AIService_1 = require("../../src/services/AIService");
20
+ /**
21
+ * `xenon: analyzeScreen` (and device control's Omni-Scan) sent the screenshot's
22
+ * base64 where a file path was expected. The AI step found no such file and
23
+ * asked the provider without an image, under a "why did this test fail"
24
+ * prompt, so `ai_insights` was generic text about no screen at all.
25
+ *
26
+ * Now the screenshot goes to the provider with a prompt about the screen, and
27
+ * when there is no AI answer `ai_insights_error` says why.
28
+ */
29
+ describe('xenon: analyzeScreen', () => {
30
+ const SCREEN = 'c2NyZWVuc2hvdA==';
31
+ let service;
32
+ const driver = { sessionId: 'session-1', getScreenshot: () => __awaiter(void 0, void 0, void 0, function* () { return SCREEN; }) };
33
+ beforeEach(() => {
34
+ service = new OmniVisionService_1.OmniVisionService();
35
+ sinon_1.default.stub(service, 'performOcr').resolves({
36
+ text: 'Sign in',
37
+ words: [{ text: 'Sign', confidence: 95, bbox: { x0: 10, y0: 10, x1: 50, y1: 30 } }],
38
+ });
39
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'initializeProvider');
40
+ });
41
+ afterEach(() => sinon_1.default.restore());
42
+ it('sends the screenshot to the AI provider and asks about the screen', () => __awaiter(void 0, void 0, void 0, function* () {
43
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value({ analyze: () => __awaiter(void 0, void 0, void 0, function* () { return ''; }) });
44
+ const call = sinon_1.default
45
+ .stub(AIService_1.AI_SERVICE, 'callProvider')
46
+ .resolves('A sign-in screen with an email field.');
47
+ const analysis = yield service.analyzeScreen(driver);
48
+ (0, chai_1.expect)(call.calledOnce).to.equal(true);
49
+ const [prompt, image] = call.firstCall.args;
50
+ (0, chai_1.expect)(image).to.equal(SCREEN);
51
+ (0, chai_1.expect)(prompt).to.match(/screen/i);
52
+ (0, chai_1.expect)(prompt).to.not.match(/root cause|failed/i);
53
+ (0, chai_1.expect)(analysis.ai_insights).to.equal('A sign-in screen with an email field.');
54
+ (0, chai_1.expect)(analysis).to.not.have.property('ai_insights_error');
55
+ (0, chai_1.expect)(analysis.ocr.text).to.equal('Sign in');
56
+ }));
57
+ it('says why there are no AI insights when no provider is configured', () => __awaiter(void 0, void 0, void 0, function* () {
58
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value(null);
59
+ const analysis = yield service.analyzeScreen(driver);
60
+ (0, chai_1.expect)(analysis.ai_insights).to.equal(null);
61
+ (0, chai_1.expect)(analysis.ai_insights_error).to.match(/No AI provider is configured/);
62
+ (0, chai_1.expect)(analysis.ocr.text).to.equal('Sign in');
63
+ }));
64
+ it('says why there are no AI insights when the provider fails', () => __awaiter(void 0, void 0, void 0, function* () {
65
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value({ analyze: () => __awaiter(void 0, void 0, void 0, function* () { return ''; }) });
66
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'callProvider').rejects(new Error('429 quota exceeded'));
67
+ const analysis = yield service.analyzeScreen(driver);
68
+ (0, chai_1.expect)(analysis.ai_insights).to.equal(null);
69
+ (0, chai_1.expect)(analysis.ai_insights_error).to.match(/429 quota exceeded/);
70
+ (0, chai_1.expect)(analysis.ocr.text).to.equal('Sign in');
71
+ }));
72
+ });
@@ -0,0 +1,161 @@
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 sharp_1 = __importDefault(require("sharp"));
19
+ const OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
20
+ const AIService_1 = require("../../src/services/AIService");
21
+ const HealingOrchestrator_1 = require("../../src/services/healing/HealingOrchestrator");
22
+ const types_1 = require("../../src/services/healing/types");
23
+ const screenScale_1 = require("../../src/services/omni-vision/screenScale");
24
+ /**
25
+ * OCR and the AI find a target in screenshot pixels. An iPhone's screenshot is
26
+ * 2x or 3x its screen in points, and XCUITest's W3C actions and element rects
27
+ * are in points, so a tap at the pixel position landed 2-3 times too far right
28
+ * and down. Nothing converted: not smartTap/visualTap, not the virtual
29
+ * elements the -custom:ai-* locators return, not the healing tiers' tap.
30
+ *
31
+ * Positions found in a screenshot are now converted to the driver's
32
+ * coordinates on iOS (screenshot size over window size). Android's W3C
33
+ * coordinates are the screenshot's pixels, so nothing changes there.
34
+ */
35
+ describe('Omni-Vision taps in points on iOS', () => {
36
+ // A 3x screen: 30 x 60 pixels for a 10 x 20 point window.
37
+ let png3x;
38
+ before(() => __awaiter(void 0, void 0, void 0, function* () {
39
+ const image = yield (0, sharp_1.default)({
40
+ create: { width: 30, height: 60, channels: 3, background: { r: 255, g: 255, b: 255 } },
41
+ })
42
+ .png()
43
+ .toBuffer();
44
+ png3x = image.toString('base64');
45
+ }));
46
+ // "OK" at pixels 12..18 x 30..36: its middle is (15, 33) px, (5, 11) pt.
47
+ const OK_WORD = { text: 'OK', confidence: 90, bbox: { x0: 12, y0: 30, x1: 18, y1: 36 } };
48
+ let service;
49
+ let ios;
50
+ let android;
51
+ function driverFor(platformName) {
52
+ return {
53
+ sessionId: `session-${platformName}`,
54
+ caps: { platformName },
55
+ getScreenshot: sinon_1.default.stub().resolves(png3x),
56
+ getWindowRect: sinon_1.default.stub().resolves({ x: 0, y: 0, width: 10, height: 20 }),
57
+ performActions: sinon_1.default.stub().resolves(),
58
+ releaseActions: sinon_1.default.stub().resolves(),
59
+ };
60
+ }
61
+ beforeEach(() => {
62
+ service = new OmniVisionService_1.OmniVisionService();
63
+ sinon_1.default.stub(service, 'performOcr').resolves({ text: 'OK', words: [OK_WORD] });
64
+ ios = driverFor('iOS');
65
+ android = driverFor('Android');
66
+ });
67
+ afterEach(() => sinon_1.default.restore());
68
+ const tappedAt = (driver) => {
69
+ const move = driver.performActions.firstCall.args[0][0].actions[0];
70
+ return { x: move.x, y: move.y };
71
+ };
72
+ it('smartTap by text taps in points on iOS', () => __awaiter(void 0, void 0, void 0, function* () {
73
+ const result = yield service.omniClickByText(ios, { text: 'OK' });
74
+ (0, chai_1.expect)(result.clicked, result.message).to.equal(true);
75
+ (0, chai_1.expect)(tappedAt(ios)).to.deep.equal({ x: 5, y: 11 });
76
+ (0, chai_1.expect)(result.target).to.deep.include({
77
+ x: 5,
78
+ y: 11,
79
+ rect: { x: 4, y: 10, width: 2, height: 2 },
80
+ });
81
+ }));
82
+ it('smartTap by text taps in pixels on Android, without asking for the window', () => __awaiter(void 0, void 0, void 0, function* () {
83
+ const result = yield service.omniClickByText(android, { text: 'OK' });
84
+ (0, chai_1.expect)(result.clicked, result.message).to.equal(true);
85
+ (0, chai_1.expect)(tappedAt(android)).to.deep.equal({ x: 15, y: 33 });
86
+ (0, chai_1.expect)(android.getWindowRect.called).to.equal(false);
87
+ }));
88
+ it('visualTap taps in points on iOS', () => __awaiter(void 0, void 0, void 0, function* () {
89
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'visualFind').resolves({ x: 15, y: 33 });
90
+ const result = yield service.omniClickByIcon(ios, { icon: 'the gear icon' });
91
+ (0, chai_1.expect)(result.clicked, result.message).to.equal(true);
92
+ (0, chai_1.expect)(tappedAt(ios)).to.deep.equal({ x: 5, y: 11 });
93
+ }));
94
+ it('gives the -custom:ai-text locator an element in points on iOS', () => __awaiter(void 0, void 0, void 0, function* () {
95
+ var _a;
96
+ const [found] = yield service.findByText(ios, 'OK');
97
+ (0, chai_1.expect)(found.rect).to.deep.equal({ x: 4, y: 10, width: 2, height: 2 });
98
+ (0, chai_1.expect)((_a = service.getVirtualElement(found.id)) === null || _a === void 0 ? void 0 : _a.rect).to.deep.equal(found.rect);
99
+ }));
100
+ it("doesn't tap on iOS when the window size can't be read", () => __awaiter(void 0, void 0, void 0, function* () {
101
+ ios.getWindowRect.rejects(new Error('WDA is not running'));
102
+ const err = yield service.omniClickByText(ios, { text: 'OK' }).then(() => null, (e) => e);
103
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message).to.match(/WDA is not running/);
104
+ (0, chai_1.expect)(ios.performActions.called).to.equal(false);
105
+ }));
106
+ it('reads the window from getWindowSize when there is no getWindowRect', () => __awaiter(void 0, void 0, void 0, function* () {
107
+ const driver = {
108
+ caps: { platformName: 'iOS' },
109
+ getWindowSize: () => __awaiter(void 0, void 0, void 0, function* () { return ({ width: 15, height: 30 }); }),
110
+ };
111
+ (0, chai_1.expect)(yield (0, screenScale_1.screenScaleOf)(driver, png3x)).to.deep.equal({ x: 2, y: 2 });
112
+ }));
113
+ it("keeps pixels for a driver that isn't iOS, or can't be placed", () => __awaiter(void 0, void 0, void 0, function* () {
114
+ (0, chai_1.expect)(yield (0, screenScale_1.screenScaleOf)({}, 'not an image')).to.deep.equal({ x: 1, y: 1 });
115
+ (0, chai_1.expect)(yield (0, screenScale_1.screenScaleOf)({ caps: { platformName: 'Android' } }, 'x')).to.deep.equal({
116
+ x: 1,
117
+ y: 1,
118
+ });
119
+ }));
120
+ it('converts the OCR and Visual AI healing tiers’ rect to points on iOS', () => __awaiter(void 0, void 0, void 0, function* () {
121
+ const orchestrator = new HealingOrchestrator_1.HealingOrchestrator({
122
+ getSignature: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
123
+ saveSignature: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }),
124
+ });
125
+ const ocr = orchestrator.providers.find((p) => p.tier === types_1.HealingTier.TIER_3_LOCAL_OCR);
126
+ sinon_1.default.stub(ocr, 'heal').resolves({
127
+ id: 'healed_ocr_1',
128
+ tier: types_1.HealingTier.TIER_3_LOCAL_OCR,
129
+ confidence: 0.9,
130
+ originalSelector: '//*[@label="OK"]',
131
+ recommendedSelector: 'ocr:text="OK"',
132
+ rect: { x: 12, y: 30, width: 6, height: 6 },
133
+ });
134
+ ios.getPageSource = sinon_1.default.stub().resolves('<AppiumAUT/>');
135
+ const healed = yield orchestrator.attemptHealing('s1', ios, 'xpath', '//*[@label="OK"]', [3]);
136
+ (0, chai_1.expect)(healed === null || healed === void 0 ? void 0 : healed.rect).to.deep.equal({ x: 4, y: 10, width: 2, height: 2 });
137
+ }));
138
+ it("drops a healed virtual element whose position on an iPhone can't be worked out", () => __awaiter(void 0, void 0, void 0, function* () {
139
+ const orchestrator = new HealingOrchestrator_1.HealingOrchestrator({
140
+ getSignature: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
141
+ saveSignature: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }),
142
+ });
143
+ const ocr = orchestrator.providers.find((p) => p.tier === types_1.HealingTier.TIER_3_LOCAL_OCR);
144
+ const found = {
145
+ tier: types_1.HealingTier.TIER_3_LOCAL_OCR,
146
+ confidence: 0.9,
147
+ originalSelector: '//*[@label="OK"]',
148
+ recommendedSelector: 'ocr:text="OK"',
149
+ rect: { x: 12, y: 30, width: 6, height: 6 },
150
+ };
151
+ const heal = sinon_1.default.stub(ocr, 'heal').resolves(Object.assign(Object.assign({}, found), { id: 'healed_ocr_1' }));
152
+ ios.getPageSource = sinon_1.default.stub().resolves('<AppiumAUT/>');
153
+ ios.getWindowRect.rejects(new Error('WDA is not running'));
154
+ (0, chai_1.expect)(yield orchestrator.attemptHealing('s1', ios, 'xpath', '//x', [3])).to.equal(null);
155
+ // A real element the tier resolved is kept, without the rect.
156
+ heal.resolves(Object.assign(Object.assign({}, found), { id: 'real-element-1' }));
157
+ const healed = yield orchestrator.attemptHealing('s1', ios, 'xpath', '//x', [3]);
158
+ (0, chai_1.expect)(healed === null || healed === void 0 ? void 0 : healed.id).to.equal('real-element-1');
159
+ (0, chai_1.expect)(healed === null || healed === void 0 ? void 0 : healed.rect).to.equal(undefined);
160
+ }));
161
+ });
@@ -0,0 +1,129 @@
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 OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
19
+ const AIService_1 = require("../../src/services/AIService");
20
+ /**
21
+ * `xenon: smartTap { text }` (and `omniClick`) compared the text with one OCR
22
+ * word at a time, so any text with a space in it ("Sign in", "Forgot
23
+ * password?") never matched: the answer was `clicked: false`. The
24
+ * `-custom:ai-text` locator had the same rule. Now the text is looked for
25
+ * across neighbouring words on one line, and the tap goes to the middle of
26
+ * the words it covers.
27
+ */
28
+ describe('Omni-Vision text taps match phrases', () => {
29
+ // Tesseract's words, in screenshot pixels. One line holds "Sign in" and,
30
+ // far to the right, "Help"; "Sign" and "in" also appear on two lines.
31
+ const word = (text, x0, y0, x1, y1) => ({
32
+ text,
33
+ confidence: 90,
34
+ bbox: { x0, y0, x1, y1 },
35
+ });
36
+ const WORDS = [
37
+ word('Sign', 100, 200, 150, 224),
38
+ word('in', 158, 200, 180, 224),
39
+ word('Help', 600, 200, 650, 224),
40
+ word('Forgot', 100, 300, 170, 324),
41
+ word('password?', 178, 300, 290, 324),
42
+ word('Sign', 100, 400, 150, 424),
43
+ word('in', 100, 440, 120, 464),
44
+ ];
45
+ let service;
46
+ let driver;
47
+ beforeEach(() => {
48
+ service = new OmniVisionService_1.OmniVisionService();
49
+ sinon_1.default.stub(service, 'performOcr').resolves({ text: '', words: WORDS });
50
+ driver = {
51
+ sessionId: 'session-1',
52
+ // An Android driver: its W3C coordinates are the screenshot's pixels.
53
+ caps: { platformName: 'Android' },
54
+ getScreenshot: sinon_1.default.stub().resolves('aGVsbG8='),
55
+ performActions: sinon_1.default.stub().resolves(),
56
+ releaseActions: sinon_1.default.stub().resolves(),
57
+ };
58
+ });
59
+ afterEach(() => sinon_1.default.restore());
60
+ const tappedAt = () => {
61
+ const move = driver.performActions.firstCall.args[0][0].actions[0];
62
+ return { x: move.x, y: move.y };
63
+ };
64
+ it('taps the middle of a two-word text', () => __awaiter(void 0, void 0, void 0, function* () {
65
+ const result = yield service.omniClickByText(driver, { text: 'Sign in' });
66
+ (0, chai_1.expect)(result.clicked, result.message).to.equal(true);
67
+ (0, chai_1.expect)(result.target).to.deep.include({
68
+ text: 'Sign in',
69
+ x: 140,
70
+ y: 212,
71
+ rect: { x: 100, y: 200, width: 80, height: 24 },
72
+ });
73
+ (0, chai_1.expect)(tappedAt()).to.deep.equal({ x: 140, y: 212 });
74
+ }));
75
+ it('matches a phrase whatever its case, and part of a word at either end', () => __awaiter(void 0, void 0, void 0, function* () {
76
+ var _a;
77
+ const result = yield service.omniClickByText(driver, { text: 'forgot PASSWORD' });
78
+ (0, chai_1.expect)(result.clicked, result.message).to.equal(true);
79
+ (0, chai_1.expect)((_a = result.target) === null || _a === void 0 ? void 0 : _a.text).to.equal('Forgot password?');
80
+ (0, chai_1.expect)(tappedAt()).to.deep.equal({ x: 195, y: 312 });
81
+ }));
82
+ it('does not join words on different lines, or far apart on one line', () => __awaiter(void 0, void 0, void 0, function* () {
83
+ for (const text of ['in Help', 'Sign in Help', 'password? Sign']) {
84
+ const result = yield service.omniClickByText(driver, { text });
85
+ (0, chai_1.expect)(result.clicked, text).to.equal(false);
86
+ }
87
+ (0, chai_1.expect)(driver.performActions.called).to.equal(false);
88
+ }));
89
+ it('still matches a single word, as before', () => __awaiter(void 0, void 0, void 0, function* () {
90
+ const result = yield service.omniClickByText(driver, { text: 'help' });
91
+ (0, chai_1.expect)(result.clicked, result.message).to.equal(true);
92
+ (0, chai_1.expect)(tappedAt()).to.deep.equal({ x: 625, y: 212 });
93
+ }));
94
+ it('finds a phrase with the -custom:ai-text locator too', () => __awaiter(void 0, void 0, void 0, function* () {
95
+ const found = yield service.findByText(driver, 'Sign in');
96
+ (0, chai_1.expect)(found).to.have.lengthOf(1);
97
+ (0, chai_1.expect)(found[0].text).to.equal('Sign in');
98
+ (0, chai_1.expect)(found[0].rect).to.deep.equal({ x: 100, y: 200, width: 80, height: 24 });
99
+ }));
100
+ });
101
+ /**
102
+ * `xenon: smartTap { icon }` / `visualTap` answered `clicked: false`, "No visual
103
+ * match found", when it never looked: no AI provider, or the AI call failed.
104
+ * It now fails with the reason; `clicked: false` means the AI looked and found
105
+ * nothing.
106
+ */
107
+ describe('Omni-Vision icon taps that cannot look', () => {
108
+ const driver = {
109
+ sessionId: 'session-1',
110
+ caps: { platformName: 'Android' },
111
+ getScreenshot: () => __awaiter(void 0, void 0, void 0, function* () { return 'aGVsbG8='; }),
112
+ performActions: sinon_1.default.stub().resolves(),
113
+ };
114
+ beforeEach(() => {
115
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'initializeProvider');
116
+ });
117
+ afterEach(() => sinon_1.default.restore());
118
+ it('fails when no AI provider is configured', () => __awaiter(void 0, void 0, void 0, function* () {
119
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value(null);
120
+ const err = yield new OmniVisionService_1.OmniVisionService().omniClickByIcon(driver, { icon: 'the gear' }).then(() => null, (e) => e);
121
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message).to.match(/No AI provider is configured/);
122
+ }));
123
+ it('answers clicked: false when the AI looked and found nothing', () => __awaiter(void 0, void 0, void 0, function* () {
124
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'visualFind').resolves(null);
125
+ const result = yield new OmniVisionService_1.OmniVisionService().omniClickByIcon(driver, { icon: 'the gear' });
126
+ (0, chai_1.expect)(result.clicked).to.equal(false);
127
+ (0, chai_1.expect)(result.message).to.match(/No visual match/);
128
+ }));
129
+ });
@@ -95,7 +95,7 @@ describe('OmniVisionService Unit Tests', () => {
95
95
  terminate: sinon_1.default.stub().resolves(),
96
96
  };
97
97
  sinon_1.default.stub(tesseract_js_1.default, 'createWorker').resolves(mockWorker);
98
- sinon_1.default.stub(AIService_1.AI_SERVICE, 'analyzeFailure').resolves('AI Insights text');
98
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'describeScreen').resolves('AI Insights text');
99
99
  const analysis = yield omniService.analyzeScreen(mockDriver);
100
100
  (0, chai_1.expect)(analysis.ocr.text).to.equal('Screen Text');
101
101
  (0, chai_1.expect)(analysis.ai_insights).to.equal('AI Insights text');