@xenon-device-management/xenon 2.15.0 → 2.16.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 (183) hide show
  1. package/README.md +38 -26
  2. package/lib/package.json +2 -1
  3. package/lib/public/assets/{AnnotationOverlay-Btq3dZRI.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-C9OoAgg_.js → ApiKeyGate-GmckX_7r.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DBC_xczB.js → DeviceMosaicView-DqODzkyo.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DqWU2-vM.js → EmptyState-7hETKEWb.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CveQ3xrA.js → FieldGroup-GOgssFEP.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BfKDSfHz.js → FilterMenu-Yvf6RGMd.js} +1 -1
  9. package/lib/public/assets/{Menu-CtdCybzA.js → Menu-KrPnfdsf.js} +1 -1
  10. package/lib/public/assets/{Modal-Bsvj1tm2.js → Modal-C6WjZFeI.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CRofOiyx.js → RecordingPage-CSRIhZEh.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-jNQHb2IR.js → RecordingsPage-CB8ST3nE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C8HtTha-.js → SegmentedControl-C9o90KId.js} +1 -1
  14. package/lib/public/assets/{SettingCard-D8K1LZNo.js → SettingCard-CPm3w9l2.js} +1 -1
  15. package/lib/public/assets/{Table-zNTX92Pn.js → Table-DwIgERRt.js} +1 -1
  16. package/lib/public/assets/{activity-CJ8LwZq9.js → activity-DrLbHifn.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CgxQZqrc.js → ai-settings-DU_Ls06P.js} +1 -1
  18. package/lib/public/assets/{api-keys-ECpo-zTo.js → api-keys-CWYdEzJ_.js} +1 -1
  19. package/lib/public/assets/{apps-BjGymyf3.js → apps-BqNVdln7.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BWfCbeHP.js → arrow-left-BtB0_Jg_.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DC7UGf_n.js → arrow-right-C8NhdtyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-BOjwybDk.js → arrow-up-right-gMg0ffvv.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CxMTo9aH.js → auth-shell-CiYZ3N4M.js} +2 -2
  24. package/lib/public/assets/{builds-page-DOCm4HkA.js → builds-page-EaaqDmLN.js} +1 -1
  25. package/lib/public/assets/{button-Cg08-EMT.js → button-DDau4Axv.js} +1 -1
  26. package/lib/public/assets/{calendar-CA1ikLJs.js → calendar-DQ83B1ed.js} +1 -1
  27. package/lib/public/assets/{check-BMTaH72W.js → check-K1ovTQam.js} +1 -1
  28. package/lib/public/assets/{chevron-right-B2QOv9Nx.js → chevron-right-Biq00pMt.js} +1 -1
  29. package/lib/public/assets/{circle-check-CYXBoUqT.js → circle-check-CBv88cH7.js} +1 -1
  30. package/lib/public/assets/{circle-x-DTwnSAnl.js → circle-x-Bmltailn.js} +1 -1
  31. package/lib/public/assets/{clock-C8_HViQP.js → clock-BPrld-3h.js} +1 -1
  32. package/lib/public/assets/{copy-Be7LNMXV.js → copy-t0QB14JI.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BOqtfGXz.js → device-explorer-BgGZ8AvV.js} +1 -1
  34. package/lib/public/assets/{download-B56lwxWP.js → download-BroHxNOh.js} +1 -1
  35. package/lib/public/assets/{forgot-password-CCU6bzLn.js → forgot-password-Bjmf49i6.js} +1 -1
  36. package/lib/public/assets/{h264Stream-C-OtmIuk.js → h264Stream-DeD__6wa.js} +1 -1
  37. package/lib/public/assets/{index-CfxsUslp.js → index-72oF89LJ.js} +1 -1
  38. package/lib/public/assets/{index-B9KQ6LsY.js → index-B0JeuZbg.js} +1 -1
  39. package/lib/public/assets/index-B0gQkEg0.js +219 -0
  40. package/lib/public/assets/{input-CZHFjbSg.js → input-DIc4MSC7.js} +1 -1
  41. package/lib/public/assets/{line-chart-Gl_Dz3X_.js → line-chart-BcWm2vW8.js} +1 -1
  42. package/lib/public/assets/{list-checks-CCgjeyGY.js → list-checks-B06IJJad.js} +1 -1
  43. package/lib/public/assets/{lock-CkOwgDcx.js → lock-pVLvnN30.js} +1 -1
  44. package/lib/public/assets/{login-D5cc4w7M.js → login-CN77vy--.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-t7qr3fvX.js → maintenance-settings-CSdzQSkY.js} +1 -1
  46. package/lib/public/assets/{monitor-DEJixKQO.js → monitor-BtrN5IkI.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CtOrk4M8.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
  48. package/lib/public/assets/{network-Dii9qdvk.js → network-BSfA8ZxG.js} +1 -1
  49. package/lib/public/assets/{overview-rolAACrO.js → overview-BfTebCK5.js} +1 -1
  50. package/lib/public/assets/{page-header-COQZxJfR.js → page-header-D8KUX6Qg.js} +1 -1
  51. package/lib/public/assets/{play-DrN1Se_R.js → play-Cvc8xzic.js} +1 -1
  52. package/lib/public/assets/{plus-5aq-9uRx.js → plus-C9Zj84UF.js} +1 -1
  53. package/lib/public/assets/{profile-page-6i5kY6kF.js → profile-page-Bi27STgs.js} +1 -1
  54. package/lib/public/assets/{reset-password-sp2IBrc7.js → reset-password-CPsOuwSu.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJDjeopU.js → runbook-page-DEQ-DBOX.js} +153 -102
  56. package/lib/public/assets/{select-Bpyn7rWO.js → select-BWpnhWXj.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BH74vmKv.js → selector-detail-redirect-BefSCSpe.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-Cw_g218H.js → selector-health-page-MSk96Zz7.js} +1 -1
  59. package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
  60. package/lib/public/assets/{settings-C3uyGhyc.js → settings-V6x-cXwy.js} +1 -1
  61. package/lib/public/assets/{stat-tile-xVqAKIlI.js → stat-tile-Duex-GZD.js} +1 -1
  62. package/lib/public/assets/{tablet-BxUScAeO.js → tablet-BBcoqBmL.js} +1 -1
  63. package/lib/public/assets/{teams-B5ljGkY2.js → teams-ts8p5rEh.js} +1 -1
  64. package/lib/public/assets/{trash-2-B701i8sy.js → trash-2-CZ2ew6AZ.js} +1 -1
  65. package/lib/public/assets/{upload-C2gr44RS.js → upload-Dv99y_le.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-NeQOFEoy.js → use-builds-data-DrYC8JCF.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DEzJq5ef.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
  68. package/lib/public/assets/{users-D_vv_yz4.js → users-D7tKMESH.js} +1 -1
  69. package/lib/public/assets/{users-MY6eNe6s.js → users-gJ5Y01yo.js} +1 -1
  70. package/lib/public/assets/{video-off-OK1lNZQn.js → video-off-CqZVuZGs.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-BU-C9lBf.js → webhook-settings-DI5DSW99.js} +1 -1
  72. package/lib/public/assets/{zap-DO9ry1iz.js → zap-ClcHFxfo.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +11 -7
  75. package/lib/src/InternalHttpClient.js +27 -5
  76. package/lib/src/XenonCapabilityManager.js +10 -3
  77. package/lib/src/app/openapi/sessions.yaml +7 -5
  78. package/lib/src/app/routers/auth.js +2 -1
  79. package/lib/src/app/routers/users.js +2 -1
  80. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  81. package/lib/src/dashboard/commandLogFields.js +42 -0
  82. package/lib/src/dashboard/event-manager.js +26 -154
  83. package/lib/src/dashboard/services/failure-analysis-service.js +8 -86
  84. package/lib/src/dashboard/services/failureCategories.js +264 -0
  85. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  86. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  87. package/lib/src/device-utils.js +14 -6
  88. package/lib/src/gateway/forwardToNode.js +4 -11
  89. package/lib/src/gateway/hubSessionToken.js +27 -4
  90. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  91. package/lib/src/helpers/outboundProxy.js +219 -0
  92. package/lib/src/index.js +21 -0
  93. package/lib/src/interceptors/CommandInterceptor.js +13 -15
  94. package/lib/src/interceptors/xenonScripts.js +28 -7
  95. package/lib/src/logger.js +3 -1
  96. package/lib/src/plugin.js +8 -3
  97. package/lib/src/scripts/database-check.js +3 -3
  98. package/lib/src/scripts/initialize-database.js +35 -33
  99. package/lib/src/scripts/run-migrations.js +1079 -27
  100. package/lib/src/services/ApiKeyService.js +16 -5
  101. package/lib/src/services/InterceptorService.js +6 -2
  102. package/lib/src/services/OrphanSweeper.js +6 -0
  103. package/lib/src/services/SelectorStateService.js +11 -7
  104. package/lib/src/services/SelectorVerificationJob.js +6 -2
  105. package/lib/src/services/SessionLifecycleService.js +32 -18
  106. package/lib/src/services/SocketClient.js +11 -0
  107. package/lib/src/services/SocketServer.js +361 -42
  108. package/lib/src/services/TeamService.js +5 -1
  109. package/lib/src/services/TracingService.js +171 -60
  110. package/lib/src/services/UserService.js +14 -3
  111. package/lib/src/services/UserSessionService.js +16 -4
  112. package/lib/src/services/healing/HealingOrchestrator.js +3 -1
  113. package/lib/src/services/identity/identityChanges.js +41 -0
  114. package/lib/src/services/logcat/SessionDeviceLogs.js +97 -10
  115. package/lib/src/services/logcat/deviceLogBook.js +5 -4
  116. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  117. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  118. package/lib/src/services/token/JwtKeyService.js +8 -2
  119. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  120. package/lib/src/sessions/LocalSession.js +29 -1
  121. package/lib/src/sessions/RemoteSession.js +12 -2
  122. package/lib/src/sessions/SessionManager.js +5 -8
  123. package/lib/test/helpers/driver-log.js +38 -0
  124. package/lib/test/helpers/fake-http-proxy.js +106 -0
  125. package/lib/test/helpers/otlp-probe.js +107 -0
  126. package/lib/test/integration/androidDevices.spec.js +1 -1
  127. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  128. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  129. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  130. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  131. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  132. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  133. package/lib/test/unit/dashboard-events-device-logs.spec.js +59 -11
  134. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  135. package/lib/test/unit/database-check.spec.js +1 -0
  136. package/lib/test/unit/device-utils.spec.js +13 -4
  137. package/lib/test/unit/event-log-session-data.spec.js +11 -4
  138. package/lib/test/unit/failure-analysis.spec.js +22 -8
  139. package/lib/test/unit/failure-categories.spec.js +24 -22
  140. package/lib/test/unit/failure-categorization.spec.js +393 -0
  141. package/lib/test/unit/healing-tier-gate.spec.js +3 -2
  142. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +72 -1
  143. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  144. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  145. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  146. package/lib/test/unit/logcat-ws.spec.js +78 -37
  147. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  148. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  149. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  150. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  151. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  152. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  153. package/lib/test/unit/proxy-option.spec.js +358 -0
  154. package/lib/test/unit/run-migrations-database.spec.js +807 -0
  155. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  156. package/lib/test/unit/run-migrations.test.js +864 -0
  157. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  158. package/lib/test/unit/selector-state-service.spec.js +72 -23
  159. package/lib/test/unit/selector-verification-clean-build.spec.js +1 -1
  160. package/lib/test/unit/selector-verification-job.spec.js +15 -6
  161. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  162. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  163. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  164. package/lib/test/unit/session-device-logs.spec.js +11 -2
  165. package/lib/test/unit/session-end-failure-analysis.spec.js +5 -5
  166. package/lib/test/unit/session-log-indexes.spec.js +1 -1
  167. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  168. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  169. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  170. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  171. package/lib/test/unit/unexpected-shutdown-reason.spec.js +2 -2
  172. package/lib/tsconfig.tsbuildinfo +1 -1
  173. package/package.json +2 -1
  174. package/schema.json +11 -7
  175. package/scripts/dev/readme-screenshots.js +276 -23
  176. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  177. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  178. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  179. package/scripts/export-openapi.js +32 -0
  180. package/scripts/lib/openapi-for-site.js +79 -0
  181. package/lib/public/assets/index-BOxe43p1.js +0 -219
  182. package/lib/public/assets/session-detail-page-AZJE6XXE.js +0 -4
  183. package/lib/src/scripts/prepare-prisma.js +0 -89
@@ -19,6 +19,7 @@ const typedi_1 = require("typedi");
19
19
  const SocketServer_1 = require("../../src/services/SocketServer");
20
20
  const EventLogService_1 = require("../../src/services/EventLogService");
21
21
  const DeviceTeamResolver_1 = require("../../src/services/device-access/DeviceTeamResolver");
22
+ const SelectorVisibilityResolver_1 = require("../../src/services/selector-health/SelectorVisibilityResolver");
22
23
  const recording_store_1 = require("../../src/services/recording/recording-store");
23
24
  const NotificationService_1 = require("../../src/services/NotificationService");
24
25
  const device_store_1 = require("../../src/data-service/device-store");
@@ -32,6 +33,7 @@ const container_registration_1 = require("../helpers/container-registration");
32
33
  * `io.to(room).emit` for the unscoped path.
33
34
  */
34
35
  function fakeIo(specs) {
36
+ var _a, _b;
35
37
  const received = new Map();
36
38
  const sockets = new Map();
37
39
  const dashboard = new Set();
@@ -43,8 +45,8 @@ function fakeIo(specs) {
43
45
  data: {
44
46
  identity: {
45
47
  principal: 'dashboard',
46
- userId: `user-${s.id}`,
47
- role: s.teamIds === undefined ? 'ADMIN' : 'MEMBER',
48
+ userId: (_a = s.userId) !== null && _a !== void 0 ? _a : `user-${s.id}`,
49
+ role: (_b = s.role) !== null && _b !== void 0 ? _b : (s.teamIds === undefined ? 'ADMIN' : 'MEMBER'),
48
50
  teamIds: s.teamIds,
49
51
  },
50
52
  },
@@ -324,15 +326,245 @@ describe('SocketServer.emitToDashboardForDevices — events reach only the teams
324
326
  (0, chai_1.expect)(events(inbox('member-a'))).to.deep.equal(['device_unblocked']);
325
327
  }));
326
328
  });
327
- it('emitToDashboard (selector events, nodes) stays unscoped', () => {
329
+ describe('adminOnly: captured network traffic, which REST serves to admins only', () => {
330
+ it("reaches admins, never the members who see the phone (a team's or the shared pool's)", () => __awaiter(void 0, void 0, void 0, function* () {
331
+ const { inbox } = connect(EVERYONE);
332
+ yield server.emitToDashboardForDevices('interceptor_request', { id: 'q-a' }, { udid: 'phone-a', adminOnly: true });
333
+ yield server.emitToDashboardForDevices('interceptor_request', { id: 'q-s' }, { udid: 'phone-s', adminOnly: true });
334
+ (0, chai_1.expect)(inbox('admin').map(([, d]) => d.id)).to.deep.equal(['q-a', 'q-s']);
335
+ for (const id of ['member-a', 'member-b', 'member-none', 'node']) {
336
+ (0, chai_1.expect)(inbox(id), id).to.deep.equal([]);
337
+ }
338
+ }));
339
+ it('with members connected it looks nothing up: only admins, who see every phone, are sent it', () => __awaiter(void 0, void 0, void 0, function* () {
340
+ const { inbox } = connect(EVERYONE);
341
+ // A slow store must not hold captured requests back either.
342
+ holdLookups();
343
+ yield server.emitToDashboardForDevices('interceptor_request', { id: 'q1' }, {
344
+ udid: 'phone-a',
345
+ adminOnly: true,
346
+ });
347
+ (0, chai_1.expect)(lookups).to.deep.equal([]);
348
+ (0, chai_1.expect)(events(inbox('admin'))).to.deep.equal(['interceptor_request']);
349
+ (0, chai_1.expect)(inbox('member-a')).to.deep.equal([]);
350
+ }));
351
+ it('the role decides, not the team list: a member with no team list still gets nothing', () => __awaiter(void 0, void 0, void 0, function* () {
352
+ // No socket has a member's role and no team list today; if one ever
353
+ // does, it must not take the synchronous room broadcast.
354
+ const { inbox } = connect([
355
+ { id: 'admin', teamIds: undefined },
356
+ { id: 'odd-member', teamIds: undefined, role: 'MEMBER' },
357
+ ]);
358
+ yield server.emitToDashboardForDevices('interceptor_request', { id: 'q1' }, { udid: 'phone-s', adminOnly: true });
359
+ (0, chai_1.expect)(events(inbox('admin'))).to.deep.equal(['interceptor_request']);
360
+ (0, chai_1.expect)(inbox('odd-member')).to.deep.equal([]);
361
+ }));
362
+ it('a socket with no identity never gets it', () => __awaiter(void 0, void 0, void 0, function* () {
363
+ const { io, inbox } = fakeIo([
364
+ { id: 'admin', teamIds: undefined },
365
+ { id: 'odd', teamIds: [] },
366
+ ]);
367
+ io.sockets.sockets.get('odd').data = {};
368
+ server.io = io;
369
+ yield server.emitToDashboardForDevices('interceptor_request', {}, {
370
+ udid: 'phone-s',
371
+ adminOnly: true,
372
+ });
373
+ (0, chai_1.expect)(events(inbox('admin'))).to.deep.equal(['interceptor_request']);
374
+ (0, chai_1.expect)(inbox('odd')).to.deep.equal([]);
375
+ }));
376
+ it('auth disabled (every socket a super admin): the room broadcast as before, with no lookup', () => __awaiter(void 0, void 0, void 0, function* () {
377
+ const { inbox, roomBroadcasts } = connect([
378
+ { id: 'lab-1', teamIds: undefined, role: 'SUPER_ADMIN' },
379
+ { id: 'lab-2', teamIds: undefined, role: 'SUPER_ADMIN' },
380
+ ]);
381
+ yield server.emitToDashboardForDevices('interceptor_request', {}, {
382
+ udid: 'phone-b',
383
+ adminOnly: true,
384
+ });
385
+ (0, chai_1.expect)(roomBroadcasts).to.deep.equal(['dashboard']);
386
+ (0, chai_1.expect)(lookups).to.deep.equal([]);
387
+ (0, chai_1.expect)(events(inbox('lab-1'))).to.deep.equal(['interceptor_request']);
388
+ (0, chai_1.expect)(events(inbox('lab-2'))).to.deep.equal(['interceptor_request']);
389
+ }));
390
+ it("keeps the phone's order: it waits for the phone's event ahead of it", () => __awaiter(void 0, void 0, void 0, function* () {
391
+ const { inbox } = connect(EVERYONE);
392
+ holdLookups();
393
+ const command = server.emitToDashboardForDevices('session_command', {}, { udid: 'phone-a' });
394
+ const captured = server.emitToDashboardForDevices('interceptor_request', {}, {
395
+ udid: 'phone-a',
396
+ adminOnly: true,
397
+ });
398
+ yield new Promise((r) => setImmediate(r));
399
+ (0, chai_1.expect)(inbox('admin'), 'nothing overtakes the pending event').to.deep.equal([]);
400
+ openGate();
401
+ yield Promise.all([command, captured]);
402
+ (0, chai_1.expect)(events(inbox('admin'))).to.deep.equal(['session_command', 'interceptor_request']);
403
+ (0, chai_1.expect)(events(inbox('member-a'))).to.deep.equal(['session_command']);
404
+ }));
405
+ it('{ udids, strip, adminOnly }: admins only, the payload untouched', () => __awaiter(void 0, void 0, void 0, function* () {
406
+ const { inbox } = connect(EVERYONE);
407
+ const payload = { recordings: [{ udid: 'phone-a' }, { udid: 'phone-s' }] };
408
+ yield server.emitToDashboardForDevices('capture_group', payload, {
409
+ udids: ['phone-a', 'phone-s'],
410
+ strip: (d, visible) => (Object.assign(Object.assign({}, d), { recordings: d.recordings.filter((r) => visible.includes(r.udid)) })),
411
+ adminOnly: true,
412
+ });
413
+ (0, chai_1.expect)(inbox('admin')[0][1]).to.equal(payload);
414
+ for (const id of ['member-a', 'member-b', 'member-none']) {
415
+ (0, chai_1.expect)(inbox(id), id).to.deep.equal([]);
416
+ }
417
+ }));
418
+ it('is still recorded in the event log once, as any other event', () => __awaiter(void 0, void 0, void 0, function* () {
419
+ connect(EVERYONE);
420
+ // interceptor_request itself is never logged (EVENT_LOG_KEEPS); its start is.
421
+ yield server.emitToDashboardForDevices('interceptor_session_started', { sessionId: 's1' }, {
422
+ udid: 'phone-a',
423
+ adminOnly: true,
424
+ });
425
+ (0, chai_1.expect)(appendSafe.callCount).to.equal(1);
426
+ }));
427
+ });
428
+ it('node events (emitToDashboard) stay unscoped', () => {
328
429
  const { inbox } = connect(EVERYONE);
329
- server.emitToDashboard('selector_fixed', { selector: '//x' });
430
+ server.emitToDashboard('node_connected', { host: 'http://node:4723' });
330
431
  for (const id of ['admin', 'member-a', 'member-b', 'member-none']) {
331
- (0, chai_1.expect)(events(inbox(id)), id).to.deep.equal(['selector_fixed']);
432
+ (0, chai_1.expect)(events(inbox(id)), id).to.deep.equal(['node_connected']);
332
433
  }
333
434
  (0, chai_1.expect)(lookups).to.deep.equal([]);
334
435
  });
335
436
  });
437
+ describe('SocketServer.emitToDashboardForSelector — a selector event reaches only those who may see the selector', () => {
438
+ /** The teams whose sessions healed each selector (Selector Health's rule, faked). */
439
+ const HEALED_ON = {
440
+ '//team-a': ['team-a'],
441
+ '//team-b': ['team-b'],
442
+ };
443
+ const TEAM_B = { strategy: 'xpath', selector: '//team-b' };
444
+ let restore;
445
+ let server;
446
+ let appendSafe;
447
+ let viewers;
448
+ let gate;
449
+ let openGate;
450
+ let failLookups;
451
+ function connect(specs) {
452
+ const fake = fakeIo(specs);
453
+ server.io = fake.io;
454
+ return fake;
455
+ }
456
+ beforeEach(() => {
457
+ restore = (0, container_registration_1.saveRegistrations)(EventLogService_1.EventLogService, SelectorVisibilityResolver_1.SelectorVisibilityResolver);
458
+ appendSafe = sinon_1.default.spy();
459
+ typedi_1.Container.set(EventLogService_1.EventLogService, { appendSafe });
460
+ viewers = [];
461
+ gate = undefined;
462
+ failLookups = false;
463
+ typedi_1.Container.set(SelectorVisibilityResolver_1.SelectorVisibilityResolver, new SelectorVisibilityResolver_1.SelectorVisibilityResolver({
464
+ sessionScope: (viewer) => __awaiter(void 0, void 0, void 0, function* () { return viewer; }),
465
+ healedIn: (key, scope) => __awaiter(void 0, void 0, void 0, function* () {
466
+ var _a;
467
+ viewers.push(scope);
468
+ if (gate)
469
+ yield gate;
470
+ if (failLookups)
471
+ throw new Error('database gone');
472
+ return ((_a = HEALED_ON[key.selector]) !== null && _a !== void 0 ? _a : []).some((t) => scope.teamIds.includes(t));
473
+ }),
474
+ }));
475
+ server = new SocketServer_1.SocketServer();
476
+ });
477
+ afterEach(() => {
478
+ sinon_1.default.restore();
479
+ restore();
480
+ });
481
+ const EVERYONE = [
482
+ { id: 'admin', teamIds: undefined },
483
+ { id: 'member-a', teamIds: ['team-a'] },
484
+ { id: 'member-b', teamIds: ['team-b'] },
485
+ { id: 'member-none', teamIds: [] },
486
+ { id: 'node', teamIds: ['team-b'], dashboard: false },
487
+ ];
488
+ it('an admin gets every selector event; a member only those of selectors they may see', () => __awaiter(void 0, void 0, void 0, function* () {
489
+ const { inbox } = connect(EVERYONE);
490
+ yield server.emitToDashboardForSelector('selector_muted', { original_selector: '//team-b' }, TEAM_B);
491
+ (0, chai_1.expect)(events(inbox('admin'))).to.deep.equal(['selector_muted']);
492
+ (0, chai_1.expect)(events(inbox('member-b'))).to.deep.equal(['selector_muted']);
493
+ for (const id of ['member-a', 'member-none', 'node']) {
494
+ (0, chai_1.expect)(inbox(id), id).to.deep.equal([]);
495
+ }
496
+ }));
497
+ it("asks about each member's user and teams; never about an admin", () => __awaiter(void 0, void 0, void 0, function* () {
498
+ connect(EVERYONE);
499
+ yield server.emitToDashboardForSelector('selector_fixed', {}, TEAM_B);
500
+ (0, chai_1.expect)(viewers).to.have.deep.members([
501
+ { userId: 'user-member-a', teamIds: ['team-a'] },
502
+ { userId: 'user-member-b', teamIds: ['team-b'] },
503
+ { userId: 'user-member-none', teamIds: [] },
504
+ ]);
505
+ }));
506
+ it("a member's two tabs cost one lookup", () => __awaiter(void 0, void 0, void 0, function* () {
507
+ const { inbox } = connect([
508
+ { id: 'tab-1', teamIds: ['team-b'], userId: 'u-b' },
509
+ { id: 'tab-2', teamIds: ['team-b'], userId: 'u-b' },
510
+ ]);
511
+ yield server.emitToDashboardForSelector('selector_progress', {}, TEAM_B);
512
+ (0, chai_1.expect)(viewers).to.have.length(1);
513
+ (0, chai_1.expect)(events(inbox('tab-1'))).to.deep.equal(['selector_progress']);
514
+ (0, chai_1.expect)(events(inbox('tab-2'))).to.deep.equal(['selector_progress']);
515
+ }));
516
+ it('a lookup that fails sends it to admins only', () => __awaiter(void 0, void 0, void 0, function* () {
517
+ failLookups = true;
518
+ const { inbox } = connect(EVERYONE);
519
+ yield server.emitToDashboardForSelector('selector_regressed', {}, TEAM_B);
520
+ (0, chai_1.expect)(events(inbox('admin'))).to.deep.equal(['selector_regressed']);
521
+ (0, chai_1.expect)(inbox('member-b')).to.deep.equal([]);
522
+ }));
523
+ it('a socket with no identity is a member of no team, with no user', () => __awaiter(void 0, void 0, void 0, function* () {
524
+ const { io, inbox } = fakeIo([{ id: 'odd', teamIds: [] }]);
525
+ io.sockets.sockets.get('odd').data = {};
526
+ server.io = io;
527
+ yield server.emitToDashboardForSelector('selector_fixed', {}, TEAM_B);
528
+ (0, chai_1.expect)(viewers).to.deep.equal([{ userId: undefined, teamIds: [] }]);
529
+ (0, chai_1.expect)(inbox('odd')).to.deep.equal([]);
530
+ }));
531
+ it("a selector's events keep their order: the second waits for the first's lookup", () => __awaiter(void 0, void 0, void 0, function* () {
532
+ const { inbox } = connect(EVERYONE);
533
+ gate = new Promise((resolve) => (openGate = resolve));
534
+ const fixed = server.emitToDashboardForSelector('selector_fixed', {}, TEAM_B);
535
+ const cancelled = server.emitToDashboardForSelector('selector_cancelled', {}, TEAM_B);
536
+ yield new Promise((r) => setImmediate(r));
537
+ (0, chai_1.expect)(inbox('admin'), 'nothing overtakes the pending event').to.deep.equal([]);
538
+ openGate();
539
+ yield Promise.all([fixed, cancelled]);
540
+ for (const id of ['admin', 'member-b']) {
541
+ (0, chai_1.expect)(events(inbox(id)), id).to.deep.equal(['selector_fixed', 'selector_cancelled']);
542
+ }
543
+ }));
544
+ it('with only unscoped sockets (auth disabled) it is the room broadcast, with no lookup', () => __awaiter(void 0, void 0, void 0, function* () {
545
+ const { inbox, roomBroadcasts } = connect([
546
+ { id: 'lab-1', teamIds: undefined, role: 'SUPER_ADMIN' },
547
+ { id: 'lab-2', teamIds: undefined, role: 'SUPER_ADMIN' },
548
+ ]);
549
+ void server.emitToDashboardForSelector('selector_fixed', {}, TEAM_B);
550
+ (0, chai_1.expect)(roomBroadcasts).to.deep.equal(['dashboard']);
551
+ (0, chai_1.expect)(viewers).to.deep.equal([]);
552
+ (0, chai_1.expect)(events(inbox('lab-1'))).to.deep.equal(['selector_fixed']);
553
+ (0, chai_1.expect)(events(inbox('lab-2'))).to.deep.equal(['selector_fixed']);
554
+ }));
555
+ it('is recorded in the event log once, unscoped', () => __awaiter(void 0, void 0, void 0, function* () {
556
+ connect(EVERYONE);
557
+ const payload = { original_selector: '//team-b' };
558
+ yield server.emitToDashboardForSelector('selector_muted', payload, TEAM_B);
559
+ (0, chai_1.expect)(appendSafe.callCount).to.equal(1);
560
+ (0, chai_1.expect)(appendSafe.firstCall.args[0]).to.deep.equal({ type: 'selector_muted', payload });
561
+ }));
562
+ it('before initialize() (no io) it only logs', () => __awaiter(void 0, void 0, void 0, function* () {
563
+ yield server.emitToDashboardForSelector('selector_muted', {}, TEAM_B);
564
+ (0, chai_1.expect)(viewers).to.deep.equal([]);
565
+ (0, chai_1.expect)(appendSafe.callCount).to.equal(1);
566
+ }));
567
+ });
336
568
  describe('Auth disabled: the lab path looks nothing up', () => {
337
569
  let restore;
338
570
  let authDisabled;
@@ -0,0 +1,137 @@
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 api_1 = require("@opentelemetry/api");
19
+ const api_logs_1 = require("@opentelemetry/api-logs");
20
+ const TracingService_1 = require("../../src/services/TracingService");
21
+ const logger_1 = require("../../src/logger");
22
+ const otlp_probe_1 = require("../helpers/otlp-probe");
23
+ const ENV_KEYS = [
24
+ 'OTEL_SDK_DISABLED',
25
+ 'OTEL_LOGS_ENABLED',
26
+ 'OTEL_TRACES_ENABLED',
27
+ 'OTEL_METRICS_ENABLED',
28
+ 'OTEL_EXPORTER_OTLP_ENDPOINT',
29
+ 'OTEL_EXPORTER_OTLP_LOGS_ENDPOINT',
30
+ 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT',
31
+ 'XENON_OTEL_DEBUG',
32
+ ];
33
+ /**
34
+ * With tracing on, Xenon builds an OTel NodeSDK for its spans. A NodeSDK
35
+ * that isn't handed log processors or metric readers builds its own from the
36
+ * standard OTEL_* variables and registers them globally before Xenon could.
37
+ * Xenon's own logger and meter providers were then refused, so:
38
+ * - logs went out as protobuf through the SDK's exporter, not Xenon's;
39
+ * - metrics went to the trace URL + /v1/metrics, even with
40
+ * OTEL_METRICS_ENABLED=false;
41
+ * - only traces on also exported logs, to the trace URL + /v1/logs;
42
+ * - XENON_OTEL_DEBUG's console log and metric exporters never printed.
43
+ *
44
+ * A probe collector shows where each signal went.
45
+ */
46
+ describe('TracingService — where logs and metrics go with tracing on', function () {
47
+ this.timeout(20000);
48
+ let probe;
49
+ let service;
50
+ let saved;
51
+ beforeEach(() => __awaiter(this, void 0, void 0, function* () {
52
+ saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
53
+ for (const k of ENV_KEYS)
54
+ delete process.env[k];
55
+ (0, otlp_probe_1.resetOtelGlobals)();
56
+ logger_1.XenonLogger.setOtlpReady(false);
57
+ probe = yield (0, otlp_probe_1.startOtlpProbe)();
58
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT = probe.url('/v1/traces');
59
+ }));
60
+ afterEach(() => __awaiter(this, void 0, void 0, function* () {
61
+ yield (service === null || service === void 0 ? void 0 : service.shutdown());
62
+ service = undefined;
63
+ sinon_1.default.restore();
64
+ (0, otlp_probe_1.resetOtelGlobals)();
65
+ logger_1.XenonLogger.setOtlpReady(false);
66
+ yield probe.close();
67
+ for (const k of ENV_KEYS) {
68
+ if (saved[k] === undefined)
69
+ delete process.env[k];
70
+ else
71
+ process.env[k] = saved[k];
72
+ }
73
+ }));
74
+ function start() {
75
+ service = new TracingService_1.TracingService();
76
+ service.initialize({ isHub: true });
77
+ return service;
78
+ }
79
+ function countProbeMetric() {
80
+ api_1.metrics.getMeter('probe').createCounter('xenon.probe.count').add(1);
81
+ }
82
+ function emitProbeLog() {
83
+ api_logs_1.logs.getLogger('probe').emit({ body: 'probe line', severityNumber: api_logs_1.SeverityNumber.INFO });
84
+ }
85
+ it('exports no metrics with OTEL_METRICS_ENABLED=false', () => __awaiter(this, void 0, void 0, function* () {
86
+ process.env.OTEL_METRICS_ENABLED = 'false';
87
+ start();
88
+ countProbeMetric();
89
+ yield (0, otlp_probe_1.flushOtelGlobals)();
90
+ (0, chai_1.expect)(probe.requests.map((r) => r.path).filter((p) => p.includes('metrics'))).to.deep.equal([]);
91
+ }));
92
+ it('exports no metrics when no metrics endpoint is set, and never to the trace URL', () => __awaiter(this, void 0, void 0, function* () {
93
+ start();
94
+ countProbeMetric();
95
+ yield (0, otlp_probe_1.flushOtelGlobals)();
96
+ (0, chai_1.expect)(probe.requests.map((r) => r.path)).to.not.include('/v1/traces/v1/metrics');
97
+ }));
98
+ it("sends metrics to Xenon's metrics endpoint as OTLP/JSON", () => __awaiter(this, void 0, void 0, function* () {
99
+ process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = probe.url('/xenon-metrics');
100
+ start();
101
+ countProbeMetric();
102
+ yield (0, otlp_probe_1.flushOtelGlobals)();
103
+ const sent = probe.requests.filter((r) => r.path === '/xenon-metrics');
104
+ (0, chai_1.expect)(sent, 'metrics export').to.have.length.greaterThan(0);
105
+ (0, chai_1.expect)(sent.every((r) => r.contentType.includes('application/json'))).to.equal(true);
106
+ (0, chai_1.expect)(sent.map((r) => r.body.toString('utf8')).join()).to.include('xenon.probe.count');
107
+ }));
108
+ it('exports no logs when only traces are configured', () => __awaiter(this, void 0, void 0, function* () {
109
+ start();
110
+ emitProbeLog();
111
+ yield (0, otlp_probe_1.flushOtelGlobals)();
112
+ (0, chai_1.expect)(probe.requests.map((r) => r.path).filter((p) => p.includes('logs'))).to.deep.equal([]);
113
+ }));
114
+ it("sends logs to Xenon's log endpoint as OTLP/JSON", () => __awaiter(this, void 0, void 0, function* () {
115
+ process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = probe.url('/xenon-logs');
116
+ start();
117
+ emitProbeLog();
118
+ yield (0, otlp_probe_1.flushOtelGlobals)();
119
+ const sent = probe.requests.filter((r) => r.path === '/xenon-logs');
120
+ (0, chai_1.expect)(sent, 'logs export').to.have.length.greaterThan(0);
121
+ (0, chai_1.expect)(sent.every((r) => r.contentType.includes('application/json'))).to.equal(true);
122
+ (0, chai_1.expect)(sent.map((r) => r.body.toString('utf8')).join()).to.include('probe line');
123
+ }));
124
+ it('prints logs and metrics to the console with XENON_OTEL_DEBUG=true', () => __awaiter(this, void 0, void 0, function* () {
125
+ const printed = sinon_1.default.stub(console, 'dir');
126
+ process.env.XENON_OTEL_DEBUG = 'true';
127
+ process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = probe.url('/xenon-logs');
128
+ start();
129
+ emitProbeLog();
130
+ countProbeMetric();
131
+ yield (0, otlp_probe_1.flushOtelGlobals)();
132
+ printed.restore();
133
+ const shown = printed.getCalls().map((c) => JSON.stringify(c.args[0]));
134
+ (0, chai_1.expect)(shown.some((s) => s.includes('probe line')), 'console log record').to.equal(true);
135
+ (0, chai_1.expect)(shown.some((s) => s.includes('xenon.probe.count')), 'console metric').to.equal(true);
136
+ }));
137
+ });