@xenon-device-management/xenon 2.13.1 → 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 (223) hide show
  1. package/README.md +11 -5
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-Cvpkk4r9.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-Bhn5ZtVM.js → ApiKeyGate-BwuaMjkX.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-BA6aEjTC.js → BugReportButton-BAt0_pzq.js} +1 -1
  6. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
  7. package/lib/public/assets/{EmptyState-CgzbuUvO.js → EmptyState-BSjhk_IV.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BHc_5qNc.js → FieldGroup-Cb5QZsE1.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-D7OMGuRc.js → FilterMenu-BAaSlWbW.js} +1 -1
  10. package/lib/public/assets/{Menu-C173JJ0l.js → Menu-JGGYrSQQ.js} +1 -1
  11. package/lib/public/assets/{Modal-ChvVUI3B.js → Modal-CFCp3n9d.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-Dhw4d_O6.js → RecordingPage-CbJj_EBT.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-z97ftGK-.js → RecordingsPage-BJeYnygR.js} +2 -2
  14. package/lib/public/assets/{SegmentedControl-DbXhBGZC.js → SegmentedControl-Bg42Atjy.js} +1 -1
  15. package/lib/public/assets/{SettingCard-C908GgiI.js → SettingCard-Njz1pUIJ.js} +1 -1
  16. package/lib/public/assets/{Table-B1DB_ZN-.js → Table-BcTm5xV2.js} +1 -1
  17. package/lib/public/assets/{activity-Css0xNvp.js → activity-DOVdsfMQ.js} +1 -1
  18. package/lib/public/assets/{ai-settings-BMQK3-yN.js → ai-settings-CsuJroRf.js} +2 -2
  19. package/lib/public/assets/{api-keys-CbJ42eDQ.js → api-keys-BKnNSTw9.js} +1 -1
  20. package/lib/public/assets/{apps-DW1pw2re.js → apps-D0SC6uw6.js} +2 -2
  21. package/lib/public/assets/{arrow-left-Dpog9L0F.js → arrow-left-MMbTmgoE.js} +1 -1
  22. package/lib/public/assets/{arrow-right-CsBIlSIw.js → arrow-right-BMfnGmxW.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-z1tRQE4R.js → arrow-up-right-DuAftMWB.js} +1 -1
  24. package/lib/public/assets/{auth-shell-mXiX5vNk.js → auth-shell-nZC2JNDO.js} +2 -2
  25. package/lib/public/assets/{builds-page-BcFn23bE.js → builds-page-hDCgTwkS.js} +1 -1
  26. package/lib/public/assets/{button-Cq_zHETv.js → button-Dda4irn_.js} +1 -1
  27. package/lib/public/assets/{calendar-CKxABX9q.js → calendar-BMr0Q62v.js} +1 -1
  28. package/lib/public/assets/{check-C7xu-hGU.js → check-CZ9iw9-e.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BiqfdCGA.js → chevron-right-BRYJJVC9.js} +1 -1
  30. package/lib/public/assets/{circle-check-CyBjjPxR.js → circle-check-CqAJjQ5O.js} +1 -1
  31. package/lib/public/assets/{circle-x-6gMRcx6z.js → circle-x-DlDJtnVz.js} +1 -1
  32. package/lib/public/assets/{clock-U1rDTT1h.js → clock-CpuDKysU.js} +1 -1
  33. package/lib/public/assets/{copy-BIM0ZX0L.js → copy-CAQydNFe.js} +1 -1
  34. package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
  35. package/lib/public/assets/{download-DDBRXnAZ.js → download-C9XG6lLv.js} +1 -1
  36. package/lib/public/assets/{forgot-password-Dpg7yxM8.js → forgot-password-CGnkRgE9.js} +1 -1
  37. package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
  38. package/lib/public/assets/{index-BGBqohfQ.js → index-BbKEXUFH.js} +5 -5
  39. package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
  40. package/lib/public/assets/{index-BQ2luT7L.js → index-DJGc1M2f.js} +1 -1
  41. package/lib/public/assets/{input-BvB1cjHE.js → input-Cm0tmSoc.js} +1 -1
  42. package/lib/public/assets/{line-chart-m78VggnW.js → line-chart-D2DyJCml.js} +1 -1
  43. package/lib/public/assets/{list-checks-cY7o1QTL.js → list-checks-UWf1GY99.js} +1 -1
  44. package/lib/public/assets/{lock-loz8txQm.js → lock-CCJv9m_I.js} +1 -1
  45. package/lib/public/assets/{login-BYOvz4lx.js → login-f8vOVW-U.js} +1 -1
  46. package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
  47. package/lib/public/assets/{monitor-C4HPHb6s.js → monitor-B2jDgwm7.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-BkdKtCzi.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
  49. package/lib/public/assets/{network-C5e0MIWo.js → network-ebT86qoi.js} +1 -1
  50. package/lib/public/assets/{overview-tlu8ijGd.js → overview-C0aykVrd.js} +1 -1
  51. package/lib/public/assets/{page-header-FXPlo6az.js → page-header-BjzgWz-T.js} +1 -1
  52. package/lib/public/assets/{play-CWdk0_Sy.js → play-DLG9t8FU.js} +1 -1
  53. package/lib/public/assets/{plus-B2xCnntV.js → plus-nBFSMu_X.js} +1 -1
  54. package/lib/public/assets/{profile-page-D080qzr4.js → profile-page-CSl0UYlv.js} +1 -1
  55. package/lib/public/assets/{reset-password-D3u4Mbvn.js → reset-password-LeOuHqmB.js} +1 -1
  56. package/lib/public/assets/{runbook-page-hPRZMy7s.js → runbook-page-CMMeQml9.js} +1 -1
  57. package/lib/public/assets/{select-12RThAXc.js → select-88jHcl-Z.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-uQwvV7EQ.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-CThoxDFO.js → selector-health-page-B4rA1Xkv.js} +2 -2
  60. package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
  61. package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
  62. package/lib/public/assets/{stat-tile-B02WEte8.js → stat-tile-BV98e_Sv.js} +1 -1
  63. package/lib/public/assets/{tablet-Dj30VGZl.js → tablet-BiAsnFDj.js} +1 -1
  64. package/lib/public/assets/{teams-uL4fjTDR.js → teams-B4-jrr-1.js} +1 -1
  65. package/lib/public/assets/{trash-2-B54e-TfY.js → trash-2-C5RSuX0e.js} +1 -1
  66. package/lib/public/assets/{upload-B3k8WCSP.js → upload-BG7_rMXK.js} +1 -1
  67. package/lib/public/assets/{use-builds-data-BFn90NH3.js → use-builds-data-J-riQYyC.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-DL2TmWZ0.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
  69. package/lib/public/assets/{users-CqBuxMlX.js → users-CFba5TSy.js} +1 -1
  70. package/lib/public/assets/{users-DjQSucKb.js → users-Cp0beJm3.js} +1 -1
  71. package/lib/public/assets/{video-off-T0rlt0GA.js → video-off-DDsEISjV.js} +1 -1
  72. package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
  73. package/lib/public/assets/{zap-gBHqutZV.js → zap-l4VaXzq8.js} +1 -1
  74. package/lib/public/index.html +2 -2
  75. package/lib/schema.json +19 -46
  76. package/lib/src/XenonCapabilityManager.js +22 -5
  77. package/lib/src/app/openapi/control.yaml +71 -20
  78. package/lib/src/app/openapi/grid.yaml +23 -5
  79. package/lib/src/app/openapi/platform.yaml +136 -28
  80. package/lib/src/app/routers/control.js +30 -5
  81. package/lib/src/app/routers/dashboard.js +29 -1
  82. package/lib/src/app/routers/grid.js +32 -10
  83. package/lib/src/app/routers/streamStartConflict.js +3 -0
  84. package/lib/src/app/routers/streamType.js +7 -2
  85. package/lib/src/app/routers/webhook.js +8 -4
  86. package/lib/src/config.js +50 -5
  87. package/lib/src/dashboard/commands.js +185 -141
  88. package/lib/src/dashboard/event-manager.js +39 -6
  89. package/lib/src/data-service/appleFamily.js +43 -0
  90. package/lib/src/data-service/db.js +11 -0
  91. package/lib/src/data-service/device-service.js +46 -1
  92. package/lib/src/data-service/device-store.js +66 -3
  93. package/lib/src/data-service/deviceClaims.js +37 -0
  94. package/lib/src/data-service/deviceFieldOwners.js +25 -11
  95. package/lib/src/data-service/deviceSettings.js +79 -0
  96. package/lib/src/data-service/prisma-store.js +152 -9
  97. package/lib/src/data-service/web-config-service.js +43 -92
  98. package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
  99. package/lib/src/device-managers/ios/WDAClient.js +44 -43
  100. package/lib/src/device-managers/shellCommands.js +96 -0
  101. package/lib/src/device-utils.js +143 -37
  102. package/lib/src/generated/client/edge.js +17 -3
  103. package/lib/src/generated/client/index-browser.js +14 -0
  104. package/lib/src/generated/client/index.d.ts +2054 -379
  105. package/lib/src/generated/client/index.js +17 -3
  106. package/lib/src/generated/client/package.json +1 -1
  107. package/lib/src/generated/client/schema.prisma +35 -7
  108. package/lib/src/generated/client/wasm.js +14 -0
  109. package/lib/src/interceptors/CommandInterceptor.js +29 -8
  110. package/lib/src/interceptors/xenonScripts.js +55 -0
  111. package/lib/src/interfaces/IPluginArgs.js +1 -2
  112. package/lib/src/middleware/deviceAccessGuard.js +63 -9
  113. package/lib/src/plugin.js +15 -1
  114. package/lib/src/prisma.js +1 -1
  115. package/lib/src/scripts/database-check.js +124 -0
  116. package/lib/src/services/AIService.js +99 -0
  117. package/lib/src/services/CleanupService.js +6 -1
  118. package/lib/src/services/InterceptorService.js +70 -13
  119. package/lib/src/services/NetworkConditioningService.js +85 -88
  120. package/lib/src/services/NotificationService.js +36 -29
  121. package/lib/src/services/OrphanSweeper.js +5 -0
  122. package/lib/src/services/PortAllocator.js +5 -0
  123. package/lib/src/services/ServerManager.js +97 -8
  124. package/lib/src/services/SessionLifecycleService.js +63 -53
  125. package/lib/src/services/ShutdownCoordinator.js +33 -0
  126. package/lib/src/services/TeamService.js +20 -3
  127. package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
  128. package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
  129. package/lib/src/services/device-access/leaseHold.js +26 -0
  130. package/lib/src/services/device-access/ticketActorAccess.js +6 -0
  131. package/lib/src/services/healing/HealingOrchestrator.js +32 -1
  132. package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
  133. package/lib/src/services/interceptor/interceptorOptions.js +44 -0
  134. package/lib/src/services/lease/activeLeases.js +37 -0
  135. package/lib/src/services/network/AndroidRadios.js +71 -0
  136. package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
  137. package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
  138. package/lib/src/services/network/adbForPhone.js +60 -0
  139. package/lib/src/services/network/phoneNetworkLock.js +20 -0
  140. package/lib/src/services/network/xenonProxy.js +107 -0
  141. package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
  142. package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
  143. package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
  144. package/lib/src/services/omni-vision/screenScale.js +68 -0
  145. package/lib/src/services/recording/busy-precheck.js +24 -3
  146. package/lib/src/services/recording/concurrency-gate.js +12 -3
  147. package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
  148. package/lib/src/services/settings/labSettings.js +149 -0
  149. package/lib/src/services/webhookEvents.js +173 -0
  150. package/lib/test/helpers/fake-adb.js +58 -0
  151. package/lib/test/helpers/fake-device-table.js +63 -24
  152. package/lib/test/helpers/scratch-database.js +1 -0
  153. package/lib/test/unit/TeamService.test.js +3 -0
  154. package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
  155. package/lib/test/unit/apple-family.spec.js +36 -0
  156. package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
  157. package/lib/test/unit/cleanup-settings.spec.js +244 -0
  158. package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
  159. package/lib/test/unit/dashboard-commands.spec.js +249 -0
  160. package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
  161. package/lib/test/unit/database-check.spec.js +108 -0
  162. package/lib/test/unit/default-plugin-args.spec.js +33 -0
  163. package/lib/test/unit/device-access-lease.spec.js +270 -0
  164. package/lib/test/unit/device-offline-webhook.spec.js +81 -0
  165. package/lib/test/unit/device-setting-columns.spec.js +66 -0
  166. package/lib/test/unit/device-settings-loki.spec.js +87 -0
  167. package/lib/test/unit/device-settings-survive.spec.js +555 -0
  168. package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
  169. package/lib/test/unit/emulator-boot.spec.js +91 -0
  170. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
  171. package/lib/test/unit/hub-node-same-host.spec.js +5 -0
  172. package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
  173. package/lib/test/unit/json-logging-option.spec.js +74 -0
  174. package/lib/test/unit/lab-settings.spec.js +218 -0
  175. package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
  176. package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
  177. package/lib/test/unit/lease-held-phone.spec.js +196 -0
  178. package/lib/test/unit/max-sessions.spec.js +240 -0
  179. package/lib/test/unit/ocr-text-match.spec.js +44 -0
  180. package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
  181. package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
  182. package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
  183. package/lib/test/unit/omni-vision.spec.js +1 -1
  184. package/lib/test/unit/partial-config-file.spec.js +114 -0
  185. package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
  186. package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
  187. package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
  188. package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
  189. package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
  190. package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
  191. package/lib/test/unit/phone-network/session-start.spec.js +110 -0
  192. package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
  193. package/lib/test/unit/plugin.spec.js +3 -3
  194. package/lib/test/unit/recording-options.spec.js +181 -0
  195. package/lib/test/unit/schema-required-args.spec.js +21 -16
  196. package/lib/test/unit/self-healing-boot.spec.js +75 -0
  197. package/lib/test/unit/self-healing-setting.spec.js +272 -0
  198. package/lib/test/unit/session-failed-webhook.spec.js +170 -0
  199. package/lib/test/unit/shell-commands.spec.js +133 -0
  200. package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
  201. package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
  202. package/lib/test/unit/stream-status-type.spec.js +4 -0
  203. package/lib/test/unit/vision-assertion.spec.js +118 -0
  204. package/lib/test/unit/wda-client-shell.spec.js +133 -0
  205. package/lib/test/unit/web-config-service.spec.js +79 -0
  206. package/lib/test/unit/webhook-delivery.spec.js +24 -0
  207. package/lib/test/unit/webhook-payloads.spec.js +273 -0
  208. package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
  209. package/lib/tsconfig.tsbuildinfo +1 -1
  210. package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
  211. package/package.json +1 -1
  212. package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
  213. package/prisma/schema.prisma +35 -7
  214. package/schema.json +19 -46
  215. package/scripts/generate-types-from-schema.js +7 -2
  216. package/scripts/lib/always-present-plugin-args.js +42 -0
  217. package/lib/public/assets/DeviceMosaicView-ChhHnpFQ.js +0 -2
  218. package/lib/public/assets/device-explorer-DSpAA_t9.js +0 -279
  219. package/lib/public/assets/maintenance-settings-BxbA7SkB.js +0 -6
  220. package/lib/public/assets/recording-group-store-B75435xR.js +0 -26
  221. package/lib/public/assets/session-detail-page-Bc02YjMm.js +0 -4
  222. package/lib/public/assets/settings-C1VIluuT.js +0 -6
  223. package/lib/public/assets/webhook-settings-DAHgnJyZ.js +0 -1
@@ -46,6 +46,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
46
46
  };
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.AI_SERVICE = exports.AIService = void 0;
49
+ exports.parseVisualVerdict = parseVisualVerdict;
49
50
  const generative_ai_1 = require("@google/generative-ai");
50
51
  const openai_1 = __importDefault(require("openai"));
51
52
  const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
@@ -55,6 +56,41 @@ const fs = __importStar(require("fs"));
55
56
  const path = __importStar(require("path"));
56
57
  const config_1 = require("../config");
57
58
  const CircuitBreaker_1 = require("./CircuitBreaker");
59
+ /** What GeminiProvider answers, instead of throwing, when the provider is rate-limited (429). */
60
+ const RATE_LIMITED = 'CONNECTION_OK_RATE_LIMITED';
61
+ /**
62
+ * A provider's answer to the assertion prompt, `{ "result": true|false,
63
+ * "reason": "..." }`, read leniently (code fences, text around the JSON,
64
+ * `message` for `reason`, "true"/"false" as strings) but never guessed: null
65
+ * when it holds no true/false `result`.
66
+ */
67
+ function parseVisualVerdict(answer) {
68
+ const text = answer.replace(/```(?:json)?/gi, '').trim();
69
+ const start = text.indexOf('{');
70
+ const end = text.lastIndexOf('}');
71
+ if (start === -1 || end <= start)
72
+ return null;
73
+ let data;
74
+ try {
75
+ data = JSON.parse(text.slice(start, end + 1));
76
+ }
77
+ catch (_a) {
78
+ return null;
79
+ }
80
+ const raw = data === null || data === void 0 ? void 0 : data.result;
81
+ const result = raw === true || raw === 'true' ? true : raw === false || raw === 'false' ? false : null;
82
+ if (result === null)
83
+ return null;
84
+ const reason = typeof data.reason === 'string'
85
+ ? data.reason
86
+ : typeof data.message === 'string'
87
+ ? data.message
88
+ : '';
89
+ return {
90
+ result,
91
+ message: reason.trim() || (result ? 'The condition holds.' : 'The condition does not hold.'),
92
+ };
93
+ }
58
94
  class GeminiProvider {
59
95
  constructor(apiKey, modelName) {
60
96
  this.genAI = new generative_ai_1.GoogleGenerativeAI(apiKey.trim());
@@ -365,6 +401,69 @@ Fix: Add a pre-emptive check for the location permission dialog or use the \`aut
365
401
  }
366
402
  });
367
403
  }
404
+ /**
405
+ * Asks the provider whether `condition` holds on the screenshot
406
+ * (`xenon: assertVisualState`). Answers only with the provider's own
407
+ * verdict: with no provider, a failed call, a rate limit or an answer that
408
+ * isn't true or false it throws, saying the condition was not checked. A
409
+ * `false` there would pass a test asserting that something is absent.
410
+ */
411
+ assertVisual(screenshotBase64, condition) {
412
+ return __awaiter(this, void 0, void 0, function* () {
413
+ var _a;
414
+ this.initializeProvider();
415
+ const notChecked = 'so the condition was not checked';
416
+ if (!this.provider) {
417
+ throw new Error(`No AI provider is configured, ${notChecked}. Set XENON_AI_PROVIDER and the provider's key.`);
418
+ }
419
+ const prompt = `
420
+ You are checking a screenshot of a mobile app for an automated test.
421
+
422
+ Condition: ${JSON.stringify(condition)}
423
+
424
+ Is the condition true of what the screenshot shows? Judge only what is visible on the screen.
425
+ Answer with JSON only, exactly: {"result": true or false, "reason": "one sentence on what you see"}
426
+ `.trim();
427
+ let answer;
428
+ try {
429
+ answer = yield this.callProvider(prompt, screenshotBase64);
430
+ }
431
+ catch (err) {
432
+ throw new Error(`The AI provider failed, ${notChecked}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
433
+ }
434
+ if (answer === RATE_LIMITED) {
435
+ throw new Error(`The AI provider is rate-limited, ${notChecked}. Try again later.`);
436
+ }
437
+ const verdict = parseVisualVerdict(answer);
438
+ if (!verdict) {
439
+ const said = answer.replace(/\s+/g, ' ').trim().slice(0, 200);
440
+ throw new Error(`Xenon could not read the AI provider's answer as true or false, ${notChecked}. ` +
441
+ `It answered: ${JSON.stringify(said)}`);
442
+ }
443
+ return verdict;
444
+ });
445
+ }
446
+ /**
447
+ * A short description of the screen in the screenshot, for
448
+ * `xenon: analyzeScreen` and device control's Omni-Scan. Throws when there
449
+ * is no provider or the call fails, so the caller can say why there is none.
450
+ */
451
+ describeScreen(screenshotBase64) {
452
+ return __awaiter(this, void 0, void 0, function* () {
453
+ this.initializeProvider();
454
+ if (!this.provider)
455
+ throw new Error('No AI provider is configured');
456
+ const prompt = `
457
+ You are looking at a screenshot of a mobile app, for a software tester.
458
+ Describe the screen in a few short sentences: which screen it is, its main elements and their state (for example filled in, empty, selected or disabled), and anything unexpected, such as an error message, a system dialog, a crash or a loading indicator.
459
+ Describe only what is visible. Answer in plain text.
460
+ `.trim();
461
+ const answer = yield this.callProvider(prompt, screenshotBase64);
462
+ if (answer === RATE_LIMITED)
463
+ throw new Error('The AI provider is rate-limited');
464
+ return answer;
465
+ });
466
+ }
368
467
  /**
369
468
  * Heals a broken locator using deep LLM reasoning (Tier 5)
370
469
  */
@@ -25,6 +25,7 @@ const logger_1 = __importDefault(require("../logger"));
25
25
  const fs_1 = __importDefault(require("fs"));
26
26
  const path_1 = __importDefault(require("path"));
27
27
  const config_1 = require("../config");
28
+ const labSettings_1 = require("./settings/labSettings");
28
29
  const recordingCleanup_1 = require("./recording/recordingCleanup");
29
30
  /** Subdirectory holding composite (multi-device) outputs, keyed by group id. */
30
31
  const GROUPS_DIR = '_groups';
@@ -34,6 +35,8 @@ let CleanupService = class CleanupService {
34
35
  }
35
36
  /**
36
37
  * Orchestrates build + session + recording cleanup based on retention policy.
38
+ * The build retention window, the build cap and the asset purge are the ones
39
+ * saved on the dashboard's Maintenance page, else `pluginArgs`'.
37
40
  *
38
41
  * Phases:
39
42
  * 1. Purge old builds (by age AND count cap) together with their sessions.
@@ -44,7 +47,9 @@ let CleanupService = class CleanupService {
44
47
  */
45
48
  runCleanup(pluginArgs) {
46
49
  return __awaiter(this, void 0, void 0, function* () {
47
- const { buildCleanupDays = 30, buildCleanupMaxCount = 100, deleteBuildAssets = true, } = pluginArgs;
50
+ // What the Maintenance page saved wins over the startup options, and is
51
+ // read here, at each run, so a change needs no restart. See labSettings.ts.
52
+ const { buildCleanupDays, buildCleanupMaxCount, deleteBuildAssets } = yield (0, labSettings_1.loadEffectiveSettings)(pluginArgs);
48
53
  this.log.info(`Starting cleanup: Retention = ${buildCleanupDays} days, Max Builds = ${buildCleanupMaxCount}, Purge Assets = ${deleteBuildAssets}`);
49
54
  const expirationDate = new Date();
50
55
  expirationDate.setDate(expirationDate.getDate() - buildCleanupDays);
@@ -72,6 +72,10 @@ const SessionArchive_1 = require("./interceptor/SessionArchive");
72
72
  const PortAllocator_1 = require("./PortAllocator");
73
73
  const SocketServer_1 = require("./SocketServer");
74
74
  const SocketEvents_1 = require("../enums/SocketEvents");
75
+ const adbForPhone_1 = require("./network/adbForPhone");
76
+ const PhoneNetworkLedger_1 = require("./network/PhoneNetworkLedger");
77
+ const phoneNetworkLock_1 = require("./network/phoneNetworkLock");
78
+ const xenonProxy_1 = require("./network/xenonProxy");
75
79
  const DEFAULT_BUFFER_CAP = 1000;
76
80
  const BODY_SPILL_THRESHOLD = 1024 * 1024;
77
81
  let InterceptorService = class InterceptorService {
@@ -88,6 +92,10 @@ let InterceptorService = class InterceptorService {
88
92
  isActive(sessionId) {
89
93
  return this.states.has(sessionId);
90
94
  }
95
+ /** The sessions whose traffic is captured on this server. */
96
+ sessionIds() {
97
+ return [...this.states.keys()];
98
+ }
91
99
  start(sessionId, device, opts) {
92
100
  return __awaiter(this, void 0, void 0, function* () {
93
101
  var _a, _b, _c, _d;
@@ -143,7 +151,7 @@ let InterceptorService = class InterceptorService {
143
151
  catch (err) {
144
152
  this.logger.warn(`Cert install (${installMode}) failed for ${device.udid}: ${err.message}. HTTPS interception may not work.`);
145
153
  }
146
- yield adapter.setProxy(device.udid, host, port);
154
+ const previousProxy = yield this.pointPhoneAtProxy(sessionId, device.udid, host, port, routing.reverseEstablished);
147
155
  const state = {
148
156
  sessionId,
149
157
  device,
@@ -155,6 +163,7 @@ let InterceptorService = class InterceptorService {
155
163
  certInstalledFilename: certFilename,
156
164
  startedAt: Date.now(),
157
165
  reverseEstablished: routing.reverseEstablished,
166
+ previousProxy,
158
167
  };
159
168
  this.states.set(sessionId, state);
160
169
  this.emit({ type: 'session_started', sessionId, port, host }, device.udid);
@@ -167,12 +176,7 @@ let InterceptorService = class InterceptorService {
167
176
  if (!state)
168
177
  return;
169
178
  this.states.delete(sessionId);
170
- try {
171
- yield this.getAndroidAdapter().clearProxy(state.device.udid);
172
- }
173
- catch (err) {
174
- this.logger.warn(`Clear proxy failed for ${state.device.udid}: ${err.message}`);
175
- }
179
+ yield this.putPhoneProxyBack(state);
176
180
  if (state.reverseEstablished) {
177
181
  try {
178
182
  yield this.getAndroidAdapter().removeReverse(state.device.udid, state.port);
@@ -284,15 +288,68 @@ let InterceptorService = class InterceptorService {
284
288
  requests: state.buffer.list(),
285
289
  };
286
290
  }
291
+ /**
292
+ * Points the whole phone at the session's proxy, and returns the proxy the
293
+ * phone had (null: none) for stop() to put back. The change is written down
294
+ * before it is made, so a restart after a crash can undo it
295
+ * (PhoneNetworkLedger, PhoneNetworkRestore).
296
+ *
297
+ * A previous proxy that is a capture an earlier run left behind (this
298
+ * machine, a capture port, nothing answering) is not the phone's own: it
299
+ * counts as none, so the end of this session doesn't put a dead proxy back.
300
+ */
301
+ pointPhoneAtProxy(sessionId, udid, host, port, reverseEstablished) {
302
+ return __awaiter(this, void 0, void 0, function* () {
303
+ const adapter = this.getAndroidAdapter();
304
+ const setting = `${host}:${port}`;
305
+ return yield (0, phoneNetworkLock_1.withPhoneNetworkLock)(udid, () => __awaiter(this, void 0, void 0, function* () {
306
+ var _a;
307
+ let previous = null;
308
+ try {
309
+ const read = yield adapter.readProxy(udid);
310
+ const leftover = read === setting ||
311
+ (yield (0, xenonProxy_1.isDeadCaptureProxy)(read, typedi_1.Container.get(PortAllocator_1.PortAllocator).rangeOf('proxy')));
312
+ if (leftover) {
313
+ this.logger.info(`[${sessionId}] ${udid} had a proxy left by an earlier capture (${read})`);
314
+ }
315
+ previous = (0, xenonProxy_1.isUnsetProxy)(read) || leftover ? null : read;
316
+ }
317
+ catch (err) {
318
+ this.logger.warn(`Could not read the proxy of ${udid}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
319
+ }
320
+ yield typedi_1.Container.get(PhoneNetworkLedger_1.PhoneNetworkLedger).note(sessionId, udid, {
321
+ proxy: Object.assign({ set: setting, previous }, (reverseEstablished ? { reversePort: port } : {})),
322
+ });
323
+ yield adapter.setProxy(udid, host, port);
324
+ return previous;
325
+ }));
326
+ });
327
+ }
328
+ /**
329
+ * Puts back the proxy the phone had before the session. A phone that can't
330
+ * be reached keeps the change written down, and is put back at its next
331
+ * session or at this server's next start.
332
+ */
333
+ putPhoneProxyBack(state) {
334
+ return __awaiter(this, void 0, void 0, function* () {
335
+ const udid = state.device.udid;
336
+ yield (0, phoneNetworkLock_1.withPhoneNetworkLock)(udid, () => __awaiter(this, void 0, void 0, function* () {
337
+ var _a;
338
+ try {
339
+ yield this.getAndroidAdapter().restoreProxy(udid, state.previousProxy);
340
+ yield typedi_1.Container.get(PhoneNetworkLedger_1.PhoneNetworkLedger).settle(state.sessionId, 'proxy');
341
+ }
342
+ catch (err) {
343
+ this.logger.warn(`Could not put back the proxy of ${udid} after session ${state.sessionId}: ` +
344
+ `${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}. Xenon tries again at the phone's next session and when it restarts.`);
345
+ }
346
+ }));
347
+ });
348
+ }
287
349
  getAndroidAdapter() {
288
350
  if (this.androidAdapter)
289
351
  return this.androidAdapter;
290
- const provider = (udid) => __awaiter(this, void 0, void 0, function* () {
291
- const { default: AndroidDeviceManager } = yield Promise.resolve().then(() => __importStar(require('../device-managers/AndroidDeviceManager')));
292
- const mgr = typedi_1.Container.get(AndroidDeviceManager);
293
- return yield mgr.getAdbForDevice(udid);
294
- });
295
- this.androidAdapter = new AndroidProxyAdapter_1.AndroidProxyAdapter(provider);
352
+ this.androidAdapter = new AndroidProxyAdapter_1.AndroidProxyAdapter(adbForPhone_1.adbForPhone);
296
353
  return this.androidAdapter;
297
354
  }
298
355
  // Establishes how the device will reach the host-side MITM proxy.
@@ -1,43 +1,10 @@
1
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
2
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
20
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
21
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
22
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
23
7
  };
24
- var __importStar = (this && this.__importStar) || (function () {
25
- var ownKeys = function(o) {
26
- ownKeys = Object.getOwnPropertyNames || function (o) {
27
- var ar = [];
28
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
- return ar;
30
- };
31
- return ownKeys(o);
32
- };
33
- return function (mod) {
34
- if (mod && mod.__esModule) return mod;
35
- var result = {};
36
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
- __setModuleDefault(result, mod);
38
- return result;
39
- };
40
- })();
41
8
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
42
9
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
43
10
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -54,6 +21,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
54
21
  exports.NetworkConditioningService = void 0;
55
22
  const typedi_1 = require("typedi");
56
23
  const logger_1 = __importDefault(require("../logger"));
24
+ const adbForPhone_1 = require("./network/adbForPhone");
25
+ const AndroidRadios_1 = require("./network/AndroidRadios");
26
+ const PhoneNetworkLedger_1 = require("./network/PhoneNetworkLedger");
27
+ const phoneNetworkLock_1 = require("./network/phoneNetworkLock");
57
28
  const NETWORK_PROFILES = {
58
29
  Normal: null,
59
30
  '4G': { latencyMs: 20, downloadKbps: 15000, uploadKbps: 7500 },
@@ -62,26 +33,47 @@ const NETWORK_PROFILES = {
62
33
  Offline: { latencyMs: 0, downloadKbps: 0, uploadKbps: 0 },
63
34
  };
64
35
  /**
65
- * Service to manage network conditioning (Throttling/Latency)
36
+ * A session's network profile (`xe:network_profile`).
37
+ *
38
+ * Every profile's delay is added to each of the session's commands by the
39
+ * session gateway (getLatency). On an Android phone `Offline` also turns
40
+ * mobile data and Wi-Fi off, and `Normal` turns them on; 4G, 3G and Edge
41
+ * change nothing on the phone. An iPhone, real or simulated, gets the delay
42
+ * only: Xcode has no command to change a simulator's network (`xcrun simctl`
43
+ * has no `network` subcommand in Xcode 26), and a real iPhone's can't be
44
+ * changed from a Mac.
45
+ *
46
+ * What `Offline` changed is undone when the session ends, however it ends
47
+ * (PhoneNetworkRestore), and written down meanwhile (PhoneNetworkLedger) so a
48
+ * restart can undo it after a crash.
66
49
  */
67
50
  let NetworkConditioningService = class NetworkConditioningService {
68
51
  constructor() {
69
52
  this.log = logger_1.default.scope('NetworkConditioning');
70
53
  this.activeConditions = new Map();
54
+ this.radios = new AndroidRadios_1.AndroidRadios(adbForPhone_1.adbForPhone);
55
+ this.toldAboutIPhones = false;
71
56
  }
72
57
  /**
73
58
  * Applies a network profile to a session/device
74
59
  */
75
60
  applyProfile(sessionId, device, profile) {
76
61
  return __awaiter(this, void 0, void 0, function* () {
62
+ var _a;
77
63
  this.log.info(`Applying network profile '${profile}' to session ${sessionId} on device ${device.udid}`);
78
- this.activeConditions.set(sessionId, profile);
79
- // Platform specific optimizations
80
- if (device.platform === 'android') {
81
- yield this.applyAndroidConditioning(device.udid, profile);
64
+ const condition = { profile, device };
65
+ this.activeConditions.set(sessionId, condition);
66
+ if (((_a = device.platform) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'android') {
67
+ if (profile === 'Offline')
68
+ yield this.takeOffline(sessionId, condition);
69
+ else if (profile === 'Normal')
70
+ yield this.turnOnline(device.udid);
71
+ // 4G, 3G and Edge: the delay only.
82
72
  }
83
- else if (device.deviceType === 'simulator') {
84
- yield this.applyIOSSimulatorConditioning(device.udid, profile);
73
+ else if (!this.toldAboutIPhones) {
74
+ this.toldAboutIPhones = true;
75
+ this.log.info('Network profiles on iPhones and iOS simulators only delay each command: the ' +
76
+ "device's own network isn't changed, so Offline leaves it online.");
85
77
  }
86
78
  });
87
79
  }
@@ -89,70 +81,75 @@ let NetworkConditioningService = class NetworkConditioningService {
89
81
  * Gets the active profile for a session
90
82
  */
91
83
  getProfile(sessionId) {
92
- return this.activeConditions.get(sessionId);
84
+ var _a;
85
+ return (_a = this.activeConditions.get(sessionId)) === null || _a === void 0 ? void 0 : _a.profile;
86
+ }
87
+ /** Whether the session has a profile on this server. */
88
+ has(sessionId) {
89
+ return this.activeConditions.has(sessionId);
90
+ }
91
+ /** The sessions with a profile on this server. */
92
+ sessionIds() {
93
+ return [...this.activeConditions.keys()];
93
94
  }
94
95
  /**
95
- * Resets network conditions for a session
96
+ * Ends the session's profile and undoes what it changed on the phone, once:
97
+ * the entry is taken before anything is awaited, so a second call finds
98
+ * nothing to do. A phone that can't be reached keeps its record in the
99
+ * ledger, and is put back at its next session or this server's next start.
96
100
  */
97
- reset(sessionId, device) {
101
+ reset(sessionId) {
98
102
  return __awaiter(this, void 0, void 0, function* () {
99
- if (this.activeConditions.has(sessionId)) {
100
- this.log.info(`Resetting network conditions for session ${sessionId}`);
101
- this.activeConditions.delete(sessionId);
102
- if (device.platform === 'android') {
103
- yield this.applyAndroidConditioning(device.udid, 'Normal');
103
+ const condition = this.activeConditions.get(sessionId);
104
+ if (!condition)
105
+ return;
106
+ this.activeConditions.delete(sessionId);
107
+ this.log.info(`Resetting network conditions for session ${sessionId}`);
108
+ const prior = condition.offlinePrior;
109
+ if (!prior)
110
+ return;
111
+ const udid = condition.device.udid;
112
+ yield (0, phoneNetworkLock_1.withPhoneNetworkLock)(udid, () => __awaiter(this, void 0, void 0, function* () {
113
+ try {
114
+ yield this.radios.turnOn(udid, AndroidRadios_1.AndroidRadios.toRestore(prior));
115
+ yield typedi_1.Container.get(PhoneNetworkLedger_1.PhoneNetworkLedger).settle(sessionId, 'offline');
116
+ this.log.info(`📱 Android: network put back on ${udid} after session ${sessionId}`);
104
117
  }
105
- else if (device.deviceType === 'simulator') {
106
- yield this.applyIOSSimulatorConditioning(device.udid, 'Normal');
118
+ catch (e) {
119
+ this.log.warn(`⚠️ Could not put the network back on ${udid} after session ${sessionId}: ${e === null || e === void 0 ? void 0 : e.message}. ` +
120
+ "Xenon tries again at the phone's next session and when it restarts.");
107
121
  }
108
- }
122
+ }));
109
123
  });
110
124
  }
111
- applyAndroidConditioning(udid, profile) {
125
+ takeOffline(sessionId, condition) {
112
126
  return __awaiter(this, void 0, void 0, function* () {
127
+ const udid = condition.device.udid;
113
128
  try {
114
- const { exec } = yield Promise.resolve().then(() => __importStar(require('child_process')));
115
- const { promisify } = yield Promise.resolve().then(() => __importStar(require('util')));
116
- const execAsync = promisify(exec);
117
- if (profile === 'Offline') {
118
- this.log.info(`📱 Android: Disabling data for ${udid}`);
119
- yield execAsync(`adb -s ${udid} shell svc data disable`);
120
- yield execAsync(`adb -s ${udid} shell svc wifi disable`);
121
- }
122
- else if (profile === 'Normal') {
123
- this.log.info(`📱 Android: Enabling data for ${udid}`);
124
- yield execAsync(`adb -s ${udid} shell svc data enable`);
125
- yield execAsync(`adb -s ${udid} shell svc wifi enable`);
126
- }
127
- // Latency is handled by the proxy
129
+ yield (0, phoneNetworkLock_1.withPhoneNetworkLock)(udid, () => __awaiter(this, void 0, void 0, function* () {
130
+ const prior = yield this.radios.read(udid);
131
+ // Written down before the change: a crash after it must still find it.
132
+ yield typedi_1.Container.get(PhoneNetworkLedger_1.PhoneNetworkLedger).note(sessionId, udid, { offline: prior });
133
+ condition.offlinePrior = prior;
134
+ this.log.info(`📱 Android: turning mobile data and Wi-Fi off on ${udid}`);
135
+ yield this.radios.turnOff(udid);
136
+ }));
128
137
  }
129
138
  catch (e) {
130
- this.log.warn(`⚠️ Failed to apply Android conditioning for ${udid}: ${e.message}`);
139
+ this.log.warn(`⚠️ Failed to apply Android conditioning for ${udid}: ${e === null || e === void 0 ? void 0 : e.message}`);
131
140
  }
132
141
  });
133
142
  }
134
- applyIOSSimulatorConditioning(udid, profile) {
143
+ turnOnline(udid) {
135
144
  return __awaiter(this, void 0, void 0, function* () {
136
145
  try {
137
- const { exec } = yield Promise.resolve().then(() => __importStar(require('child_process')));
138
- const { promisify } = yield Promise.resolve().then(() => __importStar(require('util')));
139
- const execAsync = promisify(exec);
140
- // simctl network <device> <status>
141
- // Profiles: wifi, 3g, edge, etc. (depends on OS version)
142
- let status = 'none';
143
- if (profile === '3G')
144
- status = '3g';
145
- if (profile === 'Edge')
146
- status = 'edge';
147
- if (profile === 'Offline')
148
- status = 'off';
149
- if (profile === 'Normal')
150
- status = 'none';
151
- this.log.info(`🍎 iOS Sim: Setting network to ${status} for ${udid}`);
152
- yield execAsync(`xcrun simctl network ${udid} status ${status}`);
146
+ yield (0, phoneNetworkLock_1.withPhoneNetworkLock)(udid, () => __awaiter(this, void 0, void 0, function* () {
147
+ this.log.info(`📱 Android: turning mobile data and Wi-Fi on on ${udid}`);
148
+ yield this.radios.turnOn(udid, { data: true, wifi: true });
149
+ }));
153
150
  }
154
151
  catch (e) {
155
- this.log.warn(`⚠️ Failed to apply iOS Simulator conditioning for ${udid}: ${e.message}`);
152
+ this.log.warn(`⚠️ Failed to apply Android conditioning for ${udid}: ${e === null || e === void 0 ? void 0 : e.message}`);
156
153
  }
157
154
  });
158
155
  }
@@ -161,10 +158,10 @@ let NetworkConditioningService = class NetworkConditioningService {
161
158
  */
162
159
  getLatency(sessionId) {
163
160
  var _a;
164
- const profile = this.activeConditions.get(sessionId);
165
- if (!profile)
161
+ const condition = this.activeConditions.get(sessionId);
162
+ if (!condition)
166
163
  return 0;
167
- return ((_a = NETWORK_PROFILES[profile]) === null || _a === void 0 ? void 0 : _a.latencyMs) || 0;
164
+ return ((_a = NETWORK_PROFILES[condition.profile]) === null || _a === void 0 ? void 0 : _a.latencyMs) || 0;
168
165
  }
169
166
  };
170
167
  exports.NetworkConditioningService = NetworkConditioningService;
@@ -26,9 +26,14 @@ const axios_1 = __importDefault(require("axios"));
26
26
  const logger_1 = __importDefault(require("../logger"));
27
27
  const prisma_service_1 = require("../data-service/prisma-service");
28
28
  const typedi_1 = require("typedi");
29
+ const webhookEvents_1 = require("./webhookEvents");
30
+ /** How many sessions' failure notices are remembered, so a session ending twice sends once. */
31
+ const NOTIFIED_SESSIONS_KEPT = 2000;
29
32
  let NotificationService = class NotificationService {
30
33
  constructor(prisma) {
31
34
  this.prisma = prisma;
35
+ /** Sessions whose failure was sent, oldest first (a Set keeps insertion order). */
36
+ this.notifiedSessions = new Set();
32
37
  }
33
38
  getConfigs() {
34
39
  return __awaiter(this, void 0, void 0, function* () {
@@ -72,31 +77,41 @@ let NotificationService = class NotificationService {
72
77
  });
73
78
  }
74
79
  /**
75
- * Sends a sample event to a webhook the way a real one would be sent, and
76
- * fails if the delivery does: "Send test" used to say it worked whatever
77
- * happened, and ignored the webhook's type.
80
+ * Tells the webhooks subscribed to `session_failed` that a session failed.
81
+ *
82
+ * Once per session however many times it ends: a driver crash and the
83
+ * client's own delete both end it, as do a heartbeat timeout and a delete.
84
+ * Remembered by id in memory, so a restart forgets it, which is harmless:
85
+ * a session does not end again after one.
86
+ */
87
+ notifySessionFailed(session) {
88
+ return __awaiter(this, void 0, void 0, function* () {
89
+ if (this.notifiedSessions.has(session.id))
90
+ return;
91
+ this.notifiedSessions.add(session.id);
92
+ if (this.notifiedSessions.size > NOTIFIED_SESSIONS_KEPT) {
93
+ const oldest = this.notifiedSessions.values().next().value;
94
+ this.notifiedSessions.delete(oldest);
95
+ }
96
+ yield this.dispatchEvent('session_failed', (0, webhookEvents_1.sessionFailedPayload)(session));
97
+ });
98
+ }
99
+ /**
100
+ * Sends a sample of `event` to a webhook the way a real one would be sent,
101
+ * and fails if the delivery does: "Send test" used to say it worked
102
+ * whatever happened, and ignored the webhook's type and template.
78
103
  */
79
104
  sendTest(url_1) {
80
- return __awaiter(this, arguments, void 0, function* (url, type = 'slack', payloadTemplate) {
81
- yield this.sendToWebhook({ url, type, payloadTemplate: payloadTemplate !== null && payloadTemplate !== void 0 ? payloadTemplate : null }, 'device_new', { udid: 'test-device-udid', name: 'Test Device', host: '127.0.0.1' });
105
+ return __awaiter(this, arguments, void 0, function* (url, type = 'slack', payloadTemplate, event = 'device_new') {
106
+ yield this.sendToWebhook({ url, type, payloadTemplate: payloadTemplate !== null && payloadTemplate !== void 0 ? payloadTemplate : null }, event, webhookEvents_1.WEBHOOK_EVENTS[event].sample());
82
107
  });
83
108
  }
84
109
  /** Delivers one event. Throws when the webhook refuses or can't be reached. */
85
110
  sendToWebhook(config, eventType, payload) {
86
111
  return __awaiter(this, void 0, void 0, function* () {
87
- // Principal Logic: Use custom template if defined
112
+ // A custom template, if there is one, replaces the built-in message.
88
113
  if (config.payloadTemplate) {
89
- const substitutedBody = this.substituteTemplate(config.payloadTemplate, Object.assign({ eventType }, payload));
90
- // JSON if the template renders to JSON, otherwise sent as text. Only
91
- // the parse may fall back: one catch around both used to post again,
92
- // as text, when the JSON delivery itself failed.
93
- let body;
94
- try {
95
- body = JSON.parse(substitutedBody);
96
- }
97
- catch (_a) {
98
- body = { text: substitutedBody };
99
- }
114
+ const body = (0, webhookEvents_1.renderTemplate)(config.payloadTemplate, Object.assign({ eventType }, payload));
100
115
  yield axios_1.default.post(config.url, body);
101
116
  return;
102
117
  }
@@ -109,17 +124,6 @@ let NotificationService = class NotificationService {
109
124
  }
110
125
  });
111
126
  }
112
- // Principal Logic: Recursive variable substitution with {{key}} support
113
- substituteTemplate(template, data) {
114
- return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
115
- const keys = key.trim().split('.');
116
- let value = data;
117
- for (const k of keys) {
118
- value = value ? value[k] : undefined;
119
- }
120
- return value !== undefined ? String(value) : match;
121
- });
122
- }
123
127
  sendSlackMessage(url, eventType, payload) {
124
128
  return __awaiter(this, void 0, void 0, function* () {
125
129
  var _a, _b, _c;
@@ -131,7 +135,10 @@ let NotificationService = class NotificationService {
131
135
  color = '#ff0000';
132
136
  break;
133
137
  case 'session_failed':
134
- text = `❌ *Session Failed*: ${payload.sessionId}\nReason: ${payload.failureReason}`;
138
+ text =
139
+ `❌ *Session Failed*: ${payload.sessionId}\n` +
140
+ `Reason: ${payload.failureReason || 'no reason recorded'}\n` +
141
+ `Device: ${payload.deviceName || payload.udid} (${payload.udid})`;
135
142
  color = '#ff0000';
136
143
  break;
137
144
  case 'device_new':
@@ -25,6 +25,7 @@ const logger_1 = __importDefault(require("../logger"));
25
25
  const event_manager_1 = require("../dashboard/event-manager");
26
26
  const SessionStatus_1 = require("../types/SessionStatus");
27
27
  const device_service_1 = require("../data-service/device-service");
28
+ const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
28
29
  let OrphanSweeper = class OrphanSweeper {
29
30
  constructor() {
30
31
  this.log = logger_1.default.scope('OrphanSweeper');
@@ -69,6 +70,10 @@ let OrphanSweeper = class OrphanSweeper {
69
70
  endTime: new Date(),
70
71
  },
71
72
  });
73
+ // A session this process still drove has its phone's network put
74
+ // back before the phone is released; for a previous process's it is
75
+ // the ledger's, at boot (PhoneNetworkRestore).
76
+ yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreSession(s.id, 'heartbeat timeout');
72
77
  // This session's claim only, not every row with its udid: the phone
73
78
  // may be another session's by now (deviceClaims.ts).
74
79
  yield (0, device_service_1.releaseSessionDevices)(s.id);
@@ -55,6 +55,11 @@ let PortAllocator = class PortAllocator {
55
55
  configure(overrides) {
56
56
  this.ranges = Object.assign(Object.assign({}, DEFAULT_RANGES), overrides);
57
57
  }
58
+ /** The ports leased for `purpose`, first and last included. */
59
+ rangeOf(purpose) {
60
+ const [start, end] = this.ranges[purpose];
61
+ return [start, end];
62
+ }
58
63
  acquire(purpose_1, udid_1) {
59
64
  return __awaiter(this, arguments, void 0, function* (purpose, udid, opts = {}) {
60
65
  var _a;