@xenon-device-management/xenon 2.14.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 (335) hide show
  1. package/README.md +44 -29
  2. package/lib/package.json +6 -5
  3. package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-GmckX_7r.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-DqODzkyo.js +2 -0
  6. package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-7hETKEWb.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-GOgssFEP.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-Yvf6RGMd.js} +1 -1
  9. package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-KrPnfdsf.js} +1 -1
  10. package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-C6WjZFeI.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CSRIhZEh.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-CB8ST3nE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C9o90KId.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-CPm3w9l2.js} +1 -1
  15. package/lib/public/assets/{Table-BcTm5xV2.js → Table-DwIgERRt.js} +1 -1
  16. package/lib/public/assets/{activity-DOVdsfMQ.js → activity-DrLbHifn.js} +1 -1
  17. package/lib/public/assets/ai-settings-DU_Ls06P.js +16 -0
  18. package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-CWYdEzJ_.js} +1 -1
  19. package/lib/public/assets/{apps-D0SC6uw6.js → apps-BqNVdln7.js} +1 -1
  20. package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BtB0_Jg_.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-C8NhdtyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-gMg0ffvv.js} +1 -1
  23. package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CiYZ3N4M.js} +2 -2
  24. package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-EaaqDmLN.js} +1 -1
  25. package/lib/public/assets/{button-Dda4irn_.js → button-DDau4Axv.js} +1 -1
  26. package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-DQ83B1ed.js} +1 -1
  27. package/lib/public/assets/{check-CZ9iw9-e.js → check-K1ovTQam.js} +1 -1
  28. package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-Biq00pMt.js} +1 -1
  29. package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CBv88cH7.js} +1 -1
  30. package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-Bmltailn.js} +1 -1
  31. package/lib/public/assets/{clock-CpuDKysU.js → clock-BPrld-3h.js} +1 -1
  32. package/lib/public/assets/{copy-CAQydNFe.js → copy-t0QB14JI.js} +1 -1
  33. package/lib/public/assets/derive-UnLRp4r3.js +1 -0
  34. package/lib/public/assets/device-explorer-BgGZ8AvV.js +282 -0
  35. package/lib/public/assets/{download-C9XG6lLv.js → download-BroHxNOh.js} +1 -1
  36. package/lib/public/assets/forgot-password-Bjmf49i6.js +1 -0
  37. package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-DeD__6wa.js} +6 -6
  38. package/lib/public/assets/{index-DJGc1M2f.js → index-72oF89LJ.js} +1 -1
  39. package/lib/public/assets/index-B0JeuZbg.js +15 -0
  40. package/lib/public/assets/index-B0gQkEg0.js +219 -0
  41. package/lib/public/assets/{input-Cm0tmSoc.js → input-DIc4MSC7.js} +1 -1
  42. package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-BcWm2vW8.js} +1 -1
  43. package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-B06IJJad.js} +1 -1
  44. package/lib/public/assets/{lock-CCJv9m_I.js → lock-pVLvnN30.js} +1 -1
  45. package/lib/public/assets/{login-f8vOVW-U.js → login-CN77vy--.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-CSdzQSkY.js} +1 -1
  47. package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-BtrN5IkI.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
  49. package/lib/public/assets/{network-ebT86qoi.js → network-BSfA8ZxG.js} +1 -1
  50. package/lib/public/assets/{overview-C0aykVrd.js → overview-BfTebCK5.js} +1 -1
  51. package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-D8KUX6Qg.js} +1 -1
  52. package/lib/public/assets/{play-DLG9t8FU.js → play-Cvc8xzic.js} +1 -1
  53. package/lib/public/assets/{plus-nBFSMu_X.js → plus-C9Zj84UF.js} +1 -1
  54. package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-Bi27STgs.js} +1 -1
  55. package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-CPsOuwSu.js} +1 -1
  56. package/lib/public/assets/runbook-page-DEQ-DBOX.js +304 -0
  57. package/lib/public/assets/{select-88jHcl-Z.js → select-BWpnhWXj.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BefSCSpe.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-MSk96Zz7.js} +1 -1
  60. package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
  61. package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
  62. package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-V6x-cXwy.js} +1 -1
  63. package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-Duex-GZD.js} +1 -1
  64. package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BBcoqBmL.js} +1 -1
  65. package/lib/public/assets/{teams-B4-jrr-1.js → teams-ts8p5rEh.js} +1 -1
  66. package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-CZ2ew6AZ.js} +1 -1
  67. package/lib/public/assets/{upload-BG7_rMXK.js → upload-Dv99y_le.js} +1 -1
  68. package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-DrYC8JCF.js} +1 -1
  69. package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
  70. package/lib/public/assets/{users-CFba5TSy.js → users-D7tKMESH.js} +1 -1
  71. package/lib/public/assets/{users-Cp0beJm3.js → users-gJ5Y01yo.js} +1 -1
  72. package/lib/public/assets/{video-off-DDsEISjV.js → video-off-CqZVuZGs.js} +1 -1
  73. package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-DI5DSW99.js} +1 -1
  74. package/lib/public/assets/{zap-l4VaXzq8.js → zap-ClcHFxfo.js} +1 -1
  75. package/lib/public/index.html +1 -1
  76. package/lib/schema.json +16 -12
  77. package/lib/src/InternalHttpClient.js +27 -5
  78. package/lib/src/XenonCapabilityManager.js +10 -3
  79. package/lib/src/app/dashboardPluginLink.js +117 -0
  80. package/lib/src/app/index.js +13 -34
  81. package/lib/src/app/openapi/control.yaml +6 -4
  82. package/lib/src/app/openapi/grid.yaml +8 -2
  83. package/lib/src/app/openapi/identity.yaml +103 -31
  84. package/lib/src/app/openapi/platform.yaml +33 -15
  85. package/lib/src/app/openapi/sessions.yaml +12 -3
  86. package/lib/src/app/routers/apikeys.js +14 -2
  87. package/lib/src/app/routers/auth.js +31 -6
  88. package/lib/src/app/routers/control.js +13 -5
  89. package/lib/src/app/routers/dashboard.js +42 -27
  90. package/lib/src/app/routers/grid.js +5 -3
  91. package/lib/src/app/routers/profile.js +21 -0
  92. package/lib/src/app/routers/users.js +12 -6
  93. package/lib/src/app/swagger.js +2 -0
  94. package/lib/src/app/ws/h264StreamWs.js +10 -1
  95. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  96. package/lib/src/config.js +1 -0
  97. package/lib/src/dashboard/commandLogFields.js +42 -0
  98. package/lib/src/dashboard/commands.js +5 -5
  99. package/lib/src/dashboard/event-manager.js +64 -204
  100. package/lib/src/dashboard/services/failure-analysis-service.js +118 -105
  101. package/lib/src/dashboard/services/failureCategories.js +264 -0
  102. package/lib/src/dashboard/sessionCommandSummary.js +39 -0
  103. package/lib/src/data-service/device-store.js +14 -1
  104. package/lib/src/data-service/prisma-store.js +19 -7
  105. package/lib/src/data-service/web-config-service.js +7 -0
  106. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  107. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  108. package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
  109. package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
  110. package/lib/src/device-utils.js +29 -3
  111. package/lib/src/gateway/defaultGateway.js +1 -1
  112. package/lib/src/gateway/forwardToNode.js +6 -11
  113. package/lib/src/gateway/healReport.js +87 -0
  114. package/lib/src/gateway/hubSessionToken.js +27 -4
  115. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  116. package/lib/src/gateway/sessionGateway.js +12 -2
  117. package/lib/src/generated/client/edge.js +4 -3
  118. package/lib/src/generated/client/index-browser.js +1 -0
  119. package/lib/src/generated/client/index.d.ts +32 -0
  120. package/lib/src/generated/client/index.js +4 -3
  121. package/lib/src/generated/client/package.json +1 -1
  122. package/lib/src/generated/client/schema.prisma +3 -0
  123. package/lib/src/generated/client/wasm.js +1 -0
  124. package/lib/src/helpers/outboundProxy.js +219 -0
  125. package/lib/src/index.js +21 -0
  126. package/lib/src/interceptors/CommandInterceptor.js +256 -196
  127. package/lib/src/interceptors/xenonScripts.js +28 -7
  128. package/lib/src/logger.js +3 -1
  129. package/lib/src/middleware/authMiddleware.js +15 -3
  130. package/lib/src/middleware/commandCaller.js +3 -3
  131. package/lib/src/middleware/verifyCredential.js +58 -1
  132. package/lib/src/plugin.js +16 -6
  133. package/lib/src/scripts/database-check.js +3 -3
  134. package/lib/src/scripts/initialize-database.js +35 -33
  135. package/lib/src/scripts/run-migrations.js +1079 -27
  136. package/lib/src/services/AICommandService.js +9 -2
  137. package/lib/src/services/AIService.js +192 -42
  138. package/lib/src/services/ApiKeyService.js +16 -5
  139. package/lib/src/services/EmailService.js +29 -0
  140. package/lib/src/services/InterceptorService.js +6 -2
  141. package/lib/src/services/OrphanSweeper.js +8 -0
  142. package/lib/src/services/SelectorStateService.js +11 -7
  143. package/lib/src/services/SelectorVerificationJob.js +21 -6
  144. package/lib/src/services/ServerManager.js +11 -1
  145. package/lib/src/services/SessionLifecycleService.js +118 -44
  146. package/lib/src/services/SocketClient.js +11 -0
  147. package/lib/src/services/SocketServer.js +380 -45
  148. package/lib/src/services/TeamService.js +5 -1
  149. package/lib/src/services/TracingService.js +171 -60
  150. package/lib/src/services/UserService.js +14 -3
  151. package/lib/src/services/UserSessionService.js +16 -4
  152. package/lib/src/services/bug-report/video-slice.js +4 -1
  153. package/lib/src/services/device-access/leaseOverride.js +7 -5
  154. package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
  155. package/lib/src/services/healing/HealEtalonService.js +3 -2
  156. package/lib/src/services/healing/HealingOrchestrator.js +57 -72
  157. package/lib/src/services/healing/OcrHealingProvider.js +41 -64
  158. package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
  159. package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
  160. package/lib/src/services/healing/fingerprintIdentity.js +126 -0
  161. package/lib/src/services/healing/resilioPath.js +274 -0
  162. package/lib/src/services/identity/identityChanges.js +41 -0
  163. package/lib/src/services/logcat/SessionDeviceLogs.js +358 -0
  164. package/lib/src/services/logcat/deviceLogBook.js +164 -0
  165. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  166. package/lib/src/services/ocr/ocrData.js +118 -0
  167. package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
  168. package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
  169. package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
  170. package/lib/src/services/passwordResetLink.js +50 -9
  171. package/lib/src/services/passwordResetMode.js +3 -1
  172. package/lib/src/services/publicUrl.js +45 -0
  173. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  174. package/lib/src/services/session/sessionIdentity.js +5 -3
  175. package/lib/src/services/session/shutdownReason.js +26 -0
  176. package/lib/src/services/settings/aiEngineSettings.js +219 -0
  177. package/lib/src/services/token/JwtKeyService.js +8 -2
  178. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  179. package/lib/src/services/token/mintedLifetime.js +51 -0
  180. package/lib/src/sessions/LocalSession.js +29 -1
  181. package/lib/src/sessions/RemoteSession.js +12 -2
  182. package/lib/src/sessions/SessionManager.js +4 -1
  183. package/lib/src/sessions/sessionMemory.js +25 -0
  184. package/lib/test/helpers/driver-log.js +38 -0
  185. package/lib/test/helpers/fake-ai-provider.js +188 -0
  186. package/lib/test/helpers/fake-http-proxy.js +106 -0
  187. package/lib/test/helpers/loki-stores.js +33 -0
  188. package/lib/test/helpers/otlp-probe.js +107 -0
  189. package/lib/test/helpers/page-sources.js +164 -0
  190. package/lib/test/helpers/scratch-database.js +57 -13
  191. package/lib/test/integration/androidDevices.spec.js +1 -1
  192. package/lib/test/integration/audit-ingest.spec.js +10 -10
  193. package/lib/test/integration/auth-flow.spec.js +9 -14
  194. package/lib/test/integration/auth-rate-limit.spec.js +6 -3
  195. package/lib/test/integration/bug-report-route.spec.js +22 -2
  196. package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
  197. package/lib/test/integration/forgot-password.spec.js +17 -18
  198. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  199. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  200. package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
  201. package/lib/test/integration/migration.spec.js +6 -1
  202. package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
  203. package/lib/test/integration/ports-router.spec.js +4 -4
  204. package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
  205. package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
  206. package/lib/test/integration/profile-tokens.spec.js +10 -16
  207. package/lib/test/integration/recordings-router.spec.js +16 -16
  208. package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
  209. package/lib/test/integration/role-matrix.spec.js +4 -9
  210. package/lib/test/integration/sdk-leases-router.spec.js +9 -9
  211. package/lib/test/integration/sdk-version.spec.js +2 -2
  212. package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
  213. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  214. package/lib/test/integration/team-membership-migration.spec.js +73 -12
  215. package/lib/test/integration/team-visibility-apps.spec.js +15 -22
  216. package/lib/test/integration/team-visibility-control.spec.js +96 -69
  217. package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
  218. package/lib/test/integration/team-visibility-grid.spec.js +16 -15
  219. package/lib/test/integration/team-visibility-leases.spec.js +8 -11
  220. package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
  221. package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
  222. package/lib/test/integration/users-flow.spec.js +7 -12
  223. package/lib/test/integration/users-lockout.spec.js +5 -3
  224. package/lib/test/unit/EmailService.test.js +66 -0
  225. package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
  226. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  227. package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
  228. package/lib/test/unit/ai-engine-settings.spec.js +338 -0
  229. package/lib/test/unit/ai-provider-switch.spec.js +130 -0
  230. package/lib/test/unit/ai-rate-limit.spec.js +126 -0
  231. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  232. package/lib/test/unit/android-h264-viewers.spec.js +272 -0
  233. package/lib/test/unit/android-preview-hold.spec.js +3 -2
  234. package/lib/test/unit/apikeys-router.spec.js +84 -0
  235. package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
  236. package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
  237. package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
  238. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  239. package/lib/test/unit/dashboard-events-device-logs.spec.js +255 -0
  240. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  241. package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
  242. package/lib/test/unit/database-check.spec.js +1 -0
  243. package/lib/test/unit/device-log-book.spec.js +180 -0
  244. package/lib/test/unit/device-utils.spec.js +13 -4
  245. package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
  246. package/lib/test/unit/event-log-session-data.spec.js +251 -0
  247. package/lib/test/unit/failure-analysis.spec.js +196 -0
  248. package/lib/test/unit/failure-categories.spec.js +130 -0
  249. package/lib/test/unit/failure-categorization.spec.js +393 -0
  250. package/lib/test/unit/fingerprint-identity.spec.js +433 -0
  251. package/lib/test/unit/h264-multiplexer.spec.js +46 -0
  252. package/lib/test/unit/heal-report.spec.js +71 -0
  253. package/lib/test/unit/healing-instrumentation.spec.js +6 -1
  254. package/lib/test/unit/healing-tier-gate.spec.js +74 -29
  255. package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
  256. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +243 -1
  257. package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
  258. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  259. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  260. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  261. package/lib/test/unit/lease/lease-override.spec.js +23 -6
  262. package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
  263. package/lib/test/unit/logcat-ws.spec.js +78 -37
  264. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  265. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  266. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  267. package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
  268. package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
  269. package/lib/test/unit/ocr-offline.spec.js +213 -0
  270. package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
  271. package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
  272. package/lib/test/unit/omni-vision.spec.js +5 -7
  273. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  274. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  275. package/lib/test/unit/package-engines.spec.js +27 -0
  276. package/lib/test/unit/passwordResetMode.test.js +10 -2
  277. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  278. package/lib/test/unit/profile-router.test.js +122 -1
  279. package/lib/test/unit/proxy-option.spec.js +358 -0
  280. package/lib/test/unit/queue-requester.spec.js +1 -1
  281. package/lib/test/unit/reset-link-address.spec.js +266 -0
  282. package/lib/test/unit/reset-link.test.js +10 -4
  283. package/lib/test/unit/resilio-path-healing.spec.js +270 -0
  284. package/lib/test/unit/run-migrations-database.spec.js +807 -0
  285. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  286. package/lib/test/unit/run-migrations.test.js +864 -0
  287. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  288. package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
  289. package/lib/test/unit/selector-state-service.spec.js +72 -23
  290. package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
  291. package/lib/test/unit/selector-verification-job.spec.js +28 -19
  292. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  293. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  294. package/lib/test/unit/self-healing-boot.spec.js +10 -5
  295. package/lib/test/unit/self-healing-setting.spec.js +7 -6
  296. package/lib/test/unit/session-app-resolution.spec.js +5 -1
  297. package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
  298. package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
  299. package/lib/test/unit/session-credential-strip.spec.js +1 -1
  300. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  301. package/lib/test/unit/session-device-logs.spec.js +356 -0
  302. package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
  303. package/lib/test/unit/session-log-indexes.spec.js +64 -3
  304. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  305. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  306. package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
  307. package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
  308. package/lib/test/unit/token-mint-limits.spec.js +165 -0
  309. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  310. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  311. package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
  312. package/lib/test/unit/verify-credential.spec.js +33 -0
  313. package/lib/test/unit/vision-assertion.spec.js +3 -2
  314. package/lib/test/unit/web-config-service.spec.js +19 -0
  315. package/lib/tsconfig.tsbuildinfo +1 -1
  316. package/package.json +6 -5
  317. package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
  318. package/prisma/schema.prisma +3 -0
  319. package/schema.json +16 -12
  320. package/scripts/dev/readme-screenshots.js +276 -23
  321. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  322. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  323. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  324. package/scripts/export-openapi.js +32 -0
  325. package/scripts/lib/openapi-for-site.js +79 -0
  326. package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
  327. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
  328. package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
  329. package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
  330. package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
  331. package/lib/public/assets/index-BbKEXUFH.js +0 -219
  332. package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
  333. package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
  334. package/lib/src/scripts/prepare-prisma.js +0 -89
  335. /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
@@ -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;
@@ -59,7 +59,8 @@ describe('SocketServer — a signed-in dashboard session cookie', () => {
59
59
  restore = (0, container_registration_1.saveRegistrations)(UserSessionService_1.UserSessionService, UserService_1.UserService, ApiKeyService_1.ApiKeyService, EventLogService_1.EventLogService);
60
60
  resolve = sinon_1.default.stub().callsFake((id) => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = SESSIONS[id]) !== null && _a !== void 0 ? _a : null; }));
61
61
  verify = sinon_1.default.stub().resolves(null);
62
- typedi_1.Container.set(UserSessionService_1.UserSessionService, { resolve });
62
+ // ttlMs: authMiddleware renews the cookie for the session's lifetime.
63
+ typedi_1.Container.set(UserSessionService_1.UserSessionService, { resolve, ttlMs: () => 24 * 3600000 });
63
64
  typedi_1.Container.set(UserService_1.UserService, { findById: (id) => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = USERS[id]) !== null && _a !== void 0 ? _a : null; }) });
64
65
  typedi_1.Container.set(ApiKeyService_1.ApiKeyService, { verify, verifyPair: sinon_1.default.stub().resolves(null) });
65
66
  typedi_1.Container.set(EventLogService_1.EventLogService, { appendSafe: () => undefined });
@@ -72,6 +72,10 @@ const own_node_id_1 = require("../helpers/own-node-id");
72
72
  * cannot decode H.264 (WebCodecs exists only in a secure context, so plain
73
73
  * http://hub:4723 has none), shows MJPEG, so it says so when it starts the
74
74
  * stream (`player: 'mjpeg'`), and the server then runs MJPEG only.
75
+ *
76
+ * Only once nobody plays the H.264 capture, though. A Live devices tile, or a
77
+ * second tab, may still be playing it when one page falls back to MJPEG; the
78
+ * capture then ends when its last viewer leaves, not under them.
75
79
  */
76
80
  const UDID = 'DEV-1';
77
81
  function buildApp() {
@@ -98,10 +102,31 @@ describe('POST /control/:udid/stream/start: which capture runs', () => {
98
102
  let restoreContainer;
99
103
  let savedArgs;
100
104
  let mjpegStart;
105
+ let h264;
101
106
  let h264Start;
102
107
  let h264Stop;
103
- let h264Running;
108
+ let captureKills;
109
+ let pushPacket;
104
110
  let row;
111
+ /** The real H.264 service with its capture (scrcpy) stubbed out. */
112
+ const h264Service = () => {
113
+ const svc = Object.create(AndroidH264StreamService_1.default.prototype);
114
+ svc.sessions = new Map();
115
+ svc.startPromises = new Map();
116
+ svc.scrcpyIncompatible = new Set();
117
+ svc.openCapture = (_udid, onPacket) => __awaiter(void 0, void 0, void 0, function* () {
118
+ pushPacket = onPacket;
119
+ onPacket({ type: 'config', data: Buffer.from([0]), ptsMs: 0 });
120
+ return { kill: () => (captureKills += 1) };
121
+ });
122
+ return svc;
123
+ };
124
+ /** Another page playing the phone's H.264 preview: what it got, and its leave. */
125
+ const h264Viewer = () => __awaiter(void 0, void 0, void 0, function* () {
126
+ const got = [];
127
+ const leave = (yield h264.start(UDID)).addClient((p) => got.push(p.type));
128
+ return { got, leave };
129
+ });
105
130
  const flag = (androidH264) => {
106
131
  typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs = Object.assign(Object.assign({}, savedArgs), { streaming: { androidH264 } });
107
132
  };
@@ -120,27 +145,27 @@ describe('POST /control/:udid/stream/start: which capture runs', () => {
120
145
  };
121
146
  sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({ findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return row; }) });
122
147
  sinon_1.default.stub(deviceService, 'blockDevice').resolves();
123
- h264Running = false;
148
+ captureKills = 0;
124
149
  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
- }));
150
+ h264 = h264Service();
132
151
  typedi_1.Container.set(AndroidStreamService_1.default, {
133
152
  startStream: mjpegStart,
134
153
  getStreamStatus: () => undefined,
135
154
  });
136
155
  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
- });
156
+ typedi_1.Container.set(AndroidH264StreamService_1.default, h264);
142
157
  typedi_1.Container.set(recording_store_1.RecordingStore, { isRecording: () => __awaiter(void 0, void 0, void 0, function* () { return false; }) });
158
+ // Spied after any setup start, so they count only what the route does.
159
+ h264Start = sinon_1.default.spy(h264, 'start');
160
+ h264Stop = sinon_1.default.spy(h264, 'stop');
143
161
  });
162
+ /** An H.264 capture already running for the phone, as a tile's start leaves it. */
163
+ const h264AlreadyRunning = () => __awaiter(void 0, void 0, void 0, function* () {
164
+ h264Start.restore();
165
+ yield h264.start(UDID);
166
+ h264Start = sinon_1.default.spy(h264, 'start');
167
+ });
168
+ const h264Running = () => h264.getMultiplexer(UDID) !== undefined;
144
169
  afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
145
170
  sinon_1.default.restore();
146
171
  typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs = savedArgs;
@@ -170,20 +195,39 @@ describe('POST /control/:udid/stream/start: which capture runs', () => {
170
195
  (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
171
196
  (0, chai_1.expect)(h264Start.called).to.equal(false);
172
197
  }));
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* () {
198
+ it('ends an H.264 capture nobody is watching, so the two never overlap', () => __awaiter(void 0, void 0, void 0, function* () {
174
199
  flag(true);
175
- h264Running = true;
200
+ yield h264AlreadyRunning();
176
201
  yield start({ player: 'mjpeg' });
177
202
  (0, chai_1.expect)(h264Stop.calledOnceWith(UDID)).to.equal(true);
203
+ (0, chai_1.expect)(captureKills).to.equal(1);
178
204
  (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
179
- (0, chai_1.expect)(h264Running).to.equal(false);
205
+ (0, chai_1.expect)(h264Running()).to.equal(false);
180
206
  }));
181
207
  it('stops the H.264 capture before it starts the MJPEG one', () => __awaiter(void 0, void 0, void 0, function* () {
182
208
  flag(true);
183
- h264Running = true;
209
+ yield h264AlreadyRunning();
184
210
  yield start({ player: 'mjpeg' });
185
211
  (0, chai_1.expect)(h264Stop.calledBefore(mjpegStart)).to.equal(true);
186
212
  }));
213
+ it('keeps the H.264 capture for another page still playing it', () => __awaiter(void 0, void 0, void 0, function* () {
214
+ flag(true);
215
+ const other = yield h264Viewer();
216
+ const res = yield start({ player: 'mjpeg' });
217
+ (0, chai_1.expect)(res.body.type).to.equal('mjpeg');
218
+ (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
219
+ (0, chai_1.expect)(captureKills).to.equal(0);
220
+ pushPacket({ type: 'key', data: Buffer.from([1]), ptsMs: 1 });
221
+ (0, chai_1.expect)(other.got).to.deep.equal(['config', 'key']);
222
+ }));
223
+ it('ends the H.264 capture when that last page leaves, without waiting for the idle stop', () => __awaiter(void 0, void 0, void 0, function* () {
224
+ flag(true);
225
+ const other = yield h264Viewer();
226
+ yield start({ player: 'mjpeg' });
227
+ other.leave();
228
+ (0, chai_1.expect)(captureKills).to.equal(1);
229
+ (0, chai_1.expect)(h264Running()).to.equal(false);
230
+ }));
187
231
  it('runs MJPEG alone, and stops nothing, when the flag is off', () => __awaiter(void 0, void 0, void 0, function* () {
188
232
  flag(false);
189
233
  const res = yield start();
@@ -207,4 +251,14 @@ describe('POST /control/:udid/stream/start: which capture runs', () => {
207
251
  (0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
208
252
  (0, chai_1.expect)(h264Start.called).to.equal(false);
209
253
  }));
254
+ it('ends H.264 at once for a phone being recorded, viewers or not, as before', () => __awaiter(void 0, void 0, void 0, function* () {
255
+ // A recording reads the MJPEG capture, and H.264 never runs beside it
256
+ // (ensureMjpegForRecording stopped it when the recording began).
257
+ flag(true);
258
+ yield h264Viewer();
259
+ typedi_1.Container.set(recording_store_1.RecordingStore, { isRecording: () => __awaiter(void 0, void 0, void 0, function* () { return true; }) });
260
+ yield start();
261
+ (0, chai_1.expect)(captureKills).to.equal(1);
262
+ (0, chai_1.expect)(h264Running()).to.equal(false);
263
+ }));
210
264
  });
@@ -0,0 +1,165 @@
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 jose = __importStar(require("jose"));
51
+ const typedi_1 = require("typedi");
52
+ const fs_1 = __importDefault(require("fs"));
53
+ const os_1 = __importDefault(require("os"));
54
+ const path_1 = __importDefault(require("path"));
55
+ const JwtKeyService_1 = require("../../src/services/token/JwtKeyService");
56
+ const auth_1 = require("../../src/app/routers/auth");
57
+ const container_registration_1 = require("../helpers/container-registration");
58
+ /**
59
+ * A token POST /auth/token mints never carries more than the credential
60
+ * minting it: not more scopes, and not a longer life.
61
+ *
62
+ * - The `sessionToken` beside a xenon-mcp token creates Appium sessions, so
63
+ * it is minted only when the grant includes `appium:use` (which needs the
64
+ * `sessions` scope), and carries the scopes a session is judged by. Any
65
+ * credential used to get one, a read-only key included.
66
+ * - A token minted with a Bearer token, or an API key that expires, ends no
67
+ * later than that credential. A 1-hour token used to mint a fresh 1-hour
68
+ * token, for ever.
69
+ */
70
+ describe('POST /auth/token mints nothing its creator does not have', () => {
71
+ let dir;
72
+ let restoreContainer;
73
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
74
+ restoreContainer = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService);
75
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-mint-limits-'));
76
+ const svc = new JwtKeyService_1.JwtKeyService();
77
+ yield svc.init(dir);
78
+ typedi_1.Container.set(JwtKeyService_1.JwtKeyService, svc);
79
+ }));
80
+ afterEach(() => {
81
+ restoreContainer();
82
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
83
+ });
84
+ const base = { kind: 'api-key', userId: 'u1', role: 'MEMBER', teamId: null, rateLimit: 300 };
85
+ const auth = (extra) => (Object.assign(Object.assign({}, base), extra));
86
+ describe('the session token', () => {
87
+ it('is not minted for a read-only credential', () => __awaiter(void 0, void 0, void 0, function* () {
88
+ const out = yield (0, auth_1.issueToken)(auth({ scopes: 'read' }), { audience: 'xenon-mcp' });
89
+ (0, chai_1.expect)(out.token).to.be.a('string');
90
+ (0, chai_1.expect)(out.sessionToken).to.equal(undefined);
91
+ }));
92
+ it('is not minted when the grant asked for leaves out appium:use', () => __awaiter(void 0, void 0, void 0, function* () {
93
+ const out = yield (0, auth_1.issueToken)(auth({ scopes: 'devices,sessions,read' }), {
94
+ audience: 'xenon-mcp',
95
+ scopes: ['xenon:devices:read'],
96
+ });
97
+ (0, chai_1.expect)(out.sessionToken).to.equal(undefined);
98
+ }));
99
+ it('carries `sessions`, and `admin` only when its creator has it', () => __awaiter(void 0, void 0, void 0, function* () {
100
+ const member = yield (0, auth_1.issueToken)(auth({ scopes: 'devices,sessions,read' }), {
101
+ audience: 'xenon-mcp',
102
+ });
103
+ (0, chai_1.expect)(jose.decodeJwt(String(member.sessionToken)).scopes).to.equal('sessions');
104
+ const dashboardAdmin = yield (0, auth_1.issueToken)(auth({ kind: 'user-session', role: 'ADMIN', scopes: 'admin,devices,sessions,read' }), { audience: 'xenon-mcp' });
105
+ (0, chai_1.expect)(jose.decodeJwt(String(dashboardAdmin.sessionToken)).scopes).to.equal('admin,sessions');
106
+ }));
107
+ });
108
+ // `admin` lets a session take over a lease someone else made. A token asked
109
+ // for with narrower MCP scopes doesn't get it from an admin key's breadth.
110
+ describe("the session token's admin scope follows the grant", () => {
111
+ const adminKey = () => auth({ scopes: 'admin' });
112
+ const scopesOf = (t) => jose.decodeJwt(String(t)).scopes;
113
+ it('is left out when narrower scopes are asked for', () => __awaiter(void 0, void 0, void 0, function* () {
114
+ const out = yield (0, auth_1.issueToken)(adminKey(), { audience: 'xenon-mcp', scopes: ['appium:use'] });
115
+ (0, chai_1.expect)(scopesOf(out.sessionToken)).to.equal('sessions');
116
+ }));
117
+ it('is given for the default grant, and for a full-admin one', () => __awaiter(void 0, void 0, void 0, function* () {
118
+ const byDefault = yield (0, auth_1.issueToken)(adminKey(), { audience: 'xenon-mcp' });
119
+ (0, chai_1.expect)(scopesOf(byDefault.sessionToken)).to.equal('admin,sessions');
120
+ const full = yield (0, auth_1.issueToken)(adminKey(), {
121
+ audience: 'xenon-mcp',
122
+ scopes: [
123
+ 'appium:use',
124
+ 'xenon:analytics:read',
125
+ 'xenon:devices:lock',
126
+ 'xenon:devices:read',
127
+ 'xenon:recordings',
128
+ ],
129
+ });
130
+ (0, chai_1.expect)(scopesOf(full.sessionToken)).to.equal('admin,sessions');
131
+ }));
132
+ });
133
+ describe('its lifetime', () => {
134
+ const now = () => Math.floor(Date.now() / 1000);
135
+ for (const audience of ['xenon-rest', 'xenon-mcp']) {
136
+ it(`a ${audience} token ends no later than the credential that minted it`, () => __awaiter(void 0, void 0, void 0, function* () {
137
+ const creatorExp = now() + 600;
138
+ const out = yield (0, auth_1.issueToken)(auth({ kind: 'bearer', scopes: 'sessions,read', credentialExpiresAt: creatorExp * 1000 }), { audience });
139
+ const p = jose.decodeJwt(out.token);
140
+ (0, chai_1.expect)(p.exp).to.be.at.most(creatorExp);
141
+ (0, chai_1.expect)(out.expiresIn).to.be.at.most(600);
142
+ (0, chai_1.expect)(out.expiresIn).to.be.at.least(598);
143
+ if (out.sessionToken) {
144
+ (0, chai_1.expect)(jose.decodeJwt(out.sessionToken).exp).to.be.at.most(creatorExp);
145
+ }
146
+ }));
147
+ }
148
+ it('keeps its own lifetime when the credential outlives it', () => __awaiter(void 0, void 0, void 0, function* () {
149
+ const out = yield (0, auth_1.issueToken)(auth({ scopes: 'sessions', credentialExpiresAt: Date.now() + 30 * 86400000 }), { audience: 'xenon-rest' });
150
+ (0, chai_1.expect)(out.expiresIn).to.equal(3600);
151
+ }));
152
+ it('is refused when the credential has already expired', () => __awaiter(void 0, void 0, void 0, function* () {
153
+ let caught;
154
+ try {
155
+ yield (0, auth_1.issueToken)(auth({ scopes: 'sessions', credentialExpiresAt: Date.now() - 1000 }), {
156
+ audience: 'xenon-rest',
157
+ });
158
+ }
159
+ catch (err) {
160
+ caught = err;
161
+ }
162
+ (0, chai_1.expect)(caught === null || caught === void 0 ? void 0 : caught.message).to.match(/expired/);
163
+ }));
164
+ });
165
+ });