@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.
- package/README.md +44 -29
- package/lib/package.json +6 -5
- package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-GmckX_7r.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-DqODzkyo.js +2 -0
- package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-7hETKEWb.js} +1 -1
- package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-GOgssFEP.js} +1 -1
- package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-Yvf6RGMd.js} +1 -1
- package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-KrPnfdsf.js} +1 -1
- package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-C6WjZFeI.js} +1 -1
- package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CSRIhZEh.js} +1 -1
- package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-CB8ST3nE.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C9o90KId.js} +1 -1
- package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-CPm3w9l2.js} +1 -1
- package/lib/public/assets/{Table-BcTm5xV2.js → Table-DwIgERRt.js} +1 -1
- package/lib/public/assets/{activity-DOVdsfMQ.js → activity-DrLbHifn.js} +1 -1
- package/lib/public/assets/ai-settings-DU_Ls06P.js +16 -0
- package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-CWYdEzJ_.js} +1 -1
- package/lib/public/assets/{apps-D0SC6uw6.js → apps-BqNVdln7.js} +1 -1
- package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BtB0_Jg_.js} +1 -1
- package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-C8NhdtyN.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-gMg0ffvv.js} +1 -1
- package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CiYZ3N4M.js} +2 -2
- package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-EaaqDmLN.js} +1 -1
- package/lib/public/assets/{button-Dda4irn_.js → button-DDau4Axv.js} +1 -1
- package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-DQ83B1ed.js} +1 -1
- package/lib/public/assets/{check-CZ9iw9-e.js → check-K1ovTQam.js} +1 -1
- package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-Biq00pMt.js} +1 -1
- package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CBv88cH7.js} +1 -1
- package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-Bmltailn.js} +1 -1
- package/lib/public/assets/{clock-CpuDKysU.js → clock-BPrld-3h.js} +1 -1
- package/lib/public/assets/{copy-CAQydNFe.js → copy-t0QB14JI.js} +1 -1
- package/lib/public/assets/derive-UnLRp4r3.js +1 -0
- package/lib/public/assets/device-explorer-BgGZ8AvV.js +282 -0
- package/lib/public/assets/{download-C9XG6lLv.js → download-BroHxNOh.js} +1 -1
- package/lib/public/assets/forgot-password-Bjmf49i6.js +1 -0
- package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-DeD__6wa.js} +6 -6
- package/lib/public/assets/{index-DJGc1M2f.js → index-72oF89LJ.js} +1 -1
- package/lib/public/assets/index-B0JeuZbg.js +15 -0
- package/lib/public/assets/index-B0gQkEg0.js +219 -0
- package/lib/public/assets/{input-Cm0tmSoc.js → input-DIc4MSC7.js} +1 -1
- package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-BcWm2vW8.js} +1 -1
- package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-B06IJJad.js} +1 -1
- package/lib/public/assets/{lock-CCJv9m_I.js → lock-pVLvnN30.js} +1 -1
- package/lib/public/assets/{login-f8vOVW-U.js → login-CN77vy--.js} +1 -1
- package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-CSdzQSkY.js} +1 -1
- package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-BtrN5IkI.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
- package/lib/public/assets/{network-ebT86qoi.js → network-BSfA8ZxG.js} +1 -1
- package/lib/public/assets/{overview-C0aykVrd.js → overview-BfTebCK5.js} +1 -1
- package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-D8KUX6Qg.js} +1 -1
- package/lib/public/assets/{play-DLG9t8FU.js → play-Cvc8xzic.js} +1 -1
- package/lib/public/assets/{plus-nBFSMu_X.js → plus-C9Zj84UF.js} +1 -1
- package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-Bi27STgs.js} +1 -1
- package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-CPsOuwSu.js} +1 -1
- package/lib/public/assets/runbook-page-DEQ-DBOX.js +304 -0
- package/lib/public/assets/{select-88jHcl-Z.js → select-BWpnhWXj.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BefSCSpe.js} +1 -1
- package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-MSk96Zz7.js} +1 -1
- package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
- package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
- package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-V6x-cXwy.js} +1 -1
- package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-Duex-GZD.js} +1 -1
- package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BBcoqBmL.js} +1 -1
- package/lib/public/assets/{teams-B4-jrr-1.js → teams-ts8p5rEh.js} +1 -1
- package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-CZ2ew6AZ.js} +1 -1
- package/lib/public/assets/{upload-BG7_rMXK.js → upload-Dv99y_le.js} +1 -1
- package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-DrYC8JCF.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
- package/lib/public/assets/{users-CFba5TSy.js → users-D7tKMESH.js} +1 -1
- package/lib/public/assets/{users-Cp0beJm3.js → users-gJ5Y01yo.js} +1 -1
- package/lib/public/assets/{video-off-DDsEISjV.js → video-off-CqZVuZGs.js} +1 -1
- package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-DI5DSW99.js} +1 -1
- package/lib/public/assets/{zap-l4VaXzq8.js → zap-ClcHFxfo.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/schema.json +16 -12
- package/lib/src/InternalHttpClient.js +27 -5
- package/lib/src/XenonCapabilityManager.js +10 -3
- package/lib/src/app/dashboardPluginLink.js +117 -0
- package/lib/src/app/index.js +13 -34
- package/lib/src/app/openapi/control.yaml +6 -4
- package/lib/src/app/openapi/grid.yaml +8 -2
- package/lib/src/app/openapi/identity.yaml +103 -31
- package/lib/src/app/openapi/platform.yaml +33 -15
- package/lib/src/app/openapi/sessions.yaml +12 -3
- package/lib/src/app/routers/apikeys.js +14 -2
- package/lib/src/app/routers/auth.js +31 -6
- package/lib/src/app/routers/control.js +13 -5
- package/lib/src/app/routers/dashboard.js +42 -27
- package/lib/src/app/routers/grid.js +5 -3
- package/lib/src/app/routers/profile.js +21 -0
- package/lib/src/app/routers/users.js +12 -6
- package/lib/src/app/swagger.js +2 -0
- package/lib/src/app/ws/h264StreamWs.js +10 -1
- package/lib/src/app/ws/nodeSocketRelay.js +77 -11
- package/lib/src/config.js +1 -0
- package/lib/src/dashboard/commandLogFields.js +42 -0
- package/lib/src/dashboard/commands.js +5 -5
- package/lib/src/dashboard/event-manager.js +64 -204
- package/lib/src/dashboard/services/failure-analysis-service.js +118 -105
- package/lib/src/dashboard/services/failureCategories.js +264 -0
- package/lib/src/dashboard/sessionCommandSummary.js +39 -0
- package/lib/src/data-service/device-store.js +14 -1
- package/lib/src/data-service/prisma-store.js +19 -7
- package/lib/src/data-service/web-config-service.js +7 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
- package/lib/src/device-managers/IOSDeviceManager.js +10 -0
- package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
- package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
- package/lib/src/device-utils.js +29 -3
- package/lib/src/gateway/defaultGateway.js +1 -1
- package/lib/src/gateway/forwardToNode.js +6 -11
- package/lib/src/gateway/healReport.js +87 -0
- package/lib/src/gateway/hubSessionToken.js +27 -4
- package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
- package/lib/src/gateway/sessionGateway.js +12 -2
- package/lib/src/generated/client/edge.js +4 -3
- package/lib/src/generated/client/index-browser.js +1 -0
- package/lib/src/generated/client/index.d.ts +32 -0
- package/lib/src/generated/client/index.js +4 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +3 -0
- package/lib/src/generated/client/wasm.js +1 -0
- package/lib/src/helpers/outboundProxy.js +219 -0
- package/lib/src/index.js +21 -0
- package/lib/src/interceptors/CommandInterceptor.js +256 -196
- package/lib/src/interceptors/xenonScripts.js +28 -7
- package/lib/src/logger.js +3 -1
- package/lib/src/middleware/authMiddleware.js +15 -3
- package/lib/src/middleware/commandCaller.js +3 -3
- package/lib/src/middleware/verifyCredential.js +58 -1
- package/lib/src/plugin.js +16 -6
- package/lib/src/scripts/database-check.js +3 -3
- package/lib/src/scripts/initialize-database.js +35 -33
- package/lib/src/scripts/run-migrations.js +1079 -27
- package/lib/src/services/AICommandService.js +9 -2
- package/lib/src/services/AIService.js +192 -42
- package/lib/src/services/ApiKeyService.js +16 -5
- package/lib/src/services/EmailService.js +29 -0
- package/lib/src/services/InterceptorService.js +6 -2
- package/lib/src/services/OrphanSweeper.js +8 -0
- package/lib/src/services/SelectorStateService.js +11 -7
- package/lib/src/services/SelectorVerificationJob.js +21 -6
- package/lib/src/services/ServerManager.js +11 -1
- package/lib/src/services/SessionLifecycleService.js +118 -44
- package/lib/src/services/SocketClient.js +11 -0
- package/lib/src/services/SocketServer.js +380 -45
- package/lib/src/services/TeamService.js +5 -1
- package/lib/src/services/TracingService.js +171 -60
- package/lib/src/services/UserService.js +14 -3
- package/lib/src/services/UserSessionService.js +16 -4
- package/lib/src/services/bug-report/video-slice.js +4 -1
- package/lib/src/services/device-access/leaseOverride.js +7 -5
- package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
- package/lib/src/services/healing/HealEtalonService.js +3 -2
- package/lib/src/services/healing/HealingOrchestrator.js +57 -72
- package/lib/src/services/healing/OcrHealingProvider.js +41 -64
- package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
- package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
- package/lib/src/services/healing/fingerprintIdentity.js +126 -0
- package/lib/src/services/healing/resilioPath.js +274 -0
- package/lib/src/services/identity/identityChanges.js +41 -0
- package/lib/src/services/logcat/SessionDeviceLogs.js +358 -0
- package/lib/src/services/logcat/deviceLogBook.js +164 -0
- package/lib/src/services/logcat/iosDriverLog.js +178 -0
- package/lib/src/services/ocr/ocrData.js +118 -0
- package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
- package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
- package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
- package/lib/src/services/passwordResetLink.js +50 -9
- package/lib/src/services/passwordResetMode.js +3 -1
- package/lib/src/services/publicUrl.js +45 -0
- package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
- package/lib/src/services/session/sessionIdentity.js +5 -3
- package/lib/src/services/session/shutdownReason.js +26 -0
- package/lib/src/services/settings/aiEngineSettings.js +219 -0
- package/lib/src/services/token/JwtKeyService.js +8 -2
- package/lib/src/services/token/mcpTokenTtl.js +27 -0
- package/lib/src/services/token/mintedLifetime.js +51 -0
- package/lib/src/sessions/LocalSession.js +29 -1
- package/lib/src/sessions/RemoteSession.js +12 -2
- package/lib/src/sessions/SessionManager.js +4 -1
- package/lib/src/sessions/sessionMemory.js +25 -0
- package/lib/test/helpers/driver-log.js +38 -0
- package/lib/test/helpers/fake-ai-provider.js +188 -0
- package/lib/test/helpers/fake-http-proxy.js +106 -0
- package/lib/test/helpers/loki-stores.js +33 -0
- package/lib/test/helpers/otlp-probe.js +107 -0
- package/lib/test/helpers/page-sources.js +164 -0
- package/lib/test/helpers/scratch-database.js +57 -13
- package/lib/test/integration/androidDevices.spec.js +1 -1
- package/lib/test/integration/audit-ingest.spec.js +10 -10
- package/lib/test/integration/auth-flow.spec.js +9 -14
- package/lib/test/integration/auth-rate-limit.spec.js +6 -3
- package/lib/test/integration/bug-report-route.spec.js +22 -2
- package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
- package/lib/test/integration/forgot-password.spec.js +17 -18
- package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
- package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
- package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
- package/lib/test/integration/migration.spec.js +6 -1
- package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
- package/lib/test/integration/ports-router.spec.js +4 -4
- package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
- package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
- package/lib/test/integration/profile-tokens.spec.js +10 -16
- package/lib/test/integration/recordings-router.spec.js +16 -16
- package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
- package/lib/test/integration/role-matrix.spec.js +4 -9
- package/lib/test/integration/sdk-leases-router.spec.js +9 -9
- package/lib/test/integration/sdk-version.spec.js +2 -2
- package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
- package/lib/test/integration/selector-state-flow.spec.js +5 -5
- package/lib/test/integration/team-membership-migration.spec.js +73 -12
- package/lib/test/integration/team-visibility-apps.spec.js +15 -22
- package/lib/test/integration/team-visibility-control.spec.js +96 -69
- package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
- package/lib/test/integration/team-visibility-grid.spec.js +16 -15
- package/lib/test/integration/team-visibility-leases.spec.js +8 -11
- package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
- package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
- package/lib/test/integration/users-flow.spec.js +7 -12
- package/lib/test/integration/users-lockout.spec.js +5 -3
- package/lib/test/unit/EmailService.test.js +66 -0
- package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
- package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
- package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
- package/lib/test/unit/ai-engine-settings.spec.js +338 -0
- package/lib/test/unit/ai-provider-switch.spec.js +130 -0
- package/lib/test/unit/ai-rate-limit.spec.js +126 -0
- package/lib/test/unit/android-adb-retry.spec.js +112 -0
- package/lib/test/unit/android-h264-viewers.spec.js +272 -0
- package/lib/test/unit/android-preview-hold.spec.js +3 -2
- package/lib/test/unit/apikeys-router.spec.js +84 -0
- package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
- package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
- package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
- package/lib/test/unit/command-log-network-capture.spec.js +191 -0
- package/lib/test/unit/dashboard-events-device-logs.spec.js +255 -0
- package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
- package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
- package/lib/test/unit/database-check.spec.js +1 -0
- package/lib/test/unit/device-log-book.spec.js +180 -0
- package/lib/test/unit/device-utils.spec.js +13 -4
- package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
- package/lib/test/unit/event-log-session-data.spec.js +251 -0
- package/lib/test/unit/failure-analysis.spec.js +196 -0
- package/lib/test/unit/failure-categories.spec.js +130 -0
- package/lib/test/unit/failure-categorization.spec.js +393 -0
- package/lib/test/unit/fingerprint-identity.spec.js +433 -0
- package/lib/test/unit/h264-multiplexer.spec.js +46 -0
- package/lib/test/unit/heal-report.spec.js +71 -0
- package/lib/test/unit/healing-instrumentation.spec.js +6 -1
- package/lib/test/unit/healing-tier-gate.spec.js +74 -29
- package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
- package/lib/test/unit/hub-node-gateway-appium-server.spec.js +243 -1
- package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
- package/lib/test/unit/ios-driver-log.spec.js +168 -0
- package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
- package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
- package/lib/test/unit/lease/lease-override.spec.js +23 -6
- package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
- package/lib/test/unit/logcat-ws.spec.js +78 -37
- package/lib/test/unit/logger-json-levels.spec.js +44 -0
- package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
- package/lib/test/unit/observability-example-loki.spec.js +25 -0
- package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
- package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
- package/lib/test/unit/ocr-offline.spec.js +213 -0
- package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
- package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
- package/lib/test/unit/omni-vision.spec.js +5 -7
- package/lib/test/unit/openapi-for-site.spec.js +80 -0
- package/lib/test/unit/outbound-proxy.spec.js +320 -0
- package/lib/test/unit/package-engines.spec.js +27 -0
- package/lib/test/unit/passwordResetMode.test.js +10 -2
- package/lib/test/unit/port-exhaustion.spec.js +2 -1
- package/lib/test/unit/profile-router.test.js +122 -1
- package/lib/test/unit/proxy-option.spec.js +358 -0
- package/lib/test/unit/queue-requester.spec.js +1 -1
- package/lib/test/unit/reset-link-address.spec.js +266 -0
- package/lib/test/unit/reset-link.test.js +10 -4
- package/lib/test/unit/resilio-path-healing.spec.js +270 -0
- package/lib/test/unit/run-migrations-database.spec.js +807 -0
- package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
- package/lib/test/unit/run-migrations.test.js +864 -0
- package/lib/test/unit/selector-health-actions.spec.js +3 -1
- package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
- package/lib/test/unit/selector-state-service.spec.js +72 -23
- package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
- package/lib/test/unit/selector-verification-job.spec.js +28 -19
- package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
- package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
- package/lib/test/unit/self-healing-boot.spec.js +10 -5
- package/lib/test/unit/self-healing-setting.spec.js +7 -6
- package/lib/test/unit/session-app-resolution.spec.js +5 -1
- package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
- package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
- package/lib/test/unit/session-credential-strip.spec.js +1 -1
- package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
- package/lib/test/unit/session-device-logs.spec.js +356 -0
- package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
- package/lib/test/unit/session-log-indexes.spec.js +64 -3
- package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
- package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
- package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
- package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
- package/lib/test/unit/token-mint-limits.spec.js +165 -0
- package/lib/test/unit/tracing-export-routing.spec.js +137 -0
- package/lib/test/unit/tracing-session-spans.spec.js +337 -0
- package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
- package/lib/test/unit/verify-credential.spec.js +33 -0
- package/lib/test/unit/vision-assertion.spec.js +3 -2
- package/lib/test/unit/web-config-service.spec.js +19 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
- package/prisma/schema.prisma +3 -0
- package/schema.json +16 -12
- package/scripts/dev/readme-screenshots.js +276 -23
- package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
- package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
- package/scripts/dev/screenshot-fixtures/session.js +242 -0
- package/scripts/export-openapi.js +32 -0
- package/scripts/lib/openapi-for-site.js +79 -0
- package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
- package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
- package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
- package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
- package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
- package/lib/public/assets/index-BbKEXUFH.js +0 -219
- package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
- package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
- package/lib/src/scripts/prepare-prisma.js +0 -89
- /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
|
-
|
|
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('
|
|
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(['
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
148
|
+
captureKills = 0;
|
|
124
149
|
mjpegStart = sinon_1.default.stub().resolves({ mjpegPort: 9100 });
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|