@xenon-device-management/xenon 2.2.0 → 2.4.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/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-XpewX5nn.js → AnnotationOverlay-CpoWcXKP.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BQDv3ZCC.js → ApiKeyGate-DAYuoJDl.js} +1 -1
- package/lib/public/assets/{BugReportButton-C7OmS61B.js → BugReportButton-DEXyENUv.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-C-sJRz63.js +2 -0
- package/lib/public/assets/{EmptyState-fPxujqqE.js → EmptyState-B_JRYedM.js} +1 -1
- package/lib/public/assets/{FieldGroup-5bCutWrs.js → FieldGroup-CorXlSLU.js} +1 -1
- package/lib/public/assets/{FilterMenu-owsBiDT1.js → FilterMenu-BKv95DEc.js} +1 -1
- package/lib/public/assets/{Menu-DFhqzPSt.js → Menu-Cpy5yMwg.js} +1 -1
- package/lib/public/assets/{Modal-Bw-a-E-e.js → Modal-Djp4NvyZ.js} +1 -1
- package/lib/public/assets/{RecordingPage-WtByf5bC.js → RecordingPage-CP57OKw9.js} +1 -1
- package/lib/public/assets/{RecordingsPage-FpWWFNgT.js → RecordingsPage-BjrTit38.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Jzn9c7o8.js → SegmentedControl-MGTzJhBf.js} +1 -1
- package/lib/public/assets/{SettingCard-BR7nOhpT.js → SettingCard-CYBPhKPe.js} +1 -1
- package/lib/public/assets/{Table-B_Va3V05.js → Table-CdKQ9hnU.js} +1 -1
- package/lib/public/assets/{activity-DxRFWvkN.js → activity-QsRK21QF.js} +1 -1
- package/lib/public/assets/{ai-settings-CQf-9_V6.js → ai-settings-Dn6DtRn7.js} +1 -1
- package/lib/public/assets/{api-keys-SoE1Kd7N.js → api-keys-DYyf17eU.js} +1 -1
- package/lib/public/assets/{apps-CgGXTlgn.js → apps-HKFKTe1J.js} +1 -1
- package/lib/public/assets/{arrow-left-uQ6WIwiC.js → arrow-left-BkA7oMRe.js} +1 -1
- package/lib/public/assets/{arrow-right-C9RyfzXS.js → arrow-right-BsSr58EU.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DFHi_8yG.js → arrow-up-right-B7TEV_Gr.js} +1 -1
- package/lib/public/assets/{auth-shell-Bii1t4qc.js → auth-shell-BlmNHGcG.js} +2 -2
- package/lib/public/assets/{builds-page-Bnv8Z9hi.js → builds-page-E4WIKtl8.js} +1 -1
- package/lib/public/assets/{button-_Y78wXiP.js → button-BIwCF2bR.js} +1 -1
- package/lib/public/assets/{calendar-D4MqSOc2.js → calendar-CMLnXC7N.js} +1 -1
- package/lib/public/assets/{check-uGHCXe-L.js → check-zugO3p5U.js} +1 -1
- package/lib/public/assets/{chevron-left-Dy1OqpzB.js → chevron-left-sCqxpTY6.js} +1 -1
- package/lib/public/assets/{chevron-right-BUsldEli.js → chevron-right-Bb59xwZc.js} +1 -1
- package/lib/public/assets/{circle-check-D3Qq0tKp.js → circle-check-DsvxbnTg.js} +1 -1
- package/lib/public/assets/{circle-x-B4IOD1DE.js → circle-x-w69AbmbW.js} +1 -1
- package/lib/public/assets/{clock-BfYfzvSg.js → clock-C6XOs4bc.js} +1 -1
- package/lib/public/assets/{copy-DZwYE5Va.js → copy-pHk-SWBM.js} +1 -1
- package/lib/public/assets/{device-explorer-CRG4Utt5.js → device-explorer-BDq38IcO.js} +52 -52
- package/lib/public/assets/{download-CCDYo_TN.js → download-CAHtPZmY.js} +1 -1
- package/lib/public/assets/{forgot-password-DuLCmVFb.js → forgot-password-cmvhGOUu.js} +1 -1
- package/lib/public/assets/{funnel-CtKNPqzb.js → funnel-BrDuhOtM.js} +1 -1
- package/lib/public/assets/{index-3uxl3o7K.js → index-BQCXPOXt.js} +2 -2
- package/lib/public/assets/{index-Zy1Op5C1.js → index-C-Q4QkXb.js} +1 -1
- package/lib/public/assets/{input-CzTWvUXZ.js → input-DnUVHYax.js} +1 -1
- package/lib/public/assets/{keycodes-CI-nYDFM.js → keycodes-CmOptFVa.js} +4 -4
- package/lib/public/assets/{layers-YZZGFyTX.js → layers-DRtz_bzF.js} +1 -1
- package/lib/public/assets/{lock-L8tzWgDo.js → lock-DF2VnPFm.js} +1 -1
- package/lib/public/assets/{login-DBbX-fu8.js → login-DHYffYa4.js} +1 -1
- package/lib/public/assets/{maintenance-settings-AJwkOIsE.js → maintenance-settings-r0uPsJ6n.js} +1 -1
- package/lib/public/assets/{monitor-Di_66F2m.js → monitor-7nt9oGNx.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-Cnab2zdg.js → mouse-pointer-2-ys1MKVr2.js} +1 -1
- package/lib/public/assets/{network-DiSumY-1.js → network-B8m-F79k.js} +1 -1
- package/lib/public/assets/{overview-CwE4gXl5.js → overview-BHEntlXz.js} +1 -1
- package/lib/public/assets/{page-header-CfR4Sm9V.js → page-header-CuFXVUop.js} +1 -1
- package/lib/public/assets/{plus-DOS8P9CQ.js → plus-DUSlMac2.js} +1 -1
- package/lib/public/assets/{profile-page-BYpHwQIH.js → profile-page-CBamPy_h.js} +1 -1
- package/lib/public/assets/{reset-password-BRVJU_qV.js → reset-password-DG0vZl-o.js} +1 -1
- package/lib/public/assets/{runbook-page-Bi3JvAFZ.js → runbook-page-5S3T-I4g.js} +1 -1
- package/lib/public/assets/{select-Cqx7pEou.js → select-f0ci1QC7.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C0DsncKj.js → selector-detail-page-BXA9LUf9.js} +1 -1
- package/lib/public/assets/{selector-health-CAhpdilF.js → selector-health-TyMPgAWo.js} +1 -1
- package/lib/public/assets/{selector-health-page-CrCVeW2m.js → selector-health-page-uBlXy33G.js} +1 -1
- package/lib/public/assets/{session-detail-page-F_hwYqEt.js → session-detail-page-DECim5lh.js} +1 -1
- package/lib/public/assets/{settings-C-s5pypL.js → settings-IfOFbqtv.js} +1 -1
- package/lib/public/assets/{status-pill-outline-DY5uzv6A.js → status-pill-outline-grXLf8I8.js} +1 -1
- package/lib/public/assets/{tablet-Dli7gwyg.js → tablet-lmFk4qxW.js} +1 -1
- package/lib/public/assets/{teams-ui4IhHNC.js → teams-CwMSjVVW.js} +1 -1
- package/lib/public/assets/{trash-2-BV4hSmXJ.js → trash-2-BKsgmBS6.js} +1 -1
- package/lib/public/assets/{upload-DkGy_J-L.js → upload-DXH47sQS.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BFtMqQOh.js → use-password-reset-mode-DU98dxpc.js} +1 -1
- package/lib/public/assets/{users-Nb_rzRmF.js → users-V5O64rVx.js} +1 -1
- package/lib/public/assets/{users-BU0nXIVe.js → users-eXWwQZjX.js} +1 -1
- package/lib/public/assets/{video-off-CUNzD2kH.js → video-off-BuoIHEYy.js} +1 -1
- package/lib/public/assets/{webhook-settings-Ca3UFXZe.js → webhook-settings-ClsxsKnC.js} +1 -1
- package/lib/public/assets/{zap-B2bH2vKX.js → zap-I10vdQXd.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +114 -61
- package/lib/src/app/routers/nodePhoneControl.js +288 -46
- package/lib/src/data-service/device-store.js +7 -3
- package/lib/src/data-service/deviceClaims.js +14 -0
- package/lib/src/data-service/deviceFieldOwners.js +3 -1
- package/lib/src/data-service/prisma-store.js +3 -3
- package/lib/src/device-managers/AndroidDeviceManager.js +11 -1
- package/lib/src/gateway/forwardToNode.js +10 -3
- package/lib/src/generated/client/edge.js +5 -4
- package/lib/src/generated/client/index-browser.js +2 -1
- package/lib/src/generated/client/index.d.ts +36 -1
- package/lib/src/generated/client/index.js +5 -4
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +4 -0
- package/lib/src/generated/client/wasm.js +2 -1
- package/lib/src/services/recording/RecordingOrchestrator.js +39 -5
- package/lib/src/services/recording/busy-precheck.js +5 -2
- package/lib/src/services/recording/nodeRecordingSource.js +156 -0
- package/lib/test/unit/AndroidDeviceManager.spec.js +12 -0
- package/lib/test/unit/busy-precheck.spec.js +32 -0
- package/lib/test/unit/hub-node-control-forward.spec.js +66 -10
- package/lib/test/unit/hub-node-race.spec.js +29 -3
- package/lib/test/unit/mark-node-busy-loki.spec.js +17 -4
- package/lib/test/unit/node-phone-control.spec.js +124 -4
- package/lib/test/unit/node-recording-relay.spec.js +165 -0
- package/lib/test/unit/recording-orchestrator.spec.js +86 -2
- package/lib/test/unit/screenshot-route.spec.js +104 -0
- package/lib/test/unit/upload-install-route.spec.js +205 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
- package/prisma/schema.prisma +4 -0
- package/lib/public/assets/DeviceMosaicView-wl3l2Zlt.js +0 -2
|
@@ -204,6 +204,10 @@ model Device {
|
|
|
204
204
|
// On a hub, what the phone's node last reported: busy there. `busy` is the
|
|
205
205
|
// claim OR this. Always false for a phone this server drives.
|
|
206
206
|
nodeBusy Boolean? @default(false)
|
|
207
|
+
// On a hub, who holds the phone there: the preview hold (manual_<user>_<udid>)
|
|
208
|
+
// in the node's report of its session_id, else null. Shown, never judged:
|
|
209
|
+
// the node decides access. Always null for a phone this server drives.
|
|
210
|
+
nodeHold String?
|
|
207
211
|
|
|
208
212
|
@@id([udid, host])
|
|
209
213
|
@@index([owningSessionId])
|
|
@@ -285,7 +285,8 @@ exports.Prisma.DeviceScalarFieldEnum = {
|
|
|
285
285
|
teamId: 'teamId',
|
|
286
286
|
claimSessionId: 'claimSessionId',
|
|
287
287
|
claimedAt: 'claimedAt',
|
|
288
|
-
nodeBusy: 'nodeBusy'
|
|
288
|
+
nodeBusy: 'nodeBusy',
|
|
289
|
+
nodeHold: 'nodeHold'
|
|
289
290
|
};
|
|
290
291
|
|
|
291
292
|
exports.Prisma.PendingSessionScalarFieldEnum = {
|
|
@@ -70,6 +70,7 @@ const concurrency_gate_1 = require("./concurrency-gate");
|
|
|
70
70
|
const device_service_1 = require("../../data-service/device-service");
|
|
71
71
|
const device_store_1 = require("../../data-service/device-store");
|
|
72
72
|
const manualLock_1 = require("./manualLock");
|
|
73
|
+
const nodeRecordingSource_1 = require("./nodeRecordingSource");
|
|
73
74
|
const ensureMjpegForRecording_1 = require("./ensureMjpegForRecording");
|
|
74
75
|
const probeDuration_1 = require("./probeDuration");
|
|
75
76
|
const annotationImage_1 = require("./annotationImage");
|
|
@@ -136,13 +137,15 @@ class RecordingError extends Error {
|
|
|
136
137
|
exports.RecordingError = RecordingError;
|
|
137
138
|
let RecordingOrchestrator = class RecordingOrchestrator {
|
|
138
139
|
constructor(deps = {}) {
|
|
139
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
140
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
140
141
|
/**
|
|
141
142
|
* Recording ids currently being closed out. Both the user-initiated stop and
|
|
142
143
|
* an ffmpeg self-exit finalize rows, and they can land at the same instant —
|
|
143
144
|
* this keeps exactly one of them doing it.
|
|
144
145
|
*/
|
|
145
146
|
this.finalizing = new Set();
|
|
147
|
+
/** The relays node phones' recordings read, by recording id, until finalized. */
|
|
148
|
+
this.nodeSources = new Map();
|
|
146
149
|
this._deps = {
|
|
147
150
|
busyPrecheck: (_a = deps.busyPrecheck) !== null && _a !== void 0 ? _a : typedi_1.Container.get(busy_precheck_1.BusyPrecheck),
|
|
148
151
|
store: (_b = deps.store) !== null && _b !== void 0 ? _b : typedi_1.Container.get(recording_store_1.RecordingStore),
|
|
@@ -153,8 +156,9 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
153
156
|
this.blockDeviceFn = (_f = deps.blockDeviceFn) !== null && _f !== void 0 ? _f : device_service_1.blockDevice;
|
|
154
157
|
this.unblockDeviceFn = (_g = deps.unblockDeviceFn) !== null && _g !== void 0 ? _g : device_service_1.unblockDevice;
|
|
155
158
|
this.ensureMjpegPortFn = (_h = deps.ensureMjpegPortFn) !== null && _h !== void 0 ? _h : ensureMjpegForRecording_1.ensureMjpegForRecording;
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
159
|
+
this.nodeSourceFn = (_j = deps.nodeSourceFn) !== null && _j !== void 0 ? _j : ((udid, actorId) => (0, nodeRecordingSource_1.recordingSourceFor)(udid, actorId));
|
|
160
|
+
this.probeDurationMsFn = (_k = deps.probeDurationMsFn) !== null && _k !== void 0 ? _k : probeDuration_1.probeVideoDurationMs;
|
|
161
|
+
this.now = (_l = deps.now) !== null && _l !== void 0 ? _l : Date.now;
|
|
158
162
|
}
|
|
159
163
|
get busyPrecheck() {
|
|
160
164
|
return this._deps.busyPrecheck;
|
|
@@ -267,7 +271,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
267
271
|
const host = deviceHosts[udid];
|
|
268
272
|
const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(id, 'video', `${id}.mp4`);
|
|
269
273
|
try {
|
|
270
|
-
const mjpegPort = yield this.
|
|
274
|
+
const mjpegPort = yield this.sourcePort(id, udid, actorId);
|
|
271
275
|
mjpegPorts[udid] = mjpegPort;
|
|
272
276
|
yield this.store.create({
|
|
273
277
|
id,
|
|
@@ -294,6 +298,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
294
298
|
}
|
|
295
299
|
catch (err) {
|
|
296
300
|
recLog.error(`Failed to start recording for ${udid}: ${err === null || err === void 0 ? void 0 : err.message}`);
|
|
301
|
+
yield this.closeNodeSource(id);
|
|
297
302
|
// Mark this one failed but continue with the rest of the group.
|
|
298
303
|
try {
|
|
299
304
|
yield this.store.finalize(id, {
|
|
@@ -392,6 +397,8 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
392
397
|
status = 'FAILED';
|
|
393
398
|
failReason = (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : 'ffmpeg_stop_failed';
|
|
394
399
|
}
|
|
400
|
+
// Its ffmpeg is done reading; a group's composite stops before this.
|
|
401
|
+
yield this.closeNodeSource(r.id);
|
|
395
402
|
// How long the video actually is, not how long the session was open. Those
|
|
396
403
|
// agree only while capture keeps up: a recording whose device died at 26s
|
|
397
404
|
// and was stopped 5 minutes later reported 5m36s for a 35s file (#204).
|
|
@@ -804,7 +811,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
804
811
|
}
|
|
805
812
|
const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(recordingId, 'video', `${recordingId}.mp4`);
|
|
806
813
|
try {
|
|
807
|
-
const mjpegPort = yield this.
|
|
814
|
+
const mjpegPort = yield this.sourcePort(recordingId, udid, actorId);
|
|
808
815
|
yield this.store.create({
|
|
809
816
|
id: recordingId,
|
|
810
817
|
groupId,
|
|
@@ -829,6 +836,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
829
836
|
}
|
|
830
837
|
catch (err) {
|
|
831
838
|
recLog.error(`Failed to add device ${udid} to group ${groupId}: ${err === null || err === void 0 ? void 0 : err.message}`);
|
|
839
|
+
yield this.closeNodeSource(recordingId);
|
|
832
840
|
this.gate.release(recordingId);
|
|
833
841
|
yield this.tryUnblock(udid, host);
|
|
834
842
|
try {
|
|
@@ -940,6 +948,32 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
940
948
|
}
|
|
941
949
|
});
|
|
942
950
|
}
|
|
951
|
+
/**
|
|
952
|
+
* The loopback port a recording reads: this server's own phone's MJPEG
|
|
953
|
+
* stream, or, for another server's phone, a relay to its node's, kept until
|
|
954
|
+
* the recording is finalized (nodeRecordingSource.ts).
|
|
955
|
+
*/
|
|
956
|
+
sourcePort(recordingId, udid, actorId) {
|
|
957
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
958
|
+
const relay = yield this.nodeSourceFn(udid, actorId);
|
|
959
|
+
if (!relay)
|
|
960
|
+
return this.ensureMjpegPortFn(udid);
|
|
961
|
+
this.nodeSources.set(recordingId, relay);
|
|
962
|
+
return relay.port;
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
closeNodeSource(recordingId) {
|
|
966
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
967
|
+
const relay = this.nodeSources.get(recordingId);
|
|
968
|
+
if (!relay)
|
|
969
|
+
return;
|
|
970
|
+
this.nodeSources.delete(recordingId);
|
|
971
|
+
yield relay.close().catch((err) => {
|
|
972
|
+
var _a;
|
|
973
|
+
recLog.warn(`Closing the node relay of ${recordingId} failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
}
|
|
943
977
|
tryUnblock(udid, host) {
|
|
944
978
|
return __awaiter(this, void 0, void 0, function* () {
|
|
945
979
|
try {
|
|
@@ -64,7 +64,7 @@ let BusyPrecheck = class BusyPrecheck {
|
|
|
64
64
|
*/
|
|
65
65
|
findBusy(udids, actorId, actorApiKeyId) {
|
|
66
66
|
return __awaiter(this, void 0, void 0, function* () {
|
|
67
|
-
var _a;
|
|
67
|
+
var _a, _b;
|
|
68
68
|
const store = this.storeProvider();
|
|
69
69
|
const out = [];
|
|
70
70
|
for (const udid of udids) {
|
|
@@ -81,7 +81,10 @@ let BusyPrecheck = class BusyPrecheck {
|
|
|
81
81
|
}
|
|
82
82
|
if (!device.busy)
|
|
83
83
|
continue;
|
|
84
|
-
|
|
84
|
+
// This server's own hold, else, on a hub, a node phone's preview hold
|
|
85
|
+
// its node reports (nodeHold): session_id is the hub's and stays empty
|
|
86
|
+
// for it, which read as unknown and refused the holder their own phone.
|
|
87
|
+
const blockId = (_b = (_a = device.session_id) !== null && _a !== void 0 ? _a : device.nodeHold) !== null && _b !== void 0 ? _b : undefined;
|
|
85
88
|
const lock = (0, manualLock_1.inspectManualLock)(blockId, actorId, udid);
|
|
86
89
|
// Single source of truth for "is this lock mine" — the same helper
|
|
87
90
|
// /control's guard, stream/start and stream/stop use. Checking only
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
exports.NODE_STREAM_READY_TIMEOUT_MS = void 0;
|
|
16
|
+
exports.openNodeMjpegRelay = openNodeMjpegRelay;
|
|
17
|
+
exports.recordingSourceFor = recordingSourceFor;
|
|
18
|
+
const http_1 = __importDefault(require("http"));
|
|
19
|
+
const typedi_1 = require("typedi");
|
|
20
|
+
const logger_1 = __importDefault(require("../../logger"));
|
|
21
|
+
const hubSessionToken_1 = require("../../gateway/hubSessionToken");
|
|
22
|
+
const forwardToNode_1 = require("../../gateway/forwardToNode");
|
|
23
|
+
const nodePhoneControl_1 = require("../../app/routers/nodePhoneControl");
|
|
24
|
+
const controlDevice_1 = require("../../middleware/controlDevice");
|
|
25
|
+
const relayLog = logger_1.default.scope('NodeRecordingSource');
|
|
26
|
+
/**
|
|
27
|
+
* How long a node's phone has to start streaming: an iPhone's WebDriverAgent
|
|
28
|
+
* and tunnel take tens of seconds on a cold start.
|
|
29
|
+
*/
|
|
30
|
+
exports.NODE_STREAM_READY_TIMEOUT_MS = 90000;
|
|
31
|
+
const defaultControlToken = (grant) => typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor(grant);
|
|
32
|
+
/**
|
|
33
|
+
* A node's phone, recorded on this server: a loopback server whose every
|
|
34
|
+
* connection gets the node's MJPEG stream (`GET /control/<udid>/stream`),
|
|
35
|
+
* signed afresh for the recording's user. ffmpeg reads it like one of this
|
|
36
|
+
* server's own phones, and reconnects on any hiccup; the hub's control token
|
|
37
|
+
* lasts a minute and a recording much longer, so each connection signs again.
|
|
38
|
+
* The node counts the connection as a viewer, so its idle release leaves the
|
|
39
|
+
* stream running for as long as the recording reads it. Loopback only, like
|
|
40
|
+
* this server's own phones' MJPEG servers.
|
|
41
|
+
*/
|
|
42
|
+
function openNodeMjpegRelay(target_1) {
|
|
43
|
+
return __awaiter(this, arguments, void 0, function* (target, deps = {}) {
|
|
44
|
+
var _a, _b;
|
|
45
|
+
const { udid, host, actorId } = target;
|
|
46
|
+
const origin = (0, nodePhoneControl_1.safeNodeOrigin)(host);
|
|
47
|
+
if (!origin)
|
|
48
|
+
throw new Error(`Unsafe device host for ${udid}`);
|
|
49
|
+
const controlToken = (_a = deps.controlToken) !== null && _a !== void 0 ? _a : defaultControlToken;
|
|
50
|
+
const url = `${origin}/xenon/api/control/${encodeURIComponent(udid)}/stream`;
|
|
51
|
+
const headers = () => __awaiter(this, void 0, void 0, function* () {
|
|
52
|
+
const token = yield controlToken({ userId: actorId, isAdmin: false, udid, host });
|
|
53
|
+
return Object.assign({ accept: '*/*', 'accept-encoding': 'identity' }, (token ? { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } : {}));
|
|
54
|
+
});
|
|
55
|
+
// As local recording starts the phone's stream before ffmpeg, the node's
|
|
56
|
+
// must be sending frames before the port is handed out. Its GET stream
|
|
57
|
+
// starts the stream; an iPhone's takes seconds. Otherwise the video began
|
|
58
|
+
// late, and every mark on it, timed from ffmpeg's start, was off by that.
|
|
59
|
+
yield untilStreaming(url, yield headers(), (_b = deps.readyTimeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_STREAM_READY_TIMEOUT_MS);
|
|
60
|
+
const server = http_1.default.createServer((_req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
var _a, _b, _c, _d;
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
res.on('close', () => controller.abort());
|
|
64
|
+
try {
|
|
65
|
+
const upstream = yield (0, forwardToNode_1.sendToNode)({
|
|
66
|
+
url,
|
|
67
|
+
method: 'GET',
|
|
68
|
+
headers: yield headers(),
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
const type = upstream.headers['content-type'];
|
|
72
|
+
res.writeHead((_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502, Object.assign(Object.assign({}, (type ? { 'content-type': type } : {})), { 'cache-control': 'no-cache' }));
|
|
73
|
+
upstream.on('error', () => res.destroy());
|
|
74
|
+
upstream.pipe(res);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (res.headersSent) {
|
|
78
|
+
res.destroy();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
relayLog.warn(`[${udid}] node ${origin} stream unavailable: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
|
|
82
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
83
|
+
res.end(`node unreachable: ${(_d = (_c = err === null || err === void 0 ? void 0 : err.code) !== null && _c !== void 0 ? _c : err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : err}`);
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
yield new Promise((resolve, reject) => {
|
|
87
|
+
server.once('error', reject);
|
|
88
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
89
|
+
});
|
|
90
|
+
const { port } = server.address();
|
|
91
|
+
relayLog.info(`[${udid}] recording relay for node ${origin} on 127.0.0.1:${port}`);
|
|
92
|
+
return {
|
|
93
|
+
port,
|
|
94
|
+
close: () => new Promise((resolve) => {
|
|
95
|
+
var _a;
|
|
96
|
+
(_a = server.closeAllConnections) === null || _a === void 0 ? void 0 : _a.call(server);
|
|
97
|
+
server.close(() => resolve());
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolves once the node's stream sends its first bytes; throws when the node
|
|
104
|
+
* refuses it, ends it first, or sends nothing within `timeoutMs`.
|
|
105
|
+
*/
|
|
106
|
+
function untilStreaming(url, headers, timeoutMs) {
|
|
107
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
108
|
+
var _a;
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
let timedOut = false;
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
timedOut = true;
|
|
113
|
+
controller.abort();
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
try {
|
|
116
|
+
const upstream = yield (0, forwardToNode_1.sendToNode)({ url, method: 'GET', headers, signal: controller.signal });
|
|
117
|
+
if (((_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502) !== 200) {
|
|
118
|
+
const answer = yield (0, forwardToNode_1.readAnswer)(upstream);
|
|
119
|
+
throw new Error(`the node answered ${answer.status} for the stream: ${answer.body.toString('utf8').slice(0, 200)}`);
|
|
120
|
+
}
|
|
121
|
+
yield new Promise((resolve, reject) => {
|
|
122
|
+
upstream.once('data', () => resolve());
|
|
123
|
+
upstream.once('end', () => reject(new Error("the node's stream ended before its first frame")));
|
|
124
|
+
upstream.once('error', reject);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (timedOut) {
|
|
129
|
+
throw new Error(`the node's stream sent no first frame within ${Math.round(timeoutMs / 1000)} s`);
|
|
130
|
+
}
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
controller.abort();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The recording source for a phone: a relay to its node for another server's
|
|
141
|
+
* phone, null for this server's own (or an unknown) phone, which the caller
|
|
142
|
+
* reads locally as before. A cloud provider's phone can't be recorded here.
|
|
143
|
+
*/
|
|
144
|
+
function recordingSourceFor(udid_1, actorId_1) {
|
|
145
|
+
return __awaiter(this, arguments, void 0, function* (udid, actorId, deps = {}) {
|
|
146
|
+
var _a;
|
|
147
|
+
const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
|
|
148
|
+
const device = yield findDevice(udid);
|
|
149
|
+
if (!device || !(0, nodePhoneControl_1.isOtherServersPhone)(device))
|
|
150
|
+
return null;
|
|
151
|
+
if (device.cloud) {
|
|
152
|
+
throw new Error(`${udid} is a cloud provider's phone and can't be recorded here.`);
|
|
153
|
+
}
|
|
154
|
+
return openNodeMjpegRelay({ udid, host: device.host, actorId }, deps);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -166,6 +166,18 @@ describe('Android Device Manager', () => {
|
|
|
166
166
|
(0, chai_1.expect)(devices.length).to.be.equal(1);
|
|
167
167
|
(0, chai_1.expect)(devices[0]).to.have.property('udid', 'emulator-7777');
|
|
168
168
|
}));
|
|
169
|
+
// adb install answers when the phone has installed the app, and appium-adb
|
|
170
|
+
// cut every adbExec at 20 s (adbExecTimeout): an install that took longer
|
|
171
|
+
// was reported as failed though it had worked.
|
|
172
|
+
it('gives adb install as long as an install takes, not adbExec’s 20 s', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
173
|
+
const manager = (0, test_container_1.createTestAndroidManager)({ platform: 'android' });
|
|
174
|
+
const adbExec = sandbox.stub().resolves('Success');
|
|
175
|
+
sandbox.stub(manager, 'getAdb').resolves({ adbInstance: { adbExec } });
|
|
176
|
+
yield manager.installApp('p1', '/tmp/app.apk');
|
|
177
|
+
(0, chai_1.expect)(adbExec.firstCall.args[0]).to.deep.equal(['-s', 'p1', 'install', '-r', '/tmp/app.apk']);
|
|
178
|
+
(0, chai_1.expect)(adbExec.firstCall.args[1]).to.deep.equal({ timeout: AndroidDeviceManager_1.ANDROID_INSTALL_TIMEOUT_MS });
|
|
179
|
+
(0, chai_1.expect)(AndroidDeviceManager_1.ANDROID_INSTALL_TIMEOUT_MS).to.be.at.least(5 * 60000);
|
|
180
|
+
}));
|
|
169
181
|
// A phone adb reports as plugged (and every phone connected when the server
|
|
170
182
|
// starts) went through onDeviceAdded with no androidDeviceType check: a
|
|
171
183
|
// hub set to emulators only listed a real phone until its next sync pruned
|
|
@@ -99,6 +99,38 @@ describe('BusyPrecheck', () => {
|
|
|
99
99
|
const out = yield pc.findBusy(['U1'], 'actor-1');
|
|
100
100
|
(0, chai_1.expect)(out[0]).to.deep.include({ udid: 'U1', reason: 'manual_other', blockId: 'manual_U1' });
|
|
101
101
|
}));
|
|
102
|
+
// On a hub, a node's phone held by a preview has the holder in nodeHold,
|
|
103
|
+
// from the node's report; session_id is the hub's own and stays empty.
|
|
104
|
+
// Read as unknown, the user's own preview kept them from recording it.
|
|
105
|
+
it('reads a node phone’s preview hold from nodeHold: mine to take over, another user’s not', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
106
|
+
const pc = precheck(withDevices({
|
|
107
|
+
MINE: {
|
|
108
|
+
udid: 'MINE',
|
|
109
|
+
busy: true,
|
|
110
|
+
nodeBusy: true,
|
|
111
|
+
session_id: null,
|
|
112
|
+
nodeHold: 'manual_alice_MINE',
|
|
113
|
+
},
|
|
114
|
+
THEIRS: {
|
|
115
|
+
udid: 'THEIRS',
|
|
116
|
+
busy: true,
|
|
117
|
+
nodeBusy: true,
|
|
118
|
+
session_id: null,
|
|
119
|
+
nodeHold: 'manual_bob_THEIRS',
|
|
120
|
+
},
|
|
121
|
+
NODE_SESSION: {
|
|
122
|
+
udid: 'NODE_SESSION',
|
|
123
|
+
busy: true,
|
|
124
|
+
nodeBusy: true,
|
|
125
|
+
session_id: null,
|
|
126
|
+
nodeHold: null,
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
(0, chai_1.expect)(yield pc.findBusy(['MINE', 'THEIRS', 'NODE_SESSION'], 'alice')).to.deep.equal([
|
|
130
|
+
{ udid: 'THEIRS', reason: 'manual_other', blockId: 'manual_bob_THEIRS' },
|
|
131
|
+
{ udid: 'NODE_SESSION', reason: 'unknown' },
|
|
132
|
+
]);
|
|
133
|
+
}));
|
|
102
134
|
it('returns reason=unknown for missing devices', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
103
135
|
const pc = precheck(withDevices({}));
|
|
104
136
|
const out = yield pc.findBusy(['MISSING']);
|
|
@@ -73,6 +73,7 @@ const container_registration_1 = require("../helpers/container-registration");
|
|
|
73
73
|
const scratch_database_1 = require("../helpers/scratch-database");
|
|
74
74
|
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
75
75
|
const InspectorService_1 = require("../../src/services/InspectorService");
|
|
76
|
+
const OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
|
|
76
77
|
const http_1 = __importDefault(require("http"));
|
|
77
78
|
const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
|
|
78
79
|
const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
|
|
@@ -130,7 +131,11 @@ class AndroidDeviceManager {
|
|
|
130
131
|
this.at('executeShell');
|
|
131
132
|
return 'uid=2000(shell)';
|
|
132
133
|
});
|
|
133
|
-
this.
|
|
134
|
+
this.installedBytes = [];
|
|
135
|
+
this.installApp = (_udid, appPath) => __awaiter(this, void 0, void 0, function* () {
|
|
136
|
+
this.at('installApp');
|
|
137
|
+
this.installedBytes.push(fs_1.default.readFileSync(appPath, 'utf8'));
|
|
138
|
+
});
|
|
134
139
|
}
|
|
135
140
|
at(name) {
|
|
136
141
|
this.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:${name}`);
|
|
@@ -155,7 +160,7 @@ describe('the hub’s device control on a node’s phone', () => {
|
|
|
155
160
|
dirs = [fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-control-keys-'))];
|
|
156
161
|
hubKeys = new JwtKeyService_1.JwtKeyService();
|
|
157
162
|
yield hubKeys.init(dirs[0]);
|
|
158
|
-
restore = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService, hubSessionToken_1.HubSessionTokenIssuer, device_managers_1.XenonManager, SessionOwnerResolver_1.SessionOwnerResolver, LiveSessionOwners_1.LiveSessionOwners, InspectorService_1.InspectorService);
|
|
163
|
+
restore = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService, hubSessionToken_1.HubSessionTokenIssuer, device_managers_1.XenonManager, SessionOwnerResolver_1.SessionOwnerResolver, LiveSessionOwners_1.LiveSessionOwners, InspectorService_1.InspectorService, OmniVisionService_1.OmniVisionService);
|
|
159
164
|
typedi_1.Container.set(JwtKeyService_1.JwtKeyService, hubKeys);
|
|
160
165
|
typedi_1.Container.set(hubSessionToken_1.HubSessionTokenIssuer, new hubSessionToken_1.HubSessionTokenIssuer());
|
|
161
166
|
typedi_1.Container.set(SessionOwnerResolver_1.SessionOwnerResolver, new SessionOwnerResolver_1.SessionOwnerResolver());
|
|
@@ -388,16 +393,10 @@ describe('the hub’s device control on a node’s phone', () => {
|
|
|
388
393
|
(0, chai_1.expect)(res.body).to.include({ status: 'success', sessionId: 's-1' });
|
|
389
394
|
(0, chai_1.expect)(nodeSaw('appium-session')).to.have.length(0);
|
|
390
395
|
}));
|
|
391
|
-
// Not passed on
|
|
392
|
-
//
|
|
393
|
-
// Omni's AI settings are the hub's. Anything added later is refused the
|
|
394
|
-
// same way until it is on the list.
|
|
396
|
+
// Not passed on: a path is a path on one machine. Anything added later is
|
|
397
|
+
// refused the same way until it is on a list.
|
|
395
398
|
const REFUSED = [
|
|
396
399
|
['post', 'install', { appPath: '/tmp/app.apk' }],
|
|
397
|
-
['post', 'install-repository-app', { appId: 'app-1' }],
|
|
398
|
-
['post', 'upload-install'],
|
|
399
|
-
['get', 'omni-scan'],
|
|
400
|
-
['post', 'test-locator', { strategy: '-custom:ai-text', selector: 'OK' }],
|
|
401
400
|
['post', 'an-action-added-later'],
|
|
402
401
|
];
|
|
403
402
|
describe('the live preview', () => {
|
|
@@ -493,6 +492,63 @@ describe('the hub’s device control on a node’s phone', () => {
|
|
|
493
492
|
yield preview('post', 'stream/stop').expect(200);
|
|
494
493
|
}));
|
|
495
494
|
});
|
|
495
|
+
describe('apps and Omni-Vision', () => {
|
|
496
|
+
it('installs an upload on the node, streamed through the hub', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
497
|
+
const res = yield (0, loopbackRequest_1.default)(hubApp)
|
|
498
|
+
.post('/xenon/api/control/phone-1/upload-install')
|
|
499
|
+
.set('x-test-user', 'alice')
|
|
500
|
+
.attach('app', Buffer.from('uploaded-apk'), 'build-42.apk');
|
|
501
|
+
(0, chai_1.expect)(res.status, res.text).to.equal(200);
|
|
502
|
+
(0, chai_1.expect)(res.body.success).to.equal(true);
|
|
503
|
+
(0, chai_1.expect)(manager.calls).to.deep.equal(['node-1:installApp']);
|
|
504
|
+
(0, chai_1.expect)(manager.installedBytes).to.deep.equal(['uploaded-apk']);
|
|
505
|
+
}));
|
|
506
|
+
it('installs an app from the hub’s library on the node, sending the file there', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
507
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-library-'));
|
|
508
|
+
dirs.push(dir);
|
|
509
|
+
const file = path_1.default.join(dir, 'stored-app');
|
|
510
|
+
fs_1.default.writeFileSync(file, 'library-apk');
|
|
511
|
+
const app = yield scratch.db.app.create({
|
|
512
|
+
data: {
|
|
513
|
+
name: 'Library App',
|
|
514
|
+
filename: 'library-app.apk',
|
|
515
|
+
filepath: file,
|
|
516
|
+
mimetype: 'application/vnd.android.package-archive',
|
|
517
|
+
size: 11,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
const res = yield asAlice('post', 'install-repository-app', { appId: app.id });
|
|
521
|
+
(0, chai_1.expect)(res.status, res.text).to.equal(200);
|
|
522
|
+
(0, chai_1.expect)(manager.calls).to.deep.equal(['node-1:installApp']);
|
|
523
|
+
(0, chai_1.expect)(manager.installedBytes).to.deep.equal(['library-apk']);
|
|
524
|
+
const upload = nodeSaw('upload-install');
|
|
525
|
+
(0, chai_1.expect)(upload).to.have.length(1);
|
|
526
|
+
(0, chai_1.expect)(String(upload[0].headers['content-type'])).to.match(/^multipart\/form-data/);
|
|
527
|
+
}));
|
|
528
|
+
it('runs Omni-Vision here, on the node’s screenshot', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
529
|
+
const seen = [];
|
|
530
|
+
typedi_1.Container.set(OmniVisionService_1.OmniVisionService, {
|
|
531
|
+
analyzeScreen: (driver) => __awaiter(void 0, void 0, void 0, function* () {
|
|
532
|
+
seen.push(`scan:${(yield driver.getScreenshot()).length}`);
|
|
533
|
+
return { elements: [] };
|
|
534
|
+
}),
|
|
535
|
+
findByText: (driver, text) => __awaiter(void 0, void 0, void 0, function* () {
|
|
536
|
+
seen.push(`text:${text}:${(yield driver.getScreenshot()).length}`);
|
|
537
|
+
return [{ text }];
|
|
538
|
+
}),
|
|
539
|
+
});
|
|
540
|
+
const scan = yield asAlice('get', 'omni-scan');
|
|
541
|
+
(0, chai_1.expect)(scan.status, scan.text).to.equal(200);
|
|
542
|
+
const locator = yield asAlice('post', 'test-locator', {
|
|
543
|
+
strategy: '-custom:ai-text',
|
|
544
|
+
selector: 'Sign in',
|
|
545
|
+
});
|
|
546
|
+
(0, chai_1.expect)(locator.status, locator.text).to.equal(200);
|
|
547
|
+
(0, chai_1.expect)(locator.body.value).to.deep.equal([{ text: 'Sign in' }]);
|
|
548
|
+
(0, chai_1.expect)(seen).to.deep.equal(['scan:200', 'text:Sign in:200']);
|
|
549
|
+
(0, chai_1.expect)(manager.calls).to.deep.equal(['node-1:getScreenshot', 'node-1:getScreenshot']);
|
|
550
|
+
}));
|
|
551
|
+
});
|
|
496
552
|
it('refuses the rest, naming the node, and runs none of it on the hub', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
497
553
|
for (const [method, action, body] of REFUSED) {
|
|
498
554
|
const res = yield asAlice(method, action, body);
|
|
@@ -381,8 +381,13 @@ describe('The hub/node busy race', function () {
|
|
|
381
381
|
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
382
382
|
// The node answered a forwarded stream/start: it holds the phone now,
|
|
383
383
|
// though its last report said free.
|
|
384
|
-
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('s9', NODE);
|
|
385
|
-
(0, chai_1.expect)(yield row('s9')).to.include({
|
|
384
|
+
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('s9', NODE, 'manual_u1_s9');
|
|
385
|
+
(0, chai_1.expect)(yield row('s9')).to.include({
|
|
386
|
+
busy: true,
|
|
387
|
+
nodeBusy: true,
|
|
388
|
+
nodeHold: 'manual_u1_s9',
|
|
389
|
+
session_id: null,
|
|
390
|
+
});
|
|
386
391
|
let session;
|
|
387
392
|
try {
|
|
388
393
|
session = yield (0, device_utils_1.allocateDeviceForSession)(caps('s9'), 200, 50, pluginArgs);
|
|
@@ -393,7 +398,7 @@ describe('The hub/node busy race', function () {
|
|
|
393
398
|
(0, chai_1.expect)(session, 'not handed to a session').to.be.an('error');
|
|
394
399
|
// The preview ended, and the node says so.
|
|
395
400
|
yield report(nodePhone('s9', { busy: false }));
|
|
396
|
-
(0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false });
|
|
401
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false, nodeHold: null });
|
|
397
402
|
}));
|
|
398
403
|
it('touches no other server’s row for the same udid', () => __awaiter(this, void 0, void 0, function* () {
|
|
399
404
|
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
@@ -401,6 +406,27 @@ describe('The hub/node busy race', function () {
|
|
|
401
406
|
(0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false });
|
|
402
407
|
}));
|
|
403
408
|
});
|
|
409
|
+
// The node's report is its own row, session_id included. A preview's hold
|
|
410
|
+
// there (manual_<user>_<udid>) is who holds the phone, which the hub shows
|
|
411
|
+
// (nodeHold); session_id itself stays the hub's.
|
|
412
|
+
describe('who holds a node’s phone', () => {
|
|
413
|
+
it('is the node’s preview hold, from its report, until the node lets the phone go', () => __awaiter(this, void 0, void 0, function* () {
|
|
414
|
+
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
415
|
+
yield report(nodePhone('s9', { busy: true, session_id: 'manual_u1_s9' }));
|
|
416
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: 'manual_u1_s9', session_id: null });
|
|
417
|
+
yield report(nodePhone('s9', { busy: false, session_id: null }));
|
|
418
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: null, busy: false });
|
|
419
|
+
}));
|
|
420
|
+
it('is not a session the node runs', () => __awaiter(this, void 0, void 0, function* () {
|
|
421
|
+
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
422
|
+
yield report(nodePhone('s9', { busy: true, session_id: 'appium-9' }));
|
|
423
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: null, nodeBusy: true, session_id: null });
|
|
424
|
+
}));
|
|
425
|
+
it('is taken from the report of a phone the hub didn’t know yet', () => __awaiter(this, void 0, void 0, function* () {
|
|
426
|
+
yield report(nodePhone('s9', { busy: true, session_id: 'manual_u1_s9' }));
|
|
427
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: 'manual_u1_s9', busy: true });
|
|
428
|
+
}));
|
|
429
|
+
});
|
|
404
430
|
describe('a standalone server', () => {
|
|
405
431
|
it('allocates, runs and frees its own phone as before', () => __awaiter(this, void 0, void 0, function* () {
|
|
406
432
|
yield scratch.db.device.create({
|
|
@@ -38,14 +38,27 @@ describe('markNodeBusy (in-memory store)', () => {
|
|
|
38
38
|
const model = yield db_1.XenonDatabase.DeviceModel;
|
|
39
39
|
model.insert(phone(NODE));
|
|
40
40
|
model.insert(phone('http://10.0.0.8:4725'));
|
|
41
|
-
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE);
|
|
41
|
+
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE, 'manual_u1_s9');
|
|
42
42
|
const rows = model.find({ udid: 'mark-node-busy-s9' });
|
|
43
|
-
const byHost = Object.fromEntries(rows.map((r) => [r.host, [r.busy, r.nodeBusy]]));
|
|
43
|
+
const byHost = Object.fromEntries(rows.map((r) => { var _a; return [r.host, [r.busy, r.nodeBusy, (_a = r.nodeHold) !== null && _a !== void 0 ? _a : null]]; }));
|
|
44
44
|
(0, chai_1.expect)(byHost).to.deep.equal({
|
|
45
|
-
[NODE]: [true, true],
|
|
46
|
-
'http://10.0.0.8:4725': [false, false],
|
|
45
|
+
[NODE]: [true, true, 'manual_u1_s9'],
|
|
46
|
+
'http://10.0.0.8:4725': [false, false, null],
|
|
47
47
|
});
|
|
48
48
|
}));
|
|
49
|
+
it('takes the holder from a node’s report: a preview hold, not a session', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
50
|
+
const model = yield db_1.XenonDatabase.DeviceModel;
|
|
51
|
+
model.insert(phone(NODE));
|
|
52
|
+
const store = device_store_1.DeviceStoreFactory.getStore();
|
|
53
|
+
const reported = (session_id, busy) => store.addDevices([Object.assign(Object.assign({}, phone(NODE)), { busy, session_id })], { nodeReport: true });
|
|
54
|
+
const hold = () => model.findOne({ udid: 'mark-node-busy-s9', host: NODE }).nodeHold;
|
|
55
|
+
yield reported('manual_u1_mark-node-busy-s9', true);
|
|
56
|
+
(0, chai_1.expect)(hold()).to.equal('manual_u1_mark-node-busy-s9');
|
|
57
|
+
yield reported('appium-9', true);
|
|
58
|
+
(0, chai_1.expect)(hold()).to.equal(null);
|
|
59
|
+
yield reported(null, false);
|
|
60
|
+
(0, chai_1.expect)(hold()).to.equal(null);
|
|
61
|
+
}));
|
|
49
62
|
it('does nothing for a phone it doesn’t have', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
50
63
|
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE);
|
|
51
64
|
(0, chai_1.expect)((yield db_1.XenonDatabase.DeviceModel).find({ udid: 'mark-node-busy-s9' })).to.deep.equal([]);
|