@xenon-device-management/xenon 2.14.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/lib/package.json +5 -5
- package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-Btq3dZRI.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-C9OoAgg_.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-DBC_xczB.js +2 -0
- package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-DqWU2-vM.js} +1 -1
- package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-CveQ3xrA.js} +1 -1
- package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-BfKDSfHz.js} +1 -1
- package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-CtdCybzA.js} +1 -1
- package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-Bsvj1tm2.js} +1 -1
- package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CRofOiyx.js} +1 -1
- package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-jNQHb2IR.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C8HtTha-.js} +1 -1
- package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-D8K1LZNo.js} +1 -1
- package/lib/public/assets/{Table-BcTm5xV2.js → Table-zNTX92Pn.js} +1 -1
- package/lib/public/assets/{activity-DOVdsfMQ.js → activity-CJ8LwZq9.js} +1 -1
- package/lib/public/assets/ai-settings-CgxQZqrc.js +16 -0
- package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-ECpo-zTo.js} +1 -1
- package/lib/public/assets/{apps-D0SC6uw6.js → apps-BjGymyf3.js} +1 -1
- package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BWfCbeHP.js} +1 -1
- package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-DC7UGf_n.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-BOjwybDk.js} +1 -1
- package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CxMTo9aH.js} +2 -2
- package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-DOCm4HkA.js} +1 -1
- package/lib/public/assets/{button-Dda4irn_.js → button-Cg08-EMT.js} +1 -1
- package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-CA1ikLJs.js} +1 -1
- package/lib/public/assets/{check-CZ9iw9-e.js → check-BMTaH72W.js} +1 -1
- package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-B2QOv9Nx.js} +1 -1
- package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CYXBoUqT.js} +1 -1
- package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-DTwnSAnl.js} +1 -1
- package/lib/public/assets/{clock-CpuDKysU.js → clock-C8_HViQP.js} +1 -1
- package/lib/public/assets/{copy-CAQydNFe.js → copy-Be7LNMXV.js} +1 -1
- package/lib/public/assets/derive-UnLRp4r3.js +1 -0
- package/lib/public/assets/device-explorer-BOqtfGXz.js +282 -0
- package/lib/public/assets/{download-C9XG6lLv.js → download-B56lwxWP.js} +1 -1
- package/lib/public/assets/forgot-password-CCU6bzLn.js +1 -0
- package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-C-OtmIuk.js} +6 -6
- package/lib/public/assets/index-B9KQ6LsY.js +15 -0
- package/lib/public/assets/{index-BbKEXUFH.js → index-BOxe43p1.js} +2 -2
- package/lib/public/assets/{index-DJGc1M2f.js → index-CfxsUslp.js} +1 -1
- package/lib/public/assets/{input-Cm0tmSoc.js → input-CZHFjbSg.js} +1 -1
- package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-Gl_Dz3X_.js} +1 -1
- package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-CCgjeyGY.js} +1 -1
- package/lib/public/assets/{lock-CCJv9m_I.js → lock-CkOwgDcx.js} +1 -1
- package/lib/public/assets/{login-f8vOVW-U.js → login-D5cc4w7M.js} +1 -1
- package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-t7qr3fvX.js} +1 -1
- package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-DEJixKQO.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CtOrk4M8.js} +1 -1
- package/lib/public/assets/{network-ebT86qoi.js → network-Dii9qdvk.js} +1 -1
- package/lib/public/assets/{overview-C0aykVrd.js → overview-rolAACrO.js} +1 -1
- package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-COQZxJfR.js} +1 -1
- package/lib/public/assets/{play-DLG9t8FU.js → play-DrN1Se_R.js} +1 -1
- package/lib/public/assets/{plus-nBFSMu_X.js → plus-5aq-9uRx.js} +1 -1
- package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-6i5kY6kF.js} +1 -1
- package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-sp2IBrc7.js} +1 -1
- package/lib/public/assets/runbook-page-DJDjeopU.js +253 -0
- package/lib/public/assets/{select-88jHcl-Z.js → select-Bpyn7rWO.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BH74vmKv.js} +1 -1
- package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-Cw_g218H.js} +1 -1
- package/lib/public/assets/session-detail-page-AZJE6XXE.js +4 -0
- package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-C3uyGhyc.js} +1 -1
- package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
- package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-xVqAKIlI.js} +1 -1
- package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BxUScAeO.js} +1 -1
- package/lib/public/assets/{teams-B4-jrr-1.js → teams-B5ljGkY2.js} +1 -1
- package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-B701i8sy.js} +1 -1
- package/lib/public/assets/{upload-BG7_rMXK.js → upload-C2gr44RS.js} +1 -1
- package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-NeQOFEoy.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-DEzJq5ef.js} +1 -1
- package/lib/public/assets/{users-CFba5TSy.js → users-D_vv_yz4.js} +1 -1
- package/lib/public/assets/{users-Cp0beJm3.js → users-MY6eNe6s.js} +1 -1
- package/lib/public/assets/{video-off-DDsEISjV.js → video-off-OK1lNZQn.js} +1 -1
- package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-BU-C9lBf.js} +1 -1
- package/lib/public/assets/{zap-l4VaXzq8.js → zap-DO9ry1iz.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/schema.json +5 -5
- 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 +10 -3
- package/lib/src/app/routers/apikeys.js +14 -2
- package/lib/src/app/routers/auth.js +29 -5
- 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 +10 -5
- package/lib/src/app/swagger.js +2 -0
- package/lib/src/app/ws/h264StreamWs.js +10 -1
- package/lib/src/config.js +1 -0
- package/lib/src/dashboard/commands.js +5 -5
- package/lib/src/dashboard/event-manager.js +49 -61
- package/lib/src/dashboard/services/failure-analysis-service.js +136 -45
- 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/android/AndroidH264StreamService.js +69 -20
- package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
- package/lib/src/device-utils.js +20 -2
- package/lib/src/gateway/defaultGateway.js +1 -1
- package/lib/src/gateway/forwardToNode.js +2 -0
- package/lib/src/gateway/healReport.js +87 -0
- 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/interceptors/CommandInterceptor.js +243 -181
- 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 +8 -3
- package/lib/src/services/AICommandService.js +9 -2
- package/lib/src/services/AIService.js +192 -42
- package/lib/src/services/EmailService.js +29 -0
- package/lib/src/services/OrphanSweeper.js +2 -0
- package/lib/src/services/SelectorVerificationJob.js +15 -4
- package/lib/src/services/ServerManager.js +11 -1
- package/lib/src/services/SessionLifecycleService.js +86 -26
- package/lib/src/services/SocketServer.js +19 -3
- 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 +55 -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/logcat/SessionDeviceLogs.js +271 -0
- package/lib/src/services/logcat/deviceLogBook.js +163 -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/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/mintedLifetime.js +51 -0
- package/lib/src/sessions/SessionManager.js +8 -2
- package/lib/src/sessions/sessionMemory.js +25 -0
- package/lib/test/helpers/fake-ai-provider.js +188 -0
- package/lib/test/helpers/loki-stores.js +33 -0
- package/lib/test/helpers/page-sources.js +164 -0
- package/lib/test/helpers/scratch-database.js +57 -13
- 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/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/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/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-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/dashboard-events-device-logs.spec.js +207 -0
- package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
- package/lib/test/unit/device-log-book.spec.js +180 -0
- package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
- package/lib/test/unit/event-log-session-data.spec.js +244 -0
- package/lib/test/unit/failure-analysis.spec.js +182 -0
- package/lib/test/unit/failure-categories.spec.js +128 -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 +73 -29
- package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
- package/lib/test/unit/hub-node-gateway-appium-server.spec.js +171 -0
- package/lib/test/unit/interceptor-visual-elements.spec.js +192 -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/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/package-engines.spec.js +27 -0
- package/lib/test/unit/passwordResetMode.test.js +10 -2
- package/lib/test/unit/profile-router.test.js +122 -1
- 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/selector-learning-every-session.spec.js +179 -0
- package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
- package/lib/test/unit/selector-verification-job.spec.js +13 -13
- 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.spec.js +347 -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/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/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 +5 -5
- package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
- package/prisma/schema.prisma +3 -0
- package/schema.json +5 -5
- 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/runbook-page-CMMeQml9.js +0 -124
- package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
- /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
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 express_1 = __importDefault(require("express"));
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
|
|
21
|
+
const index_1 = require("../../src/app/index");
|
|
22
|
+
const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
|
|
23
|
+
/**
|
|
24
|
+
* `enableDashboard` was described as "Serve the React dashboard at /xenon/
|
|
25
|
+
* and the Socket.io event stream". It never decided that: the server mounts
|
|
26
|
+
* /xenon on every start (ServerManager.registerRoutes) and nothing under it
|
|
27
|
+
* reads the option. What it decides is how much a hub records of a session
|
|
28
|
+
* (SessionLifecycleService, the command interceptor, the session gateway's
|
|
29
|
+
* dashboard hooks).
|
|
30
|
+
*/
|
|
31
|
+
const schema = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', '..', 'schema.json'), 'utf8'));
|
|
32
|
+
describe('the enableDashboard option', () => {
|
|
33
|
+
it('is never read by the dashboard: not to build it, not to answer a request', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
34
|
+
const reads = [];
|
|
35
|
+
const args = new Proxy(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { enableDashboard: false }), {
|
|
36
|
+
get(target, key, receiver) {
|
|
37
|
+
var _a;
|
|
38
|
+
if (key === 'enableDashboard')
|
|
39
|
+
reads.push((_a = new Error().stack) !== null && _a !== void 0 ? _a : '');
|
|
40
|
+
return Reflect.get(target, key, receiver);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const app = (0, express_1.default)();
|
|
44
|
+
app.use('/xenon', (0, index_1.createRouter)(args));
|
|
45
|
+
for (const url of ['/xenon/', '/xenon/sessions', '/xenon/api/health', '/xenon/api/session']) {
|
|
46
|
+
yield (0, loopbackRequest_1.default)(app).get(url);
|
|
47
|
+
}
|
|
48
|
+
(0, chai_1.expect)(reads, reads.join('\n\n')).to.be.empty;
|
|
49
|
+
}));
|
|
50
|
+
it('is described by what it does: records sessions, while the dashboard is always served', () => {
|
|
51
|
+
var _a;
|
|
52
|
+
const description = (_a = schema.properties.enableDashboard.description) !== null && _a !== void 0 ? _a : '';
|
|
53
|
+
(0, chai_1.expect)(description).to.match(/^Keep a full record of each Appium session for the dashboard/);
|
|
54
|
+
(0, chai_1.expect)(description).to.match(/The dashboard itself is always served at \/xenon\//);
|
|
55
|
+
(0, chai_1.expect)(description).not.to.match(/Serve the React dashboard|Socket\.io/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
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 SocketServer_1 = require("../../src/services/SocketServer");
|
|
20
|
+
const EventLogService_1 = require("../../src/services/EventLogService");
|
|
21
|
+
const prisma_service_1 = require("../../src/data-service/prisma-service");
|
|
22
|
+
const InterceptorService_1 = require("../../src/services/InterceptorService");
|
|
23
|
+
const SelectorStateService_1 = require("../../src/services/SelectorStateService");
|
|
24
|
+
const event_manager_1 = require("../../src/dashboard/event-manager");
|
|
25
|
+
const SessionManager_1 = require("../../src/sessions/SessionManager");
|
|
26
|
+
const SocketEvents_1 = require("../../src/enums/SocketEvents");
|
|
27
|
+
const scratch_database_1 = require("../helpers/scratch-database");
|
|
28
|
+
const container_registration_1 = require("../helpers/container-registration");
|
|
29
|
+
/** What a session's data may carry: the app's sign-in, its cookies, a password typed, personal data. */
|
|
30
|
+
const SECRETS = [
|
|
31
|
+
'Bearer eyJ-app-access-token',
|
|
32
|
+
'sid=app-session-cookie',
|
|
33
|
+
'hunter2',
|
|
34
|
+
'jane@example.com',
|
|
35
|
+
];
|
|
36
|
+
function capturedRequest(id) {
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
sessionId: 's1',
|
|
40
|
+
ts: 1700000000000,
|
|
41
|
+
method: 'POST',
|
|
42
|
+
url: 'https://api.example.com/v1/login',
|
|
43
|
+
host: 'api.example.com',
|
|
44
|
+
path: '/v1/login',
|
|
45
|
+
reqHeaders: { authorization: 'Bearer eyJ-app-access-token', cookie: 'sid=app-session-cookie' },
|
|
46
|
+
reqBody: JSON.stringify({ email: 'jane@example.com', password: 'hunter2' }),
|
|
47
|
+
resStatus: 200,
|
|
48
|
+
resHeaders: { 'set-cookie': 'sid=app-session-cookie; HttpOnly' },
|
|
49
|
+
resBody: JSON.stringify({ user: { email: 'jane@example.com' } }),
|
|
50
|
+
durationMs: 12,
|
|
51
|
+
mocked: false,
|
|
52
|
+
modified: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** A socket.io server with no dashboard socket: the room broadcast, recorded. */
|
|
56
|
+
function fakeIo() {
|
|
57
|
+
const broadcast = [];
|
|
58
|
+
const io = {
|
|
59
|
+
sockets: { adapter: { rooms: new Map() }, sockets: new Map() },
|
|
60
|
+
to: () => ({ emit: (event, data) => broadcast.push([event, data]) }),
|
|
61
|
+
};
|
|
62
|
+
return { io, broadcast };
|
|
63
|
+
}
|
|
64
|
+
describe("The event log keeps no copy of a session's own data", () => {
|
|
65
|
+
const scratch = (0, scratch_database_1.useScratchDatabase)();
|
|
66
|
+
let restore;
|
|
67
|
+
let eventLogEnv;
|
|
68
|
+
let appendSafe;
|
|
69
|
+
let broadcast;
|
|
70
|
+
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
71
|
+
restore = (0, container_registration_1.saveRegistrations)(SocketServer_1.SocketServer, EventLogService_1.EventLogService);
|
|
72
|
+
// The log must be on, or "no row" would prove nothing.
|
|
73
|
+
eventLogEnv = process.env.XENON_EVENT_LOG;
|
|
74
|
+
delete process.env.XENON_EVENT_LOG;
|
|
75
|
+
yield scratch.db.eventLog.deleteMany({});
|
|
76
|
+
const eventLog = new EventLogService_1.EventLogService(new prisma_service_1.PrismaService());
|
|
77
|
+
appendSafe = sinon_1.default.spy(eventLog, 'appendSafe');
|
|
78
|
+
typedi_1.Container.set(EventLogService_1.EventLogService, eventLog);
|
|
79
|
+
const server = new SocketServer_1.SocketServer();
|
|
80
|
+
const fake = fakeIo();
|
|
81
|
+
server.io = fake.io;
|
|
82
|
+
broadcast = fake.broadcast;
|
|
83
|
+
typedi_1.Container.set(SocketServer_1.SocketServer, server);
|
|
84
|
+
}));
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
sinon_1.default.restore();
|
|
87
|
+
restore();
|
|
88
|
+
if (eventLogEnv === undefined)
|
|
89
|
+
delete process.env.XENON_EVENT_LOG;
|
|
90
|
+
else
|
|
91
|
+
process.env.XENON_EVENT_LOG = eventLogEnv;
|
|
92
|
+
});
|
|
93
|
+
/** Waits for the fire-and-forget writes of `count` rows, then lets any later one land. */
|
|
94
|
+
function eventLogRows(count) {
|
|
95
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
96
|
+
const deadline = Date.now() + 5000;
|
|
97
|
+
while ((yield scratch.db.eventLog.count()) < count && Date.now() < deadline) {
|
|
98
|
+
yield new Promise((r) => setTimeout(r, 10));
|
|
99
|
+
}
|
|
100
|
+
yield new Promise((r) => setTimeout(r, 50));
|
|
101
|
+
return scratch.db.eventLog.findMany();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const live = (event) => broadcast.filter(([name]) => name === event).map(([, data]) => data);
|
|
105
|
+
describe('a captured request', () => {
|
|
106
|
+
it('reaches the live dashboard whole, and the EventLog table never', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
107
|
+
const svc = new InterceptorService_1.InterceptorService();
|
|
108
|
+
const emit = (evt) => svc.emit(evt, 'phone-a');
|
|
109
|
+
emit({ type: 'session_started', sessionId: 's1', port: 9100, host: '10.0.2.2' });
|
|
110
|
+
emit({ type: 'request', sessionId: 's1', payload: capturedRequest('q1') });
|
|
111
|
+
emit({ type: 'request', sessionId: 's1', payload: capturedRequest('q2') });
|
|
112
|
+
emit({ type: 'session_stopped', sessionId: 's1' });
|
|
113
|
+
const rows = yield eventLogRows(2);
|
|
114
|
+
// Written in the same millisecond, so in no particular order.
|
|
115
|
+
(0, chai_1.expect)(rows.map((r) => r.type)).to.have.members([
|
|
116
|
+
SocketEvents_1.SocketEvents.INTERCEPTOR_SESSION_STARTED,
|
|
117
|
+
SocketEvents_1.SocketEvents.INTERCEPTOR_SESSION_STOPPED,
|
|
118
|
+
]);
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
for (const secret of SECRETS)
|
|
121
|
+
(0, chai_1.expect)(row.payload).to.not.include(secret);
|
|
122
|
+
}
|
|
123
|
+
(0, chai_1.expect)(appendSafe.getCalls().map((c) => c.args[0].type)).to.not.include(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST);
|
|
124
|
+
// The session page's Network panel still gets the whole entry, live.
|
|
125
|
+
(0, chai_1.expect)(live(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST)).to.deep.equal([
|
|
126
|
+
capturedRequest('q1'),
|
|
127
|
+
capturedRequest('q2'),
|
|
128
|
+
]);
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
describe('a command', () => {
|
|
132
|
+
const SESSION = 's2';
|
|
133
|
+
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
134
|
+
yield scratch.db.sessionLog.deleteMany({});
|
|
135
|
+
yield scratch.db.session.deleteMany({});
|
|
136
|
+
yield scratch.db.session.create({
|
|
137
|
+
data: {
|
|
138
|
+
id: SESSION,
|
|
139
|
+
desired_capabilities: '{}',
|
|
140
|
+
session_capabilities: '{}',
|
|
141
|
+
node_id: 'node',
|
|
142
|
+
has_live_video: false,
|
|
143
|
+
device_udid: 'phone-a',
|
|
144
|
+
device_platform: 'android',
|
|
145
|
+
device_version: '14',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
sinon_1.default.stub(SessionManager_1.SESSION_MANAGER, 'getSession').returns({
|
|
149
|
+
getId: () => SESSION,
|
|
150
|
+
getDevice: () => ({ udid: 'phone-a', name: 'P', platform: 'android' }),
|
|
151
|
+
getXenonOption: () => undefined,
|
|
152
|
+
getScreenShot: () => __awaiter(void 0, void 0, void 0, function* () { return ''; }),
|
|
153
|
+
});
|
|
154
|
+
sinon_1.default.stub(SelectorStateService_1.SelectorStateService.prototype, 'onHealRecorded').resolves(null);
|
|
155
|
+
}));
|
|
156
|
+
function command(name, args, answer, healed) {
|
|
157
|
+
return event_manager_1.DASHBORD_EVENT_MANAGER.afterSessionCommand(SESSION, name, null, { body: args, method: 'POST', path: `/${name}`, originalUrl: `/${name}` }, {}, JSON.stringify({ value: answer, sessionId: SESSION }), healed);
|
|
158
|
+
}
|
|
159
|
+
it('logs and sends which command ran and how it went, never what it typed or answered', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
160
|
+
var _a, _b;
|
|
161
|
+
const pageSource = '<hierarchy><node text="jane@example.com"/></hierarchy>';
|
|
162
|
+
yield command('setValue', ['hunter2', 'element-1'], null);
|
|
163
|
+
yield command('getPageSource', [], pageSource);
|
|
164
|
+
yield command('findElement', ['xpath', '//*[@text="Pay"]'], { ELEMENT: 'e' }, {
|
|
165
|
+
originalSelector: '//*[@text="Pay"]',
|
|
166
|
+
originalStrategy: 'xpath',
|
|
167
|
+
healedSelector: 'pay_button',
|
|
168
|
+
healedStrategy: 'id',
|
|
169
|
+
confidence: 0.9,
|
|
170
|
+
tier: 2,
|
|
171
|
+
});
|
|
172
|
+
// Three commands and the heal's own event.
|
|
173
|
+
const rows = yield eventLogRows(4);
|
|
174
|
+
const commands = rows
|
|
175
|
+
.filter((r) => r.type === SocketEvents_1.SocketEvents.SESSION_COMMAND)
|
|
176
|
+
.map((r) => JSON.parse(r.payload));
|
|
177
|
+
(0, chai_1.expect)(commands.map((c) => c.command_name)).to.have.members([
|
|
178
|
+
'setValue',
|
|
179
|
+
'getPageSource',
|
|
180
|
+
'findElement',
|
|
181
|
+
]);
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
for (const secret of SECRETS)
|
|
184
|
+
(0, chai_1.expect)(row.payload).to.not.include(secret);
|
|
185
|
+
}
|
|
186
|
+
for (const logged of commands) {
|
|
187
|
+
(0, chai_1.expect)(logged).to.not.have.any.keys('body', 'response', 'screenshot', 'url');
|
|
188
|
+
(0, chai_1.expect)(logged).to.include({ session_id: SESSION, is_success: true, is_error: false });
|
|
189
|
+
}
|
|
190
|
+
// What a reader of the log needs from a heal is there.
|
|
191
|
+
(0, chai_1.expect)(commands.find((c) => c.command_name === 'findElement')).to.include({
|
|
192
|
+
is_healed: true,
|
|
193
|
+
original_strategy: 'xpath',
|
|
194
|
+
original_selector: '//*[@text="Pay"]',
|
|
195
|
+
healed_strategy: 'id',
|
|
196
|
+
healed_selector: 'pay_button',
|
|
197
|
+
healing_confidence: 0.9,
|
|
198
|
+
healing_tier: 'Fuzzy XML',
|
|
199
|
+
});
|
|
200
|
+
// The session's own record, which goes with the session, keeps them.
|
|
201
|
+
const sessionLog = yield scratch.db.sessionLog.findMany({ where: { session_id: SESSION } });
|
|
202
|
+
(0, chai_1.expect)((_a = sessionLog.find((l) => l.command_name === 'setValue')) === null || _a === void 0 ? void 0 : _a.body).to.include('hunter2');
|
|
203
|
+
(0, chai_1.expect)((_b = sessionLog.find((l) => l.command_name === 'getPageSource')) === null || _b === void 0 ? void 0 : _b.response).to.include('jane@example.com');
|
|
204
|
+
// The live event is the same summary. It reaches every dashboard that
|
|
205
|
+
// can see the phone, and no client reads more (the dashboard and Xenon
|
|
206
|
+
// Studio don't subscribe to it).
|
|
207
|
+
const sent = live(SocketEvents_1.SocketEvents.SESSION_COMMAND);
|
|
208
|
+
(0, chai_1.expect)(sent.map((c) => c.command_name)).to.deep.equal([
|
|
209
|
+
'setValue',
|
|
210
|
+
'getPageSource',
|
|
211
|
+
'findElement',
|
|
212
|
+
]);
|
|
213
|
+
for (const summary of sent) {
|
|
214
|
+
for (const secret of SECRETS)
|
|
215
|
+
(0, chai_1.expect)(JSON.stringify(summary)).to.not.include(secret);
|
|
216
|
+
(0, chai_1.expect)(summary).to.deep.equal(commands.find((c) => c.command_name === summary.command_name));
|
|
217
|
+
}
|
|
218
|
+
}));
|
|
219
|
+
});
|
|
220
|
+
it("every other event is logged once and whole, by either emit; a command's own fields are named", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
221
|
+
const server = typedi_1.Container.get(SocketServer_1.SocketServer);
|
|
222
|
+
server.emitToDashboard(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST, capturedRequest('q3'));
|
|
223
|
+
yield server.emitToDashboardForDevices(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST, capturedRequest('q4'), { udid: 'phone-a' });
|
|
224
|
+
(0, chai_1.expect)(appendSafe.called).to.equal(false);
|
|
225
|
+
const payload = { session_id: 's', command_name: 'click', body: '["hunter2"]', extra: 1 };
|
|
226
|
+
const others = Object.values(SocketEvents_1.SocketEvents).filter((event) => event !== SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST);
|
|
227
|
+
for (const event of others) {
|
|
228
|
+
yield server.emitToDashboardForDevices(event, payload, { udid: 'phone-a' });
|
|
229
|
+
}
|
|
230
|
+
server.emitToDashboard(SocketEvents_1.SocketEvents.SESSION_COMMAND, payload);
|
|
231
|
+
(0, chai_1.expect)(appendSafe.getCalls().map((c) => c.args[0])).to.deep.equal([
|
|
232
|
+
...others.map((event) => ({
|
|
233
|
+
type: event,
|
|
234
|
+
payload: event === SocketEvents_1.SocketEvents.SESSION_COMMAND
|
|
235
|
+
? { session_id: 's', command_name: 'click' }
|
|
236
|
+
: payload,
|
|
237
|
+
})),
|
|
238
|
+
{ type: SocketEvents_1.SocketEvents.SESSION_COMMAND, payload: { session_id: 's', command_name: 'click' } },
|
|
239
|
+
]);
|
|
240
|
+
// The writes are fire-and-forget: let them land here, or they land in the
|
|
241
|
+
// next test's table after its beforeEach emptied it.
|
|
242
|
+
(0, chai_1.expect)(yield eventLogRows(others.length + 1)).to.have.length(others.length + 1);
|
|
243
|
+
}));
|
|
244
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
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 AIService_1 = require("../../src/services/AIService");
|
|
19
|
+
const CircuitBreaker_1 = require("../../src/services/CircuitBreaker");
|
|
20
|
+
const failure_analysis_service_1 = require("../../src/dashboard/services/failure-analysis-service");
|
|
21
|
+
const scratch_database_1 = require("../helpers/scratch-database");
|
|
22
|
+
const fake_ai_provider_1 = require("../helpers/fake-ai-provider");
|
|
23
|
+
/**
|
|
24
|
+
* A failed session gets two things: a category from its failure reason and
|
|
25
|
+
* failed commands (rules, no AI), and, with an AI provider, an explanation.
|
|
26
|
+
* They are saved separately, so the category never waits for the AI, and only
|
|
27
|
+
* a real explanation is saved: a rate limit, a timeout or a failed call saves
|
|
28
|
+
* nothing, and leaves an explanation saved earlier in place.
|
|
29
|
+
*/
|
|
30
|
+
describe('failure analysis of a failed session', () => {
|
|
31
|
+
const scratch = (0, scratch_database_1.useScratchDatabase)();
|
|
32
|
+
const ai = (0, fake_ai_provider_1.useFakeAiProviders)();
|
|
33
|
+
const ID = 'sess-failure-analysis';
|
|
34
|
+
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
35
|
+
yield scratch.db.log.deleteMany();
|
|
36
|
+
yield scratch.db.sessionLog.deleteMany();
|
|
37
|
+
yield scratch.db.session.deleteMany();
|
|
38
|
+
yield scratch.db.session.create({
|
|
39
|
+
data: {
|
|
40
|
+
id: ID,
|
|
41
|
+
status: 'failed',
|
|
42
|
+
failure_reason: 'An element could not be located on the page',
|
|
43
|
+
desired_capabilities: '{}',
|
|
44
|
+
session_capabilities: '{}',
|
|
45
|
+
node_id: 'node',
|
|
46
|
+
has_live_video: false,
|
|
47
|
+
device_udid: 'R58M123',
|
|
48
|
+
device_platform: 'android',
|
|
49
|
+
device_version: '10',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
yield scratch.db.sessionLog.create({
|
|
53
|
+
data: {
|
|
54
|
+
session_id: ID,
|
|
55
|
+
command_name: 'findElement',
|
|
56
|
+
url: '/session/x/element',
|
|
57
|
+
method: 'POST',
|
|
58
|
+
title: 'findElement',
|
|
59
|
+
response: '{"value":{"error":"no such element"}}',
|
|
60
|
+
is_error: true,
|
|
61
|
+
is_success: false,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}));
|
|
65
|
+
afterEach(() => sinon_1.default.restore());
|
|
66
|
+
const row = () => scratch.db.session.findUniqueOrThrow({ where: { id: ID } });
|
|
67
|
+
it('files the session under its category without asking the AI', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
68
|
+
ai.use('anthropic');
|
|
69
|
+
ai.answer({ text: 'Root Cause: x' });
|
|
70
|
+
yield (0, failure_analysis_service_1.categorizeSessionFailure)(ID);
|
|
71
|
+
(0, chai_1.expect)((yield row()).failure_category).to.equal('ELEMENT_NOT_FOUND');
|
|
72
|
+
(0, chai_1.expect)(ai.calls).to.have.length(0);
|
|
73
|
+
}));
|
|
74
|
+
it("saves the provider's explanation", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
75
|
+
ai.use('anthropic');
|
|
76
|
+
ai.answer({ text: 'Root Cause: the Login button was covered by a dialog.' });
|
|
77
|
+
yield (0, failure_analysis_service_1.explainSessionFailure)(ID);
|
|
78
|
+
(0, chai_1.expect)((yield row()).ai_analysis).to.equal('Root Cause: the Login button was covered by a dialog.');
|
|
79
|
+
}));
|
|
80
|
+
for (const provider of fake_ai_provider_1.PROVIDERS) {
|
|
81
|
+
it(`saves no explanation when ${provider} is rate-limited`, () => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
+
ai.use(provider);
|
|
83
|
+
ai.answer({ status: 429 });
|
|
84
|
+
yield (0, failure_analysis_service_1.explainSessionFailure)(ID);
|
|
85
|
+
(0, chai_1.expect)(ai.calls).to.have.length(1);
|
|
86
|
+
(0, chai_1.expect)((yield row()).ai_analysis).to.equal(null);
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
it('leaves an earlier explanation when a later attempt gets none', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
90
|
+
// A session can end twice (a driver crash, then the client's delete).
|
|
91
|
+
yield scratch.db.session.update({
|
|
92
|
+
where: { id: ID },
|
|
93
|
+
data: { ai_analysis: 'Root Cause: from the first end.' },
|
|
94
|
+
});
|
|
95
|
+
ai.use('gemini');
|
|
96
|
+
ai.answer({ status: 429 });
|
|
97
|
+
yield (0, failure_analysis_service_1.explainSessionFailure)(ID);
|
|
98
|
+
(0, chai_1.expect)((yield row()).ai_analysis).to.equal('Root Cause: from the first end.');
|
|
99
|
+
}));
|
|
100
|
+
describe('how many run', () => {
|
|
101
|
+
// Analyses run after their sessions have ended, so nothing else limits
|
|
102
|
+
// them: a broken build on a big lab ends hundreds of failed sessions in
|
|
103
|
+
// minutes, and each analysis holds a screenshot while it runs.
|
|
104
|
+
let answers;
|
|
105
|
+
let analyzeFailure;
|
|
106
|
+
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
107
|
+
answers = [];
|
|
108
|
+
sinon_1.default.stub(AIService_1.AI_SERVICE, 'isEnabled').returns(true);
|
|
109
|
+
analyzeFailure = sinon_1.default
|
|
110
|
+
.stub(AIService_1.AI_SERVICE, 'analyzeFailure')
|
|
111
|
+
.callsFake(() => new Promise((resolve) => answers.push(resolve)));
|
|
112
|
+
}));
|
|
113
|
+
const settle = () => new Promise((r) => setTimeout(r, 50));
|
|
114
|
+
it('asks once for a session whose analysis is already running', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
115
|
+
const first = (0, failure_analysis_service_1.explainSessionFailure)(ID);
|
|
116
|
+
const second = (0, failure_analysis_service_1.explainSessionFailure)(ID);
|
|
117
|
+
yield settle();
|
|
118
|
+
(0, chai_1.expect)(analyzeFailure.callCount).to.equal(1);
|
|
119
|
+
answers[0]('Root Cause: once.');
|
|
120
|
+
yield Promise.all([first, second]);
|
|
121
|
+
(0, chai_1.expect)((yield row()).ai_analysis).to.equal('Root Cause: once.');
|
|
122
|
+
}));
|
|
123
|
+
it(`runs ${failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES} at once; the others wait their turn`, () => __awaiter(void 0, void 0, void 0, function* () {
|
|
124
|
+
const ids = Array.from({ length: failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES + 2 }, (_, i) => `${ID}-${i}`);
|
|
125
|
+
for (const id of ids) {
|
|
126
|
+
yield scratch.db.session.create({
|
|
127
|
+
data: {
|
|
128
|
+
id,
|
|
129
|
+
status: 'failed',
|
|
130
|
+
desired_capabilities: '{}',
|
|
131
|
+
session_capabilities: '{}',
|
|
132
|
+
node_id: 'node',
|
|
133
|
+
has_live_video: false,
|
|
134
|
+
device_udid: 'R58M123',
|
|
135
|
+
device_platform: 'android',
|
|
136
|
+
device_version: '10',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const all = ids.map((id) => (0, failure_analysis_service_1.explainSessionFailure)(id));
|
|
141
|
+
yield settle();
|
|
142
|
+
(0, chai_1.expect)(analyzeFailure.callCount).to.equal(failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES);
|
|
143
|
+
answers[0](null);
|
|
144
|
+
yield settle();
|
|
145
|
+
(0, chai_1.expect)(analyzeFailure.callCount).to.equal(failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES + 1);
|
|
146
|
+
for (let i = 0; i < 10 && answers.length < ids.length; i++) {
|
|
147
|
+
answers.forEach((answer) => answer(null));
|
|
148
|
+
yield settle();
|
|
149
|
+
}
|
|
150
|
+
answers.forEach((answer) => answer(null));
|
|
151
|
+
yield Promise.all(all);
|
|
152
|
+
(0, chai_1.expect)(analyzeFailure.callCount).to.equal(ids.length);
|
|
153
|
+
}));
|
|
154
|
+
});
|
|
155
|
+
describe('a provider that does not answer', () => {
|
|
156
|
+
const FAILURE = {
|
|
157
|
+
sessionId: ID,
|
|
158
|
+
failureReason: 'An element could not be located on the page',
|
|
159
|
+
commandLogs: [],
|
|
160
|
+
deviceLogs: [],
|
|
161
|
+
};
|
|
162
|
+
for (const provider of fake_ai_provider_1.PROVIDERS) {
|
|
163
|
+
it(`${provider}: is given up on after the time limit, and its request cancelled`, () => __awaiter(void 0, void 0, void 0, function* () {
|
|
164
|
+
var _a, _b;
|
|
165
|
+
const { model } = ai.use(provider);
|
|
166
|
+
ai.answer('hang');
|
|
167
|
+
const clock = sinon_1.default.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
168
|
+
const service = new AIService_1.AIService();
|
|
169
|
+
const analysis = service.analyzeFailure(FAILURE);
|
|
170
|
+
yield clock.tickAsync(AIService_1.FAILURE_ANALYSIS_TIMEOUT_MS - 1);
|
|
171
|
+
(0, chai_1.expect)((_a = ai.calls[0].signal) === null || _a === void 0 ? void 0 : _a.aborted).to.equal(false);
|
|
172
|
+
yield clock.tickAsync(1);
|
|
173
|
+
(0, chai_1.expect)(yield analysis).to.equal(null);
|
|
174
|
+
(0, chai_1.expect)(ai.calls).to.have.length(1);
|
|
175
|
+
(0, chai_1.expect)((_b = ai.calls[0].signal) === null || _b === void 0 ? void 0 : _b.aborted).to.equal(true);
|
|
176
|
+
// A provider that doesn't answer is a failure, like one that refuses.
|
|
177
|
+
const breaker = CircuitBreaker_1.CIRCUIT_BREAKERS.snapshot().find((b) => b.key === `ai:${provider}:${model}`);
|
|
178
|
+
(0, chai_1.expect)(breaker === null || breaker === void 0 ? void 0 : breaker.consecutiveFailures).to.equal(1);
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
require("reflect-metadata");
|
|
7
|
+
const chai_1 = require("chai");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
11
|
+
const swagger_1 = require("../../src/app/swagger");
|
|
12
|
+
const failure_analysis_service_1 = require("../../src/dashboard/services/failure-analysis-service");
|
|
13
|
+
const SessionManager_1 = require("../../src/sessions/SessionManager");
|
|
14
|
+
/**
|
|
15
|
+
* A Session's `failure_category` is written in two places: the failure
|
|
16
|
+
* analysis (one of ANALYSIS_CATEGORIES) and a restart's recovery
|
|
17
|
+
* (HUB_RESTART). Both write upper case.
|
|
18
|
+
*
|
|
19
|
+
* The API reference showed `element_not_found`, a value the server never
|
|
20
|
+
* writes, and the dashboard had a runbook for "Infrastructure", a category
|
|
21
|
+
* nothing ever assigns. These hold both to what the server writes.
|
|
22
|
+
*/
|
|
23
|
+
const WRITTEN = [...failure_analysis_service_1.ANALYSIS_CATEGORIES, SessionManager_1.HUB_RESTART_CATEGORY];
|
|
24
|
+
/**
|
|
25
|
+
* The dashboard's runbooks. web/ is an ES module package, so its .ts can't be
|
|
26
|
+
* required from here; the file imports nothing, so it is compiled and run.
|
|
27
|
+
*/
|
|
28
|
+
function loadRunbooks() {
|
|
29
|
+
const file = path_1.default.resolve(__dirname, '../../web/src/components/runbooks/runbook-content.ts');
|
|
30
|
+
const { outputText } = typescript_1.default.transpileModule(fs_1.default.readFileSync(file, 'utf8'), {
|
|
31
|
+
compilerOptions: { module: typescript_1.default.ModuleKind.CommonJS, target: typescript_1.default.ScriptTarget.ES2019 },
|
|
32
|
+
});
|
|
33
|
+
const mod = { exports: {} };
|
|
34
|
+
new Function('module', 'exports', outputText)(mod, mod.exports);
|
|
35
|
+
return mod.exports;
|
|
36
|
+
}
|
|
37
|
+
const { RUNBOOKS, runbookKey } = loadRunbooks();
|
|
38
|
+
/** Every value found under a key named `key`, at any depth. */
|
|
39
|
+
function valuesUnder(node, key, out = []) {
|
|
40
|
+
if (Array.isArray(node)) {
|
|
41
|
+
node.forEach((n) => valuesUnder(n, key, out));
|
|
42
|
+
}
|
|
43
|
+
else if (node && typeof node === 'object') {
|
|
44
|
+
for (const [k, v] of Object.entries(node)) {
|
|
45
|
+
if (k === key)
|
|
46
|
+
out.push(v);
|
|
47
|
+
valuesUnder(v, key, out);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** The `failure_category` column of every CSV example (a string with that header). */
|
|
53
|
+
function csvExampleCategories() {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const example of valuesUnder(swagger_1.swaggerSpec, 'example')) {
|
|
56
|
+
if (typeof example !== 'string')
|
|
57
|
+
continue;
|
|
58
|
+
const [header, ...rows] = example.trim().split('\n');
|
|
59
|
+
const column = header.split(',').indexOf('failure_category');
|
|
60
|
+
if (column < 0)
|
|
61
|
+
continue;
|
|
62
|
+
for (const row of rows)
|
|
63
|
+
out.push(row.split(',')[column]);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
describe('failure categories', () => {
|
|
68
|
+
it('are written upper case, as the API reference and the dashboard read them', () => {
|
|
69
|
+
(0, chai_1.expect)(failure_analysis_service_1.ANALYSIS_CATEGORIES).to.include('UNKNOWN');
|
|
70
|
+
for (const category of WRITTEN)
|
|
71
|
+
(0, chai_1.expect)(category).to.match(/^[A-Z_]+$/);
|
|
72
|
+
});
|
|
73
|
+
describe('in the API reference', () => {
|
|
74
|
+
it('every example shows one the server writes, as it writes it', () => {
|
|
75
|
+
const shown = [
|
|
76
|
+
...valuesUnder(swagger_1.swaggerSpec, 'failure_category').filter((v) => typeof v === 'string'),
|
|
77
|
+
...csvExampleCategories(),
|
|
78
|
+
];
|
|
79
|
+
// The session list's example and the build export's CSV.
|
|
80
|
+
(0, chai_1.expect)(shown).to.have.length.of.at.least(2);
|
|
81
|
+
for (const value of shown)
|
|
82
|
+
(0, chai_1.expect)(WRITTEN, String(value)).to.include(value);
|
|
83
|
+
});
|
|
84
|
+
it("the session record's description names every category, and no other", () => {
|
|
85
|
+
const described = valuesUnder(swagger_1.swaggerSpec, 'failure_category')
|
|
86
|
+
.filter((v) => !!(v === null || v === void 0 ? void 0 : v.description))
|
|
87
|
+
.map((v) => v.description);
|
|
88
|
+
(0, chai_1.expect)(described).to.have.length(1);
|
|
89
|
+
const named = Array.from(described[0].matchAll(/`([A-Z_]+)`/g), (m) => m[1]);
|
|
90
|
+
(0, chai_1.expect)([...named].sort()).to.deep.equal([...WRITTEN].sort());
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("in the dashboard's runbooks", () => {
|
|
94
|
+
it('every category the server writes has a runbook of its own', () => {
|
|
95
|
+
for (const category of WRITTEN) {
|
|
96
|
+
(0, chai_1.expect)(Object.keys(RUNBOOKS), category).to.include(runbookKey(category));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it("each quotes a text that files a failure under its category, not just the category's name", () => {
|
|
100
|
+
// The names mislead: "Xenon command failure" is a "command failed"
|
|
101
|
+
// message, rarely Xenon's, and few real crashes are worded the way App
|
|
102
|
+
// crash's patterns expect. A runbook has to say what really files a
|
|
103
|
+
// failure there, so it must quote one of its category's texts. Only
|
|
104
|
+
// quoted text in the body counts: the title ("# WDA failure") and the
|
|
105
|
+
// prose around a quote ("says a command failed") would pass anything.
|
|
106
|
+
for (const { category, patterns } of failure_analysis_service_1.ERROR_PATTERNS) {
|
|
107
|
+
const { markdown } = RUNBOOKS[runbookKey(category)];
|
|
108
|
+
const body = markdown.split('\n').slice(1).join(' ').replace(/\s+/g, ' ');
|
|
109
|
+
const quoted = Array.from(body.matchAll(/"([^"]+)"/g), (m) => m[1]);
|
|
110
|
+
(0, chai_1.expect)(patterns.some((p) => quoted.some((q) => new RegExp(p, 'i').test(q))), `the ${category} runbook quotes none of ${JSON.stringify(patterns)}`).to.equal(true);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
it('every runbook is for a category the server writes', () => {
|
|
114
|
+
const written = WRITTEN.map(runbookKey);
|
|
115
|
+
for (const key of Object.keys(RUNBOOKS)) {
|
|
116
|
+
(0, chai_1.expect)(written, `runbook "${key}"`).to.include(key);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
it('a stored category opens its own runbook from the "Why it failed" card', () => {
|
|
120
|
+
var _a, _b;
|
|
121
|
+
// failure-summary.tsx links /runbooks/<the category, lower case>.
|
|
122
|
+
const linked = (category) => RUNBOOKS[runbookKey(category.toLowerCase())];
|
|
123
|
+
(0, chai_1.expect)((_a = linked(SessionManager_1.HUB_RESTART_CATEGORY)) === null || _a === void 0 ? void 0 : _a.title).to.equal('Hub restart');
|
|
124
|
+
(0, chai_1.expect)((_b = linked('TIMEOUT')) === null || _b === void 0 ? void 0 : _b.title).to.equal('Timeout');
|
|
125
|
+
(0, chai_1.expect)(linked('UNKNOWN')).to.equal(RUNBOOKS.unknown);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|