@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
|
@@ -51,54 +51,85 @@ describe('filterProvidersByTier (§2.7 healing-tier gate)', () => {
|
|
|
51
51
|
const result = (0, HealingOrchestrator_1.filterProvidersByTier)(providers, []);
|
|
52
52
|
(0, chai_1.expect)(result).to.deep.equal([]);
|
|
53
53
|
});
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
describe('coerceHealingTiersCap
|
|
61
|
-
it('passes a
|
|
54
|
+
// The value is a privacy control: a session that leaves out Visual AI (4)
|
|
55
|
+
// and the LLM (5) keeps its screen away from the AI provider. So a value
|
|
56
|
+
// that can't be read fails closed for those two, and open for the rest:
|
|
57
|
+
// the tiers that stay on the server (1, 2, 3) still run. Through 2.14 an
|
|
58
|
+
// empty list, or a value that wasn't a list or had no numbers in it, ran
|
|
59
|
+
// every tier, the AI ones included; a list that held some numbers ran those.
|
|
60
|
+
describe('coerceHealingTiersCap', () => {
|
|
61
|
+
it('passes a list of tier numbers through unchanged', () => {
|
|
62
62
|
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)([1, 3])).to.deep.equal([1, 3]);
|
|
63
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)([5, 4, 1])).to.deep.equal([5, 4, 1]);
|
|
63
64
|
});
|
|
64
|
-
it('
|
|
65
|
-
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)([1, '2'])).to.deep.equal([1]);
|
|
66
|
-
});
|
|
67
|
-
it('non-array (string/undefined/null) → undefined (run all)', () => {
|
|
68
|
-
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)('1,2')).to.equal(undefined);
|
|
65
|
+
it('runs every tier when the session sets nothing', () => {
|
|
69
66
|
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)(undefined)).to.equal(undefined);
|
|
70
67
|
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)(null)).to.equal(undefined);
|
|
71
68
|
});
|
|
72
|
-
it('
|
|
73
|
-
|
|
74
|
-
(0, chai_1.expect)(coerced).to.equal(undefined);
|
|
75
|
-
(0, chai_1.expect)((0, HealingOrchestrator_1.filterProvidersByTier)(providers, coerced)).to.deep.equal(providers);
|
|
76
|
-
});
|
|
77
|
-
it('explicitly-empty [] also → undefined (run all; the cap restricts, it cannot disable)', () => {
|
|
78
|
-
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)([])).to.equal(undefined);
|
|
69
|
+
it('runs no tier for an empty list', () => {
|
|
70
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)([])).to.deep.equal([]);
|
|
79
71
|
});
|
|
72
|
+
for (const [label, value] of [
|
|
73
|
+
['a string', '1,2'],
|
|
74
|
+
['numbers as strings', ['1', '2']],
|
|
75
|
+
['a list with a string in it', [1, '2']],
|
|
76
|
+
['a number that is no tier', [1, 6]],
|
|
77
|
+
['tier 0', [0]],
|
|
78
|
+
['a fraction', [1.5]],
|
|
79
|
+
['a lone number', 4],
|
|
80
|
+
['an object', { llm: false }],
|
|
81
|
+
['true', true],
|
|
82
|
+
]) {
|
|
83
|
+
it(`runs only the tiers that stay on the server for ${label}`, () => {
|
|
84
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.coerceHealingTiersCap)(value)).to.deep.equal([1, 2, 3]);
|
|
85
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.filterProvidersByTier)(providers, (0, HealingOrchestrator_1.coerceHealingTiersCap)(value))).to.deep.equal(providers.slice(0, 3));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
80
88
|
});
|
|
81
89
|
// What CommandInterceptor hands attemptHealing, read from the session's
|
|
82
90
|
// capabilities.
|
|
83
91
|
describe('healingTiersFromCaps', () => {
|
|
84
92
|
it('reads xe:options.healingTiers', () => {
|
|
85
|
-
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({ 'xe:options': { healingTiers: [1, 2] } })).to.deep.equal(
|
|
86
|
-
1, 2,
|
|
87
|
-
|
|
93
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({ 'xe:options': { healingTiers: [1, 2] } })).to.deep.equal({
|
|
94
|
+
tiers: [1, 2],
|
|
95
|
+
});
|
|
88
96
|
});
|
|
89
97
|
it('still reads the xenon:options alias', () => {
|
|
90
|
-
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({ 'xenon:options': { healingTiers: [3] } })).to.deep.equal(
|
|
98
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({ 'xenon:options': { healingTiers: [3] } })).to.deep.equal({
|
|
99
|
+
tiers: [3],
|
|
100
|
+
});
|
|
91
101
|
});
|
|
92
102
|
it('prefers xe:options when both set it', () => {
|
|
93
103
|
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({
|
|
94
104
|
'xenon:options': { healingTiers: [5] },
|
|
95
105
|
'xe:options': { healingTiers: [1] },
|
|
96
|
-
})).to.deep.equal([1]);
|
|
106
|
+
})).to.deep.equal({ tiers: [1] });
|
|
107
|
+
});
|
|
108
|
+
it('runs every tier with no capabilities, or none set', () => {
|
|
109
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)(undefined)).to.deep.equal({ tiers: undefined });
|
|
110
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({})).to.deep.equal({ tiers: undefined });
|
|
111
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({ 'xe:options': {} })).to.deep.equal({ tiers: undefined });
|
|
97
112
|
});
|
|
98
|
-
it('
|
|
99
|
-
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)(
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
it('names a value it could not read, for the log', () => {
|
|
114
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({ 'xe:options': { healingTiers: ['1'] } })).to.deep.equal({
|
|
115
|
+
tiers: [1, 2, 3],
|
|
116
|
+
unreadable: 'healingTiers is ["1"], not a list of tier numbers from 1 to 5',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
// xenonOptionsIn skips a namespace that isn't an object, so this one used
|
|
120
|
+
// to read as "nothing set", and ran every tier.
|
|
121
|
+
it('runs only the tiers that stay on the server when xe:options is not an object', () => {
|
|
122
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({ 'xe:options': '{"healingTiers":[1]}' })).to.deep.equal({
|
|
123
|
+
tiers: [1, 2, 3],
|
|
124
|
+
unreadable: 'xe:options is "{\\"healingTiers\\":[1]}", not an object',
|
|
125
|
+
});
|
|
126
|
+
(0, chai_1.expect)((0, HealingOrchestrator_1.healingTiersFromCaps)({
|
|
127
|
+
'xenon:options': ['healingTiers'],
|
|
128
|
+
'xe:options': { healingTiers: [1, 2, 3, 4, 5] },
|
|
129
|
+
})).to.deep.equal({
|
|
130
|
+
tiers: [1, 2, 3],
|
|
131
|
+
unreadable: 'xenon:options is ["healingTiers"], not an object',
|
|
132
|
+
});
|
|
102
133
|
});
|
|
103
134
|
});
|
|
104
135
|
});
|
|
@@ -136,6 +167,20 @@ describe('HealingOrchestrator.attemptHealing allowedTiers dispatch gate', () =>
|
|
|
136
167
|
yield orchestrator.attemptHealing('sess-1', mockDriver, 'xpath', '//broken', [1]);
|
|
137
168
|
(0, chai_1.expect)(dispatched).to.deep.equal(['ResilioTree Provider']);
|
|
138
169
|
}));
|
|
170
|
+
it('collects nothing and dispatches nothing when no tier is allowed', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
171
|
+
let collected = 0;
|
|
172
|
+
const driver = Object.assign(Object.assign({}, mockDriver), { getPageSource: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
173
|
+
collected++;
|
|
174
|
+
return '<xml/>';
|
|
175
|
+
}), getScreenshot: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
176
|
+
collected++;
|
|
177
|
+
return 'fake-screenshot';
|
|
178
|
+
}) });
|
|
179
|
+
const healed = yield orchestrator.attemptHealing('sess-3', driver, 'xpath', '//broken', []);
|
|
180
|
+
(0, chai_1.expect)(healed).to.equal(null);
|
|
181
|
+
(0, chai_1.expect)(dispatched).to.deep.equal([]);
|
|
182
|
+
(0, chai_1.expect)(collected).to.equal(0);
|
|
183
|
+
}));
|
|
139
184
|
it('dispatches to the full provider set when allowedTiers is omitted (regression)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
140
185
|
yield orchestrator.attemptHealing('sess-2', mockDriver, 'xpath', '//broken');
|
|
141
186
|
(0, chai_1.expect)(dispatched).to.deep.equal([
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
require("reflect-metadata");
|
|
16
|
+
const chai_1 = require("chai");
|
|
17
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
18
|
+
const typedi_1 = require("typedi");
|
|
19
|
+
const base_driver_1 = require("@appium/base-driver");
|
|
20
|
+
const CommandInterceptor_1 = require("../../src/interceptors/CommandInterceptor");
|
|
21
|
+
const HealingOrchestrator_1 = require("../../src/services/healing/HealingOrchestrator");
|
|
22
|
+
const AIService_1 = require("../../src/services/AIService");
|
|
23
|
+
const SelfHealingSwitch_1 = require("../../src/services/settings/SelfHealingSwitch");
|
|
24
|
+
const SessionManager_1 = require("../../src/sessions/SessionManager");
|
|
25
|
+
const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
|
|
26
|
+
const container_registration_1 = require("../helpers/container-registration");
|
|
27
|
+
const scratch_database_1 = require("../helpers/scratch-database");
|
|
28
|
+
/**
|
|
29
|
+
* A session's `xe:options.healingTiers` keeps its screen away from the AI
|
|
30
|
+
* provider: with `[1, 2, 3]`, Visual AI (4) and the LLM (5) never run, so the
|
|
31
|
+
* screenshot and page source never leave the server.
|
|
32
|
+
*
|
|
33
|
+
* Through 2.14 the interceptor read the tiers from SESSION_MANAGER, which holds
|
|
34
|
+
* a local session only with the dashboard on or a video recorded. A session
|
|
35
|
+
* with `record_video: false` on a server with the dashboard off (the default),
|
|
36
|
+
* a node's for its hub included, wasn't there: the interceptor found no
|
|
37
|
+
* capabilities and ran every tier. The tiers are now read from the session's
|
|
38
|
+
* own driver, which Appium hands the plugin with every command. These sessions
|
|
39
|
+
* are registered nowhere, as such a session isn't.
|
|
40
|
+
*/
|
|
41
|
+
describe("a session's healing tiers, on a session the dashboard doesn't track", () => {
|
|
42
|
+
(0, scratch_database_1.useScratchDatabase)();
|
|
43
|
+
let restoreContainer;
|
|
44
|
+
let dispatched;
|
|
45
|
+
let visualFind;
|
|
46
|
+
let healLocator;
|
|
47
|
+
const drivers = [];
|
|
48
|
+
const pluginArgs = Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { enableDashboard: false });
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
restoreContainer = (0, container_registration_1.saveRegistrations)(HealingOrchestrator_1.HealingOrchestrator, SelfHealingSwitch_1.SelfHealingSwitch);
|
|
51
|
+
// The switch as at boot with nothing saved: healing on.
|
|
52
|
+
typedi_1.Container.set(SelfHealingSwitch_1.SelfHealingSwitch, new SelfHealingSwitch_1.SelfHealingSwitch());
|
|
53
|
+
const etalons = {
|
|
54
|
+
getSignature: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
55
|
+
saveSignature: () => __awaiter(void 0, void 0, void 0, function* () { }),
|
|
56
|
+
};
|
|
57
|
+
const orchestrator = new HealingOrchestrator_1.HealingOrchestrator(etalons);
|
|
58
|
+
dispatched = [];
|
|
59
|
+
const providers = orchestrator.providers;
|
|
60
|
+
// The three local tiers are only recorded. Visual AI and the LLM run as
|
|
61
|
+
// they are, up to the AI service, which is stubbed: what reaches it is
|
|
62
|
+
// what would have gone to the provider.
|
|
63
|
+
for (const provider of providers.slice(0, 3)) {
|
|
64
|
+
provider.heal = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
65
|
+
dispatched.push(provider.name);
|
|
66
|
+
return null;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const provider of providers.slice(3)) {
|
|
70
|
+
const heal = provider.heal.bind(provider);
|
|
71
|
+
provider.heal = (context) => __awaiter(void 0, void 0, void 0, function* () {
|
|
72
|
+
dispatched.push(provider.name);
|
|
73
|
+
return heal(context);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
typedi_1.Container.set(HealingOrchestrator_1.HealingOrchestrator, orchestrator);
|
|
77
|
+
sinon_1.default.stub(AIService_1.AI_SERVICE, 'isEnabled').returns(true);
|
|
78
|
+
visualFind = sinon_1.default.stub(AIService_1.AI_SERVICE, 'visualFind').resolves(null);
|
|
79
|
+
healLocator = sinon_1.default.stub(AIService_1.AI_SERVICE, 'healLocator').resolves(null);
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
+
sinon_1.default.restore();
|
|
83
|
+
restoreContainer();
|
|
84
|
+
for (const driver of drivers.splice(0))
|
|
85
|
+
yield driver.deleteSession();
|
|
86
|
+
}));
|
|
87
|
+
/**
|
|
88
|
+
* The driver Appium hands the plugin for a session's commands: a session
|
|
89
|
+
* created from the capabilities the client sent, as Appium creates it.
|
|
90
|
+
*/
|
|
91
|
+
function sessionWith(xenonOptions) {
|
|
92
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
93
|
+
const driver = new base_driver_1.BaseDriver({});
|
|
94
|
+
// Every driver warns that it doesn't know `xe:options`; not this spec's business.
|
|
95
|
+
sinon_1.default.stub(driver.log, 'warn');
|
|
96
|
+
const alwaysMatch = {
|
|
97
|
+
platformName: 'Android',
|
|
98
|
+
'appium:automationName': 'UiAutomator2',
|
|
99
|
+
};
|
|
100
|
+
if (xenonOptions)
|
|
101
|
+
Object.assign(alwaysMatch, xenonOptions);
|
|
102
|
+
yield driver.createSession({ alwaysMatch, firstMatch: [{}] });
|
|
103
|
+
drivers.push(driver);
|
|
104
|
+
Object.assign(driver, {
|
|
105
|
+
getPageSource: () => __awaiter(this, void 0, void 0, function* () { return '<hierarchy><node text="Pay now"/></hierarchy>'; }),
|
|
106
|
+
getScreenshot: () => __awaiter(this, void 0, void 0, function* () { return 'iVBORw0KGgo='; }),
|
|
107
|
+
findElements: () => __awaiter(this, void 0, void 0, function* () { return []; }),
|
|
108
|
+
});
|
|
109
|
+
return driver;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** A findElement the driver answers with NoSuchElement, as a broken selector does. */
|
|
113
|
+
function findMissing(driver_1, _a) {
|
|
114
|
+
return __awaiter(this, arguments, void 0, function* (driver, { isHub }) {
|
|
115
|
+
const sessionId = driver.sessionId;
|
|
116
|
+
return typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor)
|
|
117
|
+
.handle(() => __awaiter(this, void 0, void 0, function* () {
|
|
118
|
+
throw Object.assign(new Error('NoSuchElement: An element could not be located'), {
|
|
119
|
+
name: 'NoSuchElementError',
|
|
120
|
+
});
|
|
121
|
+
}), driver, 'findElement', ['xpath', '//android.widget.Button[@text="Pay"]', sessionId], pluginArgs, isHub)
|
|
122
|
+
.then(() => undefined, (err) => err);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
for (const [where, isHub] of [
|
|
126
|
+
['on a standalone server', true],
|
|
127
|
+
["on a node running its hub's session", false],
|
|
128
|
+
]) {
|
|
129
|
+
describe(where, () => {
|
|
130
|
+
it('never calls the AI provider for a session that allows tiers 1, 2 and 3', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
131
|
+
const driver = yield sessionWith({ 'xe:options': { healingTiers: [1, 2, 3] } });
|
|
132
|
+
// The case: with the dashboard off and no video, nothing registers it.
|
|
133
|
+
(0, chai_1.expect)(SessionManager_1.SESSION_MANAGER.getSession(driver.sessionId)).to.equal(undefined);
|
|
134
|
+
const thrown = yield findMissing(driver, { isHub });
|
|
135
|
+
(0, chai_1.expect)(visualFind.called, 'Visual AI sent the screenshot').to.equal(false);
|
|
136
|
+
(0, chai_1.expect)(healLocator.called, 'the LLM was sent the page source').to.equal(false);
|
|
137
|
+
(0, chai_1.expect)(dispatched).to.deep.equal([
|
|
138
|
+
'ResilioTree Provider',
|
|
139
|
+
'Fuzzy XML Provider',
|
|
140
|
+
'OCR Text Provider',
|
|
141
|
+
]);
|
|
142
|
+
// Nothing healed it, so the test sees its own failure.
|
|
143
|
+
(0, chai_1.expect)(thrown === null || thrown === void 0 ? void 0 : thrown.name).to.equal('NoSuchElementError');
|
|
144
|
+
}));
|
|
145
|
+
it('reads the xenon:options alias the same way', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
146
|
+
const driver = yield sessionWith({ 'xenon:options': { healingTiers: [2] } });
|
|
147
|
+
yield findMissing(driver, { isHub });
|
|
148
|
+
(0, chai_1.expect)(visualFind.called).to.equal(false);
|
|
149
|
+
(0, chai_1.expect)(healLocator.called).to.equal(false);
|
|
150
|
+
(0, chai_1.expect)(dispatched).to.deep.equal(['Fuzzy XML Provider']);
|
|
151
|
+
}));
|
|
152
|
+
it('still runs every tier for a session that asks for none in particular', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
153
|
+
const driver = yield sessionWith(null);
|
|
154
|
+
yield findMissing(driver, { isHub });
|
|
155
|
+
(0, chai_1.expect)(dispatched).to.deep.equal([
|
|
156
|
+
'ResilioTree Provider',
|
|
157
|
+
'Fuzzy XML Provider',
|
|
158
|
+
'OCR Text Provider',
|
|
159
|
+
'Visual AI Provider',
|
|
160
|
+
'LLM Reasoning Provider',
|
|
161
|
+
]);
|
|
162
|
+
(0, chai_1.expect)(visualFind.calledOnce).to.equal(true);
|
|
163
|
+
(0, chai_1.expect)(healLocator.calledOnce).to.equal(true);
|
|
164
|
+
}));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
describe('a value that is not a list of tier numbers', () => {
|
|
168
|
+
for (const [label, value] of [
|
|
169
|
+
['a string', '1,2,3'],
|
|
170
|
+
['numbers as strings', ['1', '2']],
|
|
171
|
+
['a list with a string in it', [4, 'llm']],
|
|
172
|
+
['a number that is no tier', [1, 6]],
|
|
173
|
+
['a lone number', 4],
|
|
174
|
+
['an object', { visualAi: false }],
|
|
175
|
+
]) {
|
|
176
|
+
it(`runs only the tiers that stay on the server: ${label}`, () => __awaiter(void 0, void 0, void 0, function* () {
|
|
177
|
+
const driver = yield sessionWith({ 'xe:options': { healingTiers: value } });
|
|
178
|
+
yield findMissing(driver, { isHub: true });
|
|
179
|
+
(0, chai_1.expect)(visualFind.called).to.equal(false);
|
|
180
|
+
(0, chai_1.expect)(healLocator.called).to.equal(false);
|
|
181
|
+
(0, chai_1.expect)(dispatched).to.deep.equal([
|
|
182
|
+
'ResilioTree Provider',
|
|
183
|
+
'Fuzzy XML Provider',
|
|
184
|
+
'OCR Text Provider',
|
|
185
|
+
]);
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
it('runs only the tiers that stay on the server when xe:options is not an object', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
189
|
+
const driver = yield sessionWith({ 'xe:options': '{"healingTiers":[1,2,3]}' });
|
|
190
|
+
yield findMissing(driver, { isHub: true });
|
|
191
|
+
(0, chai_1.expect)(visualFind.called).to.equal(false);
|
|
192
|
+
(0, chai_1.expect)(healLocator.called).to.equal(false);
|
|
193
|
+
(0, chai_1.expect)(dispatched).to.deep.equal([
|
|
194
|
+
'ResilioTree Provider',
|
|
195
|
+
'Fuzzy XML Provider',
|
|
196
|
+
'OCR Text Provider',
|
|
197
|
+
]);
|
|
198
|
+
}));
|
|
199
|
+
it('says so in the log, once per session', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
200
|
+
const warn = sinon_1.default.stub(typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor).log, 'warn');
|
|
201
|
+
const driver = yield sessionWith({ 'xe:options': { healingTiers: ['1', '2'] } });
|
|
202
|
+
yield findMissing(driver, { isHub: true });
|
|
203
|
+
yield findMissing(driver, { isHub: true });
|
|
204
|
+
const said = warn.getCalls().filter((c) => /healingTiers/.test(String(c.args[0])));
|
|
205
|
+
(0, chai_1.expect)(said).to.have.length(1);
|
|
206
|
+
(0, chai_1.expect)(String(said[0].args[0])).to.contain('["1","2"]');
|
|
207
|
+
}));
|
|
208
|
+
});
|
|
209
|
+
it('heals with no tier, and takes no screenshot, for a session that lists none', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
210
|
+
const driver = yield sessionWith({ 'xe:options': { healingTiers: [] } });
|
|
211
|
+
const screenshot = sinon_1.default.spy(driver, 'getScreenshot');
|
|
212
|
+
const source = sinon_1.default.spy(driver, 'getPageSource');
|
|
213
|
+
const thrown = yield findMissing(driver, { isHub: true });
|
|
214
|
+
(0, chai_1.expect)(dispatched).to.deep.equal([]);
|
|
215
|
+
(0, chai_1.expect)(screenshot.called).to.equal(false);
|
|
216
|
+
(0, chai_1.expect)(source.called).to.equal(false);
|
|
217
|
+
(0, chai_1.expect)(thrown === null || thrown === void 0 ? void 0 : thrown.name).to.equal('NoSuchElementError');
|
|
218
|
+
}));
|
|
219
|
+
});
|
|
@@ -71,6 +71,11 @@ const hubSessionToken_1 = require("../../src/gateway/hubSessionToken");
|
|
|
71
71
|
const internalCall_1 = require("../../src/gateway/internalCall");
|
|
72
72
|
const nodeWebDriverUrl_1 = require("../../src/gateway/nodeWebDriverUrl");
|
|
73
73
|
const defaultGateway_1 = require("../../src/gateway/defaultGateway");
|
|
74
|
+
const CommandInterceptor_1 = require("../../src/interceptors/CommandInterceptor");
|
|
75
|
+
const HealingOrchestrator_1 = require("../../src/services/healing/HealingOrchestrator");
|
|
76
|
+
const types_1 = require("../../src/services/healing/types");
|
|
77
|
+
const SelfHealingSwitch_1 = require("../../src/services/settings/SelfHealingSwitch");
|
|
78
|
+
const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
|
|
74
79
|
const container_registration_1 = require("../helpers/container-registration");
|
|
75
80
|
const scratch_database_1 = require("../helpers/scratch-database");
|
|
76
81
|
/**
|
|
@@ -104,6 +109,15 @@ const DRIVER_ROUTES = {
|
|
|
104
109
|
POST: { command: 'stopRecordingScreen', payloadParams: { optional: ['options'] } },
|
|
105
110
|
},
|
|
106
111
|
};
|
|
112
|
+
/**
|
|
113
|
+
* The node's findElement as its plugin runs it, when a test sets it: the
|
|
114
|
+
* driver's own find goes through Xenon's CommandInterceptor.
|
|
115
|
+
*/
|
|
116
|
+
let nodeFind;
|
|
117
|
+
/** The hub's findElement as its plugin runs it, for a session the hub runs itself. */
|
|
118
|
+
let hubFind;
|
|
119
|
+
/** The node's answer to an execute script, when a test sets it. */
|
|
120
|
+
let nodeExecute;
|
|
107
121
|
function fakeDriver(name) {
|
|
108
122
|
const { errors } = appiumBaseDriver();
|
|
109
123
|
const commands = [];
|
|
@@ -120,14 +134,21 @@ function fakeDriver(name) {
|
|
|
120
134
|
if (command === 'getUrl')
|
|
121
135
|
return `https://${name}.example/`;
|
|
122
136
|
if (command === 'findElement') {
|
|
137
|
+
if (name === 'node' && nodeFind)
|
|
138
|
+
return nodeFind(args);
|
|
139
|
+
if (name === 'hub' && hubFind)
|
|
140
|
+
return hubFind(args);
|
|
123
141
|
if (args[1] === 'missing')
|
|
124
142
|
throw new errors.NoSuchElementError();
|
|
125
143
|
return { 'element-6066-11e4-a52e-4f735466cecf': `${name}-el` };
|
|
126
144
|
}
|
|
127
145
|
if (command === 'stopRecordingScreen')
|
|
128
146
|
return 'node-video.mp4';
|
|
129
|
-
if (command === 'execute')
|
|
147
|
+
if (command === 'execute') {
|
|
148
|
+
if (name === 'node' && nodeExecute)
|
|
149
|
+
return nodeExecute(args);
|
|
130
150
|
return { ranOn: name, script: args[0] };
|
|
151
|
+
}
|
|
131
152
|
if (command === 'deleteSession') {
|
|
132
153
|
sessions.delete(args[0]);
|
|
133
154
|
return null;
|
|
@@ -458,6 +479,72 @@ describe('the session gateway between a hub and a node, in Appium 3’s own serv
|
|
|
458
479
|
const row = yield scratch.db.session.findUnique({ where: { id } });
|
|
459
480
|
(0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.name).to.equal('Checkout');
|
|
460
481
|
}));
|
|
482
|
+
describe('a network-capture script', () => {
|
|
483
|
+
// A captured request's headers and bodies can carry the app's sign-in
|
|
484
|
+
// tokens. They are for admins only (the /interceptor routes, the Network
|
|
485
|
+
// panel, the live events), but the hub's command log is read by anyone
|
|
486
|
+
// who can see the session (`session_log`, bug reports).
|
|
487
|
+
const SECRET = 'Bearer eyJ-app-access-token';
|
|
488
|
+
const HAR = {
|
|
489
|
+
log: {
|
|
490
|
+
entries: [{ request: { headers: [{ name: 'authorization', value: SECRET }] } }],
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
afterEach(() => {
|
|
494
|
+
nodeExecute = undefined;
|
|
495
|
+
});
|
|
496
|
+
function loggedRows(id, count) {
|
|
497
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
498
|
+
const deadline = Date.now() + 5000;
|
|
499
|
+
let rows = yield scratch.db.sessionLog.findMany({ where: { session_id: id } });
|
|
500
|
+
while (rows.length < count && Date.now() < deadline) {
|
|
501
|
+
yield new Promise((r) => setTimeout(r, 10));
|
|
502
|
+
rows = yield scratch.db.sessionLog.findMany({ where: { session_id: id } });
|
|
503
|
+
}
|
|
504
|
+
return rows;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
it('reaches the test whole, and the hub’s command log keeps neither its arguments nor its answer', () => __awaiter(this, void 0, void 0, function* () {
|
|
508
|
+
var _a;
|
|
509
|
+
yield boot({ dashboard: true });
|
|
510
|
+
const { id } = yield remoteSession();
|
|
511
|
+
nodeExecute = (_a) => __awaiter(this, [_a], void 0, function* ([script]) { return /addMock/.test(script) ? { id: 'mock-1' } : script.includes('exportHar') ? HAR : [HAR]; });
|
|
512
|
+
const har = yield execute(id, 'xenon: exportHar', []);
|
|
513
|
+
(0, chai_1.expect)(har.status).to.equal(200);
|
|
514
|
+
(0, chai_1.expect)(har.body.value, 'the test gets its capture').to.deep.equal(HAR);
|
|
515
|
+
yield execute(id, 'xe:getRequests', []);
|
|
516
|
+
yield execute(id, 'getMocks', []);
|
|
517
|
+
yield execute(id, 'xenon: addMock', [
|
|
518
|
+
{ match: { url: '**/login' }, respond: { body: { token: SECRET } } },
|
|
519
|
+
]);
|
|
520
|
+
const rows = yield loggedRows(id, 4);
|
|
521
|
+
(0, chai_1.expect)(rows.map((r) => r.command_name)).to.deep.equal([
|
|
522
|
+
'execute',
|
|
523
|
+
'execute',
|
|
524
|
+
'execute',
|
|
525
|
+
'execute',
|
|
526
|
+
]);
|
|
527
|
+
for (const row of rows) {
|
|
528
|
+
(0, chai_1.expect)(`${row.body}${row.response}`, (_a = row.body) !== null && _a !== void 0 ? _a : '').to.not.include('eyJ-app-access-token');
|
|
529
|
+
(0, chai_1.expect)(row.is_success).to.equal(true);
|
|
530
|
+
}
|
|
531
|
+
// Which script ran stays on record.
|
|
532
|
+
(0, chai_1.expect)(rows.map((r) => { var _a; return JSON.parse((_a = r.body) !== null && _a !== void 0 ? _a : '{}').script; })).to.deep.equal([
|
|
533
|
+
'xenon: exportHar',
|
|
534
|
+
'xe:getRequests',
|
|
535
|
+
'getMocks',
|
|
536
|
+
'xenon: addMock',
|
|
537
|
+
]);
|
|
538
|
+
}));
|
|
539
|
+
it('any other script is logged whole, as before', () => __awaiter(this, void 0, void 0, function* () {
|
|
540
|
+
yield boot({ dashboard: true });
|
|
541
|
+
const { id } = yield remoteSession();
|
|
542
|
+
nodeExecute = () => __awaiter(this, void 0, void 0, function* () { return ({ output: SECRET }); });
|
|
543
|
+
yield execute(id, 'mobile: shell', [{ command: 'echo' }]);
|
|
544
|
+
const [row] = yield loggedRows(id, 1);
|
|
545
|
+
(0, chai_1.expect)(row.response).to.include('eyJ-app-access-token');
|
|
546
|
+
}));
|
|
547
|
+
});
|
|
461
548
|
it('with the hub’s dashboard off, sends a session-details command on to the node', () => __awaiter(this, void 0, void 0, function* () {
|
|
462
549
|
yield boot({ dashboard: false });
|
|
463
550
|
const { id } = yield remoteSession();
|
|
@@ -639,4 +726,159 @@ describe('the session gateway between a hub and a node, in Appium 3’s own serv
|
|
|
639
726
|
yield (0, loopbackRequest_1.default)(nodeOrigin).get(`${nodeBase}/session/${id}/url`).expect(200);
|
|
640
727
|
}));
|
|
641
728
|
});
|
|
729
|
+
/**
|
|
730
|
+
* A find on a node's phone runs on the node, so the node's interceptor
|
|
731
|
+
* heals it. Through 2.14 the heal was recorded nowhere: the node keeps no
|
|
732
|
+
* record of a session the hub created, and the hub saw only the element the
|
|
733
|
+
* node answered with, so the find was logged as found (and counted toward
|
|
734
|
+
* the selector's verification as a clean find). The node now hands the heal
|
|
735
|
+
* back on its answer, and the hub, which owns the session record, records
|
|
736
|
+
* it as it records a local session's.
|
|
737
|
+
*/
|
|
738
|
+
describe("a heal on a node's phone", () => {
|
|
739
|
+
const W3C = 'element-6066-11e4-a52e-4f735466cecf';
|
|
740
|
+
let attemptHealing;
|
|
741
|
+
let nodeLogged;
|
|
742
|
+
let restoreHealing;
|
|
743
|
+
beforeEach(() => {
|
|
744
|
+
restoreHealing = (0, container_registration_1.saveRegistrations)(HealingOrchestrator_1.HealingOrchestrator, SelfHealingSwitch_1.SelfHealingSwitch);
|
|
745
|
+
typedi_1.Container.set(SelfHealingSwitch_1.SelfHealingSwitch, new SelfHealingSwitch_1.SelfHealingSwitch());
|
|
746
|
+
attemptHealing = sinon_1.default.stub().resolves({
|
|
747
|
+
id: 'node-healed-el',
|
|
748
|
+
tier: types_1.HealingTier.TIER_2_FUZZY_XML,
|
|
749
|
+
confidence: 0.82,
|
|
750
|
+
originalSelector: 'broken',
|
|
751
|
+
originalStrategy: 'xpath',
|
|
752
|
+
recommendedSelector: "//*[@resource-id='com.example:id/login']",
|
|
753
|
+
recommendedStrategy: 'xpath',
|
|
754
|
+
});
|
|
755
|
+
typedi_1.Container.set(HealingOrchestrator_1.HealingOrchestrator, { attemptHealing });
|
|
756
|
+
// Hub and node share this process; the node's own record of a heal is
|
|
757
|
+
// the node's (on a real node it has no Session row to write it to).
|
|
758
|
+
nodeLogged = sinon_1.default.stub(CommandInterceptor_1.CommandInterceptor.prototype, 'logHealingEvent').resolves();
|
|
759
|
+
const { errors } = appiumBaseDriver();
|
|
760
|
+
nodeFind = hubFind = (args) => typedi_1.Container.get(CommandInterceptor_1.CommandInterceptor).handle(() => __awaiter(this, void 0, void 0, function* () {
|
|
761
|
+
if (args[1] === 'broken')
|
|
762
|
+
throw new errors.NoSuchElementError();
|
|
763
|
+
return { [W3C]: 'node-el' };
|
|
764
|
+
}), { sessionId: args[2] }, 'findElement', args, Object.assign({}, IPluginArgs_1.DefaultPluginArgs), false);
|
|
765
|
+
});
|
|
766
|
+
afterEach(() => {
|
|
767
|
+
nodeFind = hubFind = undefined;
|
|
768
|
+
restoreHealing();
|
|
769
|
+
});
|
|
770
|
+
/** The session's logged finds; the hub logs a command after it has answered it. */
|
|
771
|
+
function logsOf(id) {
|
|
772
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
773
|
+
for (let i = 0; i < 100; i++) {
|
|
774
|
+
const rows = yield scratch.db.sessionLog.findMany({
|
|
775
|
+
where: { session_id: id, command_name: 'findElement' },
|
|
776
|
+
});
|
|
777
|
+
if (rows.length)
|
|
778
|
+
return rows;
|
|
779
|
+
yield new Promise((r) => setTimeout(r, 20));
|
|
780
|
+
}
|
|
781
|
+
return [];
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
it('is recorded on the hub, against the session, with what it healed to', () => __awaiter(this, void 0, void 0, function* () {
|
|
785
|
+
yield boot({ dashboard: true });
|
|
786
|
+
const { id } = yield remoteSession();
|
|
787
|
+
const res = yield (0, loopbackRequest_1.default)(hubUrl)
|
|
788
|
+
.post(`/wd/hub/session/${id}/element`)
|
|
789
|
+
.send({ using: 'xpath', value: 'broken' });
|
|
790
|
+
(0, chai_1.expect)(res.status).to.equal(200);
|
|
791
|
+
(0, chai_1.expect)(res.body.value[W3C]).to.equal('node-healed-el');
|
|
792
|
+
const [row] = yield logsOf(id);
|
|
793
|
+
(0, chai_1.expect)(row, 'the find was logged').to.not.equal(undefined);
|
|
794
|
+
(0, chai_1.expect)(row).to.deep.include({
|
|
795
|
+
is_healed: true,
|
|
796
|
+
original_strategy: 'xpath',
|
|
797
|
+
original_selector: 'broken',
|
|
798
|
+
healed_strategy: 'xpath',
|
|
799
|
+
healed_selector: "//*[@resource-id='com.example:id/login']",
|
|
800
|
+
healing_confidence: 0.82,
|
|
801
|
+
healing_tier: 'Fuzzy XML',
|
|
802
|
+
});
|
|
803
|
+
(0, chai_1.expect)(nodeLogged.called, 'recorded on the node as well').to.equal(false);
|
|
804
|
+
}));
|
|
805
|
+
it("is Xenon's own: the client's answer carries nothing about it", () => __awaiter(this, void 0, void 0, function* () {
|
|
806
|
+
yield boot({ dashboard: true });
|
|
807
|
+
const { id } = yield remoteSession();
|
|
808
|
+
const res = yield (0, loopbackRequest_1.default)(hubUrl)
|
|
809
|
+
.post(`/wd/hub/session/${id}/element`)
|
|
810
|
+
.send({ using: 'xpath', value: 'broken' });
|
|
811
|
+
(0, chai_1.expect)(Object.keys(res.headers).filter((h) => h.startsWith('x-xenon'))).to.deep.equal([]);
|
|
812
|
+
}));
|
|
813
|
+
it("records the selector of a find on a node's phone that needed no heal", () => __awaiter(this, void 0, void 0, function* () {
|
|
814
|
+
yield boot({ dashboard: true });
|
|
815
|
+
const { id } = yield remoteSession();
|
|
816
|
+
yield (0, loopbackRequest_1.default)(hubUrl)
|
|
817
|
+
.post(`/wd/hub/session/${id}/element`)
|
|
818
|
+
.send({ using: 'id', value: 'login' })
|
|
819
|
+
.expect(200);
|
|
820
|
+
const [row] = yield logsOf(id);
|
|
821
|
+
(0, chai_1.expect)(row).to.deep.include({
|
|
822
|
+
is_healed: false,
|
|
823
|
+
original_strategy: 'id',
|
|
824
|
+
original_selector: 'login',
|
|
825
|
+
});
|
|
826
|
+
}));
|
|
827
|
+
it('takes a heal only from the node, never from the client', () => __awaiter(this, void 0, void 0, function* () {
|
|
828
|
+
yield boot({ dashboard: true });
|
|
829
|
+
const { id } = yield remoteSession();
|
|
830
|
+
const forged = Buffer.from(JSON.stringify({ originalSelector: 'login', healedSelector: '//x', confidence: 1 })).toString('base64url');
|
|
831
|
+
yield (0, loopbackRequest_1.default)(hubUrl)
|
|
832
|
+
.post(`/wd/hub/session/${id}/element`)
|
|
833
|
+
.set('x-xenon-heal', forged)
|
|
834
|
+
.send({ using: 'id', value: 'login' })
|
|
835
|
+
.expect(200);
|
|
836
|
+
const [row] = yield logsOf(id);
|
|
837
|
+
(0, chai_1.expect)(row.is_healed).to.equal(false);
|
|
838
|
+
(0, chai_1.expect)(nodeRequests[0].headers['x-xenon-heal']).to.equal(undefined);
|
|
839
|
+
}));
|
|
840
|
+
it('is recorded by the node itself when the find did not come from a hub', () => __awaiter(this, void 0, void 0, function* () {
|
|
841
|
+
yield boot();
|
|
842
|
+
const { id } = yield remoteSession({ registered: false });
|
|
843
|
+
const res = yield (0, loopbackRequest_1.default)(nodeOrigin)
|
|
844
|
+
.post(`/node/session/${id}/element`)
|
|
845
|
+
.send({ using: 'xpath', value: 'broken' });
|
|
846
|
+
(0, chai_1.expect)(res.status).to.equal(200);
|
|
847
|
+
(0, chai_1.expect)(res.headers['x-xenon-heal']).to.equal(undefined);
|
|
848
|
+
(0, chai_1.expect)(nodeLogged.calledOnce).to.equal(true);
|
|
849
|
+
}));
|
|
850
|
+
it('is recorded on the hub for its own session, whatever hub token a client sends', () => __awaiter(this, void 0, void 0, function* () {
|
|
851
|
+
yield boot({ dashboard: true });
|
|
852
|
+
const id = `hub-session-${stamp}`;
|
|
853
|
+
hub.sessions.add(id);
|
|
854
|
+
const res = yield (0, loopbackRequest_1.default)(hubUrl)
|
|
855
|
+
.post(`/wd/hub/session/${id}/element`)
|
|
856
|
+
.set(hubSessionToken_1.HUB_TOKEN_HEADER, 'not-a-hub')
|
|
857
|
+
.send({ using: 'xpath', value: 'broken' });
|
|
858
|
+
(0, chai_1.expect)(res.status).to.equal(200);
|
|
859
|
+
(0, chai_1.expect)(res.headers['x-xenon-heal']).to.equal(undefined);
|
|
860
|
+
(0, chai_1.expect)(nodeLogged.calledOnce, 'recorded by the server that healed it').to.equal(true);
|
|
861
|
+
}));
|
|
862
|
+
it('is neither sent nor recorded on the node when too long for the header', () => __awaiter(this, void 0, void 0, function* () {
|
|
863
|
+
yield boot({ dashboard: true });
|
|
864
|
+
const { id } = yield remoteSession();
|
|
865
|
+
attemptHealing.resolves({
|
|
866
|
+
id: 'node-healed-el',
|
|
867
|
+
tier: types_1.HealingTier.TIER_2_FUZZY_XML,
|
|
868
|
+
confidence: 0.82,
|
|
869
|
+
originalSelector: 'broken',
|
|
870
|
+
originalStrategy: 'xpath',
|
|
871
|
+
recommendedSelector: `//*[@text='${'x'.repeat(10000)}']`,
|
|
872
|
+
recommendedStrategy: 'xpath',
|
|
873
|
+
});
|
|
874
|
+
const res = yield (0, loopbackRequest_1.default)(hubUrl)
|
|
875
|
+
.post(`/wd/hub/session/${id}/element`)
|
|
876
|
+
.send({ using: 'xpath', value: 'broken' });
|
|
877
|
+
(0, chai_1.expect)(res.status).to.equal(200);
|
|
878
|
+
(0, chai_1.expect)(res.body.value[W3C]).to.equal('node-healed-el');
|
|
879
|
+
const [row] = yield logsOf(id);
|
|
880
|
+
(0, chai_1.expect)(row.is_healed).to.equal(false);
|
|
881
|
+
(0, chai_1.expect)(nodeLogged.called, 'recorded on the node, which has no row for it').to.equal(false);
|
|
882
|
+
}));
|
|
883
|
+
});
|
|
642
884
|
});
|