@xenon-device-management/xenon 2.14.0 → 2.15.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 (259) hide show
  1. package/README.md +7 -4
  2. package/lib/package.json +5 -5
  3. package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-Btq3dZRI.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-C9OoAgg_.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-DBC_xczB.js +2 -0
  6. package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-DqWU2-vM.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-CveQ3xrA.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-BfKDSfHz.js} +1 -1
  9. package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-CtdCybzA.js} +1 -1
  10. package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-Bsvj1tm2.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CRofOiyx.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-jNQHb2IR.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C8HtTha-.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-D8K1LZNo.js} +1 -1
  15. package/lib/public/assets/{Table-BcTm5xV2.js → Table-zNTX92Pn.js} +1 -1
  16. package/lib/public/assets/{activity-DOVdsfMQ.js → activity-CJ8LwZq9.js} +1 -1
  17. package/lib/public/assets/ai-settings-CgxQZqrc.js +16 -0
  18. package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-ECpo-zTo.js} +1 -1
  19. package/lib/public/assets/{apps-D0SC6uw6.js → apps-BjGymyf3.js} +1 -1
  20. package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BWfCbeHP.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-DC7UGf_n.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-BOjwybDk.js} +1 -1
  23. package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CxMTo9aH.js} +2 -2
  24. package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-DOCm4HkA.js} +1 -1
  25. package/lib/public/assets/{button-Dda4irn_.js → button-Cg08-EMT.js} +1 -1
  26. package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-CA1ikLJs.js} +1 -1
  27. package/lib/public/assets/{check-CZ9iw9-e.js → check-BMTaH72W.js} +1 -1
  28. package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-B2QOv9Nx.js} +1 -1
  29. package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CYXBoUqT.js} +1 -1
  30. package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-DTwnSAnl.js} +1 -1
  31. package/lib/public/assets/{clock-CpuDKysU.js → clock-C8_HViQP.js} +1 -1
  32. package/lib/public/assets/{copy-CAQydNFe.js → copy-Be7LNMXV.js} +1 -1
  33. package/lib/public/assets/derive-UnLRp4r3.js +1 -0
  34. package/lib/public/assets/device-explorer-BOqtfGXz.js +282 -0
  35. package/lib/public/assets/{download-C9XG6lLv.js → download-B56lwxWP.js} +1 -1
  36. package/lib/public/assets/forgot-password-CCU6bzLn.js +1 -0
  37. package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-C-OtmIuk.js} +6 -6
  38. package/lib/public/assets/index-B9KQ6LsY.js +15 -0
  39. package/lib/public/assets/{index-BbKEXUFH.js → index-BOxe43p1.js} +2 -2
  40. package/lib/public/assets/{index-DJGc1M2f.js → index-CfxsUslp.js} +1 -1
  41. package/lib/public/assets/{input-Cm0tmSoc.js → input-CZHFjbSg.js} +1 -1
  42. package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-Gl_Dz3X_.js} +1 -1
  43. package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-CCgjeyGY.js} +1 -1
  44. package/lib/public/assets/{lock-CCJv9m_I.js → lock-CkOwgDcx.js} +1 -1
  45. package/lib/public/assets/{login-f8vOVW-U.js → login-D5cc4w7M.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-t7qr3fvX.js} +1 -1
  47. package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-DEJixKQO.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CtOrk4M8.js} +1 -1
  49. package/lib/public/assets/{network-ebT86qoi.js → network-Dii9qdvk.js} +1 -1
  50. package/lib/public/assets/{overview-C0aykVrd.js → overview-rolAACrO.js} +1 -1
  51. package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-COQZxJfR.js} +1 -1
  52. package/lib/public/assets/{play-DLG9t8FU.js → play-DrN1Se_R.js} +1 -1
  53. package/lib/public/assets/{plus-nBFSMu_X.js → plus-5aq-9uRx.js} +1 -1
  54. package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-6i5kY6kF.js} +1 -1
  55. package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-sp2IBrc7.js} +1 -1
  56. package/lib/public/assets/runbook-page-DJDjeopU.js +253 -0
  57. package/lib/public/assets/{select-88jHcl-Z.js → select-Bpyn7rWO.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BH74vmKv.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-Cw_g218H.js} +1 -1
  60. package/lib/public/assets/session-detail-page-AZJE6XXE.js +4 -0
  61. package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-C3uyGhyc.js} +1 -1
  62. package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
  63. package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-xVqAKIlI.js} +1 -1
  64. package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BxUScAeO.js} +1 -1
  65. package/lib/public/assets/{teams-B4-jrr-1.js → teams-B5ljGkY2.js} +1 -1
  66. package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-B701i8sy.js} +1 -1
  67. package/lib/public/assets/{upload-BG7_rMXK.js → upload-C2gr44RS.js} +1 -1
  68. package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-NeQOFEoy.js} +1 -1
  69. package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-DEzJq5ef.js} +1 -1
  70. package/lib/public/assets/{users-CFba5TSy.js → users-D_vv_yz4.js} +1 -1
  71. package/lib/public/assets/{users-Cp0beJm3.js → users-MY6eNe6s.js} +1 -1
  72. package/lib/public/assets/{video-off-DDsEISjV.js → video-off-OK1lNZQn.js} +1 -1
  73. package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-BU-C9lBf.js} +1 -1
  74. package/lib/public/assets/{zap-l4VaXzq8.js → zap-DO9ry1iz.js} +1 -1
  75. package/lib/public/index.html +1 -1
  76. package/lib/schema.json +5 -5
  77. package/lib/src/app/dashboardPluginLink.js +117 -0
  78. package/lib/src/app/index.js +13 -34
  79. package/lib/src/app/openapi/control.yaml +6 -4
  80. package/lib/src/app/openapi/grid.yaml +8 -2
  81. package/lib/src/app/openapi/identity.yaml +103 -31
  82. package/lib/src/app/openapi/platform.yaml +33 -15
  83. package/lib/src/app/openapi/sessions.yaml +10 -3
  84. package/lib/src/app/routers/apikeys.js +14 -2
  85. package/lib/src/app/routers/auth.js +29 -5
  86. package/lib/src/app/routers/control.js +13 -5
  87. package/lib/src/app/routers/dashboard.js +42 -27
  88. package/lib/src/app/routers/grid.js +5 -3
  89. package/lib/src/app/routers/profile.js +21 -0
  90. package/lib/src/app/routers/users.js +10 -5
  91. package/lib/src/app/swagger.js +2 -0
  92. package/lib/src/app/ws/h264StreamWs.js +10 -1
  93. package/lib/src/config.js +1 -0
  94. package/lib/src/dashboard/commands.js +5 -5
  95. package/lib/src/dashboard/event-manager.js +49 -61
  96. package/lib/src/dashboard/services/failure-analysis-service.js +136 -45
  97. package/lib/src/dashboard/sessionCommandSummary.js +39 -0
  98. package/lib/src/data-service/device-store.js +14 -1
  99. package/lib/src/data-service/prisma-store.js +19 -7
  100. package/lib/src/data-service/web-config-service.js +7 -0
  101. package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
  102. package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
  103. package/lib/src/device-utils.js +20 -2
  104. package/lib/src/gateway/defaultGateway.js +1 -1
  105. package/lib/src/gateway/forwardToNode.js +2 -0
  106. package/lib/src/gateway/healReport.js +87 -0
  107. package/lib/src/gateway/sessionGateway.js +12 -2
  108. package/lib/src/generated/client/edge.js +4 -3
  109. package/lib/src/generated/client/index-browser.js +1 -0
  110. package/lib/src/generated/client/index.d.ts +32 -0
  111. package/lib/src/generated/client/index.js +4 -3
  112. package/lib/src/generated/client/package.json +1 -1
  113. package/lib/src/generated/client/schema.prisma +3 -0
  114. package/lib/src/generated/client/wasm.js +1 -0
  115. package/lib/src/interceptors/CommandInterceptor.js +243 -181
  116. package/lib/src/middleware/authMiddleware.js +15 -3
  117. package/lib/src/middleware/commandCaller.js +3 -3
  118. package/lib/src/middleware/verifyCredential.js +58 -1
  119. package/lib/src/plugin.js +8 -3
  120. package/lib/src/services/AICommandService.js +9 -2
  121. package/lib/src/services/AIService.js +192 -42
  122. package/lib/src/services/EmailService.js +29 -0
  123. package/lib/src/services/OrphanSweeper.js +2 -0
  124. package/lib/src/services/SelectorVerificationJob.js +15 -4
  125. package/lib/src/services/ServerManager.js +11 -1
  126. package/lib/src/services/SessionLifecycleService.js +86 -26
  127. package/lib/src/services/SocketServer.js +19 -3
  128. package/lib/src/services/bug-report/video-slice.js +4 -1
  129. package/lib/src/services/device-access/leaseOverride.js +7 -5
  130. package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
  131. package/lib/src/services/healing/HealEtalonService.js +3 -2
  132. package/lib/src/services/healing/HealingOrchestrator.js +55 -72
  133. package/lib/src/services/healing/OcrHealingProvider.js +41 -64
  134. package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
  135. package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
  136. package/lib/src/services/healing/fingerprintIdentity.js +126 -0
  137. package/lib/src/services/healing/resilioPath.js +274 -0
  138. package/lib/src/services/logcat/SessionDeviceLogs.js +271 -0
  139. package/lib/src/services/logcat/deviceLogBook.js +163 -0
  140. package/lib/src/services/ocr/ocrData.js +118 -0
  141. package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
  142. package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
  143. package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
  144. package/lib/src/services/passwordResetLink.js +50 -9
  145. package/lib/src/services/passwordResetMode.js +3 -1
  146. package/lib/src/services/publicUrl.js +45 -0
  147. package/lib/src/services/session/sessionIdentity.js +5 -3
  148. package/lib/src/services/session/shutdownReason.js +26 -0
  149. package/lib/src/services/settings/aiEngineSettings.js +219 -0
  150. package/lib/src/services/token/mintedLifetime.js +51 -0
  151. package/lib/src/sessions/SessionManager.js +8 -2
  152. package/lib/src/sessions/sessionMemory.js +25 -0
  153. package/lib/test/helpers/fake-ai-provider.js +188 -0
  154. package/lib/test/helpers/loki-stores.js +33 -0
  155. package/lib/test/helpers/page-sources.js +164 -0
  156. package/lib/test/helpers/scratch-database.js +57 -13
  157. package/lib/test/integration/audit-ingest.spec.js +10 -10
  158. package/lib/test/integration/auth-flow.spec.js +9 -14
  159. package/lib/test/integration/auth-rate-limit.spec.js +6 -3
  160. package/lib/test/integration/bug-report-route.spec.js +22 -2
  161. package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
  162. package/lib/test/integration/forgot-password.spec.js +17 -18
  163. package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
  164. package/lib/test/integration/migration.spec.js +6 -1
  165. package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
  166. package/lib/test/integration/ports-router.spec.js +4 -4
  167. package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
  168. package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
  169. package/lib/test/integration/profile-tokens.spec.js +10 -16
  170. package/lib/test/integration/recordings-router.spec.js +16 -16
  171. package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
  172. package/lib/test/integration/role-matrix.spec.js +4 -9
  173. package/lib/test/integration/sdk-leases-router.spec.js +9 -9
  174. package/lib/test/integration/sdk-version.spec.js +2 -2
  175. package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
  176. package/lib/test/integration/team-membership-migration.spec.js +73 -12
  177. package/lib/test/integration/team-visibility-apps.spec.js +15 -22
  178. package/lib/test/integration/team-visibility-control.spec.js +96 -69
  179. package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
  180. package/lib/test/integration/team-visibility-grid.spec.js +16 -15
  181. package/lib/test/integration/team-visibility-leases.spec.js +8 -11
  182. package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
  183. package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
  184. package/lib/test/integration/users-flow.spec.js +7 -12
  185. package/lib/test/integration/users-lockout.spec.js +5 -3
  186. package/lib/test/unit/EmailService.test.js +66 -0
  187. package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
  188. package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
  189. package/lib/test/unit/ai-engine-settings.spec.js +338 -0
  190. package/lib/test/unit/ai-provider-switch.spec.js +130 -0
  191. package/lib/test/unit/ai-rate-limit.spec.js +126 -0
  192. package/lib/test/unit/android-h264-viewers.spec.js +272 -0
  193. package/lib/test/unit/android-preview-hold.spec.js +3 -2
  194. package/lib/test/unit/apikeys-router.spec.js +84 -0
  195. package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
  196. package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
  197. package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
  198. package/lib/test/unit/dashboard-events-device-logs.spec.js +207 -0
  199. package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
  200. package/lib/test/unit/device-log-book.spec.js +180 -0
  201. package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
  202. package/lib/test/unit/event-log-session-data.spec.js +244 -0
  203. package/lib/test/unit/failure-analysis.spec.js +182 -0
  204. package/lib/test/unit/failure-categories.spec.js +128 -0
  205. package/lib/test/unit/fingerprint-identity.spec.js +433 -0
  206. package/lib/test/unit/h264-multiplexer.spec.js +46 -0
  207. package/lib/test/unit/heal-report.spec.js +71 -0
  208. package/lib/test/unit/healing-instrumentation.spec.js +6 -1
  209. package/lib/test/unit/healing-tier-gate.spec.js +73 -29
  210. package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
  211. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +171 -0
  212. package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
  213. package/lib/test/unit/lease/lease-override.spec.js +23 -6
  214. package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
  215. package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
  216. package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
  217. package/lib/test/unit/ocr-offline.spec.js +213 -0
  218. package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
  219. package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
  220. package/lib/test/unit/omni-vision.spec.js +5 -7
  221. package/lib/test/unit/package-engines.spec.js +27 -0
  222. package/lib/test/unit/passwordResetMode.test.js +10 -2
  223. package/lib/test/unit/profile-router.test.js +122 -1
  224. package/lib/test/unit/queue-requester.spec.js +1 -1
  225. package/lib/test/unit/reset-link-address.spec.js +266 -0
  226. package/lib/test/unit/reset-link.test.js +10 -4
  227. package/lib/test/unit/resilio-path-healing.spec.js +270 -0
  228. package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
  229. package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
  230. package/lib/test/unit/selector-verification-job.spec.js +13 -13
  231. package/lib/test/unit/self-healing-boot.spec.js +10 -5
  232. package/lib/test/unit/self-healing-setting.spec.js +7 -6
  233. package/lib/test/unit/session-app-resolution.spec.js +5 -1
  234. package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
  235. package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
  236. package/lib/test/unit/session-credential-strip.spec.js +1 -1
  237. package/lib/test/unit/session-device-logs.spec.js +347 -0
  238. package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
  239. package/lib/test/unit/session-log-indexes.spec.js +64 -3
  240. package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
  241. package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
  242. package/lib/test/unit/token-mint-limits.spec.js +165 -0
  243. package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
  244. package/lib/test/unit/verify-credential.spec.js +33 -0
  245. package/lib/test/unit/vision-assertion.spec.js +3 -2
  246. package/lib/test/unit/web-config-service.spec.js +19 -0
  247. package/lib/tsconfig.tsbuildinfo +1 -1
  248. package/package.json +5 -5
  249. package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
  250. package/prisma/schema.prisma +3 -0
  251. package/schema.json +5 -5
  252. package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
  253. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
  254. package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
  255. package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
  256. package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
  257. package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
  258. package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
  259. /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
@@ -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
+ });
@@ -0,0 +1,163 @@
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 typedi_1 = require("typedi");
52
+ const plugin_1 = require("../../src/plugin");
53
+ const DeviceService = __importStar(require("../../src/data-service/device-service"));
54
+ const device_store_1 = require("../../src/data-service/device-store");
55
+ const SessionManager_1 = require("../../src/sessions/SessionManager");
56
+ const SessionMetricsService_1 = require("../../src/services/metrics/SessionMetricsService");
57
+ const PhoneNetworkRestore_1 = require("../../src/services/network/PhoneNetworkRestore");
58
+ const SocketServer_1 = require("../../src/services/SocketServer");
59
+ const MetricsService_1 = require("../../src/services/MetricsService");
60
+ const NotificationService_1 = require("../../src/services/NotificationService");
61
+ const AIService_1 = require("../../src/services/AIService");
62
+ const shutdownReason_1 = require("../../src/services/session/shutdownReason");
63
+ const prisma_1 = require("../../src/prisma");
64
+ const container_registration_1 = require("../helpers/container-registration");
65
+ const scratch_database_1 = require("../helpers/scratch-database");
66
+ /**
67
+ * A session Appium ends by itself reaches `XenonPlugin.onUnexpectedShutdown`
68
+ * with a cause. For an idle session that is Appium's new-command timeout.
69
+ * The plugin threw the cause away and recorded "Driver shut down
70
+ * unexpectedly", which no failure pattern matches, so an idle session on this
71
+ * server's own phones was filed UNKNOWN and its "Why it failed" card opened
72
+ * the general runbook rather than the Timeout one.
73
+ */
74
+ // What @appium/base-driver 10.2 passes when a session sits idle too long.
75
+ const IDLE = "New Command Timeout of 60 seconds expired. Try customizing the timeout using the 'newCommandTimeout' desired capability";
76
+ describe('the reason of a session Appium ends by itself', () => {
77
+ it("is Appium's cause: its new-command timeout", () => {
78
+ (0, chai_1.expect)((0, shutdownReason_1.unexpectedShutdownReason)(new Error(IDLE))).to.equal(IDLE);
79
+ });
80
+ it("is a driver's own account of giving up, which still reads as one", () => {
81
+ (0, chai_1.expect)((0, shutdownReason_1.unexpectedShutdownReason)(new Error('The driver was unexpectedly shut down!'))).to.equal('The driver was unexpectedly shut down!');
82
+ });
83
+ it('is the old fixed reason when there is no cause to tell', () => {
84
+ for (const cause of [undefined, null, new Error(''), new Error(' '), {}, 42]) {
85
+ (0, chai_1.expect)((0, shutdownReason_1.unexpectedShutdownReason)(cause), String(cause)).to.equal(shutdownReason_1.UNEXPECTED_SHUTDOWN_REASON);
86
+ }
87
+ // The umbrella's placeholder when a driver gives none.
88
+ (0, chai_1.expect)((0, shutdownReason_1.unexpectedShutdownReason)(new Error('Unknown error'))).to.equal(shutdownReason_1.UNEXPECTED_SHUTDOWN_REASON);
89
+ });
90
+ it('is the message alone, never a stack', () => {
91
+ const err = new Error(IDLE);
92
+ (0, chai_1.expect)((0, shutdownReason_1.unexpectedShutdownReason)(err)).not.to.contain(' at ');
93
+ });
94
+ });
95
+ describe('a session Appium ends for being idle, on a hub that records sessions', function () {
96
+ this.timeout(30000);
97
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
98
+ let restoreRegs;
99
+ let wasHub;
100
+ const plugin = {
101
+ pluginArgs: { enableDashboard: true },
102
+ xenonLog: { withSession: () => ({ info() { } }) },
103
+ };
104
+ const seed = (id) => scratch.db.session.create({
105
+ data: {
106
+ id,
107
+ status: 'running',
108
+ desired_capabilities: '{}',
109
+ session_capabilities: '{}',
110
+ node_id: 'n-1',
111
+ has_live_video: false,
112
+ device_udid: 'R5CT',
113
+ device_platform: 'android',
114
+ device_version: '14',
115
+ },
116
+ });
117
+ const shutDown = (id, cause) => plugin_1.XenonPlugin.prototype.onUnexpectedShutdown.call(plugin, { sessionId: id, caps: { udid: 'R5CT' } }, cause);
118
+ beforeEach(() => __awaiter(this, void 0, void 0, function* () {
119
+ restoreRegs = (0, container_registration_1.saveRegistrations)(PhoneNetworkRestore_1.PhoneNetworkRestore, SessionMetricsService_1.SessionMetricsService, SocketServer_1.SocketServer, MetricsService_1.MetricsService, NotificationService_1.NotificationService);
120
+ typedi_1.Container.set(PhoneNetworkRestore_1.PhoneNetworkRestore, { restoreSession: sinon_1.default.stub().resolves() });
121
+ typedi_1.Container.set(SessionMetricsService_1.SessionMetricsService, { stop: sinon_1.default.stub().resolves() });
122
+ typedi_1.Container.set(SocketServer_1.SocketServer, {
123
+ emitToDashboard: sinon_1.default.stub(),
124
+ emitToDashboardForDevices: sinon_1.default.stub().resolves(),
125
+ hasScopedDashboard: sinon_1.default.stub().returns(false),
126
+ });
127
+ typedi_1.Container.set(MetricsService_1.MetricsService, {
128
+ incrementSessionSuccess: sinon_1.default.stub(),
129
+ incrementSessionFailure: sinon_1.default.stub(),
130
+ });
131
+ typedi_1.Container.set(NotificationService_1.NotificationService, { notifySessionFailed: sinon_1.default.stub().resolves() });
132
+ sinon_1.default.stub(DeviceService, 'releaseSessionDevices').resolves();
133
+ sinon_1.default
134
+ .stub(device_store_1.DeviceStoreFactory, 'getStore')
135
+ .returns({ getDevices: () => __awaiter(this, void 0, void 0, function* () { return []; }), findDevices: () => __awaiter(this, void 0, void 0, function* () { return []; }) });
136
+ sinon_1.default.stub(SessionManager_1.SESSION_MANAGER, 'getSession').returns(undefined);
137
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'isEnabled').returns(false);
138
+ wasHub = plugin_1.XenonPlugin.IS_HUB;
139
+ plugin_1.XenonPlugin.IS_HUB = true;
140
+ yield scratch.db.session.deleteMany({});
141
+ }));
142
+ afterEach(() => {
143
+ plugin_1.XenonPlugin.IS_HUB = wasHub;
144
+ sinon_1.default.restore();
145
+ restoreRegs();
146
+ });
147
+ it("is failed with Appium's reason and filed TIMEOUT", () => __awaiter(this, void 0, void 0, function* () {
148
+ yield seed('s-idle');
149
+ yield shutDown('s-idle', new Error(IDLE));
150
+ const row = yield prisma_1.prisma.session.findUnique({ where: { id: 's-idle' } });
151
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.status).to.equal('failed');
152
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.failure_reason).to.equal(IDLE);
153
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.failure_category).to.equal('TIMEOUT');
154
+ }));
155
+ it('keeps the old reason, filed UNKNOWN, when Appium gives no cause', () => __awaiter(this, void 0, void 0, function* () {
156
+ yield seed('s-crash');
157
+ yield shutDown('s-crash', undefined);
158
+ const row = yield prisma_1.prisma.session.findUnique({ where: { id: 's-crash' } });
159
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.status).to.equal('failed');
160
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.failure_reason).to.equal(shutdownReason_1.UNEXPECTED_SHUTDOWN_REASON);
161
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.failure_category).to.equal('UNKNOWN');
162
+ }));
163
+ });
@@ -24,6 +24,7 @@ const UserService_1 = require("../../src/services/UserService");
24
24
  const ApiKeyService_1 = require("../../src/services/ApiKeyService");
25
25
  const verifyCredential_1 = require("../../src/middleware/verifyCredential");
26
26
  const container_registration_1 = require("../helpers/container-registration");
27
+ const commandCaller_1 = require("../../src/middleware/commandCaller");
27
28
  /**
28
29
  * The credential checks authMiddleware applies to the header pair and to a
29
30
  * Bearer JWT, pulled out so the WebDriver per-command check verifies exactly
@@ -80,6 +81,38 @@ describe('verifyCredential', () => {
80
81
  const token = yield keys.sign({ sub: 'u1' }, { audience: 'xenon-rest', ttlSeconds: 60 });
81
82
  (0, chai_1.expect)(yield (0, verifyCredential_1.verifyBearerCredential)(token)).to.equal(null);
82
83
  }));
84
+ // A token's scopes are fixed when it is minted, for up to a day. An
85
+ // admin demoted to member since keeps no `admin` from it: REST
86
+ // (resolveActor, scopeGuard) and per-command auth's override read these.
87
+ describe("the scopes, by the user's role now", () => {
88
+ const mint = (scopes, audience = 'xenon-rest') => keys.sign({ sub: 'u1', scopes }, { audience, ttlSeconds: 60 });
89
+ const withRole = (role) => typedi_1.Container.get(UserService_1.UserService).findById.resolves(Object.assign(Object.assign({}, active), { role }));
90
+ it('drops admin from the token of a user who is now a member', () => __awaiter(void 0, void 0, void 0, function* () {
91
+ withRole('MEMBER');
92
+ for (const audience of ['xenon-rest', 'xenon-mcp']) {
93
+ const out = yield (0, verifyCredential_1.verifyBearerCredential)(yield mint('admin,devices,sessions,read', audience));
94
+ (0, chai_1.expect)(out === null || out === void 0 ? void 0 : out.payload.scopes, audience).to.equal('devices,sessions,read');
95
+ }
96
+ }));
97
+ it('keeps admin for an ADMIN or SUPER_ADMIN', () => __awaiter(void 0, void 0, void 0, function* () {
98
+ for (const role of ['ADMIN', 'SUPER_ADMIN']) {
99
+ withRole(role);
100
+ const out = yield (0, verifyCredential_1.verifyBearerCredential)(yield mint('admin,sessions'));
101
+ (0, chai_1.expect)(out === null || out === void 0 ? void 0 : out.payload.scopes, role).to.equal('admin,sessions');
102
+ }
103
+ }));
104
+ it('gives a demoted admin no override in per-command auth', () => __awaiter(void 0, void 0, void 0, function* () {
105
+ withRole('MEMBER');
106
+ const verdict = yield new commandCaller_1.CommandCallerVerifier().verify({
107
+ kind: 'bearer',
108
+ token: yield mint('admin,sessions'),
109
+ });
110
+ (0, chai_1.expect)(verdict).to.deep.equal({
111
+ valid: true,
112
+ caller: { userId: 'u1', overrideAdmin: false },
113
+ });
114
+ }));
115
+ });
83
116
  it('throws when the signing key is not available, rather than calling the token invalid', () => __awaiter(void 0, void 0, void 0, function* () {
84
117
  typedi_1.Container.set(JwtKeyService_1.JwtKeyService, new JwtKeyService_1.JwtKeyService()); // never initialised
85
118
  const token = yield keys.sign({ sub: 'u1' }, { audience: 'xenon-rest', ttlSeconds: 60 });
@@ -94,10 +94,11 @@ describe('xenon: assertVisualState', () => {
94
94
  (0, chai_1.expect)(err.message).to.match(/429 quota exceeded/);
95
95
  }));
96
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');
97
+ // Any provider's 429 (ai-rate-limit.spec.ts).
98
+ withProvider(new AIService_1.AIRateLimitedError('429 Too Many Requests'));
99
99
  const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
100
100
  (0, chai_1.expect)(err.message).to.match(/rate-limited/i);
101
+ (0, chai_1.expect)(err.message).to.match(/not checked/);
101
102
  }));
102
103
  it('fails without asking when there is no condition', () => __awaiter(void 0, void 0, void 0, function* () {
103
104
  const call = withProvider('{"result": true, "reason": "ok"}');
@@ -58,6 +58,25 @@ describe('WebConfigService', () => {
58
58
  yield service().setConfig({ enableSelfHealing: true });
59
59
  (0, chai_1.expect)(yield service().getConfig()).to.deep.equal({ enableSelfHealing: true });
60
60
  }));
61
+ it("keeps the AI engine page's provider, models and base URL, an empty one included", () => __awaiter(void 0, void 0, void 0, function* () {
62
+ yield service().setConfig({
63
+ aiProvider: 'ollama',
64
+ ollamaModel: 'llava',
65
+ aiBaseUrl: 'http://gpu-box.lab:11434',
66
+ geminiModel: '',
67
+ });
68
+ (0, chai_1.expect)(yield service().getConfig()).to.deep.equal({
69
+ aiProvider: 'ollama',
70
+ ollamaModel: 'llava',
71
+ aiBaseUrl: 'http://gpu-box.lab:11434',
72
+ geminiModel: '',
73
+ });
74
+ }));
75
+ it('never saves an API key, whatever it is sent', () => __awaiter(void 0, void 0, void 0, function* () {
76
+ yield service().setConfig({ aiProvider: 'gemini', geminiApiKey: 'secret' });
77
+ const rows = yield scratch.db.webConfig.findMany();
78
+ (0, chai_1.expect)(rows.map((row) => row.name)).to.deep.equal(['aiProvider']);
79
+ }));
61
80
  it('changes a setting saved before', () => __awaiter(void 0, void 0, void 0, function* () {
62
81
  yield service().setConfig({ buildCleanupDays: 7, buildCleanupMaxCount: 5 });
63
82
  yield service().setConfig({ buildCleanupDays: 14 });