@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.
Files changed (105) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-XpewX5nn.js → AnnotationOverlay-CpoWcXKP.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BQDv3ZCC.js → ApiKeyGate-DAYuoJDl.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-C7OmS61B.js → BugReportButton-DEXyENUv.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-C-sJRz63.js +2 -0
  6. package/lib/public/assets/{EmptyState-fPxujqqE.js → EmptyState-B_JRYedM.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-5bCutWrs.js → FieldGroup-CorXlSLU.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-owsBiDT1.js → FilterMenu-BKv95DEc.js} +1 -1
  9. package/lib/public/assets/{Menu-DFhqzPSt.js → Menu-Cpy5yMwg.js} +1 -1
  10. package/lib/public/assets/{Modal-Bw-a-E-e.js → Modal-Djp4NvyZ.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-WtByf5bC.js → RecordingPage-CP57OKw9.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-FpWWFNgT.js → RecordingsPage-BjrTit38.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Jzn9c7o8.js → SegmentedControl-MGTzJhBf.js} +1 -1
  14. package/lib/public/assets/{SettingCard-BR7nOhpT.js → SettingCard-CYBPhKPe.js} +1 -1
  15. package/lib/public/assets/{Table-B_Va3V05.js → Table-CdKQ9hnU.js} +1 -1
  16. package/lib/public/assets/{activity-DxRFWvkN.js → activity-QsRK21QF.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CQf-9_V6.js → ai-settings-Dn6DtRn7.js} +1 -1
  18. package/lib/public/assets/{api-keys-SoE1Kd7N.js → api-keys-DYyf17eU.js} +1 -1
  19. package/lib/public/assets/{apps-CgGXTlgn.js → apps-HKFKTe1J.js} +1 -1
  20. package/lib/public/assets/{arrow-left-uQ6WIwiC.js → arrow-left-BkA7oMRe.js} +1 -1
  21. package/lib/public/assets/{arrow-right-C9RyfzXS.js → arrow-right-BsSr58EU.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DFHi_8yG.js → arrow-up-right-B7TEV_Gr.js} +1 -1
  23. package/lib/public/assets/{auth-shell-Bii1t4qc.js → auth-shell-BlmNHGcG.js} +2 -2
  24. package/lib/public/assets/{builds-page-Bnv8Z9hi.js → builds-page-E4WIKtl8.js} +1 -1
  25. package/lib/public/assets/{button-_Y78wXiP.js → button-BIwCF2bR.js} +1 -1
  26. package/lib/public/assets/{calendar-D4MqSOc2.js → calendar-CMLnXC7N.js} +1 -1
  27. package/lib/public/assets/{check-uGHCXe-L.js → check-zugO3p5U.js} +1 -1
  28. package/lib/public/assets/{chevron-left-Dy1OqpzB.js → chevron-left-sCqxpTY6.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BUsldEli.js → chevron-right-Bb59xwZc.js} +1 -1
  30. package/lib/public/assets/{circle-check-D3Qq0tKp.js → circle-check-DsvxbnTg.js} +1 -1
  31. package/lib/public/assets/{circle-x-B4IOD1DE.js → circle-x-w69AbmbW.js} +1 -1
  32. package/lib/public/assets/{clock-BfYfzvSg.js → clock-C6XOs4bc.js} +1 -1
  33. package/lib/public/assets/{copy-DZwYE5Va.js → copy-pHk-SWBM.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CRG4Utt5.js → device-explorer-BDq38IcO.js} +52 -52
  35. package/lib/public/assets/{download-CCDYo_TN.js → download-CAHtPZmY.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DuLCmVFb.js → forgot-password-cmvhGOUu.js} +1 -1
  37. package/lib/public/assets/{funnel-CtKNPqzb.js → funnel-BrDuhOtM.js} +1 -1
  38. package/lib/public/assets/{index-3uxl3o7K.js → index-BQCXPOXt.js} +2 -2
  39. package/lib/public/assets/{index-Zy1Op5C1.js → index-C-Q4QkXb.js} +1 -1
  40. package/lib/public/assets/{input-CzTWvUXZ.js → input-DnUVHYax.js} +1 -1
  41. package/lib/public/assets/{keycodes-CI-nYDFM.js → keycodes-CmOptFVa.js} +4 -4
  42. package/lib/public/assets/{layers-YZZGFyTX.js → layers-DRtz_bzF.js} +1 -1
  43. package/lib/public/assets/{lock-L8tzWgDo.js → lock-DF2VnPFm.js} +1 -1
  44. package/lib/public/assets/{login-DBbX-fu8.js → login-DHYffYa4.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-AJwkOIsE.js → maintenance-settings-r0uPsJ6n.js} +1 -1
  46. package/lib/public/assets/{monitor-Di_66F2m.js → monitor-7nt9oGNx.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-Cnab2zdg.js → mouse-pointer-2-ys1MKVr2.js} +1 -1
  48. package/lib/public/assets/{network-DiSumY-1.js → network-B8m-F79k.js} +1 -1
  49. package/lib/public/assets/{overview-CwE4gXl5.js → overview-BHEntlXz.js} +1 -1
  50. package/lib/public/assets/{page-header-CfR4Sm9V.js → page-header-CuFXVUop.js} +1 -1
  51. package/lib/public/assets/{plus-DOS8P9CQ.js → plus-DUSlMac2.js} +1 -1
  52. package/lib/public/assets/{profile-page-BYpHwQIH.js → profile-page-CBamPy_h.js} +1 -1
  53. package/lib/public/assets/{reset-password-BRVJU_qV.js → reset-password-DG0vZl-o.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Bi3JvAFZ.js → runbook-page-5S3T-I4g.js} +1 -1
  55. package/lib/public/assets/{select-Cqx7pEou.js → select-f0ci1QC7.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C0DsncKj.js → selector-detail-page-BXA9LUf9.js} +1 -1
  57. package/lib/public/assets/{selector-health-CAhpdilF.js → selector-health-TyMPgAWo.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CrCVeW2m.js → selector-health-page-uBlXy33G.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-F_hwYqEt.js → session-detail-page-DECim5lh.js} +1 -1
  60. package/lib/public/assets/{settings-C-s5pypL.js → settings-IfOFbqtv.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-DY5uzv6A.js → status-pill-outline-grXLf8I8.js} +1 -1
  62. package/lib/public/assets/{tablet-Dli7gwyg.js → tablet-lmFk4qxW.js} +1 -1
  63. package/lib/public/assets/{teams-ui4IhHNC.js → teams-CwMSjVVW.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV4hSmXJ.js → trash-2-BKsgmBS6.js} +1 -1
  65. package/lib/public/assets/{upload-DkGy_J-L.js → upload-DXH47sQS.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-BFtMqQOh.js → use-password-reset-mode-DU98dxpc.js} +1 -1
  67. package/lib/public/assets/{users-Nb_rzRmF.js → users-V5O64rVx.js} +1 -1
  68. package/lib/public/assets/{users-BU0nXIVe.js → users-eXWwQZjX.js} +1 -1
  69. package/lib/public/assets/{video-off-CUNzD2kH.js → video-off-BuoIHEYy.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-Ca3UFXZe.js → webhook-settings-ClsxsKnC.js} +1 -1
  71. package/lib/public/assets/{zap-B2bH2vKX.js → zap-I10vdQXd.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +114 -61
  74. package/lib/src/app/routers/nodePhoneControl.js +288 -46
  75. package/lib/src/data-service/device-store.js +7 -3
  76. package/lib/src/data-service/deviceClaims.js +14 -0
  77. package/lib/src/data-service/deviceFieldOwners.js +3 -1
  78. package/lib/src/data-service/prisma-store.js +3 -3
  79. package/lib/src/device-managers/AndroidDeviceManager.js +11 -1
  80. package/lib/src/gateway/forwardToNode.js +10 -3
  81. package/lib/src/generated/client/edge.js +5 -4
  82. package/lib/src/generated/client/index-browser.js +2 -1
  83. package/lib/src/generated/client/index.d.ts +36 -1
  84. package/lib/src/generated/client/index.js +5 -4
  85. package/lib/src/generated/client/package.json +1 -1
  86. package/lib/src/generated/client/schema.prisma +4 -0
  87. package/lib/src/generated/client/wasm.js +2 -1
  88. package/lib/src/services/recording/RecordingOrchestrator.js +39 -5
  89. package/lib/src/services/recording/busy-precheck.js +5 -2
  90. package/lib/src/services/recording/nodeRecordingSource.js +156 -0
  91. package/lib/test/unit/AndroidDeviceManager.spec.js +12 -0
  92. package/lib/test/unit/busy-precheck.spec.js +32 -0
  93. package/lib/test/unit/hub-node-control-forward.spec.js +66 -10
  94. package/lib/test/unit/hub-node-race.spec.js +29 -3
  95. package/lib/test/unit/mark-node-busy-loki.spec.js +17 -4
  96. package/lib/test/unit/node-phone-control.spec.js +124 -4
  97. package/lib/test/unit/node-recording-relay.spec.js +165 -0
  98. package/lib/test/unit/recording-orchestrator.spec.js +86 -2
  99. package/lib/test/unit/screenshot-route.spec.js +104 -0
  100. package/lib/test/unit/upload-install-route.spec.js +205 -0
  101. package/lib/tsconfig.tsbuildinfo +1 -1
  102. package/package.json +1 -1
  103. package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
  104. package/prisma/schema.prisma +4 -0
  105. package/lib/public/assets/DeviceMosaicView-wl3l2Zlt.js +0 -2
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "prisma-client-2fecbe6024329bf05b69cdf448f1b687e32645450beda6a210a882d44a7ffb8d",
2
+ "name": "prisma-client-631e8540dfbb77c390eb0db189885974a87d3fa754d038cb48bcc6a9f3489314",
3
3
  "main": "index.js",
4
4
  "types": "index.d.ts",
5
5
  "browser": "index-browser.js",
@@ -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.probeDurationMsFn = (_j = deps.probeDurationMsFn) !== null && _j !== void 0 ? _j : probeDuration_1.probeVideoDurationMs;
157
- this.now = (_k = deps.now) !== null && _k !== void 0 ? _k : Date.now;
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.ensureMjpegPortFn(udid);
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.ensureMjpegPortFn(udid);
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
- const blockId = (_a = device.session_id) !== null && _a !== void 0 ? _a : undefined;
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.installApp = () => __awaiter(this, void 0, void 0, function* () { return this.at('installApp'); });
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 yet: uploads need a relayed body, an app from the hub's
392
- // library isn't on the node, and a path is a path on one machine.
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({ busy: true, nodeBusy: true, session_id: null });
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([]);