@xenon-device-management/xenon 2.13.2 → 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-BXruClDk.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-qTS4LPLC.js → ApiKeyGate-BwuaMjkX.js} +1 -1
- package/lib/public/assets/{BugReportButton-BoGhlI7F.js → BugReportButton-BAt0_pzq.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
- package/lib/public/assets/{EmptyState-Cr17PdDv.js → EmptyState-BSjhk_IV.js} +1 -1
- package/lib/public/assets/{FieldGroup-Bvn08svv.js → FieldGroup-Cb5QZsE1.js} +1 -1
- package/lib/public/assets/{FilterMenu-DDSO8aDf.js → FilterMenu-BAaSlWbW.js} +1 -1
- package/lib/public/assets/{Menu-B-Qn2X3T.js → Menu-JGGYrSQQ.js} +1 -1
- package/lib/public/assets/{Modal-Cg9V5JYU.js → Modal-CFCp3n9d.js} +1 -1
- package/lib/public/assets/{RecordingPage-BTUs9VTW.js → RecordingPage-CbJj_EBT.js} +1 -1
- package/lib/public/assets/{RecordingsPage-DYcfxITK.js → RecordingsPage-BJeYnygR.js} +2 -2
- package/lib/public/assets/{SegmentedControl-Zy7suhT5.js → SegmentedControl-Bg42Atjy.js} +1 -1
- package/lib/public/assets/{SettingCard-CWYmycoe.js → SettingCard-Njz1pUIJ.js} +1 -1
- package/lib/public/assets/{Table-io9fwa98.js → Table-BcTm5xV2.js} +1 -1
- package/lib/public/assets/{activity-Bdf_uIWl.js → activity-DOVdsfMQ.js} +1 -1
- package/lib/public/assets/{ai-settings-Bce5SJob.js → ai-settings-CsuJroRf.js} +2 -2
- package/lib/public/assets/{api-keys-CPqtVpm3.js → api-keys-BKnNSTw9.js} +1 -1
- package/lib/public/assets/{apps-BgZan0H9.js → apps-D0SC6uw6.js} +2 -2
- package/lib/public/assets/{arrow-left-B27ckB31.js → arrow-left-MMbTmgoE.js} +1 -1
- package/lib/public/assets/{arrow-right-DOF8ITCz.js → arrow-right-BMfnGmxW.js} +1 -1
- package/lib/public/assets/{arrow-up-right-eFG5KTFS.js → arrow-up-right-DuAftMWB.js} +1 -1
- package/lib/public/assets/{auth-shell-DlC3_27j.js → auth-shell-nZC2JNDO.js} +2 -2
- package/lib/public/assets/{builds-page-BuXpPfKo.js → builds-page-hDCgTwkS.js} +1 -1
- package/lib/public/assets/{button-fIW8owMI.js → button-Dda4irn_.js} +1 -1
- package/lib/public/assets/{calendar-WNjmrF-a.js → calendar-BMr0Q62v.js} +1 -1
- package/lib/public/assets/{check-BdTh1Sq7.js → check-CZ9iw9-e.js} +1 -1
- package/lib/public/assets/{chevron-right-B4BKlsqk.js → chevron-right-BRYJJVC9.js} +1 -1
- package/lib/public/assets/{circle-check-zJU80_EF.js → circle-check-CqAJjQ5O.js} +1 -1
- package/lib/public/assets/{circle-x-5Uwquo0h.js → circle-x-DlDJtnVz.js} +1 -1
- package/lib/public/assets/{clock-BtUprDsZ.js → clock-CpuDKysU.js} +1 -1
- package/lib/public/assets/{copy-DGSbZI3u.js → copy-CAQydNFe.js} +1 -1
- package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
- package/lib/public/assets/{download-WG6l7BgD.js → download-C9XG6lLv.js} +1 -1
- package/lib/public/assets/{forgot-password-DnudqFFo.js → forgot-password-CGnkRgE9.js} +1 -1
- package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
- package/lib/public/assets/{index-DI1fGIyj.js → index-BbKEXUFH.js} +5 -5
- package/lib/public/assets/{index-HDQVe1gi.js → index-DJGc1M2f.js} +1 -1
- package/lib/public/assets/{input-BjDRKCea.js → input-Cm0tmSoc.js} +1 -1
- package/lib/public/assets/{line-chart-C0zdgd0v.js → line-chart-D2DyJCml.js} +1 -1
- package/lib/public/assets/{list-checks-BdW5CjM8.js → list-checks-UWf1GY99.js} +1 -1
- package/lib/public/assets/{lock-Dy3L4I8E.js → lock-CCJv9m_I.js} +1 -1
- package/lib/public/assets/{login-BHbY9CLG.js → login-f8vOVW-U.js} +1 -1
- package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
- package/lib/public/assets/{monitor-8xjkeHHi.js → monitor-B2jDgwm7.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-CXdKMXQ8.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
- package/lib/public/assets/{network-Cj8JOKON.js → network-ebT86qoi.js} +1 -1
- package/lib/public/assets/{overview-CTg7F0im.js → overview-C0aykVrd.js} +1 -1
- package/lib/public/assets/{page-header--aTyJXq6.js → page-header-BjzgWz-T.js} +1 -1
- package/lib/public/assets/{play-B-FodgHV.js → play-DLG9t8FU.js} +1 -1
- package/lib/public/assets/{plus-CsIk10Cq.js → plus-nBFSMu_X.js} +1 -1
- package/lib/public/assets/{profile-page-DkFKkMfl.js → profile-page-CSl0UYlv.js} +1 -1
- package/lib/public/assets/{reset-password-I2-zqLlI.js → reset-password-LeOuHqmB.js} +1 -1
- package/lib/public/assets/{runbook-page-DJTVCJX7.js → runbook-page-CMMeQml9.js} +1 -1
- package/lib/public/assets/{select-DvCSv25z.js → select-88jHcl-Z.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-BLVfMJs_.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
- package/lib/public/assets/{selector-health-page-oqadRokn.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-DV9Ix-4O.js → stat-tile-BV98e_Sv.js} +1 -1
- package/lib/public/assets/{tablet-6b_SEN2X.js → tablet-BiAsnFDj.js} +1 -1
- package/lib/public/assets/{teams-BjZyfuHh.js → teams-B4-jrr-1.js} +1 -1
- package/lib/public/assets/{trash-2-CTZpPSdd.js → trash-2-C5RSuX0e.js} +1 -1
- package/lib/public/assets/{upload-P3XzwQUb.js → upload-BG7_rMXK.js} +1 -1
- package/lib/public/assets/{use-builds-data-1cOCMf-z.js → use-builds-data-J-riQYyC.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-DgF9w088.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
- package/lib/public/assets/{users-DMF9Mk5H.js → users-CFba5TSy.js} +1 -1
- package/lib/public/assets/{users-CiNTKUiz.js → users-Cp0beJm3.js} +1 -1
- package/lib/public/assets/{video-off-CHp1KU_T.js → video-off-DDsEISjV.js} +1 -1
- package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
- package/lib/public/assets/{zap-DRz0tDq5.js → zap-l4VaXzq8.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/schema.json +19 -21
- 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 +12 -2
- package/lib/src/app/routers/dashboard.js +29 -1
- package/lib/src/app/routers/grid.js +32 -10
- 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 +6 -1
- package/lib/src/data-service/device-store.js +62 -2
- 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 +150 -8
- 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 +96 -40
- 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/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 +94 -8
- package/lib/src/services/SessionLifecycleService.js +47 -49
- package/lib/src/services/ShutdownCoordinator.js +33 -0
- package/lib/src/services/TeamService.js +20 -3
- 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/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/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-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/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 +14 -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/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 -21
- package/scripts/generate-types-from-schema.js +1 -2
- package/scripts/lib/always-present-plugin-args.js +4 -1
- package/lib/public/assets/DeviceMosaicView-8J0oYqMM.js +0 -2
- package/lib/public/assets/device-explorer-C-KnhMHI.js +0 -279
- package/lib/public/assets/maintenance-settings-zDVlOMVK.js +0 -6
- package/lib/public/assets/recording-group-store-WVM3hoXn.js +0 -26
- package/lib/public/assets/session-detail-page-CGn55Pvl.js +0 -4
- package/lib/public/assets/settings-MsVU2ukE.js +0 -6
- package/lib/public/assets/webhook-settings-DiS8geIm.js +0 -1
|
@@ -0,0 +1,170 @@
|
|
|
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 axios_1 = __importDefault(require("axios"));
|
|
18
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
19
|
+
const typedi_1 = require("typedi");
|
|
20
|
+
const event_manager_1 = require("../../src/dashboard/event-manager");
|
|
21
|
+
const SessionManager_1 = require("../../src/sessions/SessionManager");
|
|
22
|
+
const SessionMetricsService_1 = require("../../src/services/metrics/SessionMetricsService");
|
|
23
|
+
const SocketServer_1 = require("../../src/services/SocketServer");
|
|
24
|
+
const MetricsService_1 = require("../../src/services/MetricsService");
|
|
25
|
+
const NotificationService_1 = require("../../src/services/NotificationService");
|
|
26
|
+
const device_store_1 = require("../../src/data-service/device-store");
|
|
27
|
+
const SessionStatus_1 = require("../../src/types/SessionStatus");
|
|
28
|
+
const prisma_1 = require("../../src/prisma");
|
|
29
|
+
const container_registration_1 = require("../helpers/container-registration");
|
|
30
|
+
/**
|
|
31
|
+
* `session_failed` goes out when a session ends as failed, whichever way it
|
|
32
|
+
* ends, and once per session.
|
|
33
|
+
*
|
|
34
|
+
* It was sent only from the client's own delete, so a session that timed out
|
|
35
|
+
* for inactivity, whose driver crashed, or whose heartbeat stopped was marked
|
|
36
|
+
* failed on the dashboard and counted in the failure metric, and no webhook
|
|
37
|
+
* heard of it. Every one of those ends through `onSessionStopped`, which is
|
|
38
|
+
* where the final status is decided, so that is where it is sent. A session
|
|
39
|
+
* that ends twice (a crash, then the client's delete) is still sent once.
|
|
40
|
+
*/
|
|
41
|
+
describe('session_failed: when it is sent', () => {
|
|
42
|
+
const URL = 'https://hooks.example.com/T000/B000/xyz';
|
|
43
|
+
let restore;
|
|
44
|
+
let post;
|
|
45
|
+
let row;
|
|
46
|
+
let svc;
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
restore = (0, container_registration_1.saveRegistrations)(SessionMetricsService_1.SessionMetricsService, SocketServer_1.SocketServer, MetricsService_1.MetricsService);
|
|
49
|
+
typedi_1.Container.set(SessionMetricsService_1.SessionMetricsService, { stop: sinon_1.default.stub().resolves() });
|
|
50
|
+
typedi_1.Container.set(SocketServer_1.SocketServer, {
|
|
51
|
+
emitToDashboard: sinon_1.default.stub(),
|
|
52
|
+
emitToDashboardForDevices: sinon_1.default.stub().resolves(),
|
|
53
|
+
hasScopedDashboard: sinon_1.default.stub().returns(false),
|
|
54
|
+
});
|
|
55
|
+
typedi_1.Container.set(MetricsService_1.MetricsService, {
|
|
56
|
+
incrementSessionSuccess: sinon_1.default.stub(),
|
|
57
|
+
incrementSessionFailure: sinon_1.default.stub(),
|
|
58
|
+
});
|
|
59
|
+
sinon_1.default.stub(SessionManager_1.SESSION_MANAGER, 'getSession').returns(undefined);
|
|
60
|
+
sinon_1.default
|
|
61
|
+
.stub(device_store_1.DeviceStoreFactory, 'getStore')
|
|
62
|
+
.returns({ getDevices: () => __awaiter(void 0, void 0, void 0, function* () { return []; }), findDevices: () => __awaiter(void 0, void 0, void 0, function* () { return []; }) });
|
|
63
|
+
sinon_1.default.stub(prisma_1.prisma.sessionLog, 'findFirst').resolves(null);
|
|
64
|
+
row = {
|
|
65
|
+
id: 'sess-1',
|
|
66
|
+
name: 'Checkout flow',
|
|
67
|
+
status: 'running',
|
|
68
|
+
failure_reason: null,
|
|
69
|
+
device_udid: 'R58M123',
|
|
70
|
+
device_name: 'Galaxy S9+',
|
|
71
|
+
device_platform: 'android',
|
|
72
|
+
device_version: '10',
|
|
73
|
+
startTime: new Date('2026-10-04T09:00:00.000Z'),
|
|
74
|
+
endTime: null,
|
|
75
|
+
desired_capabilities: '{"appium:app":"/secret.apk"}',
|
|
76
|
+
};
|
|
77
|
+
sinon_1.default.stub(prisma_1.prisma.session, 'findFirst').callsFake((() => __awaiter(void 0, void 0, void 0, function* () { return (Object.assign({}, row)); })));
|
|
78
|
+
sinon_1.default.stub(prisma_1.prisma.session, 'update').callsFake(((_a) => __awaiter(void 0, [_a], void 0, function* ({ data }) {
|
|
79
|
+
row = Object.assign(Object.assign({}, row), data);
|
|
80
|
+
return row;
|
|
81
|
+
})));
|
|
82
|
+
// A real service with a slack webhook for the event, so what is checked is
|
|
83
|
+
// what would reach the channel.
|
|
84
|
+
svc = typedi_1.Container.get(NotificationService_1.NotificationService);
|
|
85
|
+
post = sinon_1.default.stub(axios_1.default, 'post').resolves({ status: 200 });
|
|
86
|
+
sinon_1.default
|
|
87
|
+
.stub(svc, 'getConfigs')
|
|
88
|
+
.resolves([
|
|
89
|
+
{ id: 'w', url: URL, events: '["session_failed"]', type: 'slack', active: true },
|
|
90
|
+
]);
|
|
91
|
+
// A session id nobody has notified for yet, however many specs ran before.
|
|
92
|
+
row.id = `sess-${Math.random().toString(36).slice(2)}`;
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
sinon_1.default.restore();
|
|
96
|
+
restore();
|
|
97
|
+
});
|
|
98
|
+
/** onSessionStopped fires the webhook without being awaited: let it finish. */
|
|
99
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
|
|
100
|
+
it('is sent when a session times out for inactivity (device-utils)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
101
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, 'failed', 'Session timed out due to inactivity');
|
|
102
|
+
yield settle();
|
|
103
|
+
(0, chai_1.expect)(post.calledOnce).to.equal(true);
|
|
104
|
+
const text = post.firstCall.args[1].attachments[0].text;
|
|
105
|
+
(0, chai_1.expect)(text).to.contain(row.id);
|
|
106
|
+
(0, chai_1.expect)(text).to.contain('Session timed out due to inactivity');
|
|
107
|
+
(0, chai_1.expect)(text).to.contain('Galaxy S9+');
|
|
108
|
+
(0, chai_1.expect)(text).not.to.contain('undefined');
|
|
109
|
+
}));
|
|
110
|
+
it('is sent when the driver crashes (plugin onUnexpectedShutdown)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
111
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Driver shut down unexpectedly');
|
|
112
|
+
yield settle();
|
|
113
|
+
(0, chai_1.expect)(post.calledOnce).to.equal(true);
|
|
114
|
+
(0, chai_1.expect)(post.firstCall.args[1].attachments[0].text).to.contain('Driver shut down unexpectedly');
|
|
115
|
+
}));
|
|
116
|
+
it('is sent when the heartbeat stops (OrphanSweeper)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
117
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Session heartbeat timeout');
|
|
118
|
+
yield settle();
|
|
119
|
+
(0, chai_1.expect)(post.calledOnce).to.equal(true);
|
|
120
|
+
}));
|
|
121
|
+
it('is sent when the client deletes a session a command failed in', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
122
|
+
prisma_1.prisma.sessionLog.findFirst.resolves({
|
|
123
|
+
command_name: 'findElement',
|
|
124
|
+
response: '{"value":{"error":"no such element"}}',
|
|
125
|
+
});
|
|
126
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
|
|
127
|
+
yield settle();
|
|
128
|
+
(0, chai_1.expect)(post.calledOnce).to.equal(true);
|
|
129
|
+
(0, chai_1.expect)(post.firstCall.args[1].attachments[0].text).to.contain('no such element');
|
|
130
|
+
}));
|
|
131
|
+
it('is sent when the test marked the session failed and the client then deletes it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
132
|
+
row.status = 'failed';
|
|
133
|
+
row.failure_reason = 'Assertion failed: total is 12';
|
|
134
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
|
|
135
|
+
yield settle();
|
|
136
|
+
(0, chai_1.expect)(post.calledOnce).to.equal(true);
|
|
137
|
+
(0, chai_1.expect)(post.firstCall.args[1].attachments[0].text).to.contain('Assertion failed: total is 12');
|
|
138
|
+
}));
|
|
139
|
+
it('is not sent for a session that passes', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
140
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
|
|
141
|
+
yield settle();
|
|
142
|
+
(0, chai_1.expect)(row.status).to.equal('success');
|
|
143
|
+
(0, chai_1.expect)(post.called).to.equal(false);
|
|
144
|
+
}));
|
|
145
|
+
it('is sent once when a session ends twice: a crash, then the client’s delete', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
146
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Driver shut down unexpectedly');
|
|
147
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id);
|
|
148
|
+
yield settle();
|
|
149
|
+
(0, chai_1.expect)(post.callCount).to.equal(1);
|
|
150
|
+
}));
|
|
151
|
+
it('is not sent when the server itself is shutting down', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
152
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'Hub shutdown', {
|
|
153
|
+
notify: false,
|
|
154
|
+
});
|
|
155
|
+
yield settle();
|
|
156
|
+
(0, chai_1.expect)(row.status).to.equal('failed'); // still recorded as failed
|
|
157
|
+
(0, chai_1.expect)(post.called).to.equal(false);
|
|
158
|
+
}));
|
|
159
|
+
it('does not hold the session’s end up for a webhook, and survives one that fails', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
160
|
+
post.rejects(new Error('slack is down'));
|
|
161
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'boom');
|
|
162
|
+
yield settle();
|
|
163
|
+
(0, chai_1.expect)(row.status).to.equal('failed');
|
|
164
|
+
}));
|
|
165
|
+
it('does not send the session’s capabilities to the webhook', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
166
|
+
yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(row.id, SessionStatus_1.SessionStatus.FAILED, 'boom');
|
|
167
|
+
yield settle();
|
|
168
|
+
(0, chai_1.expect)(JSON.stringify(post.firstCall.args[1])).not.to.contain('secret.apk');
|
|
169
|
+
}));
|
|
170
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
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 shellCommands_1 = require("../../src/device-managers/shellCommands");
|
|
19
|
+
const AndroidDeviceManager_1 = __importDefault(require("../../src/device-managers/AndroidDeviceManager"));
|
|
20
|
+
// The device control Shell tab's allow-list was a `startsWith` test, and the
|
|
21
|
+
// command then reached the phone's `sh`: `ls; reboot`, `ls && pm uninstall …`
|
|
22
|
+
// and `lsof` all ran. A command now has to be an allowed command word for word,
|
|
23
|
+
// and its arguments may only use characters a shell gives no meaning to.
|
|
24
|
+
describe('parseShellCommand', () => {
|
|
25
|
+
const allowed = (cmd) => (0, shellCommands_1.parseShellCommand)(cmd, shellCommands_1.ANDROID_SHELL_COMMANDS);
|
|
26
|
+
it('accepts an allowed command and its plain arguments', () => {
|
|
27
|
+
(0, chai_1.expect)(allowed('ls -la /sdcard/Download')).to.deep.equal({
|
|
28
|
+
argv: ['ls', '-la', '/sdcard/Download'],
|
|
29
|
+
});
|
|
30
|
+
(0, chai_1.expect)(allowed(' getprop ro.product.model ')).to.deep.equal({
|
|
31
|
+
argv: ['getprop', 'ro.product.model'],
|
|
32
|
+
});
|
|
33
|
+
(0, chai_1.expect)(allowed('pm list packages -3')).to.deep.equal({
|
|
34
|
+
argv: ['pm', 'list', 'packages', '-3'],
|
|
35
|
+
});
|
|
36
|
+
(0, chai_1.expect)(allowed('dumpsys battery')).to.deep.equal({ argv: ['dumpsys', 'battery'] });
|
|
37
|
+
(0, chai_1.expect)(allowed('date +%s')).to.deep.equal({ argv: ['date', '+%s'] });
|
|
38
|
+
});
|
|
39
|
+
for (const cmd of [
|
|
40
|
+
'ls; reboot',
|
|
41
|
+
'ls;reboot',
|
|
42
|
+
'ls && pm uninstall com.example.app',
|
|
43
|
+
'ls || reboot',
|
|
44
|
+
'ls | sh',
|
|
45
|
+
'ls `reboot`',
|
|
46
|
+
'getprop $(reboot)',
|
|
47
|
+
'ls > /sdcard/x',
|
|
48
|
+
'ls < /sdcard/x',
|
|
49
|
+
'ls &',
|
|
50
|
+
'ls\nreboot',
|
|
51
|
+
"ls 'a b'",
|
|
52
|
+
'ls "a b"',
|
|
53
|
+
'ls \\; reboot',
|
|
54
|
+
'getprop ${PATH}',
|
|
55
|
+
'ls *',
|
|
56
|
+
'ls ~',
|
|
57
|
+
]) {
|
|
58
|
+
it(`refuses a shell metacharacter: ${JSON.stringify(cmd)}`, () => {
|
|
59
|
+
(0, chai_1.expect)(allowed(cmd)).to.have.property('refused');
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
it('refuses a command that only starts like an allowed one', () => {
|
|
63
|
+
(0, chai_1.expect)(allowed('lsof')).to.have.property('refused');
|
|
64
|
+
(0, chai_1.expect)(allowed('pstree')).to.have.property('refused');
|
|
65
|
+
(0, chai_1.expect)(allowed('dumpsys batteryproperties')).to.have.property('refused');
|
|
66
|
+
(0, chai_1.expect)(allowed('pm list packagesx')).to.have.property('refused');
|
|
67
|
+
});
|
|
68
|
+
it('refuses arguments to a command that takes none', () => {
|
|
69
|
+
(0, chai_1.expect)(allowed('dumpsys battery set level 5')).to.have.property('refused');
|
|
70
|
+
(0, chai_1.expect)(allowed('cat /proc/meminfo /data/system/users/0.xml')).to.have.property('refused');
|
|
71
|
+
(0, chai_1.expect)(allowed('whoami --help')).to.have.property('refused');
|
|
72
|
+
});
|
|
73
|
+
it('refuses an empty command and anything not listed', () => {
|
|
74
|
+
(0, chai_1.expect)(allowed(' ')).to.have.property('refused');
|
|
75
|
+
(0, chai_1.expect)(allowed('reboot')).to.have.property('refused');
|
|
76
|
+
(0, chai_1.expect)(allowed('pm uninstall com.example.app')).to.have.property('refused');
|
|
77
|
+
(0, chai_1.expect)(allowed('cat /data/system/users/0.xml')).to.have.property('refused');
|
|
78
|
+
});
|
|
79
|
+
it('keeps every command the Android allow-list offered usable', () => {
|
|
80
|
+
for (const cmd of [
|
|
81
|
+
'ls',
|
|
82
|
+
'ps -A',
|
|
83
|
+
'top -n 1',
|
|
84
|
+
'dumpsys battery',
|
|
85
|
+
'dumpsys wifi',
|
|
86
|
+
'dumpsys power',
|
|
87
|
+
'whoami',
|
|
88
|
+
'getprop',
|
|
89
|
+
'pm list packages',
|
|
90
|
+
'ip addr',
|
|
91
|
+
'cat /proc/meminfo',
|
|
92
|
+
'cat /proc/cpuinfo',
|
|
93
|
+
'date',
|
|
94
|
+
'uptime',
|
|
95
|
+
'netstat -tn',
|
|
96
|
+
]) {
|
|
97
|
+
(0, chai_1.expect)(allowed(cmd), cmd).to.have.property('argv');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it("lets a simulator's generic commands take no arguments", () => {
|
|
101
|
+
(0, chai_1.expect)((0, shellCommands_1.parseShellCommand)('ls', shellCommands_1.IOS_SIMULATOR_COMMANDS)).to.have.property('argv');
|
|
102
|
+
(0, chai_1.expect)((0, shellCommands_1.parseShellCommand)('ls /Users', shellCommands_1.IOS_SIMULATOR_COMMANDS)).to.have.property('refused');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('AndroidDeviceManager.executeShell', () => {
|
|
106
|
+
afterEach(() => sinon_1.default.restore());
|
|
107
|
+
function managerWithAdb() {
|
|
108
|
+
const manager = new AndroidDeviceManager_1.default({});
|
|
109
|
+
const adbExec = sinon_1.default.stub().resolves('ok');
|
|
110
|
+
sinon_1.default.stub(manager, 'getAdb').resolves({ adbInstance: { adbExec } });
|
|
111
|
+
return { manager, adbExec };
|
|
112
|
+
}
|
|
113
|
+
it('passes an allowed command to adb shell word by word', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
114
|
+
const { manager, adbExec } = managerWithAdb();
|
|
115
|
+
(0, chai_1.expect)(yield manager.executeShell('R5CT32ABCDE', 'getprop ro.product.model')).to.equal('ok');
|
|
116
|
+
(0, chai_1.expect)(adbExec.firstCall.args[0]).to.deep.equal([
|
|
117
|
+
'-s',
|
|
118
|
+
'R5CT32ABCDE',
|
|
119
|
+
'shell',
|
|
120
|
+
'getprop',
|
|
121
|
+
'ro.product.model',
|
|
122
|
+
]);
|
|
123
|
+
}));
|
|
124
|
+
it('never reaches adb for a refused command', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
125
|
+
const { manager, adbExec } = managerWithAdb();
|
|
126
|
+
for (const cmd of ['ls; reboot', 'lsof', 'dumpsys battery set level 5']) {
|
|
127
|
+
const err = yield manager.executeShell('R5CT32ABCDE', cmd).then(() => null, (e) => e);
|
|
128
|
+
(0, chai_1.expect)(err, cmd).to.be.an('Error');
|
|
129
|
+
(0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message, cmd).to.match(/not allowed/);
|
|
130
|
+
}
|
|
131
|
+
(0, chai_1.expect)(adbExec.called).to.equal(false);
|
|
132
|
+
}));
|
|
133
|
+
});
|
|
@@ -353,6 +353,8 @@ describe('Auth disabled: the lab path looks nothing up', () => {
|
|
|
353
353
|
const row = { udid: 'phone-a', host: 'h', teamId: 'team-a' };
|
|
354
354
|
sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
|
|
355
355
|
removeDevices: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }),
|
|
356
|
+
// removeDevice reads the row for the device_offline webhook's name and platform.
|
|
357
|
+
findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
356
358
|
addDevices: () => __awaiter(void 0, void 0, void 0, function* () { return [row]; }),
|
|
357
359
|
updateDevice: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }),
|
|
358
360
|
getDevices: () => __awaiter(void 0, void 0, void 0, function* () { return [Object.assign(Object.assign({}, row), { sessionStartTime: 0, totalUtilizationTimeMilliSec: 0 })]; }),
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
36
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
37
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
38
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
39
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
40
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
41
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
require("reflect-metadata");
|
|
49
|
+
const chai_1 = require("chai");
|
|
50
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
51
|
+
const express_1 = __importDefault(require("express"));
|
|
52
|
+
const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
|
|
53
|
+
const typedi_1 = require("typedi");
|
|
54
|
+
const control_1 = __importDefault(require("../../src/app/routers/control"));
|
|
55
|
+
const device_store_1 = require("../../src/data-service/device-store");
|
|
56
|
+
const deviceService = __importStar(require("../../src/data-service/device-service"));
|
|
57
|
+
const recording_store_1 = require("../../src/services/recording/recording-store");
|
|
58
|
+
const IOSStreamService_1 = __importDefault(require("../../src/device-managers/ios/IOSStreamService"));
|
|
59
|
+
const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
|
|
60
|
+
const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
|
|
61
|
+
const PluginContext_1 = require("../../src/PluginContext");
|
|
62
|
+
const container_registration_1 = require("../helpers/container-registration");
|
|
63
|
+
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
64
|
+
const own_node_id_1 = require("../helpers/own-node-id");
|
|
65
|
+
/**
|
|
66
|
+
* Which capture pipelines POST /control/:udid/stream/start runs.
|
|
67
|
+
*
|
|
68
|
+
* With `streaming.androidH264` on, the start ran the H.264 service (scrcpy),
|
|
69
|
+
* and device control's `<img>` then asked GET /stream, which ran the screencap
|
|
70
|
+
* MJPEG loop beside it: two captures of one phone, the very thing the comment
|
|
71
|
+
* in the handler says must not happen. Device control, and any browser that
|
|
72
|
+
* cannot decode H.264 (WebCodecs exists only in a secure context, so plain
|
|
73
|
+
* http://hub:4723 has none), shows MJPEG, so it says so when it starts the
|
|
74
|
+
* stream (`player: 'mjpeg'`), and the server then runs MJPEG only.
|
|
75
|
+
*/
|
|
76
|
+
const UDID = 'DEV-1';
|
|
77
|
+
function buildApp() {
|
|
78
|
+
const app = (0, express_1.default)();
|
|
79
|
+
app.use(express_1.default.json());
|
|
80
|
+
const apiRouter = express_1.default.Router();
|
|
81
|
+
apiRouter.use((req, _res, next) => {
|
|
82
|
+
req.auth = {
|
|
83
|
+
kind: 'user-session',
|
|
84
|
+
userId: 'usr_alice',
|
|
85
|
+
role: 'ADMIN',
|
|
86
|
+
scopes: 'admin,devices,sessions,read',
|
|
87
|
+
rateLimit: 100,
|
|
88
|
+
};
|
|
89
|
+
next();
|
|
90
|
+
});
|
|
91
|
+
control_1.default.register(apiRouter);
|
|
92
|
+
app.use('/xenon/api', apiRouter);
|
|
93
|
+
return app;
|
|
94
|
+
}
|
|
95
|
+
describe('POST /control/:udid/stream/start: which capture runs', () => {
|
|
96
|
+
(0, own_node_id_1.useOwnNodeId)();
|
|
97
|
+
const loopback = (0, loopbackServer_1.loopbackServers)();
|
|
98
|
+
let restoreContainer;
|
|
99
|
+
let savedArgs;
|
|
100
|
+
let mjpegStart;
|
|
101
|
+
let h264Start;
|
|
102
|
+
let h264Stop;
|
|
103
|
+
let h264Running;
|
|
104
|
+
let row;
|
|
105
|
+
const flag = (androidH264) => {
|
|
106
|
+
typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs = Object.assign(Object.assign({}, savedArgs), { streaming: { androidH264 } });
|
|
107
|
+
};
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
restoreContainer = (0, container_registration_1.saveRegistrations)(AndroidStreamService_1.default, AndroidH264StreamService_1.default, IOSStreamService_1.default, recording_store_1.RecordingStore);
|
|
110
|
+
savedArgs = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs;
|
|
111
|
+
row = {
|
|
112
|
+
udid: UDID,
|
|
113
|
+
host: '127.0.0.1',
|
|
114
|
+
nodeId: own_node_id_1.OWN_NODE_ID,
|
|
115
|
+
platform: 'android',
|
|
116
|
+
busy: false,
|
|
117
|
+
session_id: null,
|
|
118
|
+
screenWidth: '1080',
|
|
119
|
+
screenHeight: '2220',
|
|
120
|
+
};
|
|
121
|
+
sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({ findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return row; }) });
|
|
122
|
+
sinon_1.default.stub(deviceService, 'blockDevice').resolves();
|
|
123
|
+
h264Running = false;
|
|
124
|
+
mjpegStart = sinon_1.default.stub().resolves({ mjpegPort: 9100 });
|
|
125
|
+
h264Start = sinon_1.default.stub().callsFake(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
|
+
h264Running = true;
|
|
127
|
+
return {};
|
|
128
|
+
}));
|
|
129
|
+
h264Stop = sinon_1.default.stub().callsFake(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
130
|
+
h264Running = false;
|
|
131
|
+
}));
|
|
132
|
+
typedi_1.Container.set(AndroidStreamService_1.default, {
|
|
133
|
+
startStream: mjpegStart,
|
|
134
|
+
getStreamStatus: () => undefined,
|
|
135
|
+
});
|
|
136
|
+
typedi_1.Container.set(IOSStreamService_1.default, { getStreamStatus: () => undefined });
|
|
137
|
+
typedi_1.Container.set(AndroidH264StreamService_1.default, {
|
|
138
|
+
start: h264Start,
|
|
139
|
+
stop: h264Stop,
|
|
140
|
+
getMultiplexer: () => (h264Running ? { clientCount: 0 } : undefined),
|
|
141
|
+
});
|
|
142
|
+
typedi_1.Container.set(recording_store_1.RecordingStore, { isRecording: () => __awaiter(void 0, void 0, void 0, function* () { return false; }) });
|
|
143
|
+
});
|
|
144
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
145
|
+
sinon_1.default.restore();
|
|
146
|
+
typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs = savedArgs;
|
|
147
|
+
restoreContainer();
|
|
148
|
+
yield loopback.closeAll();
|
|
149
|
+
}));
|
|
150
|
+
const start = (body) => __awaiter(void 0, void 0, void 0, function* () {
|
|
151
|
+
return (0, loopbackRequest_1.default)(yield loopback.serve(buildApp()))
|
|
152
|
+
.post(`/xenon/api/control/${UDID}/stream/start`)
|
|
153
|
+
.send(body !== null && body !== void 0 ? body : {});
|
|
154
|
+
});
|
|
155
|
+
it('runs the H.264 service alone when the flag is on and the page can play it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
156
|
+
flag(true);
|
|
157
|
+
const res = yield start();
|
|
158
|
+
(0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
|
|
159
|
+
(0, chai_1.expect)(res.body.type).to.equal('h264');
|
|
160
|
+
(0, chai_1.expect)(res.body.h264Path).to.equal(`/xenon/api/control/${UDID}/stream/h264`);
|
|
161
|
+
(0, chai_1.expect)(h264Start.calledOnce).to.equal(true);
|
|
162
|
+
(0, chai_1.expect)(mjpegStart.called).to.equal(false);
|
|
163
|
+
}));
|
|
164
|
+
it('runs the MJPEG capture alone when the page says it shows MJPEG', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
165
|
+
flag(true);
|
|
166
|
+
const res = yield start({ player: 'mjpeg' });
|
|
167
|
+
(0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
|
|
168
|
+
(0, chai_1.expect)(res.body.type).to.equal('mjpeg');
|
|
169
|
+
(0, chai_1.expect)(res.body.streamUrl).to.equal(`/xenon/api/control/${UDID}/stream`);
|
|
170
|
+
(0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
|
|
171
|
+
(0, chai_1.expect)(h264Start.called).to.equal(false);
|
|
172
|
+
}));
|
|
173
|
+
it('ends an H.264 capture already running for the phone, so the two never overlap', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
174
|
+
flag(true);
|
|
175
|
+
h264Running = true;
|
|
176
|
+
yield start({ player: 'mjpeg' });
|
|
177
|
+
(0, chai_1.expect)(h264Stop.calledOnceWith(UDID)).to.equal(true);
|
|
178
|
+
(0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
|
|
179
|
+
(0, chai_1.expect)(h264Running).to.equal(false);
|
|
180
|
+
}));
|
|
181
|
+
it('stops the H.264 capture before it starts the MJPEG one', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
182
|
+
flag(true);
|
|
183
|
+
h264Running = true;
|
|
184
|
+
yield start({ player: 'mjpeg' });
|
|
185
|
+
(0, chai_1.expect)(h264Stop.calledBefore(mjpegStart)).to.equal(true);
|
|
186
|
+
}));
|
|
187
|
+
it('runs MJPEG alone, and stops nothing, when the flag is off', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
188
|
+
flag(false);
|
|
189
|
+
const res = yield start();
|
|
190
|
+
(0, chai_1.expect)(res.body.type).to.equal('mjpeg');
|
|
191
|
+
(0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
|
|
192
|
+
(0, chai_1.expect)(h264Start.called).to.equal(false);
|
|
193
|
+
(0, chai_1.expect)(h264Stop.called).to.equal(false);
|
|
194
|
+
}));
|
|
195
|
+
it('ignores a player it does not know, and uses the server’s choice', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
196
|
+
flag(true);
|
|
197
|
+
const res = yield start({ player: 'hologram' });
|
|
198
|
+
(0, chai_1.expect)(res.body.type).to.equal('h264');
|
|
199
|
+
(0, chai_1.expect)(h264Start.calledOnce).to.equal(true);
|
|
200
|
+
(0, chai_1.expect)(mjpegStart.called).to.equal(false);
|
|
201
|
+
}));
|
|
202
|
+
it('keeps MJPEG for a phone that is being recorded, as before', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
203
|
+
flag(true);
|
|
204
|
+
typedi_1.Container.set(recording_store_1.RecordingStore, { isRecording: () => __awaiter(void 0, void 0, void 0, function* () { return true; }) });
|
|
205
|
+
const res = yield start();
|
|
206
|
+
(0, chai_1.expect)(res.body.type).to.equal('mjpeg');
|
|
207
|
+
(0, chai_1.expect)(mjpegStart.calledOnce).to.equal(true);
|
|
208
|
+
(0, chai_1.expect)(h264Start.called).to.equal(false);
|
|
209
|
+
}));
|
|
210
|
+
});
|
|
@@ -20,4 +20,8 @@ describe('resolveStreamType', () => {
|
|
|
20
20
|
(0, chai_1.expect)((0, streamType_1.resolveStreamType)('android', true, false)).to.equal('h264');
|
|
21
21
|
(0, chai_1.expect)((0, streamType_1.resolveStreamType)('androidtv', true, false)).to.equal('h264');
|
|
22
22
|
});
|
|
23
|
+
it('a page that shows MJPEG => mjpeg, whatever the flag', () => {
|
|
24
|
+
(0, chai_1.expect)((0, streamType_1.resolveStreamType)('android', true, false, false)).to.equal('mjpeg');
|
|
25
|
+
(0, chai_1.expect)((0, streamType_1.resolveStreamType)('android', true, false, true)).to.equal('h264');
|
|
26
|
+
});
|
|
23
27
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
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 VisionAssertionService_1 = require("../../src/services/omni-vision/VisionAssertionService");
|
|
19
|
+
const AIService_1 = require("../../src/services/AIService");
|
|
20
|
+
/**
|
|
21
|
+
* `xenon: assertVisualState` answered `{ result: true, message: 'Assertion
|
|
22
|
+
* placeholder' }` whenever the AI step gave it nothing usable: with no AI
|
|
23
|
+
* provider, when the provider failed, and usually even with one, because the
|
|
24
|
+
* condition was wrapped in a "find the centre coordinates, answer {x, y}"
|
|
25
|
+
* prompt. A test asserting a screen state passed without anything being
|
|
26
|
+
* checked.
|
|
27
|
+
*
|
|
28
|
+
* Now it asks the provider a yes/no question of the screenshot and answers
|
|
29
|
+
* with the provider's verdict. Whenever there is no verdict (no provider, a
|
|
30
|
+
* failed call, an answer that isn't true or false, no screenshot, no
|
|
31
|
+
* condition) it fails with an error the test sees, never `true` and never
|
|
32
|
+
* `false`: a `false` would pass a test that asserts a state is absent.
|
|
33
|
+
*/
|
|
34
|
+
describe('xenon: assertVisualState', () => {
|
|
35
|
+
const SCREEN = 'c2NyZWVuc2hvdA==';
|
|
36
|
+
let service;
|
|
37
|
+
let driver;
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
service = new VisionAssertionService_1.VisionAssertionService();
|
|
40
|
+
driver = { getScreenshot: sinon_1.default.stub().resolves(SCREEN) };
|
|
41
|
+
// The real provider set-up reads the server's AI settings; these tests set the provider themselves.
|
|
42
|
+
sinon_1.default.stub(AIService_1.AI_SERVICE, 'initializeProvider');
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => sinon_1.default.restore());
|
|
45
|
+
const failureOf = (p) => p.then((value) => {
|
|
46
|
+
throw new Error(`expected a failure, got ${JSON.stringify(value)}`);
|
|
47
|
+
}, (e) => e);
|
|
48
|
+
function withProvider(answer) {
|
|
49
|
+
sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value({ analyze: () => __awaiter(this, void 0, void 0, function* () { return ''; }) });
|
|
50
|
+
const call = sinon_1.default.stub(AIService_1.AI_SERVICE, 'callProvider');
|
|
51
|
+
if (answer instanceof Error)
|
|
52
|
+
call.rejects(answer);
|
|
53
|
+
else
|
|
54
|
+
call.resolves(answer);
|
|
55
|
+
return call;
|
|
56
|
+
}
|
|
57
|
+
it('fails, rather than passing, when no AI provider is configured', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
58
|
+
sinon_1.default.stub(AIService_1.AI_SERVICE, 'provider').value(null);
|
|
59
|
+
const err = yield failureOf(service.assertState(driver, 'The Login button is visible'));
|
|
60
|
+
(0, chai_1.expect)(err.message).to.match(/No AI provider is configured/);
|
|
61
|
+
(0, chai_1.expect)(err.message).to.match(/not checked/);
|
|
62
|
+
}));
|
|
63
|
+
it('asks a yes/no question about the condition, with the screenshot', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
64
|
+
const call = withProvider('{"result": true, "reason": "A Login button is shown."}');
|
|
65
|
+
const answer = yield service.assertState(driver, 'The Login button is visible');
|
|
66
|
+
(0, chai_1.expect)(answer).to.deep.equal({ result: true, message: 'A Login button is shown.' });
|
|
67
|
+
(0, chai_1.expect)(call.calledOnce).to.equal(true);
|
|
68
|
+
const [prompt, image] = call.firstCall.args;
|
|
69
|
+
(0, chai_1.expect)(image).to.equal(SCREEN);
|
|
70
|
+
(0, chai_1.expect)(prompt).to.include('The Login button is visible');
|
|
71
|
+
(0, chai_1.expect)(prompt).to.match(/true or false/i);
|
|
72
|
+
(0, chai_1.expect)(prompt).to.not.match(/coordinates/i);
|
|
73
|
+
}));
|
|
74
|
+
it("answers false when the provider says the condition doesn't hold", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
75
|
+
withProvider('```json\n{"result": false, "reason": "The cart shows 2 items."}\n```');
|
|
76
|
+
(0, chai_1.expect)(yield service.assertState(driver, 'The cart is empty')).to.deep.equal({
|
|
77
|
+
result: false,
|
|
78
|
+
message: 'The cart shows 2 items.',
|
|
79
|
+
});
|
|
80
|
+
}));
|
|
81
|
+
it("fails when the provider's answer is not a true/false verdict", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
+
for (const answer of ['{"x": 120, "y": 340}', 'Yes, I can see it.', '{"result": "maybe"}']) {
|
|
83
|
+
sinon_1.default.restore();
|
|
84
|
+
sinon_1.default.stub(AIService_1.AI_SERVICE, 'initializeProvider');
|
|
85
|
+
driver = { getScreenshot: sinon_1.default.stub().resolves(SCREEN) };
|
|
86
|
+
withProvider(answer);
|
|
87
|
+
const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
|
|
88
|
+
(0, chai_1.expect)(err.message, answer).to.match(/could not read/i);
|
|
89
|
+
}
|
|
90
|
+
}));
|
|
91
|
+
it('fails when the provider call fails', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
92
|
+
withProvider(new Error('429 quota exceeded'));
|
|
93
|
+
const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
|
|
94
|
+
(0, chai_1.expect)(err.message).to.match(/429 quota exceeded/);
|
|
95
|
+
}));
|
|
96
|
+
it('fails when the provider is rate-limited', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
97
|
+
// Gemini's provider answers this instead of throwing on a 429.
|
|
98
|
+
withProvider('CONNECTION_OK_RATE_LIMITED');
|
|
99
|
+
const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
|
|
100
|
+
(0, chai_1.expect)(err.message).to.match(/rate-limited/i);
|
|
101
|
+
}));
|
|
102
|
+
it('fails without asking when there is no condition', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
103
|
+
const call = withProvider('{"result": true, "reason": "ok"}');
|
|
104
|
+
for (const empty of ['', ' ', undefined]) {
|
|
105
|
+
const err = yield failureOf(service.assertState(driver, empty));
|
|
106
|
+
(0, chai_1.expect)(err.message).to.match(/needs a condition/);
|
|
107
|
+
}
|
|
108
|
+
(0, chai_1.expect)(call.called).to.equal(false);
|
|
109
|
+
(0, chai_1.expect)(driver.getScreenshot.called).to.equal(false);
|
|
110
|
+
}));
|
|
111
|
+
it('fails when no screenshot can be taken', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
112
|
+
const call = withProvider('{"result": true, "reason": "ok"}');
|
|
113
|
+
driver.getScreenshot.rejects(new Error('WDA is not running'));
|
|
114
|
+
const err = yield failureOf(service.assertState(driver, 'The cart is empty'));
|
|
115
|
+
(0, chai_1.expect)(err.message).to.match(/WDA is not running/);
|
|
116
|
+
(0, chai_1.expect)(call.called).to.equal(false);
|
|
117
|
+
}));
|
|
118
|
+
});
|