@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
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const typedi_1 = require("typedi");
19
+ const plugin_1 = require("../../src/plugin");
20
+ const ServerManager_1 = require("../../src/services/ServerManager");
21
+ const PluginContext_1 = require("../../src/PluginContext");
22
+ const SelfHealingSwitch_1 = require("../../src/services/settings/SelfHealingSwitch");
23
+ const logger_1 = __importDefault(require("../../src/logger"));
24
+ /**
25
+ * The self-healing value saved on the Settings page is read once at boot, so a
26
+ * restart doesn't put the server back on the option it was started with. It
27
+ * has to be loaded once the database is ready (migrations ran) and before the
28
+ * first route can answer or the first command arrive.
29
+ */
30
+ describe('loading the saved self-healing setting, at boot', () => {
31
+ let statics;
32
+ beforeEach(() => {
33
+ statics = {
34
+ nodeId: plugin_1.XenonPlugin.NODE_ID,
35
+ port: plugin_1.XenonPlugin.port,
36
+ basePath: plugin_1.XenonPlugin.nodeBasePath,
37
+ };
38
+ });
39
+ afterEach(() => {
40
+ sinon_1.default.restore();
41
+ plugin_1.XenonPlugin.NODE_ID = statics.nodeId;
42
+ plugin_1.XenonPlugin.port = statics.port;
43
+ plugin_1.XenonPlugin.nodeBasePath = statics.basePath;
44
+ });
45
+ it('runs once the database is ready, before routes are registered', () => __awaiter(void 0, void 0, void 0, function* () {
46
+ const steps = [];
47
+ const step = (name) => () => {
48
+ steps.push(name);
49
+ return Promise.resolve();
50
+ };
51
+ const boot = ServerManager_1.ServerManager.prototype;
52
+ sinon_1.default.stub(boot, 'resolvePluginArgs').resolves({});
53
+ sinon_1.default.stub(logger_1.default, 'banner');
54
+ sinon_1.default.stub(PluginContext_1.PluginContext.prototype, 'setContext');
55
+ sinon_1.default.stub(boot, 'syncDatabaseAndAIConfig').callsFake(step('database config'));
56
+ sinon_1.default.stub(boot, 'initializeCoreSubsystems').callsFake(step('database and migrations'));
57
+ // The real one kills every go-ios process on the machine.
58
+ sinon_1.default.stub(boot, 'reapLeftoverGoIos').callsFake(step('reap'));
59
+ sinon_1.default.stub(SelfHealingSwitch_1.SelfHealingSwitch.prototype, 'load').callsFake(step('self-healing setting'));
60
+ sinon_1.default.stub(boot, 'registerRoutes').callsFake(() => {
61
+ steps.push('routes');
62
+ throw new Error('boot stopped by the test');
63
+ });
64
+ yield typedi_1.Container.get(ServerManager_1.ServerManager)
65
+ .updateServer({}, null, { port: 4723 })
66
+ .then(() => chai_1.expect.fail('the stubbed routes step should have stopped the boot'), (err) => (0, chai_1.expect)(err.message).to.equal('boot stopped by the test'));
67
+ (0, chai_1.expect)(steps).to.deep.equal([
68
+ 'database config',
69
+ 'database and migrations',
70
+ 'reap',
71
+ 'self-healing setting',
72
+ 'routes',
73
+ ]);
74
+ }));
75
+ });
@@ -0,0 +1,272 @@
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 express_1 = __importDefault(require("express"));
18
+ const sinon_1 = __importDefault(require("sinon"));
19
+ const typedi_1 = require("typedi");
20
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
21
+ const dashboard_1 = __importDefault(require("../../src/app/routers/dashboard"));
22
+ const PluginContext_1 = require("../../src/PluginContext");
23
+ const CommandInterceptor_1 = require("../../src/interceptors/CommandInterceptor");
24
+ const HealingOrchestrator_1 = require("../../src/services/healing/HealingOrchestrator");
25
+ const SelfHealingSwitch_1 = require("../../src/services/settings/SelfHealingSwitch");
26
+ const web_config_service_1 = require("../../src/data-service/web-config-service");
27
+ const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
28
+ const event_manager_1 = require("../../src/dashboard/event-manager");
29
+ const SessionManager_1 = require("../../src/sessions/SessionManager");
30
+ const logger_1 = __importDefault(require("../../src/logger"));
31
+ const container_registration_1 = require("../helpers/container-registration");
32
+ const scratch_database_1 = require("../helpers/scratch-database");
33
+ /**
34
+ * The Settings page's "AI self-healing" toggle used to do nothing: POST /config
35
+ * stored only the fields its table knew, GET /config never sent the switch back
36
+ * (so the page always showed Enabled), and CommandInterceptor read the copy of
37
+ * the plugin options the server started with, which nothing updates.
38
+ *
39
+ * It follows the other lab settings now: a value saved in the dashboard wins
40
+ * over the startup option, which wins over the default, and applies to the very
41
+ * next command without a restart. The interceptor runs on every command, so it
42
+ * reads an in-memory value (SelfHealingSwitch), never the database.
43
+ */
44
+ describe('the AI self-healing switch', () => {
45
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
46
+ let context;
47
+ let startedWith;
48
+ let restoreContainer;
49
+ let healingSwitch;
50
+ const startup = (over = {}) => {
51
+ context.pluginArgs = Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), over);
52
+ return context.pluginArgs;
53
+ };
54
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
55
+ context = typedi_1.Container.get(PluginContext_1.PluginContext);
56
+ startedWith = context.pluginArgs;
57
+ restoreContainer = (0, container_registration_1.saveRegistrations)(HealingOrchestrator_1.HealingOrchestrator, SelfHealingSwitch_1.SelfHealingSwitch);
58
+ // A switch of its own, as at boot, so no saved value leaks in from another spec.
59
+ typedi_1.Container.set(SelfHealingSwitch_1.SelfHealingSwitch, new SelfHealingSwitch_1.SelfHealingSwitch());
60
+ healingSwitch = typedi_1.Container.get(SelfHealingSwitch_1.SelfHealingSwitch);
61
+ yield scratch.db.webConfig.deleteMany({});
62
+ }));
63
+ afterEach(() => {
64
+ context.pluginArgs = startedWith;
65
+ restoreContainer();
66
+ sinon_1.default.restore();
67
+ });
68
+ function app() {
69
+ const a = (0, express_1.default)();
70
+ a.use(express_1.default.json());
71
+ a.use((req, _res, next) => {
72
+ req.auth = {
73
+ kind: 'user-session',
74
+ userId: 'usr_x',
75
+ role: 'SUPER_ADMIN',
76
+ scopes: 'admin,devices,sessions,read',
77
+ rateLimit: 1000,
78
+ };
79
+ next();
80
+ });
81
+ dashboard_1.default.register(a);
82
+ return a;
83
+ }
84
+ const save = (body) => (0, loopbackRequest_1.default)(app()).post('/config').send(body).timeout(5000);
85
+ const read = () => (0, loopbackRequest_1.default)(app()).get('/config').timeout(5000);
86
+ describe('as the Settings page saves and reads it', () => {
87
+ it('is kept: what POST /config saves, GET /config sends back', () => __awaiter(void 0, void 0, void 0, function* () {
88
+ startup();
89
+ const saved = yield save({ enableSelfHealing: false });
90
+ (0, chai_1.expect)(saved.status).to.equal(200);
91
+ const shown = yield read();
92
+ (0, chai_1.expect)(shown.status).to.equal(200);
93
+ (0, chai_1.expect)(shown.body.enableSelfHealing).to.equal(false);
94
+ (0, chai_1.expect)(yield typedi_1.Container.get(web_config_service_1.WebConfigService).getConfig()).to.deep.equal({
95
+ enableSelfHealing: false,
96
+ });
97
+ }));
98
+ it('can be turned back on', () => __awaiter(void 0, void 0, void 0, function* () {
99
+ startup();
100
+ yield save({ enableSelfHealing: false });
101
+ yield save({ enableSelfHealing: true });
102
+ (0, chai_1.expect)((yield read()).body.enableSelfHealing).to.equal(true);
103
+ }));
104
+ it('is on, and says so, when nothing was saved and the server was started with nothing', () => __awaiter(void 0, void 0, void 0, function* () {
105
+ startup();
106
+ const shown = yield read();
107
+ (0, chai_1.expect)(shown.body.enableSelfHealing).to.equal(true);
108
+ (0, chai_1.expect)(shown.body.defaults.enableSelfHealing).to.equal(true);
109
+ }));
110
+ it('shows the option the server started with when nothing was saved', () => __awaiter(void 0, void 0, void 0, function* () {
111
+ startup({ enableSelfHealing: false });
112
+ (0, chai_1.expect)((yield read()).body.enableSelfHealing).to.equal(false);
113
+ }));
114
+ it('shows a saved value over the option the server started with', () => __awaiter(void 0, void 0, void 0, function* () {
115
+ startup({ enableSelfHealing: false });
116
+ yield save({ enableSelfHealing: true });
117
+ (0, chai_1.expect)((yield read()).body.enableSelfHealing).to.equal(true);
118
+ }));
119
+ it('is saved on its own, without touching the other settings', () => __awaiter(void 0, void 0, void 0, function* () {
120
+ startup();
121
+ yield save({ buildCleanupDays: 7 });
122
+ yield save({ enableSelfHealing: false });
123
+ (0, chai_1.expect)(yield typedi_1.Container.get(web_config_service_1.WebConfigService).getConfig()).to.deep.equal({
124
+ buildCleanupDays: 7,
125
+ enableSelfHealing: false,
126
+ });
127
+ }));
128
+ for (const bad of ['false', 'no', 0, 1, null, {}, []]) {
129
+ it(`refuses ${JSON.stringify(bad)} with a 400 naming the setting, and saves nothing`, () => __awaiter(void 0, void 0, void 0, function* () {
130
+ startup();
131
+ const refused = yield save({ enableSelfHealing: bad, buildCleanupDays: 9 });
132
+ (0, chai_1.expect)(refused.status).to.equal(400);
133
+ (0, chai_1.expect)(refused.body).to.include({ error: 'invalid_setting', field: 'enableSelfHealing' });
134
+ (0, chai_1.expect)(yield typedi_1.Container.get(web_config_service_1.WebConfigService).getConfig()).to.deep.equal({});
135
+ }));
136
+ }
137
+ });
138
+ describe('on a running interceptor', () => {
139
+ const noSuchElement = () => Object.assign(new Error('NoSuchElement: An element could not be located'), {
140
+ name: 'NoSuchElementError',
141
+ });
142
+ let attemptHealing;
143
+ let pluginArgs;
144
+ beforeEach(() => {
145
+ pluginArgs = startup();
146
+ attemptHealing = sinon_1.default.stub().resolves(null);
147
+ typedi_1.Container.set(HealingOrchestrator_1.HealingOrchestrator, { attemptHealing });
148
+ });
149
+ /** A findElement the driver answers with NoSuchElement, as a broken selector does. */
150
+ function findMissing(sessionId) {
151
+ return __awaiter(this, void 0, void 0, function* () {
152
+ let thrown;
153
+ try {
154
+ yield typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor).handle(() => __awaiter(this, void 0, void 0, function* () {
155
+ throw noSuchElement();
156
+ }), { sessionId }, 'findElement', ['xpath', '//android.widget.Button[@text="Gone"]', sessionId], pluginArgs, false);
157
+ }
158
+ catch (err) {
159
+ thrown = err;
160
+ }
161
+ return thrown;
162
+ });
163
+ }
164
+ it('heals a missing element while the switch is on, as it always did', () => __awaiter(void 0, void 0, void 0, function* () {
165
+ const thrown = yield findMissing('sess-on');
166
+ (0, chai_1.expect)(attemptHealing.calledOnce).to.equal(true);
167
+ // Nothing healed it, so the test still sees its own NoSuchElement.
168
+ (0, chai_1.expect)(thrown === null || thrown === void 0 ? void 0 : thrown.name).to.equal('NoSuchElementError');
169
+ }));
170
+ it('stops healing for the next command once off is saved, with no restart', () => __awaiter(void 0, void 0, void 0, function* () {
171
+ yield findMissing('sess-1');
172
+ (0, chai_1.expect)(attemptHealing.callCount).to.equal(1);
173
+ (0, chai_1.expect)((yield save({ enableSelfHealing: false })).status).to.equal(200);
174
+ const thrown = yield findMissing('sess-1');
175
+ (0, chai_1.expect)(attemptHealing.callCount).to.equal(1);
176
+ // The failure reaches the test untouched, which is the point of turning it off.
177
+ (0, chai_1.expect)(thrown === null || thrown === void 0 ? void 0 : thrown.name).to.equal('NoSuchElementError');
178
+ }));
179
+ it('heals again once on is saved after off', () => __awaiter(void 0, void 0, void 0, function* () {
180
+ yield save({ enableSelfHealing: false });
181
+ yield findMissing('sess-2');
182
+ (0, chai_1.expect)(attemptHealing.called).to.equal(false);
183
+ yield save({ enableSelfHealing: true });
184
+ yield findMissing('sess-2');
185
+ (0, chai_1.expect)(attemptHealing.calledOnce).to.equal(true);
186
+ }));
187
+ it('does not read the database for a command', () => __awaiter(void 0, void 0, void 0, function* () {
188
+ yield save({ enableSelfHealing: false });
189
+ const reads = sinon_1.default.spy(typedi_1.Container.get(web_config_service_1.WebConfigService), 'getConfig');
190
+ yield findMissing('sess-3');
191
+ yield findMissing('sess-3');
192
+ (0, chai_1.expect)(reads.called).to.equal(false);
193
+ }));
194
+ it('follows the startup option when nothing is saved', () => __awaiter(void 0, void 0, void 0, function* () {
195
+ pluginArgs = startup({ enableSelfHealing: false });
196
+ yield findMissing('sess-4');
197
+ (0, chai_1.expect)(attemptHealing.called).to.equal(false);
198
+ }));
199
+ it('lets a saved on beat a startup off', () => __awaiter(void 0, void 0, void 0, function* () {
200
+ pluginArgs = startup({ enableSelfHealing: false });
201
+ yield save({ enableSelfHealing: true });
202
+ yield findMissing('sess-5');
203
+ (0, chai_1.expect)(attemptHealing.calledOnce).to.equal(true);
204
+ }));
205
+ it("still hands a session's own healing tiers to the healing it runs", () => __awaiter(void 0, void 0, void 0, function* () {
206
+ sinon_1.default.stub(SessionManager_1.SESSION_MANAGER, 'getSession').returns({
207
+ getCapabilities: () => ({ 'xe:options': { healingTiers: [1, 2] } }),
208
+ });
209
+ const driver = { sessionId: 'sess-6' };
210
+ yield typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor)
211
+ .handle(() => __awaiter(void 0, void 0, void 0, function* () {
212
+ throw noSuchElement();
213
+ }), driver, 'findElement', ['id', 'gone', 'sess-6'], pluginArgs, false)
214
+ .catch(() => undefined);
215
+ (0, chai_1.expect)(attemptHealing.calledOnce).to.equal(true);
216
+ (0, chai_1.expect)(attemptHealing.firstCall.args).to.deep.equal(['sess-6', driver, 'id', 'gone', [1, 2]]);
217
+ }));
218
+ describe('and what it learns from a found element', () => {
219
+ let learn;
220
+ beforeEach(() => {
221
+ sinon_1.default.stub(event_manager_1.DASHBORD_EVENT_MANAGER, 'afterSessionCommand').resolves();
222
+ learn = sinon_1.default.stub(CommandInterceptor_1.CommandInterceptor.prototype, 'triggerLearning');
223
+ });
224
+ const afterFind = () => typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor).runPostCommandHooks('sess-7', 'findElement', {}, ['id', 'there'], { ELEMENT: 'el-1' }, pluginArgs);
225
+ it('learns the selector while the switch is on', () => __awaiter(void 0, void 0, void 0, function* () {
226
+ yield afterFind();
227
+ (0, chai_1.expect)(learn.calledOnce).to.equal(true);
228
+ }));
229
+ it('learns nothing once off is saved', () => __awaiter(void 0, void 0, void 0, function* () {
230
+ yield save({ enableSelfHealing: false });
231
+ yield afterFind();
232
+ (0, chai_1.expect)(learn.called).to.equal(false);
233
+ }));
234
+ });
235
+ });
236
+ describe('at boot', () => {
237
+ it('is the saved value, over the startup option', () => __awaiter(void 0, void 0, void 0, function* () {
238
+ const pluginArgs = startup({ enableSelfHealing: true });
239
+ yield scratch.db.webConfig.create({
240
+ data: { id: 'enableSelfHealing', name: 'enableSelfHealing', value: 'false' },
241
+ });
242
+ yield healingSwitch.load();
243
+ (0, chai_1.expect)(healingSwitch.isEnabled(pluginArgs)).to.equal(false);
244
+ }));
245
+ it('is the startup option when nothing is saved', () => __awaiter(void 0, void 0, void 0, function* () {
246
+ yield healingSwitch.load();
247
+ (0, chai_1.expect)(healingSwitch.isEnabled(startup({ enableSelfHealing: false }))).to.equal(false);
248
+ (0, chai_1.expect)(healingSwitch.isEnabled(startup({ enableSelfHealing: true }))).to.equal(true);
249
+ }));
250
+ it('is on when neither was set', () => __awaiter(void 0, void 0, void 0, function* () {
251
+ yield healingSwitch.load();
252
+ (0, chai_1.expect)(healingSwitch.isEnabled({})).to.equal(true);
253
+ }));
254
+ it('is the startup option, and says so once, when the saved value cannot be read', () => __awaiter(void 0, void 0, void 0, function* () {
255
+ const warn = sinon_1.default.stub(logger_1.default, 'warn');
256
+ sinon_1.default.stub(typedi_1.Container.get(web_config_service_1.WebConfigService), 'getConfig').rejects(new Error('db is down'));
257
+ yield healingSwitch.load();
258
+ const off = startup({ enableSelfHealing: false });
259
+ const on = startup({ enableSelfHealing: true });
260
+ (0, chai_1.expect)(healingSwitch.isEnabled(off)).to.equal(false);
261
+ (0, chai_1.expect)(healingSwitch.isEnabled(on)).to.equal(true);
262
+ // However many commands follow, it is said once, at the load.
263
+ (0, chai_1.expect)(warn.calledOnce).to.equal(true);
264
+ (0, chai_1.expect)(String(warn.firstCall.args[0])).to.match(/self-healing/i);
265
+ (0, chai_1.expect)(String(warn.firstCall.args[0])).to.include('db is down');
266
+ }));
267
+ it('uses the startup option until it has loaded', () => {
268
+ (0, chai_1.expect)(healingSwitch.isEnabled({ enableSelfHealing: false })).to.equal(false);
269
+ (0, chai_1.expect)(healingSwitch.isEnabled({ enableSelfHealing: true })).to.equal(true);
270
+ });
271
+ });
272
+ });
@@ -0,0 +1,170 @@
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 axios_1 = __importDefault(require("axios"));
18
+ const sinon_1 = __importDefault(require("sinon"));
19
+ const typedi_1 = require("typedi");
20
+ const event_manager_1 = require("../../src/dashboard/event-manager");
21
+ const SessionManager_1 = require("../../src/sessions/SessionManager");
22
+ const SessionMetricsService_1 = require("../../src/services/metrics/SessionMetricsService");
23
+ const SocketServer_1 = require("../../src/services/SocketServer");
24
+ const MetricsService_1 = require("../../src/services/MetricsService");
25
+ const NotificationService_1 = require("../../src/services/NotificationService");
26
+ const device_store_1 = require("../../src/data-service/device-store");
27
+ const SessionStatus_1 = require("../../src/types/SessionStatus");
28
+ const prisma_1 = require("../../src/prisma");
29
+ const container_registration_1 = require("../helpers/container-registration");
30
+ /**
31
+ * `session_failed` goes out when a session ends as failed, whichever way it
32
+ * ends, and once per session.
33
+ *
34
+ * It was sent only from the client's own delete, so a session that timed out
35
+ * for inactivity, whose driver crashed, or whose heartbeat stopped was marked
36
+ * failed on the dashboard and counted in the failure metric, and no webhook
37
+ * heard of it. Every one of those ends through `onSessionStopped`, which is
38
+ * where the final status is decided, so that is where it is sent. A session
39
+ * that ends twice (a crash, then the client's delete) is still sent once.
40
+ */
41
+ describe('session_failed: when it is sent', () => {
42
+ const URL = 'https://hooks.example.com/T000/B000/xyz';
43
+ let restore;
44
+ let post;
45
+ let row;
46
+ let svc;
47
+ beforeEach(() => {
48
+ restore = (0, container_registration_1.saveRegistrations)(SessionMetricsService_1.SessionMetricsService, SocketServer_1.SocketServer, MetricsService_1.MetricsService);
49
+ typedi_1.Container.set(SessionMetricsService_1.SessionMetricsService, { stop: sinon_1.default.stub().resolves() });
50
+ typedi_1.Container.set(SocketServer_1.SocketServer, {
51
+ emitToDashboard: sinon_1.default.stub(),
52
+ emitToDashboardForDevices: sinon_1.default.stub().resolves(),
53
+ hasScopedDashboard: sinon_1.default.stub().returns(false),
54
+ });
55
+ typedi_1.Container.set(MetricsService_1.MetricsService, {
56
+ incrementSessionSuccess: sinon_1.default.stub(),
57
+ incrementSessionFailure: sinon_1.default.stub(),
58
+ });
59
+ sinon_1.default.stub(SessionManager_1.SESSION_MANAGER, 'getSession').returns(undefined);
60
+ sinon_1.default
61
+ .stub(device_store_1.DeviceStoreFactory, 'getStore')
62
+ .returns({ getDevices: () => __awaiter(void 0, void 0, void 0, function* () { return []; }), findDevices: () => __awaiter(void 0, void 0, void 0, function* () { return []; }) });
63
+ sinon_1.default.stub(prisma_1.prisma.sessionLog, 'findFirst').resolves(null);
64
+ row = {
65
+ id: 'sess-1',
66
+ name: 'Checkout flow',
67
+ status: 'running',
68
+ failure_reason: null,
69
+ device_udid: 'R58M123',
70
+ device_name: 'Galaxy S9+',
71
+ device_platform: 'android',
72
+ device_version: '10',
73
+ startTime: new Date('2026-10-04T09:00:00.000Z'),
74
+ endTime: null,
75
+ desired_capabilities: '{"appium:app":"/secret.apk"}',
76
+ };
77
+ sinon_1.default.stub(prisma_1.prisma.session, 'findFirst').callsFake((() => __awaiter(void 0, void 0, void 0, function* () { return (Object.assign({}, row)); })));
78
+ sinon_1.default.stub(prisma_1.prisma.session, 'update').callsFake(((_a) => __awaiter(void 0, [_a], void 0, function* ({ data }) {
79
+ row = Object.assign(Object.assign({}, row), data);
80
+ return row;
81
+ })));
82
+ // A real service with a slack webhook for the event, so what is checked is
83
+ // what would reach the channel.
84
+ svc = typedi_1.Container.get(NotificationService_1.NotificationService);
85
+ post = sinon_1.default.stub(axios_1.default, 'post').resolves({ status: 200 });
86
+ sinon_1.default
87
+ .stub(svc, 'getConfigs')
88
+ .resolves([
89
+ { id: 'w', url: URL, events: '["session_failed"]', type: 'slack', active: true },
90
+ ]);
91
+ // A session id nobody has notified for yet, however many specs ran before.
92
+ row.id = `sess-${Math.random().toString(36).slice(2)}`;
93
+ });
94
+ afterEach(() => {
95
+ sinon_1.default.restore();
96
+ restore();
97
+ });
98
+ /** onSessionStopped fires the webhook without being awaited: let it finish. */
99
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
100
+ it('is sent when a session times out for inactivity (device-utils)', () => __awaiter(void 0, void 0, void 0, function* () {
101
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, 'failed', 'Session timed out due to inactivity');
102
+ yield settle();
103
+ (0, chai_1.expect)(post.calledOnce).to.equal(true);
104
+ const text = post.firstCall.args[1].attachments[0].text;
105
+ (0, chai_1.expect)(text).to.contain(row.id);
106
+ (0, chai_1.expect)(text).to.contain('Session timed out due to inactivity');
107
+ (0, chai_1.expect)(text).to.contain('Galaxy S9+');
108
+ (0, chai_1.expect)(text).not.to.contain('undefined');
109
+ }));
110
+ it('is sent when the driver crashes (plugin onUnexpectedShutdown)', () => __awaiter(void 0, void 0, void 0, function* () {
111
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Driver shut down unexpectedly');
112
+ yield settle();
113
+ (0, chai_1.expect)(post.calledOnce).to.equal(true);
114
+ (0, chai_1.expect)(post.firstCall.args[1].attachments[0].text).to.contain('Driver shut down unexpectedly');
115
+ }));
116
+ it('is sent when the heartbeat stops (OrphanSweeper)', () => __awaiter(void 0, void 0, void 0, function* () {
117
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Session heartbeat timeout');
118
+ yield settle();
119
+ (0, chai_1.expect)(post.calledOnce).to.equal(true);
120
+ }));
121
+ it('is sent when the client deletes a session a command failed in', () => __awaiter(void 0, void 0, void 0, function* () {
122
+ prisma_1.prisma.sessionLog.findFirst.resolves({
123
+ command_name: 'findElement',
124
+ response: '{"value":{"error":"no such element"}}',
125
+ });
126
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
127
+ yield settle();
128
+ (0, chai_1.expect)(post.calledOnce).to.equal(true);
129
+ (0, chai_1.expect)(post.firstCall.args[1].attachments[0].text).to.contain('no such element');
130
+ }));
131
+ it('is sent when the test marked the session failed and the client then deletes it', () => __awaiter(void 0, void 0, void 0, function* () {
132
+ row.status = 'failed';
133
+ row.failure_reason = 'Assertion failed: total is 12';
134
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
135
+ yield settle();
136
+ (0, chai_1.expect)(post.calledOnce).to.equal(true);
137
+ (0, chai_1.expect)(post.firstCall.args[1].attachments[0].text).to.contain('Assertion failed: total is 12');
138
+ }));
139
+ it('is not sent for a session that passes', () => __awaiter(void 0, void 0, void 0, function* () {
140
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
141
+ yield settle();
142
+ (0, chai_1.expect)(row.status).to.equal('success');
143
+ (0, chai_1.expect)(post.called).to.equal(false);
144
+ }));
145
+ it('is sent once when a session ends twice: a crash, then the client’s delete', () => __awaiter(void 0, void 0, void 0, function* () {
146
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Driver shut down unexpectedly');
147
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
148
+ yield settle();
149
+ (0, chai_1.expect)(post.callCount).to.equal(1);
150
+ }));
151
+ it('is not sent when the server itself is shutting down', () => __awaiter(void 0, void 0, void 0, function* () {
152
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Hub shutdown', {
153
+ notify: false,
154
+ });
155
+ yield settle();
156
+ (0, chai_1.expect)(row.status).to.equal('failed'); // still recorded as failed
157
+ (0, chai_1.expect)(post.called).to.equal(false);
158
+ }));
159
+ it('does not hold the session’s end up for a webhook, and survives one that fails', () => __awaiter(void 0, void 0, void 0, function* () {
160
+ post.rejects(new Error('slack is down'));
161
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'boom');
162
+ yield settle();
163
+ (0, chai_1.expect)(row.status).to.equal('failed');
164
+ }));
165
+ it('does not send the session’s capabilities to the webhook', () => __awaiter(void 0, void 0, void 0, function* () {
166
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'boom');
167
+ yield settle();
168
+ (0, chai_1.expect)(JSON.stringify(post.firstCall.args[1])).not.to.contain('secret.apk');
169
+ }));
170
+ });
@@ -0,0 +1,133 @@
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 shellCommands_1 = require("../../src/device-managers/shellCommands");
19
+ const AndroidDeviceManager_1 = __importDefault(require("../../src/device-managers/AndroidDeviceManager"));
20
+ // The device control Shell tab's allow-list was a `startsWith` test, and the
21
+ // command then reached the phone's `sh`: `ls; reboot`, `ls && pm uninstall …`
22
+ // and `lsof` all ran. A command now has to be an allowed command word for word,
23
+ // and its arguments may only use characters a shell gives no meaning to.
24
+ describe('parseShellCommand', () => {
25
+ const allowed = (cmd) => (0, shellCommands_1.parseShellCommand)(cmd, shellCommands_1.ANDROID_SHELL_COMMANDS);
26
+ it('accepts an allowed command and its plain arguments', () => {
27
+ (0, chai_1.expect)(allowed('ls -la /sdcard/Download')).to.deep.equal({
28
+ argv: ['ls', '-la', '/sdcard/Download'],
29
+ });
30
+ (0, chai_1.expect)(allowed(' getprop ro.product.model ')).to.deep.equal({
31
+ argv: ['getprop', 'ro.product.model'],
32
+ });
33
+ (0, chai_1.expect)(allowed('pm list packages -3')).to.deep.equal({
34
+ argv: ['pm', 'list', 'packages', '-3'],
35
+ });
36
+ (0, chai_1.expect)(allowed('dumpsys battery')).to.deep.equal({ argv: ['dumpsys', 'battery'] });
37
+ (0, chai_1.expect)(allowed('date +%s')).to.deep.equal({ argv: ['date', '+%s'] });
38
+ });
39
+ for (const cmd of [
40
+ 'ls; reboot',
41
+ 'ls;reboot',
42
+ 'ls && pm uninstall com.example.app',
43
+ 'ls || reboot',
44
+ 'ls | sh',
45
+ 'ls `reboot`',
46
+ 'getprop $(reboot)',
47
+ 'ls > /sdcard/x',
48
+ 'ls < /sdcard/x',
49
+ 'ls &',
50
+ 'ls\nreboot',
51
+ "ls 'a b'",
52
+ 'ls "a b"',
53
+ 'ls \\; reboot',
54
+ 'getprop ${PATH}',
55
+ 'ls *',
56
+ 'ls ~',
57
+ ]) {
58
+ it(`refuses a shell metacharacter: ${JSON.stringify(cmd)}`, () => {
59
+ (0, chai_1.expect)(allowed(cmd)).to.have.property('refused');
60
+ });
61
+ }
62
+ it('refuses a command that only starts like an allowed one', () => {
63
+ (0, chai_1.expect)(allowed('lsof')).to.have.property('refused');
64
+ (0, chai_1.expect)(allowed('pstree')).to.have.property('refused');
65
+ (0, chai_1.expect)(allowed('dumpsys batteryproperties')).to.have.property('refused');
66
+ (0, chai_1.expect)(allowed('pm list packagesx')).to.have.property('refused');
67
+ });
68
+ it('refuses arguments to a command that takes none', () => {
69
+ (0, chai_1.expect)(allowed('dumpsys battery set level 5')).to.have.property('refused');
70
+ (0, chai_1.expect)(allowed('cat /proc/meminfo /data/system/users/0.xml')).to.have.property('refused');
71
+ (0, chai_1.expect)(allowed('whoami --help')).to.have.property('refused');
72
+ });
73
+ it('refuses an empty command and anything not listed', () => {
74
+ (0, chai_1.expect)(allowed(' ')).to.have.property('refused');
75
+ (0, chai_1.expect)(allowed('reboot')).to.have.property('refused');
76
+ (0, chai_1.expect)(allowed('pm uninstall com.example.app')).to.have.property('refused');
77
+ (0, chai_1.expect)(allowed('cat /data/system/users/0.xml')).to.have.property('refused');
78
+ });
79
+ it('keeps every command the Android allow-list offered usable', () => {
80
+ for (const cmd of [
81
+ 'ls',
82
+ 'ps -A',
83
+ 'top -n 1',
84
+ 'dumpsys battery',
85
+ 'dumpsys wifi',
86
+ 'dumpsys power',
87
+ 'whoami',
88
+ 'getprop',
89
+ 'pm list packages',
90
+ 'ip addr',
91
+ 'cat /proc/meminfo',
92
+ 'cat /proc/cpuinfo',
93
+ 'date',
94
+ 'uptime',
95
+ 'netstat -tn',
96
+ ]) {
97
+ (0, chai_1.expect)(allowed(cmd), cmd).to.have.property('argv');
98
+ }
99
+ });
100
+ it("lets a simulator's generic commands take no arguments", () => {
101
+ (0, chai_1.expect)((0, shellCommands_1.parseShellCommand)('ls', shellCommands_1.IOS_SIMULATOR_COMMANDS)).to.have.property('argv');
102
+ (0, chai_1.expect)((0, shellCommands_1.parseShellCommand)('ls /Users', shellCommands_1.IOS_SIMULATOR_COMMANDS)).to.have.property('refused');
103
+ });
104
+ });
105
+ describe('AndroidDeviceManager.executeShell', () => {
106
+ afterEach(() => sinon_1.default.restore());
107
+ function managerWithAdb() {
108
+ const manager = new AndroidDeviceManager_1.default({});
109
+ const adbExec = sinon_1.default.stub().resolves('ok');
110
+ sinon_1.default.stub(manager, 'getAdb').resolves({ adbInstance: { adbExec } });
111
+ return { manager, adbExec };
112
+ }
113
+ it('passes an allowed command to adb shell word by word', () => __awaiter(void 0, void 0, void 0, function* () {
114
+ const { manager, adbExec } = managerWithAdb();
115
+ (0, chai_1.expect)(yield manager.executeShell('R5CT32ABCDE', 'getprop ro.product.model')).to.equal('ok');
116
+ (0, chai_1.expect)(adbExec.firstCall.args[0]).to.deep.equal([
117
+ '-s',
118
+ 'R5CT32ABCDE',
119
+ 'shell',
120
+ 'getprop',
121
+ 'ro.product.model',
122
+ ]);
123
+ }));
124
+ it('never reaches adb for a refused command', () => __awaiter(void 0, void 0, void 0, function* () {
125
+ const { manager, adbExec } = managerWithAdb();
126
+ for (const cmd of ['ls; reboot', 'lsof', 'dumpsys battery set level 5']) {
127
+ const err = yield manager.executeShell('R5CT32ABCDE', cmd).then(() => null, (e) => e);
128
+ (0, chai_1.expect)(err, cmd).to.be.an('Error');
129
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message, cmd).to.match(/not allowed/);
130
+ }
131
+ (0, chai_1.expect)(adbExec.called).to.equal(false);
132
+ }));
133
+ });