@xenon-device-management/xenon 2.13.1 → 2.14.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 +11 -5
- package/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-Cvpkk4r9.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-Bhn5ZtVM.js → ApiKeyGate-BwuaMjkX.js} +1 -1
- package/lib/public/assets/{BugReportButton-BA6aEjTC.js → BugReportButton-BAt0_pzq.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
- package/lib/public/assets/{EmptyState-CgzbuUvO.js → EmptyState-BSjhk_IV.js} +1 -1
- package/lib/public/assets/{FieldGroup-BHc_5qNc.js → FieldGroup-Cb5QZsE1.js} +1 -1
- package/lib/public/assets/{FilterMenu-D7OMGuRc.js → FilterMenu-BAaSlWbW.js} +1 -1
- package/lib/public/assets/{Menu-C173JJ0l.js → Menu-JGGYrSQQ.js} +1 -1
- package/lib/public/assets/{Modal-ChvVUI3B.js → Modal-CFCp3n9d.js} +1 -1
- package/lib/public/assets/{RecordingPage-Dhw4d_O6.js → RecordingPage-CbJj_EBT.js} +1 -1
- package/lib/public/assets/{RecordingsPage-z97ftGK-.js → RecordingsPage-BJeYnygR.js} +2 -2
- package/lib/public/assets/{SegmentedControl-DbXhBGZC.js → SegmentedControl-Bg42Atjy.js} +1 -1
- package/lib/public/assets/{SettingCard-C908GgiI.js → SettingCard-Njz1pUIJ.js} +1 -1
- package/lib/public/assets/{Table-B1DB_ZN-.js → Table-BcTm5xV2.js} +1 -1
- package/lib/public/assets/{activity-Css0xNvp.js → activity-DOVdsfMQ.js} +1 -1
- package/lib/public/assets/{ai-settings-BMQK3-yN.js → ai-settings-CsuJroRf.js} +2 -2
- package/lib/public/assets/{api-keys-CbJ42eDQ.js → api-keys-BKnNSTw9.js} +1 -1
- package/lib/public/assets/{apps-DW1pw2re.js → apps-D0SC6uw6.js} +2 -2
- package/lib/public/assets/{arrow-left-Dpog9L0F.js → arrow-left-MMbTmgoE.js} +1 -1
- package/lib/public/assets/{arrow-right-CsBIlSIw.js → arrow-right-BMfnGmxW.js} +1 -1
- package/lib/public/assets/{arrow-up-right-z1tRQE4R.js → arrow-up-right-DuAftMWB.js} +1 -1
- package/lib/public/assets/{auth-shell-mXiX5vNk.js → auth-shell-nZC2JNDO.js} +2 -2
- package/lib/public/assets/{builds-page-BcFn23bE.js → builds-page-hDCgTwkS.js} +1 -1
- package/lib/public/assets/{button-Cq_zHETv.js → button-Dda4irn_.js} +1 -1
- package/lib/public/assets/{calendar-CKxABX9q.js → calendar-BMr0Q62v.js} +1 -1
- package/lib/public/assets/{check-C7xu-hGU.js → check-CZ9iw9-e.js} +1 -1
- package/lib/public/assets/{chevron-right-BiqfdCGA.js → chevron-right-BRYJJVC9.js} +1 -1
- package/lib/public/assets/{circle-check-CyBjjPxR.js → circle-check-CqAJjQ5O.js} +1 -1
- package/lib/public/assets/{circle-x-6gMRcx6z.js → circle-x-DlDJtnVz.js} +1 -1
- package/lib/public/assets/{clock-U1rDTT1h.js → clock-CpuDKysU.js} +1 -1
- package/lib/public/assets/{copy-BIM0ZX0L.js → copy-CAQydNFe.js} +1 -1
- package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
- package/lib/public/assets/{download-DDBRXnAZ.js → download-C9XG6lLv.js} +1 -1
- package/lib/public/assets/{forgot-password-Dpg7yxM8.js → forgot-password-CGnkRgE9.js} +1 -1
- package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
- package/lib/public/assets/{index-BGBqohfQ.js → index-BbKEXUFH.js} +5 -5
- package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
- package/lib/public/assets/{index-BQ2luT7L.js → index-DJGc1M2f.js} +1 -1
- package/lib/public/assets/{input-BvB1cjHE.js → input-Cm0tmSoc.js} +1 -1
- package/lib/public/assets/{line-chart-m78VggnW.js → line-chart-D2DyJCml.js} +1 -1
- package/lib/public/assets/{list-checks-cY7o1QTL.js → list-checks-UWf1GY99.js} +1 -1
- package/lib/public/assets/{lock-loz8txQm.js → lock-CCJv9m_I.js} +1 -1
- package/lib/public/assets/{login-BYOvz4lx.js → login-f8vOVW-U.js} +1 -1
- package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
- package/lib/public/assets/{monitor-C4HPHb6s.js → monitor-B2jDgwm7.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-BkdKtCzi.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
- package/lib/public/assets/{network-C5e0MIWo.js → network-ebT86qoi.js} +1 -1
- package/lib/public/assets/{overview-tlu8ijGd.js → overview-C0aykVrd.js} +1 -1
- package/lib/public/assets/{page-header-FXPlo6az.js → page-header-BjzgWz-T.js} +1 -1
- package/lib/public/assets/{play-CWdk0_Sy.js → play-DLG9t8FU.js} +1 -1
- package/lib/public/assets/{plus-B2xCnntV.js → plus-nBFSMu_X.js} +1 -1
- package/lib/public/assets/{profile-page-D080qzr4.js → profile-page-CSl0UYlv.js} +1 -1
- package/lib/public/assets/{reset-password-D3u4Mbvn.js → reset-password-LeOuHqmB.js} +1 -1
- package/lib/public/assets/{runbook-page-hPRZMy7s.js → runbook-page-CMMeQml9.js} +1 -1
- package/lib/public/assets/{select-12RThAXc.js → select-88jHcl-Z.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-uQwvV7EQ.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
- package/lib/public/assets/{selector-health-page-CThoxDFO.js → selector-health-page-B4rA1Xkv.js} +2 -2
- package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
- package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
- package/lib/public/assets/{stat-tile-B02WEte8.js → stat-tile-BV98e_Sv.js} +1 -1
- package/lib/public/assets/{tablet-Dj30VGZl.js → tablet-BiAsnFDj.js} +1 -1
- package/lib/public/assets/{teams-uL4fjTDR.js → teams-B4-jrr-1.js} +1 -1
- package/lib/public/assets/{trash-2-B54e-TfY.js → trash-2-C5RSuX0e.js} +1 -1
- package/lib/public/assets/{upload-B3k8WCSP.js → upload-BG7_rMXK.js} +1 -1
- package/lib/public/assets/{use-builds-data-BFn90NH3.js → use-builds-data-J-riQYyC.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-DL2TmWZ0.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
- package/lib/public/assets/{users-CqBuxMlX.js → users-CFba5TSy.js} +1 -1
- package/lib/public/assets/{users-DjQSucKb.js → users-Cp0beJm3.js} +1 -1
- package/lib/public/assets/{video-off-T0rlt0GA.js → video-off-DDsEISjV.js} +1 -1
- package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
- package/lib/public/assets/{zap-gBHqutZV.js → zap-l4VaXzq8.js} +1 -1
- package/lib/public/index.html +2 -2
- package/lib/schema.json +19 -46
- package/lib/src/XenonCapabilityManager.js +22 -5
- package/lib/src/app/openapi/control.yaml +71 -20
- package/lib/src/app/openapi/grid.yaml +23 -5
- package/lib/src/app/openapi/platform.yaml +136 -28
- package/lib/src/app/routers/control.js +30 -5
- package/lib/src/app/routers/dashboard.js +29 -1
- package/lib/src/app/routers/grid.js +32 -10
- package/lib/src/app/routers/streamStartConflict.js +3 -0
- package/lib/src/app/routers/streamType.js +7 -2
- package/lib/src/app/routers/webhook.js +8 -4
- package/lib/src/config.js +50 -5
- package/lib/src/dashboard/commands.js +185 -141
- package/lib/src/dashboard/event-manager.js +39 -6
- package/lib/src/data-service/appleFamily.js +43 -0
- package/lib/src/data-service/db.js +11 -0
- package/lib/src/data-service/device-service.js +46 -1
- package/lib/src/data-service/device-store.js +66 -3
- package/lib/src/data-service/deviceClaims.js +37 -0
- package/lib/src/data-service/deviceFieldOwners.js +25 -11
- package/lib/src/data-service/deviceSettings.js +79 -0
- package/lib/src/data-service/prisma-store.js +152 -9
- package/lib/src/data-service/web-config-service.js +43 -92
- package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
- package/lib/src/device-managers/ios/WDAClient.js +44 -43
- package/lib/src/device-managers/shellCommands.js +96 -0
- package/lib/src/device-utils.js +143 -37
- package/lib/src/generated/client/edge.js +17 -3
- package/lib/src/generated/client/index-browser.js +14 -0
- package/lib/src/generated/client/index.d.ts +2054 -379
- package/lib/src/generated/client/index.js +17 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +35 -7
- package/lib/src/generated/client/wasm.js +14 -0
- package/lib/src/interceptors/CommandInterceptor.js +29 -8
- package/lib/src/interceptors/xenonScripts.js +55 -0
- package/lib/src/interfaces/IPluginArgs.js +1 -2
- package/lib/src/middleware/deviceAccessGuard.js +63 -9
- package/lib/src/plugin.js +15 -1
- package/lib/src/prisma.js +1 -1
- package/lib/src/scripts/database-check.js +124 -0
- package/lib/src/services/AIService.js +99 -0
- package/lib/src/services/CleanupService.js +6 -1
- package/lib/src/services/InterceptorService.js +70 -13
- package/lib/src/services/NetworkConditioningService.js +85 -88
- package/lib/src/services/NotificationService.js +36 -29
- package/lib/src/services/OrphanSweeper.js +5 -0
- package/lib/src/services/PortAllocator.js +5 -0
- package/lib/src/services/ServerManager.js +97 -8
- package/lib/src/services/SessionLifecycleService.js +63 -53
- package/lib/src/services/ShutdownCoordinator.js +33 -0
- package/lib/src/services/TeamService.js +20 -3
- package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
- package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
- package/lib/src/services/device-access/leaseHold.js +26 -0
- package/lib/src/services/device-access/ticketActorAccess.js +6 -0
- package/lib/src/services/healing/HealingOrchestrator.js +32 -1
- package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
- package/lib/src/services/interceptor/interceptorOptions.js +44 -0
- package/lib/src/services/lease/activeLeases.js +37 -0
- package/lib/src/services/network/AndroidRadios.js +71 -0
- package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
- package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
- package/lib/src/services/network/adbForPhone.js +60 -0
- package/lib/src/services/network/phoneNetworkLock.js +20 -0
- package/lib/src/services/network/xenonProxy.js +107 -0
- package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
- package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
- package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
- package/lib/src/services/omni-vision/screenScale.js +68 -0
- package/lib/src/services/recording/busy-precheck.js +24 -3
- package/lib/src/services/recording/concurrency-gate.js +12 -3
- package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
- package/lib/src/services/settings/labSettings.js +149 -0
- package/lib/src/services/webhookEvents.js +173 -0
- package/lib/test/helpers/fake-adb.js +58 -0
- package/lib/test/helpers/fake-device-table.js +63 -24
- package/lib/test/helpers/scratch-database.js +1 -0
- package/lib/test/unit/TeamService.test.js +3 -0
- package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
- package/lib/test/unit/apple-family.spec.js +36 -0
- package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
- package/lib/test/unit/cleanup-settings.spec.js +244 -0
- package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
- package/lib/test/unit/dashboard-commands.spec.js +249 -0
- package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
- package/lib/test/unit/database-check.spec.js +108 -0
- package/lib/test/unit/default-plugin-args.spec.js +33 -0
- package/lib/test/unit/device-access-lease.spec.js +270 -0
- package/lib/test/unit/device-offline-webhook.spec.js +81 -0
- package/lib/test/unit/device-setting-columns.spec.js +66 -0
- package/lib/test/unit/device-settings-loki.spec.js +87 -0
- package/lib/test/unit/device-settings-survive.spec.js +555 -0
- package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
- package/lib/test/unit/emulator-boot.spec.js +91 -0
- package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
- package/lib/test/unit/hub-node-same-host.spec.js +5 -0
- package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
- package/lib/test/unit/json-logging-option.spec.js +74 -0
- package/lib/test/unit/lab-settings.spec.js +218 -0
- package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
- package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
- package/lib/test/unit/lease-held-phone.spec.js +196 -0
- package/lib/test/unit/max-sessions.spec.js +240 -0
- package/lib/test/unit/ocr-text-match.spec.js +44 -0
- package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
- package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
- package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
- package/lib/test/unit/omni-vision.spec.js +1 -1
- package/lib/test/unit/partial-config-file.spec.js +114 -0
- package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
- package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
- package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
- package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
- package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
- package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
- package/lib/test/unit/phone-network/session-start.spec.js +110 -0
- package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
- package/lib/test/unit/plugin.spec.js +3 -3
- package/lib/test/unit/recording-options.spec.js +181 -0
- package/lib/test/unit/schema-required-args.spec.js +21 -16
- package/lib/test/unit/self-healing-boot.spec.js +75 -0
- package/lib/test/unit/self-healing-setting.spec.js +272 -0
- package/lib/test/unit/session-failed-webhook.spec.js +170 -0
- package/lib/test/unit/shell-commands.spec.js +133 -0
- package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
- package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
- package/lib/test/unit/stream-status-type.spec.js +4 -0
- package/lib/test/unit/vision-assertion.spec.js +118 -0
- package/lib/test/unit/wda-client-shell.spec.js +133 -0
- package/lib/test/unit/web-config-service.spec.js +79 -0
- package/lib/test/unit/webhook-delivery.spec.js +24 -0
- package/lib/test/unit/webhook-payloads.spec.js +273 -0
- package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
- package/package.json +1 -1
- package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
- package/prisma/schema.prisma +35 -7
- package/schema.json +19 -46
- package/scripts/generate-types-from-schema.js +7 -2
- package/scripts/lib/always-present-plugin-args.js +42 -0
- package/lib/public/assets/DeviceMosaicView-ChhHnpFQ.js +0 -2
- package/lib/public/assets/device-explorer-DSpAA_t9.js +0 -279
- package/lib/public/assets/maintenance-settings-BxbA7SkB.js +0 -6
- package/lib/public/assets/recording-group-store-B75435xR.js +0 -26
- package/lib/public/assets/session-detail-page-Bc02YjMm.js +0 -4
- package/lib/public/assets/settings-C1VIluuT.js +0 -6
- package/lib/public/assets/webhook-settings-DAHgnJyZ.js +0 -1
|
@@ -71,15 +71,19 @@ const LogcatStreamService_1 = require("../device-managers/android/LogcatStreamSe
|
|
|
71
71
|
const IOSLogStreamService_1 = require("../device-managers/ios/IOSLogStreamService");
|
|
72
72
|
const logSource_1 = require("./logcat/logSource");
|
|
73
73
|
const SessionOwnerResolver_1 = require("./device-access/SessionOwnerResolver");
|
|
74
|
+
const activeLeases_1 = require("./lease/activeLeases");
|
|
74
75
|
const ticketActorAccess_1 = require("./device-access/ticketActorAccess");
|
|
75
76
|
// enable resolveJsonModule in tsconfig must be true for this to work
|
|
76
77
|
const package_json_1 = __importDefault(require("../../package.json"));
|
|
77
78
|
const IPluginArgs_1 = require("../interfaces/IPluginArgs");
|
|
78
79
|
const config_service_1 = require("../data-service/config-service");
|
|
79
80
|
const PluginContext_1 = require("../PluginContext");
|
|
81
|
+
const SelfHealingSwitch_1 = require("./settings/SelfHealingSwitch");
|
|
80
82
|
const device_store_1 = require("../data-service/device-store");
|
|
81
83
|
const device_utils_1 = require("../device-utils");
|
|
82
84
|
const localDeviceHosts_1 = require("../device-managers/localDeviceHosts");
|
|
85
|
+
const deviceSettings_1 = require("../data-service/deviceSettings");
|
|
86
|
+
const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
|
|
83
87
|
const app_1 = require("../app");
|
|
84
88
|
const registerCommandAuth_1 = require("../app/registerCommandAuth");
|
|
85
89
|
const defaultGateway_1 = require("../gateway/defaultGateway");
|
|
@@ -128,10 +132,14 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
128
132
|
this.logger.warn('Authentication is DISABLED — every /xenon/api caller is treated as a ' +
|
|
129
133
|
'synthetic SUPER_ADMIN. Local development only.');
|
|
130
134
|
}
|
|
135
|
+
this.applyRecordingOptions(pluginArgs);
|
|
131
136
|
yield this.syncDatabaseAndAIConfig(pluginArgs);
|
|
132
137
|
yield this.initializeCoreSubsystems(pluginArgs, cliArgs.port);
|
|
133
138
|
// Before anything below starts go-ios: the reap would kill it too.
|
|
134
139
|
yield this.reapLeftoverGoIos();
|
|
140
|
+
// The Settings page's AI self-healing toggle, which the command interceptor
|
|
141
|
+
// reads from memory at every command: load it before a command can arrive.
|
|
142
|
+
yield typedi_1.Container.get(SelfHealingSwitch_1.SelfHealingSwitch).load();
|
|
135
143
|
this.registerRoutes(expressApp, httpServer, cliArgs, pluginArgs);
|
|
136
144
|
yield this.bootEmulators(pluginArgs);
|
|
137
145
|
this.registerDependenciesInContainer(pluginArgs, cliArgs, nodeId);
|
|
@@ -169,6 +177,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
169
177
|
authorize: (0, ticketActorAccess_1.makeTicketActorAuthorizer)({
|
|
170
178
|
findDevice: (udid) => device_store_1.DeviceStoreFactory.getStore().findDevice({ udid }),
|
|
171
179
|
resolveSessionOwner: (sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId),
|
|
180
|
+
findActiveLease: (udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host),
|
|
181
|
+
resolveLeaseHolder: (actorId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(actorId),
|
|
172
182
|
}),
|
|
173
183
|
// One WebSocket, two transports. The device's platform picks which —
|
|
174
184
|
// see resolveLogSource. The filter is only meaningful for ostrace,
|
|
@@ -255,6 +265,9 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
255
265
|
yield (0, device_utils_1.updateDeviceList)(localHosts, pluginArgs.hub, pluginArgs.tlsRejectUnauthorized);
|
|
256
266
|
// remove stale devices
|
|
257
267
|
yield (0, device_utils_1.removeStaleDevices)(localHosts, pluginArgs.tlsRejectUnauthorized);
|
|
268
|
+
// In the background: put back the network a previous run left changed on
|
|
269
|
+
// this server's phones (an Offline profile, an interceptor proxy).
|
|
270
|
+
void this.cleanUpPhoneNetworks(localHosts, nodeId);
|
|
258
271
|
this.logger.info(`🚀 Xenon will be served at http://${pluginArgs.bindHostOrIp}:${cliArgs.port}/xenon with id ${nodeId}`);
|
|
259
272
|
this.logger.info('You can provide the following URLs to access Xenon:');
|
|
260
273
|
for (const url of (0, networkAddresses_1.listReachableBaseUrls)(cliArgs.port)) {
|
|
@@ -263,6 +276,29 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
263
276
|
}
|
|
264
277
|
});
|
|
265
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* What a previous run of this server left on its phones' network: the
|
|
281
|
+
* changes its ledger holds, and on this server's own Android phones a proxy
|
|
282
|
+
* that points at a capture port of this machine where nothing answers.
|
|
283
|
+
* Each change is logged. A phone not connected now is put back at its next
|
|
284
|
+
* session here, or at the next start.
|
|
285
|
+
*/
|
|
286
|
+
cleanUpPhoneNetworks(localHosts, nodeId) {
|
|
287
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
288
|
+
var _a;
|
|
289
|
+
try {
|
|
290
|
+
const devices = yield device_store_1.DeviceStoreFactory.getStore().getAllDevices();
|
|
291
|
+
const androids = devices
|
|
292
|
+
.filter((d) => { var _a; return ((_a = d.platform) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'android' && !d.cloud; })
|
|
293
|
+
.filter((d) => (0, localDeviceHosts_1.isOwnDevice)(localHosts, nodeId, d))
|
|
294
|
+
.map((d) => d.udid);
|
|
295
|
+
yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).cleanUpAtBoot([...new Set(androids)]);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
this.logger.warn(`Could not check the phones' network settings: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
266
302
|
resolvePluginArgs(cliArgs) {
|
|
267
303
|
return __awaiter(this, void 0, void 0, function* () {
|
|
268
304
|
const pluginConfigs = cliArgs.plugin;
|
|
@@ -287,6 +323,24 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
287
323
|
return pluginArgs;
|
|
288
324
|
});
|
|
289
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* `maxConcurrentRecordings` and `recordingsAssetsPath` are schema options,
|
|
328
|
+
* but only their environment variables ever reached `config`. Apply the
|
|
329
|
+
* options now (option, else the variable, else the default) before the
|
|
330
|
+
* artifact store is built from the path and the recordings router first
|
|
331
|
+
* reads the cap. See `recordingConfigFrom`.
|
|
332
|
+
*/
|
|
333
|
+
applyRecordingOptions(pluginArgs) {
|
|
334
|
+
const envCap = process.env.XENON_MAX_CONCURRENT_RECORDINGS;
|
|
335
|
+
const applied = (0, config_1.recordingConfigFrom)(pluginArgs);
|
|
336
|
+
if (pluginArgs.maxConcurrentRecordings === undefined &&
|
|
337
|
+
(envCap === null || envCap === void 0 ? void 0 : envCap.trim()) &&
|
|
338
|
+
Number(envCap) !== applied.maxConcurrentRecordings) {
|
|
339
|
+
this.logger.warn(`Ignoring XENON_MAX_CONCURRENT_RECORDINGS=${JSON.stringify(envCap)}: ` +
|
|
340
|
+
`it must be a whole number of at least 1. The cap is ${applied.maxConcurrentRecordings}.`);
|
|
341
|
+
}
|
|
342
|
+
(0, config_1.updateConfig)(applied);
|
|
343
|
+
}
|
|
290
344
|
syncDatabaseAndAIConfig(pluginArgs) {
|
|
291
345
|
return __awaiter(this, void 0, void 0, function* () {
|
|
292
346
|
const { updateConfig } = yield Promise.resolve().then(() => __importStar(require('../config')));
|
|
@@ -331,11 +385,15 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
331
385
|
}
|
|
332
386
|
initializeCoreSubsystems(pluginArgs, port) {
|
|
333
387
|
return __awaiter(this, void 0, void 0, function* () {
|
|
388
|
+
// Before anything opens the database: the URL must suit the database
|
|
389
|
+
// client this install was built with (SQLite for the published plugin).
|
|
390
|
+
const { assertSupportedDatabase } = yield Promise.resolve().then(() => __importStar(require('../scripts/database-check')));
|
|
391
|
+
assertSupportedDatabase();
|
|
334
392
|
yield (0, device_utils_1.initializeStorage)();
|
|
335
393
|
const { runMigrations } = yield Promise.resolve().then(() => __importStar(require('../scripts/run-migrations')));
|
|
336
394
|
yield runMigrations();
|
|
337
395
|
// A hub keeps its nodes' phones: the sessions on them outlive its restart.
|
|
338
|
-
yield device_store_1.DeviceStoreFactory.getStore()
|
|
396
|
+
yield (0, deviceSettings_1.resetDevicesAtBoot)(device_store_1.DeviceStoreFactory.getStore(), pluginArgs, (0, localDeviceHosts_1.localDeviceHosts)(pluginArgs, port));
|
|
339
397
|
const { bootstrapIdentity } = yield Promise.resolve().then(() => __importStar(require('./identity/bootstrap')));
|
|
340
398
|
yield bootstrapIdentity();
|
|
341
399
|
typedi_1.Container.get(EmailService_1.EmailService).warnIfLogFallbackEnabled();
|
|
@@ -414,16 +472,47 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
414
472
|
`${router.describeAppiumPath()}.`);
|
|
415
473
|
}
|
|
416
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Boot the emulators `emulators` lists, with each one's launch options.
|
|
477
|
+
*
|
|
478
|
+
* An iOS-only server, or one told to use real Android devices only
|
|
479
|
+
* (`androidDeviceType: real`, which discovery would then ignore them for),
|
|
480
|
+
* boots none. Anything else boots them, `platform: both` included: it used
|
|
481
|
+
* to boot only when `platform` named Android, so with the default it did
|
|
482
|
+
* nothing. A boot that fails is logged and the rest go on: an emulator that
|
|
483
|
+
* won't start must not keep the server from coming up with the phones it
|
|
484
|
+
* has, and with `both` counted this step now runs on many more servers.
|
|
485
|
+
*/
|
|
417
486
|
bootEmulators(pluginArgs) {
|
|
418
487
|
return __awaiter(this, void 0, void 0, function* () {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
488
|
+
const emulators = pluginArgs.emulators || [];
|
|
489
|
+
if (emulators.length === 0)
|
|
490
|
+
return;
|
|
491
|
+
const platform = String(pluginArgs.platform).toLowerCase();
|
|
492
|
+
if (platform === 'ios') {
|
|
493
|
+
this.logger.warn(`Not booting the ${emulators.length} configured emulator(s): platform is ios.`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (pluginArgs.androidDeviceType === 'real') {
|
|
497
|
+
this.logger.warn(`Not booting the ${emulators.length} configured emulator(s): androidDeviceType is real.`);
|
|
498
|
+
return;
|
|
426
499
|
}
|
|
500
|
+
this.logger.info(`Booting ${emulators.length} configured emulator(s)...`);
|
|
501
|
+
let adb;
|
|
502
|
+
try {
|
|
503
|
+
adb = yield appium_adb_1.ADB.createADB({});
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
this.logger.warn(`Not booting the configured emulators: no usable adb (${err === null || err === void 0 ? void 0 : err.message}).`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const results = yield Promise.allSettled(emulators.map((emulator) => adb.launchAVD(emulator.avdName, emulator)));
|
|
510
|
+
results.forEach((result, index) => {
|
|
511
|
+
var _a, _b;
|
|
512
|
+
if (result.status === 'rejected') {
|
|
513
|
+
this.logger.warn(`Could not boot emulator ${emulators[index].avdName}: ${(_b = (_a = result.reason) === null || _a === void 0 ? void 0 : _a.message) !== null && _b !== void 0 ? _b : result.reason}`);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
427
516
|
});
|
|
428
517
|
}
|
|
429
518
|
registerDependenciesInContainer(pluginArgs, cliArgs, nodeId) {
|
|
@@ -98,6 +98,8 @@ const SessionStatus_1 = require("../types/SessionStatus");
|
|
|
98
98
|
const SessionType_1 = __importDefault(require("../enums/SessionType"));
|
|
99
99
|
const async_lock_1 = __importDefault(require("async-lock"));
|
|
100
100
|
const base_driver_1 = require("@appium/base-driver");
|
|
101
|
+
const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
|
|
102
|
+
const interceptorOptions_1 = require("./interceptor/interceptorOptions");
|
|
101
103
|
/**
|
|
102
104
|
* How long the hub waits for a node (or cloud provider) to create a session.
|
|
103
105
|
* A create is sent once (InternalRequestConfig `retry: false`): the node may
|
|
@@ -222,9 +224,7 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
222
224
|
// Who asked, for the queue's team scoping (queueVisibility). On this row
|
|
223
225
|
// only, never in `caps`, and last so a client can't supply its own.
|
|
224
226
|
[queueVisibility_1.REQUESTER_KEY]: authResult.requester }));
|
|
225
|
-
const
|
|
226
|
-
this.logger.debug(`📱 Acquiring lock: ${lockName}`);
|
|
227
|
-
const device = yield commandsQueueGuard.acquire(lockName, () => __awaiter(this, void 0, void 0, function* () {
|
|
227
|
+
const allocate = () => __awaiter(this, void 0, void 0, function* () {
|
|
228
228
|
var _a;
|
|
229
229
|
try {
|
|
230
230
|
return yield (0, device_utils_1.allocateDeviceForSession)(caps, pluginArgs.deviceAvailabilityTimeoutMs, pluginArgs.deviceAvailabilityQueryIntervalMs, pluginArgs, sessionTeamIds, {
|
|
@@ -238,7 +238,21 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
238
238
|
yield (0, pending_sessions_service_1.removePendingSession)(pendingSessionId);
|
|
239
239
|
throw err;
|
|
240
240
|
}
|
|
241
|
-
})
|
|
241
|
+
});
|
|
242
|
+
// Creates for a platform queue on one lock, first come first served, and
|
|
243
|
+
// each has its full deviceAvailabilityTimeoutMs from the head of the
|
|
244
|
+
// queue. A lease-bound session allocates nothing (its phone is the
|
|
245
|
+
// lease's) and doesn't queue: behind the lock it waited for every create
|
|
246
|
+
// ahead of it, each of which may wait minutes for a busy phone.
|
|
247
|
+
let device;
|
|
248
|
+
if ((0, leaseSessionCaps_1.leaseIdOf)(caps)) {
|
|
249
|
+
device = yield allocate();
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const lockName = this.getLockName(caps);
|
|
253
|
+
this.logger.debug(`📱 Acquiring lock: ${lockName}`);
|
|
254
|
+
device = yield commandsQueueGuard.acquire(lockName, allocate);
|
|
255
|
+
}
|
|
242
256
|
yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Allocating node resources...');
|
|
243
257
|
this.logger.debug(`device.host: ${device.host} and pluginArgs.bindHostOrIp: ${pluginArgs.bindHostOrIp}`);
|
|
244
258
|
const allocation = {
|
|
@@ -865,26 +879,11 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
865
879
|
applyPostSessionLogic(session, caps, device) {
|
|
866
880
|
return __awaiter(this, void 0, void 0, function* () {
|
|
867
881
|
const context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
if (caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_ENABLED]) {
|
|
874
|
-
try {
|
|
875
|
-
const { InterceptorService } = yield Promise.resolve().then(() => __importStar(require('./InterceptorService')));
|
|
876
|
-
yield typedi_1.Container.get(InterceptorService).start(session.getId(), device, {
|
|
877
|
-
enabled: true,
|
|
878
|
-
bufferSize: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_BUFFER_SIZE],
|
|
879
|
-
captureBodies: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_CAPTURE_BODIES] !== false,
|
|
880
|
-
mocks: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_MOCKS] || [],
|
|
881
|
-
includeHosts: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_INCLUDE_HOSTS] || [],
|
|
882
|
-
excludeHosts: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_EXCLUDE_HOSTS] || [],
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
catch (err) {
|
|
886
|
-
this.logger.warn(`🕸️ Failed to start interceptor for ${session.getId()}: ${err.message}`);
|
|
887
|
-
}
|
|
882
|
+
// Only the server that drives the phone changes its network: a node
|
|
883
|
+
// applies its own sessions' profile and interceptor, and a hub that did
|
|
884
|
+
// it too ran its own adb against the node's phone.
|
|
885
|
+
if (session.getType() === SessionType_1.default.LOCAL) {
|
|
886
|
+
yield this.applySessionNetwork(session.getId(), caps, device, context.pluginArgs);
|
|
888
887
|
}
|
|
889
888
|
const isDashboardEnabled = !!context.pluginArgs.enableDashboard;
|
|
890
889
|
const shouldSaveLogs = session.getType() !== SessionType_1.default.CLOUD;
|
|
@@ -918,6 +917,36 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
918
917
|
}
|
|
919
918
|
});
|
|
920
919
|
}
|
|
920
|
+
/**
|
|
921
|
+
* The session's network profile and network capture, on a phone this
|
|
922
|
+
* server drives. First what an earlier session left on the phone and
|
|
923
|
+
* couldn't put back is undone, so this session starts from the phone's own
|
|
924
|
+
* settings and records those as the ones to put back. Each ending undoes
|
|
925
|
+
* these through PhoneNetworkRestore.
|
|
926
|
+
*/
|
|
927
|
+
applySessionNetwork(sessionId, caps, device, pluginArgs) {
|
|
928
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
929
|
+
var _a;
|
|
930
|
+
if (((_a = device.platform) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'android') {
|
|
931
|
+
yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreLeftovers({ udid: device.udid });
|
|
932
|
+
}
|
|
933
|
+
const networkProfile = caps[XenonCapabilityManager_1.XENON_CAPABILITIES.NETWORK_PROFILE];
|
|
934
|
+
if (networkProfile) {
|
|
935
|
+
const { NetworkConditioningService } = yield Promise.resolve().then(() => __importStar(require('./NetworkConditioningService')));
|
|
936
|
+
yield typedi_1.Container.get(NetworkConditioningService).applyProfile(sessionId, device, networkProfile);
|
|
937
|
+
}
|
|
938
|
+
const interceptor = (0, interceptorOptions_1.resolveInterceptorOptions)(caps, pluginArgs.interceptor);
|
|
939
|
+
if (interceptor.enabled) {
|
|
940
|
+
try {
|
|
941
|
+
const { InterceptorService } = yield Promise.resolve().then(() => __importStar(require('./InterceptorService')));
|
|
942
|
+
yield typedi_1.Container.get(InterceptorService).start(sessionId, device, interceptor);
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
this.logger.warn(`🕸️ Failed to start interceptor for ${sessionId}: ${err.message}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
921
950
|
/**
|
|
922
951
|
* The row a hub keeps for a session it routes to a node or a cloud when the
|
|
923
952
|
* dashboard's own (onSessionStarted) isn't written: with the dashboard off,
|
|
@@ -1085,6 +1114,10 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
1085
1114
|
// which releases ports and archives video twice.
|
|
1086
1115
|
if (sessionId) {
|
|
1087
1116
|
yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
|
|
1117
|
+
// The phone's network (profile, interceptor proxy) and the capture,
|
|
1118
|
+
// before the phone is released to the next session. Whether or not
|
|
1119
|
+
// SESSION_MANAGER holds the session.
|
|
1120
|
+
yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreSession(sessionId, 'session deleted');
|
|
1088
1121
|
yield (0, device_service_1.releaseSessionDevices)(sessionId);
|
|
1089
1122
|
this.logger.info(`📱 Unblocking the device that is blocked for session ${sessionId}`);
|
|
1090
1123
|
const session = SessionManager_1.SESSION_MANAGER.getSession(sessionId);
|
|
@@ -1125,37 +1158,10 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
1125
1158
|
// Another concurrent deleteSession already cleaned up.
|
|
1126
1159
|
return;
|
|
1127
1160
|
}
|
|
1128
|
-
|
|
1129
|
-
try {
|
|
1130
|
-
const { NetworkConditioningService } = yield Promise.resolve().then(() => __importStar(require('./NetworkConditioningService')));
|
|
1131
|
-
yield typedi_1.Container.get(NetworkConditioningService).reset(sessionId, device);
|
|
1132
|
-
}
|
|
1133
|
-
catch (resetErr) {
|
|
1134
|
-
this.logger.warn(`⚠️ NetworkConditioningService.reset failed for session ${sessionId}: ${resetErr.message}`);
|
|
1135
|
-
}
|
|
1136
|
-
try {
|
|
1137
|
-
const { InterceptorService } = yield Promise.resolve().then(() => __importStar(require('./InterceptorService')));
|
|
1138
|
-
const interceptor = typedi_1.Container.get(InterceptorService);
|
|
1139
|
-
if (interceptor.isActive(sessionId))
|
|
1140
|
-
yield interceptor.stop(sessionId);
|
|
1141
|
-
}
|
|
1142
|
-
catch (interceptorErr) {
|
|
1143
|
-
this.logger.warn(`⚠️ InterceptorService.stop failed for session ${sessionId}: ${interceptorErr.message}`);
|
|
1144
|
-
}
|
|
1161
|
+
// The phone's network was put back before it was released (above).
|
|
1145
1162
|
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, status, reason);
|
|
1146
1163
|
SessionManager_1.SESSION_MANAGER.removeSession(sessionId);
|
|
1147
1164
|
typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).forget(sessionId);
|
|
1148
|
-
try {
|
|
1149
|
-
const { getSessionById } = yield Promise.resolve().then(() => __importStar(require('../dashboard/services/session-service')));
|
|
1150
|
-
const sessionData = yield getSessionById(sessionId);
|
|
1151
|
-
if (sessionData && (sessionData.status === 'failed' || sessionData.failure_reason)) {
|
|
1152
|
-
const { NotificationService } = yield Promise.resolve().then(() => __importStar(require('./NotificationService')));
|
|
1153
|
-
yield typedi_1.Container.get(NotificationService).dispatchEvent('session_failed', sessionData);
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
catch (err) {
|
|
1157
|
-
/* ignore notification errors */
|
|
1158
|
-
}
|
|
1159
1165
|
}));
|
|
1160
1166
|
}
|
|
1161
1167
|
}
|
|
@@ -1228,6 +1234,7 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
1228
1234
|
stopSessionForShutdown(sessionId, reason) {
|
|
1229
1235
|
return __awaiter(this, void 0, void 0, function* () {
|
|
1230
1236
|
yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
|
|
1237
|
+
yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreSession(sessionId, reason);
|
|
1231
1238
|
try {
|
|
1232
1239
|
yield (0, device_service_1.releaseSessionDevices)(sessionId);
|
|
1233
1240
|
}
|
|
@@ -1246,7 +1253,10 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
1246
1253
|
}
|
|
1247
1254
|
}
|
|
1248
1255
|
try {
|
|
1249
|
-
|
|
1256
|
+
// A shutdown fails the sessions it drains; no test caused it, so no webhook.
|
|
1257
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, SessionStatus_1.SessionStatus.FAILED, reason, {
|
|
1258
|
+
notify: false,
|
|
1259
|
+
});
|
|
1250
1260
|
}
|
|
1251
1261
|
catch (err) {
|
|
1252
1262
|
this.logger.warn(`[shutdown] onSessionStopped failed for ${sessionId}: ${err.message}`);
|
|
@@ -24,6 +24,7 @@ const logger_1 = __importDefault(require("../logger"));
|
|
|
24
24
|
const SessionManager_1 = require("../sessions/SessionManager");
|
|
25
25
|
const SessionLifecycleService_1 = require("./SessionLifecycleService");
|
|
26
26
|
const SessionType_1 = __importDefault(require("../enums/SessionType"));
|
|
27
|
+
const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
|
|
27
28
|
// Graceful shutdown phase that runs BEFORE the existing infrastructure
|
|
28
29
|
// teardown in index.ts:cleanup(). Active sessions get a bounded chance to
|
|
29
30
|
// archive their video + release ports + mark themselves failed, so we don't
|
|
@@ -57,6 +58,7 @@ let ShutdownCoordinator = class ShutdownCoordinator {
|
|
|
57
58
|
const sessions = SessionManager_1.SESSION_MANAGER.getAllSessions().filter((session) => session.getType() === SessionType_1.default.LOCAL);
|
|
58
59
|
if (sessions.length === 0) {
|
|
59
60
|
this.logger.info('No active sessions to drain');
|
|
61
|
+
yield this.restorePhoneNetworks(timeoutMs);
|
|
60
62
|
return { attempted: 0, completed: 0 };
|
|
61
63
|
}
|
|
62
64
|
this.logger.info(`Draining ${sessions.length} active session(s) (overall=${timeoutMs}ms, per-session=${perSessionTimeoutMs}ms)`);
|
|
@@ -84,10 +86,41 @@ let ShutdownCoordinator = class ShutdownCoordinator {
|
|
|
84
86
|
}));
|
|
85
87
|
const results = yield Promise.allSettled(tasks);
|
|
86
88
|
const completed = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
|
89
|
+
yield this.restorePhoneNetworks(Math.max(0, deadline - Date.now()));
|
|
87
90
|
this.logger.info(`Drain complete: ${completed}/${sessions.length} session(s) finalized`);
|
|
88
91
|
return { attempted: sessions.length, completed };
|
|
89
92
|
});
|
|
90
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The network of every phone a session of this server still has changed
|
|
96
|
+
* (profile, interceptor proxy), sessions SESSION_MANAGER doesn't hold
|
|
97
|
+
* included, within what is left of the budget. A shutdown that never gets
|
|
98
|
+
* here (Appium exits on SIGTERM first) leaves it to the next start, from
|
|
99
|
+
* the ledger (PhoneNetworkRestore.cleanUpAtBoot).
|
|
100
|
+
*/
|
|
101
|
+
restorePhoneNetworks(budgetMs) {
|
|
102
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
103
|
+
var _a;
|
|
104
|
+
if (budgetMs <= 0)
|
|
105
|
+
return;
|
|
106
|
+
let timer;
|
|
107
|
+
try {
|
|
108
|
+
yield Promise.race([
|
|
109
|
+
typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreAll('Xenon is shutting down'),
|
|
110
|
+
new Promise((resolve) => {
|
|
111
|
+
timer = setTimeout(resolve, budgetMs);
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
this.logger.warn(`[shutdown] Putting phone networks back failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (timer)
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
91
124
|
};
|
|
92
125
|
exports.ShutdownCoordinator = ShutdownCoordinator;
|
|
93
126
|
exports.ShutdownCoordinator = ShutdownCoordinator = __decorate([
|
|
@@ -52,20 +52,37 @@ let TeamService = class TeamService {
|
|
|
52
52
|
delete(id) {
|
|
53
53
|
return __awaiter(this, void 0, void 0, function* () {
|
|
54
54
|
// Apps too: App.teamId is onDelete SetNull, so deleting a team that owns
|
|
55
|
-
// apps would quietly share them with every member.
|
|
56
|
-
|
|
55
|
+
// apps would quietly share them with every member. So is a phone's saved
|
|
56
|
+
// team (DeviceSetting): a phone of the team that is not connected now
|
|
57
|
+
// would come back in the shared pool.
|
|
58
|
+
const [connected, saved, members, apps] = yield Promise.all([
|
|
57
59
|
prisma_1.prisma.device.count({ where: { teamId: id } }),
|
|
60
|
+
prisma_1.prisma.deviceSetting.findMany({ where: { teamId: id }, select: { udid: true, host: true } }),
|
|
58
61
|
prisma_1.prisma.teamMember.count({ where: { teamId: id } }),
|
|
59
62
|
prisma_1.prisma.app.count({ where: { teamId: id } }),
|
|
60
63
|
]);
|
|
64
|
+
const away = yield this.notConnected(saved);
|
|
65
|
+
const devices = connected + away;
|
|
61
66
|
if (devices > 0 || members > 0 || apps > 0) {
|
|
62
|
-
|
|
67
|
+
const phones = away > 0 ? `${devices} device(s) (${away} not connected now)` : `${devices} device(s)`;
|
|
68
|
+
throw new Error(`Team still has ${phones}, ${members} member(s) and ${apps} app(s). Reassign them before deleting.`);
|
|
63
69
|
}
|
|
64
70
|
// Clear teamId on any (revoked) ApiKey rows so the FK doesn't block delete.
|
|
65
71
|
yield prisma_1.prisma.apiKey.updateMany({ where: { teamId: id }, data: { teamId: null } });
|
|
66
72
|
yield prisma_1.prisma.team.delete({ where: { id } });
|
|
67
73
|
});
|
|
68
74
|
}
|
|
75
|
+
/** How many of these saved phones have no Device row: not connected now. */
|
|
76
|
+
notConnected(saved) {
|
|
77
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
78
|
+
if (saved.length === 0)
|
|
79
|
+
return 0;
|
|
80
|
+
const here = yield prisma_1.prisma.device.count({
|
|
81
|
+
where: { OR: saved.map(({ udid, host }) => ({ udid, host })) },
|
|
82
|
+
});
|
|
83
|
+
return saved.length - here;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
69
86
|
listMembers(teamId) {
|
|
70
87
|
return __awaiter(this, void 0, void 0, function* () {
|
|
71
88
|
const rows = yield prisma_1.prisma.teamMember.findMany({
|
|
@@ -46,9 +46,34 @@ let SessionOwnerResolver = class SessionOwnerResolver {
|
|
|
46
46
|
this.db = db;
|
|
47
47
|
this.ownerCache = new Map();
|
|
48
48
|
this.nameCache = new Map();
|
|
49
|
+
this.leaseHolderCache = new Map();
|
|
49
50
|
/** The owners of this server's live sessions. A field, not a constructor parameter: TypeDI. */
|
|
50
51
|
this.liveOwners = () => typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners);
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The user behind a lease's `actorId`: the owner of the API key that
|
|
55
|
+
* created it, or, for a lease made with a bearer token, the user id itself.
|
|
56
|
+
* Positive results are cached, as for sessions: a lease's creator never
|
|
57
|
+
* changes.
|
|
58
|
+
*/
|
|
59
|
+
leaseHolderOf(actorId) {
|
|
60
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
var _a;
|
|
62
|
+
if (!actorId)
|
|
63
|
+
return null;
|
|
64
|
+
const cached = this.leaseHolderCache.get(actorId);
|
|
65
|
+
if (cached)
|
|
66
|
+
return cached;
|
|
67
|
+
const key = yield this.db.apiKey.findUnique({
|
|
68
|
+
where: { id: actorId },
|
|
69
|
+
select: { userId: true },
|
|
70
|
+
});
|
|
71
|
+
const holder = key ? ((_a = key.userId) !== null && _a !== void 0 ? _a : null) : actorId;
|
|
72
|
+
if (holder)
|
|
73
|
+
this.remember(this.leaseHolderCache, actorId, holder);
|
|
74
|
+
return holder;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
52
77
|
ownerOf(sessionId) {
|
|
53
78
|
return __awaiter(this, void 0, void 0, function* () {
|
|
54
79
|
var _a, _b;
|
|
@@ -165,6 +190,7 @@ let SessionOwnerResolver = class SessionOwnerResolver {
|
|
|
165
190
|
clear() {
|
|
166
191
|
this.ownerCache.clear();
|
|
167
192
|
this.nameCache.clear();
|
|
193
|
+
this.leaseHolderCache.clear();
|
|
168
194
|
}
|
|
169
195
|
remember(cache, key, value) {
|
|
170
196
|
if (cache.size >= MAX_CACHE_ENTRIES)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isSelfManualLock = isSelfManualLock;
|
|
4
4
|
exports.isOwnSession = isOwnSession;
|
|
5
|
+
exports.isLeaseHolder = isLeaseHolder;
|
|
5
6
|
exports.evaluateDeviceAccess = evaluateDeviceAccess;
|
|
6
7
|
exports.ownershipUnavailableBody = ownershipUnavailableBody;
|
|
7
8
|
exports.denyBody = denyBody;
|
|
@@ -23,10 +24,39 @@ function isOwnSession(sessionOwnerUserId, actorUserId) {
|
|
|
23
24
|
const owner = sessionOwnerUserId !== null && sessionOwnerUserId !== void 0 ? sessionOwnerUserId : '';
|
|
24
25
|
return !!(owner && actorUserId && owner === actorUserId);
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* True when the caller holds the lease: by the key that created it, or as
|
|
29
|
+
* the user behind it (any credential of theirs, the dashboard included), as
|
|
30
|
+
* ownership is keyed on the user everywhere else.
|
|
31
|
+
*/
|
|
32
|
+
function isLeaseHolder(lease, actorUserId, actorApiKeyId) {
|
|
33
|
+
if (!lease)
|
|
34
|
+
return false;
|
|
35
|
+
if (actorApiKeyId && lease.actorId === actorApiKeyId)
|
|
36
|
+
return true;
|
|
37
|
+
return !!actorUserId && (lease.actorId === actorUserId || lease.holderUserId === actorUserId);
|
|
38
|
+
}
|
|
26
39
|
function evaluateDeviceAccess(input) {
|
|
27
|
-
var _a;
|
|
40
|
+
var _a, _b;
|
|
28
41
|
if (input.isAdmin)
|
|
29
42
|
return ALLOW;
|
|
43
|
+
if (input.lease) {
|
|
44
|
+
if (isLeaseHolder(input.lease, input.actorUserId, input.actorApiKeyId))
|
|
45
|
+
return ALLOW;
|
|
46
|
+
// A session on a leased phone proved it may use the lease; its owner
|
|
47
|
+
// keeps driving it, as with any session of their own.
|
|
48
|
+
if (input.sessionId &&
|
|
49
|
+
!(0, manualLock_1.isManualLock)(input.sessionId) &&
|
|
50
|
+
isOwnSession(input.sessionOwnerUserId, input.actorUserId)) {
|
|
51
|
+
return ALLOW;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
allow: false,
|
|
55
|
+
code: 'device_held_by_another_user',
|
|
56
|
+
holderId: (_a = input.lease.holderUserId) !== null && _a !== void 0 ? _a : '',
|
|
57
|
+
heldBy: 'lease',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
30
60
|
if (!input.busy)
|
|
31
61
|
return ALLOW;
|
|
32
62
|
const asUser = (0, manualLock_1.inspectManualLock)(input.sessionId, input.actorUserId, input.udid);
|
|
@@ -42,7 +72,7 @@ function evaluateDeviceAccess(input) {
|
|
|
42
72
|
}
|
|
43
73
|
// Not a manual lock: a real Appium session holds the device (or the row is
|
|
44
74
|
// busy with no id at all, which is an inconsistent state we fail closed on).
|
|
45
|
-
const owner = (
|
|
75
|
+
const owner = (_b = input.sessionOwnerUserId) !== null && _b !== void 0 ? _b : '';
|
|
46
76
|
if (isOwnSession(input.sessionOwnerUserId, input.actorUserId))
|
|
47
77
|
return ALLOW;
|
|
48
78
|
return { allow: false, code: 'device_in_use_by_session', holderId: owner };
|
|
@@ -62,11 +92,13 @@ function ownershipUnavailableBody() {
|
|
|
62
92
|
message: 'Could not verify device ownership. Try again.',
|
|
63
93
|
};
|
|
64
94
|
}
|
|
65
|
-
function denyBody(code, holderId, holderName) {
|
|
95
|
+
function denyBody(code, holderId, holderName, heldBy) {
|
|
66
96
|
const who = holderName || 'another user';
|
|
67
|
-
const message =
|
|
68
|
-
? `Device is
|
|
69
|
-
:
|
|
97
|
+
const message = heldBy === 'lease'
|
|
98
|
+
? `Device is leased by ${who} through the SDK. It is free again when their lease ends.`
|
|
99
|
+
: code === 'device_held_by_another_user'
|
|
100
|
+
? `Device is being controlled by ${who}. Ask them to release it, or use an admin key to force-release.`
|
|
101
|
+
: `Device is in use by an Appium session owned by ${who}.`;
|
|
70
102
|
return {
|
|
71
103
|
success: false,
|
|
72
104
|
error: code,
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.leaseHoldFor = leaseHoldFor;
|
|
13
|
+
/**
|
|
14
|
+
* The lease hold on a device row for an ownership decision, or null. Throws
|
|
15
|
+
* when either lookup does: the caller fails closed.
|
|
16
|
+
*/
|
|
17
|
+
function leaseHoldFor(device, findActiveLease, resolveLeaseHolder) {
|
|
18
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
19
|
+
if (!(device === null || device === void 0 ? void 0 : device.udid) || !device.host)
|
|
20
|
+
return null;
|
|
21
|
+
const lease = yield findActiveLease(device.udid, device.host);
|
|
22
|
+
if (!lease)
|
|
23
|
+
return null;
|
|
24
|
+
return { actorId: lease.actorId, holderUserId: yield resolveLeaseHolder(lease.actorId) };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -13,6 +13,7 @@ exports.makeTicketActorAuthorizer = makeTicketActorAuthorizer;
|
|
|
13
13
|
const manualLock_1 = require("../recording/manualLock");
|
|
14
14
|
const deviceAccessPolicy_1 = require("./deviceAccessPolicy");
|
|
15
15
|
const deviceClaims_1 = require("../../data-service/deviceClaims");
|
|
16
|
+
const leaseHold_1 = require("./leaseHold");
|
|
16
17
|
/**
|
|
17
18
|
* Build the ownership check for a caller authenticated by a stream ticket.
|
|
18
19
|
*
|
|
@@ -53,6 +54,10 @@ function makeTicketActorAuthorizer(deps) {
|
|
|
53
54
|
const sessionOwnerUserId = device.busy && device.session_id && !(0, manualLock_1.isManualLock)(device.session_id)
|
|
54
55
|
? yield deps.resolveSessionOwner(device.session_id)
|
|
55
56
|
: null;
|
|
57
|
+
// As deviceAccessGuard: a leased phone is its lease holder's.
|
|
58
|
+
const lease = deps.findActiveLease && deps.resolveLeaseHolder && !actor.isAdmin
|
|
59
|
+
? yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, deps.findActiveLease, deps.resolveLeaseHolder)
|
|
60
|
+
: null;
|
|
56
61
|
return (0, deviceAccessPolicy_1.evaluateDeviceAccess)({
|
|
57
62
|
udid,
|
|
58
63
|
// heldHere, as deviceAccessGuard reads it.
|
|
@@ -63,6 +68,7 @@ function makeTicketActorAuthorizer(deps) {
|
|
|
63
68
|
actorUserId: actor.actorId,
|
|
64
69
|
actorApiKeyId: actor.apiKeyId,
|
|
65
70
|
isAdmin: !!actor.isAdmin,
|
|
71
|
+
lease,
|
|
66
72
|
}).allow;
|
|
67
73
|
});
|
|
68
74
|
};
|