@xenon-device-management/xenon 2.13.2 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +11 -5
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-BXruClDk.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-qTS4LPLC.js → ApiKeyGate-BwuaMjkX.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-BoGhlI7F.js → BugReportButton-BAt0_pzq.js} +1 -1
  6. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
  7. package/lib/public/assets/{EmptyState-Cr17PdDv.js → EmptyState-BSjhk_IV.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-Bvn08svv.js → FieldGroup-Cb5QZsE1.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-DDSO8aDf.js → FilterMenu-BAaSlWbW.js} +1 -1
  10. package/lib/public/assets/{Menu-B-Qn2X3T.js → Menu-JGGYrSQQ.js} +1 -1
  11. package/lib/public/assets/{Modal-Cg9V5JYU.js → Modal-CFCp3n9d.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-BTUs9VTW.js → RecordingPage-CbJj_EBT.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-DYcfxITK.js → RecordingsPage-BJeYnygR.js} +2 -2
  14. package/lib/public/assets/{SegmentedControl-Zy7suhT5.js → SegmentedControl-Bg42Atjy.js} +1 -1
  15. package/lib/public/assets/{SettingCard-CWYmycoe.js → SettingCard-Njz1pUIJ.js} +1 -1
  16. package/lib/public/assets/{Table-io9fwa98.js → Table-BcTm5xV2.js} +1 -1
  17. package/lib/public/assets/{activity-Bdf_uIWl.js → activity-DOVdsfMQ.js} +1 -1
  18. package/lib/public/assets/{ai-settings-Bce5SJob.js → ai-settings-CsuJroRf.js} +2 -2
  19. package/lib/public/assets/{api-keys-CPqtVpm3.js → api-keys-BKnNSTw9.js} +1 -1
  20. package/lib/public/assets/{apps-BgZan0H9.js → apps-D0SC6uw6.js} +2 -2
  21. package/lib/public/assets/{arrow-left-B27ckB31.js → arrow-left-MMbTmgoE.js} +1 -1
  22. package/lib/public/assets/{arrow-right-DOF8ITCz.js → arrow-right-BMfnGmxW.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-eFG5KTFS.js → arrow-up-right-DuAftMWB.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DlC3_27j.js → auth-shell-nZC2JNDO.js} +2 -2
  25. package/lib/public/assets/{builds-page-BuXpPfKo.js → builds-page-hDCgTwkS.js} +1 -1
  26. package/lib/public/assets/{button-fIW8owMI.js → button-Dda4irn_.js} +1 -1
  27. package/lib/public/assets/{calendar-WNjmrF-a.js → calendar-BMr0Q62v.js} +1 -1
  28. package/lib/public/assets/{check-BdTh1Sq7.js → check-CZ9iw9-e.js} +1 -1
  29. package/lib/public/assets/{chevron-right-B4BKlsqk.js → chevron-right-BRYJJVC9.js} +1 -1
  30. package/lib/public/assets/{circle-check-zJU80_EF.js → circle-check-CqAJjQ5O.js} +1 -1
  31. package/lib/public/assets/{circle-x-5Uwquo0h.js → circle-x-DlDJtnVz.js} +1 -1
  32. package/lib/public/assets/{clock-BtUprDsZ.js → clock-CpuDKysU.js} +1 -1
  33. package/lib/public/assets/{copy-DGSbZI3u.js → copy-CAQydNFe.js} +1 -1
  34. package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
  35. package/lib/public/assets/{download-WG6l7BgD.js → download-C9XG6lLv.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DnudqFFo.js → forgot-password-CGnkRgE9.js} +1 -1
  37. package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
  38. package/lib/public/assets/{index-DI1fGIyj.js → index-BbKEXUFH.js} +5 -5
  39. package/lib/public/assets/{index-HDQVe1gi.js → index-DJGc1M2f.js} +1 -1
  40. package/lib/public/assets/{input-BjDRKCea.js → input-Cm0tmSoc.js} +1 -1
  41. package/lib/public/assets/{line-chart-C0zdgd0v.js → line-chart-D2DyJCml.js} +1 -1
  42. package/lib/public/assets/{list-checks-BdW5CjM8.js → list-checks-UWf1GY99.js} +1 -1
  43. package/lib/public/assets/{lock-Dy3L4I8E.js → lock-CCJv9m_I.js} +1 -1
  44. package/lib/public/assets/{login-BHbY9CLG.js → login-f8vOVW-U.js} +1 -1
  45. package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
  46. package/lib/public/assets/{monitor-8xjkeHHi.js → monitor-B2jDgwm7.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CXdKMXQ8.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
  48. package/lib/public/assets/{network-Cj8JOKON.js → network-ebT86qoi.js} +1 -1
  49. package/lib/public/assets/{overview-CTg7F0im.js → overview-C0aykVrd.js} +1 -1
  50. package/lib/public/assets/{page-header--aTyJXq6.js → page-header-BjzgWz-T.js} +1 -1
  51. package/lib/public/assets/{play-B-FodgHV.js → play-DLG9t8FU.js} +1 -1
  52. package/lib/public/assets/{plus-CsIk10Cq.js → plus-nBFSMu_X.js} +1 -1
  53. package/lib/public/assets/{profile-page-DkFKkMfl.js → profile-page-CSl0UYlv.js} +1 -1
  54. package/lib/public/assets/{reset-password-I2-zqLlI.js → reset-password-LeOuHqmB.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJTVCJX7.js → runbook-page-CMMeQml9.js} +1 -1
  56. package/lib/public/assets/{select-DvCSv25z.js → select-88jHcl-Z.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BLVfMJs_.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-oqadRokn.js → selector-health-page-B4rA1Xkv.js} +2 -2
  59. package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
  60. package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
  61. package/lib/public/assets/{stat-tile-DV9Ix-4O.js → stat-tile-BV98e_Sv.js} +1 -1
  62. package/lib/public/assets/{tablet-6b_SEN2X.js → tablet-BiAsnFDj.js} +1 -1
  63. package/lib/public/assets/{teams-BjZyfuHh.js → teams-B4-jrr-1.js} +1 -1
  64. package/lib/public/assets/{trash-2-CTZpPSdd.js → trash-2-C5RSuX0e.js} +1 -1
  65. package/lib/public/assets/{upload-P3XzwQUb.js → upload-BG7_rMXK.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-1cOCMf-z.js → use-builds-data-J-riQYyC.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DgF9w088.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
  68. package/lib/public/assets/{users-DMF9Mk5H.js → users-CFba5TSy.js} +1 -1
  69. package/lib/public/assets/{users-CiNTKUiz.js → users-Cp0beJm3.js} +1 -1
  70. package/lib/public/assets/{video-off-CHp1KU_T.js → video-off-DDsEISjV.js} +1 -1
  71. package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
  72. package/lib/public/assets/{zap-DRz0tDq5.js → zap-l4VaXzq8.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +19 -21
  75. package/lib/src/XenonCapabilityManager.js +22 -5
  76. package/lib/src/app/openapi/control.yaml +71 -20
  77. package/lib/src/app/openapi/grid.yaml +23 -5
  78. package/lib/src/app/openapi/platform.yaml +136 -28
  79. package/lib/src/app/routers/control.js +12 -2
  80. package/lib/src/app/routers/dashboard.js +29 -1
  81. package/lib/src/app/routers/grid.js +32 -10
  82. package/lib/src/app/routers/streamType.js +7 -2
  83. package/lib/src/app/routers/webhook.js +8 -4
  84. package/lib/src/config.js +50 -5
  85. package/lib/src/dashboard/commands.js +185 -141
  86. package/lib/src/dashboard/event-manager.js +39 -6
  87. package/lib/src/data-service/appleFamily.js +43 -0
  88. package/lib/src/data-service/db.js +11 -0
  89. package/lib/src/data-service/device-service.js +6 -1
  90. package/lib/src/data-service/device-store.js +62 -2
  91. package/lib/src/data-service/deviceClaims.js +37 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +25 -11
  93. package/lib/src/data-service/deviceSettings.js +79 -0
  94. package/lib/src/data-service/prisma-store.js +150 -8
  95. package/lib/src/data-service/web-config-service.js +43 -92
  96. package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
  97. package/lib/src/device-managers/ios/WDAClient.js +44 -43
  98. package/lib/src/device-managers/shellCommands.js +96 -0
  99. package/lib/src/device-utils.js +96 -40
  100. package/lib/src/generated/client/edge.js +17 -3
  101. package/lib/src/generated/client/index-browser.js +14 -0
  102. package/lib/src/generated/client/index.d.ts +2054 -379
  103. package/lib/src/generated/client/index.js +17 -3
  104. package/lib/src/generated/client/package.json +1 -1
  105. package/lib/src/generated/client/schema.prisma +35 -7
  106. package/lib/src/generated/client/wasm.js +14 -0
  107. package/lib/src/interceptors/CommandInterceptor.js +29 -8
  108. package/lib/src/interceptors/xenonScripts.js +55 -0
  109. package/lib/src/interfaces/IPluginArgs.js +1 -2
  110. package/lib/src/plugin.js +15 -1
  111. package/lib/src/prisma.js +1 -1
  112. package/lib/src/scripts/database-check.js +124 -0
  113. package/lib/src/services/AIService.js +99 -0
  114. package/lib/src/services/CleanupService.js +6 -1
  115. package/lib/src/services/InterceptorService.js +70 -13
  116. package/lib/src/services/NetworkConditioningService.js +85 -88
  117. package/lib/src/services/NotificationService.js +36 -29
  118. package/lib/src/services/OrphanSweeper.js +5 -0
  119. package/lib/src/services/PortAllocator.js +5 -0
  120. package/lib/src/services/ServerManager.js +94 -8
  121. package/lib/src/services/SessionLifecycleService.js +47 -49
  122. package/lib/src/services/ShutdownCoordinator.js +33 -0
  123. package/lib/src/services/TeamService.js +20 -3
  124. package/lib/src/services/healing/HealingOrchestrator.js +32 -1
  125. package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
  126. package/lib/src/services/interceptor/interceptorOptions.js +44 -0
  127. package/lib/src/services/network/AndroidRadios.js +71 -0
  128. package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
  129. package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
  130. package/lib/src/services/network/adbForPhone.js +60 -0
  131. package/lib/src/services/network/phoneNetworkLock.js +20 -0
  132. package/lib/src/services/network/xenonProxy.js +107 -0
  133. package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
  134. package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
  135. package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
  136. package/lib/src/services/omni-vision/screenScale.js +68 -0
  137. package/lib/src/services/recording/concurrency-gate.js +12 -3
  138. package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
  139. package/lib/src/services/settings/labSettings.js +149 -0
  140. package/lib/src/services/webhookEvents.js +173 -0
  141. package/lib/test/helpers/fake-adb.js +58 -0
  142. package/lib/test/helpers/fake-device-table.js +63 -24
  143. package/lib/test/helpers/scratch-database.js +1 -0
  144. package/lib/test/unit/TeamService.test.js +3 -0
  145. package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
  146. package/lib/test/unit/apple-family.spec.js +36 -0
  147. package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
  148. package/lib/test/unit/cleanup-settings.spec.js +244 -0
  149. package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
  150. package/lib/test/unit/dashboard-commands.spec.js +249 -0
  151. package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
  152. package/lib/test/unit/database-check.spec.js +108 -0
  153. package/lib/test/unit/default-plugin-args.spec.js +33 -0
  154. package/lib/test/unit/device-offline-webhook.spec.js +81 -0
  155. package/lib/test/unit/device-setting-columns.spec.js +66 -0
  156. package/lib/test/unit/device-settings-loki.spec.js +87 -0
  157. package/lib/test/unit/device-settings-survive.spec.js +555 -0
  158. package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
  159. package/lib/test/unit/emulator-boot.spec.js +91 -0
  160. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
  161. package/lib/test/unit/hub-node-same-host.spec.js +5 -0
  162. package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
  163. package/lib/test/unit/json-logging-option.spec.js +74 -0
  164. package/lib/test/unit/lab-settings.spec.js +218 -0
  165. package/lib/test/unit/max-sessions.spec.js +240 -0
  166. package/lib/test/unit/ocr-text-match.spec.js +44 -0
  167. package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
  168. package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
  169. package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
  170. package/lib/test/unit/omni-vision.spec.js +1 -1
  171. package/lib/test/unit/partial-config-file.spec.js +14 -0
  172. package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
  173. package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
  174. package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
  175. package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
  176. package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
  177. package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
  178. package/lib/test/unit/phone-network/session-start.spec.js +110 -0
  179. package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
  180. package/lib/test/unit/plugin.spec.js +3 -3
  181. package/lib/test/unit/recording-options.spec.js +181 -0
  182. package/lib/test/unit/self-healing-boot.spec.js +75 -0
  183. package/lib/test/unit/self-healing-setting.spec.js +272 -0
  184. package/lib/test/unit/session-failed-webhook.spec.js +170 -0
  185. package/lib/test/unit/shell-commands.spec.js +133 -0
  186. package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
  187. package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
  188. package/lib/test/unit/stream-status-type.spec.js +4 -0
  189. package/lib/test/unit/vision-assertion.spec.js +118 -0
  190. package/lib/test/unit/wda-client-shell.spec.js +133 -0
  191. package/lib/test/unit/web-config-service.spec.js +79 -0
  192. package/lib/test/unit/webhook-delivery.spec.js +24 -0
  193. package/lib/test/unit/webhook-payloads.spec.js +273 -0
  194. package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
  195. package/lib/tsconfig.tsbuildinfo +1 -1
  196. package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
  197. package/package.json +1 -1
  198. package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
  199. package/prisma/schema.prisma +35 -7
  200. package/schema.json +19 -21
  201. package/scripts/generate-types-from-schema.js +1 -2
  202. package/scripts/lib/always-present-plugin-args.js +4 -1
  203. package/lib/public/assets/DeviceMosaicView-8J0oYqMM.js +0 -2
  204. package/lib/public/assets/device-explorer-C-KnhMHI.js +0 -279
  205. package/lib/public/assets/maintenance-settings-zDVlOMVK.js +0 -6
  206. package/lib/public/assets/recording-group-store-WVM3hoXn.js +0 -26
  207. package/lib/public/assets/session-detail-page-CGn55Pvl.js +0 -4
  208. package/lib/public/assets/settings-MsVU2ukE.js +0 -6
  209. package/lib/public/assets/webhook-settings-DiS8geIm.js +0 -1
@@ -0,0 +1,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
+ });
@@ -353,6 +353,8 @@ describe('Auth disabled: the lab path looks nothing up', () => {
353
353
  const row = { udid: 'phone-a', host: 'h', teamId: 'team-a' };
354
354
  sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
355
355
  removeDevices: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }),
356
+ // removeDevice reads the row for the device_offline webhook's name and platform.
357
+ findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
356
358
  addDevices: () => __awaiter(void 0, void 0, void 0, function* () { return [row]; }),
357
359
  updateDevice: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }),
358
360
  getDevices: () => __awaiter(void 0, void 0, void 0, function* () { return [Object.assign(Object.assign({}, row), { sessionStartTime: 0, totalUtilizationTimeMilliSec: 0 })]; }),
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ require("reflect-metadata");
49
+ const chai_1 = require("chai");
50
+ const sinon_1 = __importDefault(require("sinon"));
51
+ const express_1 = __importDefault(require("express"));
52
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
53
+ const typedi_1 = require("typedi");
54
+ const control_1 = __importDefault(require("../../src/app/routers/control"));
55
+ const device_store_1 = require("../../src/data-service/device-store");
56
+ const deviceService = __importStar(require("../../src/data-service/device-service"));
57
+ const recording_store_1 = require("../../src/services/recording/recording-store");
58
+ const IOSStreamService_1 = __importDefault(require("../../src/device-managers/ios/IOSStreamService"));
59
+ const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
60
+ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
61
+ const PluginContext_1 = require("../../src/PluginContext");
62
+ const container_registration_1 = require("../helpers/container-registration");
63
+ const loopbackServer_1 = require("../helpers/loopbackServer");
64
+ const own_node_id_1 = require("../helpers/own-node-id");
65
+ /**
66
+ * Which capture pipelines POST /control/:udid/stream/start runs.
67
+ *
68
+ * With `streaming.androidH264` on, the start ran the H.264 service (scrcpy),
69
+ * and device control's `<img>` then asked GET /stream, which ran the screencap
70
+ * MJPEG loop beside it: two captures of one phone, the very thing the comment
71
+ * in the handler says must not happen. Device control, and any browser that
72
+ * cannot decode H.264 (WebCodecs exists only in a secure context, so plain
73
+ * http://hub:4723 has none), shows MJPEG, so it says so when it starts the
74
+ * stream (`player: 'mjpeg'`), and the server then runs MJPEG only.
75
+ */
76
+ const UDID = 'DEV-1';
77
+ function buildApp() {
78
+ const app = (0, express_1.default)();
79
+ app.use(express_1.default.json());
80
+ const apiRouter = express_1.default.Router();
81
+ apiRouter.use((req, _res, next) => {
82
+ req.auth = {
83
+ kind: 'user-session',
84
+ userId: 'usr_alice',
85
+ role: 'ADMIN',
86
+ scopes: 'admin,devices,sessions,read',
87
+ rateLimit: 100,
88
+ };
89
+ next();
90
+ });
91
+ control_1.default.register(apiRouter);
92
+ app.use('/xenon/api', apiRouter);
93
+ return app;
94
+ }
95
+ describe('POST /control/:udid/stream/start: which capture runs', () => {
96
+ (0, own_node_id_1.useOwnNodeId)();
97
+ const loopback = (0, loopbackServer_1.loopbackServers)();
98
+ let restoreContainer;
99
+ let savedArgs;
100
+ let mjpegStart;
101
+ let h264Start;
102
+ let h264Stop;
103
+ let h264Running;
104
+ let row;
105
+ const flag = (androidH264) => {
106
+ typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs = Object.assign(Object.assign({}, savedArgs), { streaming: { androidH264 } });
107
+ };
108
+ beforeEach(() => {
109
+ restoreContainer = (0, container_registration_1.saveRegistrations)(AndroidStreamService_1.default, AndroidH264StreamService_1.default, IOSStreamService_1.default, recording_store_1.RecordingStore);
110
+ savedArgs = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs;
111
+ row = {
112
+ udid: UDID,
113
+ host: '127.0.0.1',
114
+ nodeId: own_node_id_1.OWN_NODE_ID,
115
+ platform: 'android',
116
+ busy: false,
117
+ session_id: null,
118
+ screenWidth: '1080',
119
+ screenHeight: '2220',
120
+ };
121
+ sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({ findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return row; }) });
122
+ sinon_1.default.stub(deviceService, 'blockDevice').resolves();
123
+ h264Running = false;
124
+ mjpegStart = sinon_1.default.stub().resolves({ mjpegPort: 9100 });
125
+ h264Start = sinon_1.default.stub().callsFake(() => __awaiter(void 0, void 0, void 0, function* () {
126
+ h264Running = true;
127
+ return {};
128
+ }));
129
+ h264Stop = sinon_1.default.stub().callsFake(() => __awaiter(void 0, void 0, void 0, function* () {
130
+ h264Running = false;
131
+ }));
132
+ typedi_1.Container.set(AndroidStreamService_1.default, {
133
+ startStream: mjpegStart,
134
+ getStreamStatus: () => undefined,
135
+ });
136
+ typedi_1.Container.set(IOSStreamService_1.default, { getStreamStatus: () => undefined });
137
+ typedi_1.Container.set(AndroidH264StreamService_1.default, {
138
+ start: h264Start,
139
+ stop: h264Stop,
140
+ getMultiplexer: () => (h264Running ? { clientCount: 0 } : undefined),
141
+ });
142
+ typedi_1.Container.set(recording_store_1.RecordingStore, { isRecording: () => __awaiter(void 0, void 0, void 0, function* () { return false; }) });
143
+ });
144
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
145
+ sinon_1.default.restore();
146
+ typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs = savedArgs;
147
+ restoreContainer();
148
+ yield loopback.closeAll();
149
+ }));
150
+ const start = (body) => __awaiter(void 0, void 0, void 0, function* () {
151
+ return (0, loopbackRequest_1.default)(yield loopback.serve(buildApp()))
152
+ .post(`/xenon/api/control/${UDID}/stream/start`)
153
+ .send(body !== null && body !== void 0 ? body : {});
154
+ });
155
+ it('runs the H.264 service alone when the flag is on and the page can play it', () => __awaiter(void 0, void 0, void 0, function* () {
156
+ flag(true);
157
+ const res = yield start();
158
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
159
+ (0, chai_1.expect)(res.body.type).to.equal('h264');
160
+ (0, chai_1.expect)(res.body.h264Path).to.equal(`/xenon/api/control/${UDID}/stream/h264`);
161
+ (0, chai_1.expect)(h264Start.calledOnce).to.equal(true);
162
+ (0, chai_1.expect)(mjpegStart.called).to.equal(false);
163
+ }));
164
+ it('runs the MJPEG capture alone when the page says it shows MJPEG', () => __awaiter(void 0, void 0, void 0, function* () {
165
+ flag(true);
166
+ const res = yield start({ player: 'mjpeg' });
167
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
168
+ (0, chai_1.expect)(res.body.type).to.equal('mjpeg');
169
+ (0, chai_1.expect)(res.body.streamUrl).to.equal(`/xenon/api/control/${UDID}/stream`);
170
+ (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
171
+ (0, chai_1.expect)(h264Start.called).to.equal(false);
172
+ }));
173
+ it('ends an H.264 capture already running for the phone, so the two never overlap', () => __awaiter(void 0, void 0, void 0, function* () {
174
+ flag(true);
175
+ h264Running = true;
176
+ yield start({ player: 'mjpeg' });
177
+ (0, chai_1.expect)(h264Stop.calledOnceWith(UDID)).to.equal(true);
178
+ (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
179
+ (0, chai_1.expect)(h264Running).to.equal(false);
180
+ }));
181
+ it('stops the H.264 capture before it starts the MJPEG one', () => __awaiter(void 0, void 0, void 0, function* () {
182
+ flag(true);
183
+ h264Running = true;
184
+ yield start({ player: 'mjpeg' });
185
+ (0, chai_1.expect)(h264Stop.calledBefore(mjpegStart)).to.equal(true);
186
+ }));
187
+ it('runs MJPEG alone, and stops nothing, when the flag is off', () => __awaiter(void 0, void 0, void 0, function* () {
188
+ flag(false);
189
+ const res = yield start();
190
+ (0, chai_1.expect)(res.body.type).to.equal('mjpeg');
191
+ (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
192
+ (0, chai_1.expect)(h264Start.called).to.equal(false);
193
+ (0, chai_1.expect)(h264Stop.called).to.equal(false);
194
+ }));
195
+ it('ignores a player it does not know, and uses the server’s choice', () => __awaiter(void 0, void 0, void 0, function* () {
196
+ flag(true);
197
+ const res = yield start({ player: 'hologram' });
198
+ (0, chai_1.expect)(res.body.type).to.equal('h264');
199
+ (0, chai_1.expect)(h264Start.calledOnce).to.equal(true);
200
+ (0, chai_1.expect)(mjpegStart.called).to.equal(false);
201
+ }));
202
+ it('keeps MJPEG for a phone that is being recorded, as before', () => __awaiter(void 0, void 0, void 0, function* () {
203
+ flag(true);
204
+ typedi_1.Container.set(recording_store_1.RecordingStore, { isRecording: () => __awaiter(void 0, void 0, void 0, function* () { return true; }) });
205
+ const res = yield start();
206
+ (0, chai_1.expect)(res.body.type).to.equal('mjpeg');
207
+ (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
208
+ (0, chai_1.expect)(h264Start.called).to.equal(false);
209
+ }));
210
+ });
@@ -20,4 +20,8 @@ describe('resolveStreamType', () => {
20
20
  (0, chai_1.expect)((0, streamType_1.resolveStreamType)('android', true, false)).to.equal('h264');
21
21
  (0, chai_1.expect)((0, streamType_1.resolveStreamType)('androidtv', true, false)).to.equal('h264');
22
22
  });
23
+ it('a page that shows MJPEG => mjpeg, whatever the flag', () => {
24
+ (0, chai_1.expect)((0, streamType_1.resolveStreamType)('android', true, false, false)).to.equal('mjpeg');
25
+ (0, chai_1.expect)((0, streamType_1.resolveStreamType)('android', true, false, true)).to.equal('h264');
26
+ });
23
27
  });
@@ -0,0 +1,118 @@
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 VisionAssertionService_1 = require("../../src/services/omni-vision/VisionAssertionService");
19
+ const AIService_1 = require("../../src/services/AIService");
20
+ /**
21
+ * `xenon: assertVisualState` answered `{ result: true, message: 'Assertion
22
+ * placeholder' }` whenever the AI step gave it nothing usable: with no AI
23
+ * provider, when the provider failed, and usually even with one, because the
24
+ * condition was wrapped in a "find the centre coordinates, answer {x, y}"
25
+ * prompt. A test asserting a screen state passed without anything being
26
+ * checked.
27
+ *
28
+ * Now it asks the provider a yes/no question of the screenshot and answers
29
+ * with the provider's verdict. Whenever there is no verdict (no provider, a
30
+ * failed call, an answer that isn't true or false, no screenshot, no
31
+ * condition) it fails with an error the test sees, never `true` and never
32
+ * `false`: a `false` would pass a test that asserts a state is absent.
33
+ */
34
+ describe('xenon: assertVisualState', () => {
35
+ const SCREEN = 'c2NyZWVuc2hvdA==';
36
+ let service;
37
+ let driver;
38
+ beforeEach(() => {
39
+ service = new VisionAssertionService_1.VisionAssertionService();
40
+ driver = { getScreenshot: sinon_1.default.stub().resolves(SCREEN) };
41
+ // The real provider set-up reads the server's AI settings; these tests set the provider themselves.
42
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'initializeProvider');
43
+ });
44
+ afterEach(() => sinon_1.default.restore());
45
+ const failureOf = (p) => p.then((value) => {
46
+ throw new Error(`expected a failure, got ${JSON.stringify(value)}`);
47
+ }, (e) => e);
48
+ function withProvider(answer) {
49
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value({ analyze: () => __awaiter(this, void 0, void 0, function* () { return ''; }) });
50
+ const call = sinon_1.default.stub(AIService_1.AI_SERVICE, 'callProvider');
51
+ if (answer instanceof Error)
52
+ call.rejects(answer);
53
+ else
54
+ call.resolves(answer);
55
+ return call;
56
+ }
57
+ it('fails, rather than passing, when no AI provider is configured', () => __awaiter(void 0, void 0, void 0, function* () {
58
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value(null);
59
+ const err = yield failureOf(service.assertState(driver, 'The Login button is visible'));
60
+ (0, chai_1.expect)(err.message).to.match(/No AI provider is configured/);
61
+ (0, chai_1.expect)(err.message).to.match(/not checked/);
62
+ }));
63
+ it('asks a yes/no question about the condition, with the screenshot', () => __awaiter(void 0, void 0, void 0, function* () {
64
+ const call = withProvider('{"result": true, "reason": "A Login button is shown."}');
65
+ const answer = yield service.assertState(driver, 'The Login button is visible');
66
+ (0, chai_1.expect)(answer).to.deep.equal({ result: true, message: 'A Login button is shown.' });
67
+ (0, chai_1.expect)(call.calledOnce).to.equal(true);
68
+ const [prompt, image] = call.firstCall.args;
69
+ (0, chai_1.expect)(image).to.equal(SCREEN);
70
+ (0, chai_1.expect)(prompt).to.include('The Login button is visible');
71
+ (0, chai_1.expect)(prompt).to.match(/true or false/i);
72
+ (0, chai_1.expect)(prompt).to.not.match(/coordinates/i);
73
+ }));
74
+ it("answers false when the provider says the condition doesn't hold", () => __awaiter(void 0, void 0, void 0, function* () {
75
+ withProvider('```json\n{"result": false, "reason": "The cart shows 2 items."}\n```');
76
+ (0, chai_1.expect)(yield service.assertState(driver, 'The cart is empty')).to.deep.equal({
77
+ result: false,
78
+ message: 'The cart shows 2 items.',
79
+ });
80
+ }));
81
+ it("fails when the provider's answer is not a true/false verdict", () => __awaiter(void 0, void 0, void 0, function* () {
82
+ for (const answer of ['{"x": 120, "y": 340}', 'Yes, I can see it.', '{"result": "maybe"}']) {
83
+ sinon_1.default.restore();
84
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'initializeProvider');
85
+ driver = { getScreenshot: sinon_1.default.stub().resolves(SCREEN) };
86
+ withProvider(answer);
87
+ const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
88
+ (0, chai_1.expect)(err.message, answer).to.match(/could not read/i);
89
+ }
90
+ }));
91
+ it('fails when the provider call fails', () => __awaiter(void 0, void 0, void 0, function* () {
92
+ withProvider(new Error('429 quota exceeded'));
93
+ const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
94
+ (0, chai_1.expect)(err.message).to.match(/429 quota exceeded/);
95
+ }));
96
+ it('fails when the provider is rate-limited', () => __awaiter(void 0, void 0, void 0, function* () {
97
+ // Gemini's provider answers this instead of throwing on a 429.
98
+ withProvider('CONNECTION_OK_RATE_LIMITED');
99
+ const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
100
+ (0, chai_1.expect)(err.message).to.match(/rate-limited/i);
101
+ }));
102
+ it('fails without asking when there is no condition', () => __awaiter(void 0, void 0, void 0, function* () {
103
+ const call = withProvider('{"result": true, "reason": "ok"}');
104
+ for (const empty of ['', ' ', undefined]) {
105
+ const err = yield failureOf(service.assertState(driver, empty));
106
+ (0, chai_1.expect)(err.message).to.match(/needs a condition/);
107
+ }
108
+ (0, chai_1.expect)(call.called).to.equal(false);
109
+ (0, chai_1.expect)(driver.getScreenshot.called).to.equal(false);
110
+ }));
111
+ it('fails when no screenshot can be taken', () => __awaiter(void 0, void 0, void 0, function* () {
112
+ const call = withProvider('{"result": true, "reason": "ok"}');
113
+ driver.getScreenshot.rejects(new Error('WDA is not running'));
114
+ const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
115
+ (0, chai_1.expect)(err.message).to.match(/WDA is not running/);
116
+ (0, chai_1.expect)(call.called).to.equal(false);
117
+ }));
118
+ });