@xenon-device-management/xenon 2.1.0 → 2.3.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 (124) hide show
  1. package/lib/package.json +3 -3
  2. package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-AxcBYoKp.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-CIU02EIx.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-_akEEjXm.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-Bhk1udmn.js +2 -0
  6. package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-DRKRm19_.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-zQQHPh7x.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-C9_StnDi.js} +1 -1
  9. package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-0J7io6pF.js} +1 -1
  10. package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-C677ljNX.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-BDJFck-L.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-DQkimbKK.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-DvGMZBIE.js} +1 -1
  14. package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-ogyouYBq.js} +1 -1
  15. package/lib/public/assets/{Table-ucR4bnmc.js → Table-BLFCOLlO.js} +1 -1
  16. package/lib/public/assets/{activity-K-hdylwq.js → activity-BU55TGRG.js} +1 -1
  17. package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-GpDAiHav.js} +1 -1
  18. package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-dOzP0k8_.js} +1 -1
  19. package/lib/public/assets/{apps-Dez1petz.js → apps-mAwAQmkn.js} +1 -1
  20. package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-Dg9mxe9T.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-BVcmUyq3.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-ByR5pZrH.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Jpgk8CgY.js} +2 -2
  24. package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-VnRTrcdl.js} +1 -1
  25. package/lib/public/assets/{button-Dd-IhMC1.js → button-D-X4wjO8.js} +1 -1
  26. package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-ur_p5ETA.js} +1 -1
  27. package/lib/public/assets/{check-C4Jrjg8f.js → check-K33iMeyq.js} +1 -1
  28. package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-DMVWShd-.js} +1 -1
  29. package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-DphLJ2_X.js} +1 -1
  30. package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-CNFhu8oE.js} +1 -1
  31. package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-BH_cUbqZ.js} +1 -1
  32. package/lib/public/assets/{clock-B8vmwkHj.js → clock-CVMVdVvp.js} +1 -1
  33. package/lib/public/assets/{copy-BAqnFI-F.js → copy-CZ2XHafL.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-B94BhaqF.js} +52 -52
  35. package/lib/public/assets/{download-B6UEAp_1.js → download-UUiyw7kO.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-CrS14F79.js} +1 -1
  37. package/lib/public/assets/{funnel-Da90BBbT.js → funnel-BMOE5qJY.js} +1 -1
  38. package/lib/public/assets/{index-BuIIcmGp.js → index-2XPPbPwy.js} +43 -43
  39. package/lib/public/assets/{index-MX7IzqyW.js → index-D0R9Yic3.js} +1 -1
  40. package/lib/public/assets/{input-Bq9aY-We.js → input-C-1b8J7i.js} +1 -1
  41. package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-Cly7dQc9.js} +4 -4
  42. package/lib/public/assets/{layers-bXjpswQI.js → layers-DFZfqGdX.js} +1 -1
  43. package/lib/public/assets/{lock-pJ72y5Fy.js → lock-ylHMJpfP.js} +1 -1
  44. package/lib/public/assets/{login-UOcHALV2.js → login-DWsWKqDx.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-9B6_MTqW.js} +1 -1
  46. package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-CzX0hBH1.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-CZHD7N3z.js} +1 -1
  48. package/lib/public/assets/{network-CKVqcyp6.js → network-Bikc4MyT.js} +1 -1
  49. package/lib/public/assets/{overview-BORGkN3N.js → overview-DzaA0aMe.js} +1 -1
  50. package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-DqGlDufy.js} +1 -1
  51. package/lib/public/assets/{plus-BpFKVdC8.js → plus-Ct6aQTGM.js} +1 -1
  52. package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-CCnUQ1Qk.js} +1 -1
  53. package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-AmkGGXo5.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-DQQn9VyE.js} +1 -1
  55. package/lib/public/assets/{select-DIZWIoVR.js → select-k5ogtEWW.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-BCcwC5ku.js} +1 -1
  57. package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-BVyX6kIh.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CElBB6XT.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-6bqo_-ND.js} +1 -1
  60. package/lib/public/assets/{settings-BcRBKUXd.js → settings-DKBj9KyN.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-CZNpvqej.js} +1 -1
  62. package/lib/public/assets/{tablet-_sXqZztt.js → tablet-B6HI5OxV.js} +1 -1
  63. package/lib/public/assets/{teams-DGHT8MKm.js → teams-owoDHbOU.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-D61PoUrZ.js} +1 -1
  65. package/lib/public/assets/{upload-CIDH9wkj.js → upload-1ZYEVvgD.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-Dx0ghiiQ.js} +1 -1
  67. package/lib/public/assets/{users-7yviqflc.js → users-C5CS5Qez.js} +1 -1
  68. package/lib/public/assets/{users-BUE7MoTq.js → users-PbGMd5sD.js} +1 -1
  69. package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-_XusI0HK.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Bi_HHQlQ.js} +1 -1
  71. package/lib/public/assets/{zap-CWE94qcK.js → zap-uzHWFAKI.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +119 -203
  74. package/lib/src/app/routers/nodePhoneControl.js +520 -0
  75. package/lib/src/app/ws/h264StreamWs.js +30 -4
  76. package/lib/src/app/ws/logcatWs.js +36 -7
  77. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  78. package/lib/src/data-service/device-store.js +17 -2
  79. package/lib/src/data-service/deviceClaims.js +32 -0
  80. package/lib/src/data-service/deviceFieldOwners.js +3 -1
  81. package/lib/src/data-service/prisma-store.js +9 -1
  82. package/lib/src/device-managers/AndroidDeviceManager.js +38 -28
  83. package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
  84. package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
  85. package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
  86. package/lib/src/gateway/forwardToNode.js +10 -3
  87. package/lib/src/generated/client/edge.js +5 -4
  88. package/lib/src/generated/client/index-browser.js +2 -1
  89. package/lib/src/generated/client/index.d.ts +36 -1
  90. package/lib/src/generated/client/index.js +5 -4
  91. package/lib/src/generated/client/package.json +1 -1
  92. package/lib/src/generated/client/schema.prisma +4 -0
  93. package/lib/src/generated/client/wasm.js +2 -1
  94. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  95. package/lib/src/services/ServerManager.js +13 -0
  96. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  97. package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
  98. package/lib/test/helpers/own-node-id.js +29 -0
  99. package/lib/test/setup/scratch-default-database.js +32 -0
  100. package/lib/test/unit/AndroidDeviceManager.spec.js +60 -0
  101. package/lib/test/unit/android-h264-service.spec.js +18 -0
  102. package/lib/test/unit/clipboard-route.spec.js +4 -0
  103. package/lib/test/unit/control-forward-decision.spec.js +17 -17
  104. package/lib/test/unit/device-access-guard.spec.js +38 -0
  105. package/lib/test/unit/hub-node-control-forward.spec.js +374 -1
  106. package/lib/test/unit/hub-node-race.spec.js +51 -0
  107. package/lib/test/unit/install-repository-app-team.spec.js +4 -0
  108. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
  109. package/lib/test/unit/logcat-ws.spec.js +8 -0
  110. package/lib/test/unit/mark-node-busy-loki.spec.js +66 -0
  111. package/lib/test/unit/node-phone-control.spec.js +225 -0
  112. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  113. package/lib/test/unit/screenshot-route.spec.js +104 -0
  114. package/lib/test/unit/stream-leave-route.spec.js +4 -0
  115. package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
  116. package/lib/test/unit/stream-stop-recording.spec.js +4 -0
  117. package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
  118. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  119. package/lib/test/unit/upload-install-route.spec.js +205 -0
  120. package/lib/tsconfig.tsbuildinfo +1 -1
  121. package/package.json +3 -3
  122. package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
  123. package/prisma/schema.prisma +4 -0
  124. package/lib/public/assets/DeviceMosaicView-D-rGOBbJ.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 = {
@@ -21,6 +21,7 @@ const controlDevice_1 = require("./controlDevice");
21
21
  const SessionOwnerResolver_1 = require("../services/device-access/SessionOwnerResolver");
22
22
  const deviceAccessPolicy_1 = require("../services/device-access/deviceAccessPolicy");
23
23
  const actor_1 = require("../services/device-access/actor");
24
+ const deviceClaims_1 = require("../data-service/deviceClaims");
24
25
  const STATE_CHANGING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
25
26
  /**
26
27
  * Mutations under /control that this guard must NOT handle.
@@ -129,7 +130,9 @@ function deviceAccessGuard(deps = {}) {
129
130
  }
130
131
  const decision = (0, deviceAccessPolicy_1.evaluateDeviceAccess)({
131
132
  udid,
132
- busy: !!device.busy,
133
+ // Not device.busy: a node's phone busy only by its node's report is
134
+ // the node's to judge (heldHere).
135
+ busy: (0, deviceClaims_1.heldHere)(device),
133
136
  sessionId: device.session_id,
134
137
  sessionOwnerUserId,
135
138
  actorUserId: actor.userId,
@@ -63,6 +63,7 @@ const networkAddresses_1 = require("../helpers/networkAddresses");
63
63
  const logger_1 = __importDefault(require("../logger"));
64
64
  const h264StreamWs_1 = require("../app/ws/h264StreamWs");
65
65
  const logcatWs_1 = require("../app/ws/logcatWs");
66
+ const nodeSocketRelay_1 = require("../app/ws/nodeSocketRelay");
66
67
  const upgradeRouter_1 = require("../app/ws/upgradeRouter");
67
68
  const StreamTicketService_1 = require("./token/StreamTicketService");
68
69
  const AndroidH264StreamService_1 = __importDefault(require("../device-managers/android/AndroidH264StreamService"));
@@ -147,6 +148,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
147
148
  // streaming.androidH264 config itself, so this WS auto-start honours a
148
149
  // { source: 'screenrecord' } rollback just like the REST stream/start.
149
150
  startStream: (udid) => typedi_1.Container.get(AndroidH264StreamService_1.default).start(udid),
151
+ // A node's phone is streamed by its node and relayed from here.
152
+ nodeSocket: (udid, actor) => (0, nodeSocketRelay_1.openNodeSocket)({ udid, actor, path: 'stream/h264' }),
150
153
  });
151
154
  // Continuous logcat WebSocket (Android; replaces the 3s dump-poll).
152
155
  // A route of the upgrade router: only /logcat upgrades reach it.
@@ -191,6 +194,16 @@ let ServerManager = ServerManager_1 = class ServerManager {
191
194
  // server-side level filter applies.
192
195
  return { mux: yield typedi_1.Container.get(LogcatStreamService_1.LogcatStreamService).start(udid) };
193
196
  }),
197
+ // A node's phone: its node streams the log, with the same filter.
198
+ nodeSocket: (udid, actor, filter) => {
199
+ var _a;
200
+ return (0, nodeSocketRelay_1.openNodeSocket)({
201
+ udid,
202
+ actor,
203
+ path: 'logcat',
204
+ query: new URLSearchParams(Object.assign(Object.assign({}, (((_a = filter.levels) === null || _a === void 0 ? void 0 : _a.length) ? { levels: filter.levels.join(',') } : {})), (filter.process ? { process: filter.process } : {}))),
205
+ });
206
+ },
194
207
  });
195
208
  }
196
209
  // Principal Cleaning: Attempt to recover remote sessions before marking as failed
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.makeTicketActorAuthorizer = makeTicketActorAuthorizer;
13
13
  const manualLock_1 = require("../recording/manualLock");
14
14
  const deviceAccessPolicy_1 = require("./deviceAccessPolicy");
15
+ const deviceClaims_1 = require("../../data-service/deviceClaims");
15
16
  /**
16
17
  * Build the ownership check for a caller authenticated by a stream ticket.
17
18
  *
@@ -54,7 +55,8 @@ function makeTicketActorAuthorizer(deps) {
54
55
  : null;
55
56
  return (0, deviceAccessPolicy_1.evaluateDeviceAccess)({
56
57
  udid,
57
- busy: !!device.busy,
58
+ // heldHere, as deviceAccessGuard reads it.
59
+ busy: (0, deviceClaims_1.heldHere)(device),
58
60
  sessionId: device.session_id,
59
61
  sessionOwnerUserId,
60
62
  // The ticket's actorId is a User id — see StreamTicketService.
@@ -36,11 +36,13 @@ const logger_1 = __importDefault(require("../../logger"));
36
36
  */
37
37
  function leaseReapReason(lease, now) {
38
38
  var _a;
39
+ // The end time first: past it, heartbeats are refused, so a lease that
40
+ // expired has missed its heartbeats too by the next sweep.
41
+ if (lease.expiresAt < now)
42
+ return `expired at ${new Date(lease.expiresAt).toISOString()}`;
39
43
  const thresholdMs = ((_a = lease.heartbeatSeconds) !== null && _a !== void 0 ? _a : 30) * 1000 * 3;
40
44
  if (lease.lastHeartbeatAt + thresholdMs < now)
41
45
  return 'missed heartbeats';
42
- if (lease.expiresAt < now)
43
- return `expired at ${new Date(lease.expiresAt).toISOString()}`;
44
46
  return null;
45
47
  }
46
48
  let LeaseOrphanSweeper = class LeaseOrphanSweeper {
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OWN_NODE_ID = void 0;
4
+ exports.useOwnNodeId = useOwnNodeId;
5
+ const typedi_1 = require("typedi");
6
+ const PluginContext_1 = require("../../src/PluginContext");
7
+ /**
8
+ * The node id of "this server" in a spec, for fake rows that are its own
9
+ * phones. /control sends another server's phone's actions to that server
10
+ * (src/app/routers/nodePhoneControl.ts) and decides whose a phone is by its
11
+ * row's nodeId, which discovery always writes. A fake row without one, whose
12
+ * host isn't one of this server's, is another server's phone.
13
+ */
14
+ exports.OWN_NODE_ID = 'this-server';
15
+ /**
16
+ * Give the PluginContext OWN_NODE_ID for each test of the enclosing
17
+ * `describe`, and put the old one back after it. Call it inside a describe.
18
+ */
19
+ function useOwnNodeId() {
20
+ let saved = '';
21
+ beforeEach(() => {
22
+ const context = typedi_1.Container.get(PluginContext_1.PluginContext);
23
+ saved = context.nodeId;
24
+ context.nodeId = exports.OWN_NODE_ID;
25
+ });
26
+ afterEach(() => {
27
+ typedi_1.Container.get(PluginContext_1.PluginContext).nodeId = saved;
28
+ });
29
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ // Loaded with --require by `npm run test:all`, before any spec.
3
+ //
4
+ // src/config.ts reads DATABASE_URL once, at import time, and falls back to
5
+ // ~/.cache/xenon/xenon.db. Specs that don't stub the store (EventLogService,
6
+ // MetricsService and others writing in passing) used to write their rows into
7
+ // that file, the database a developer's own server uses. This points the whole
8
+ // run at a freshly migrated scratch file instead, and removes it at exit.
9
+ // Specs that need a database of their own still use useScratchDatabase().
10
+ /* eslint-disable @typescript-eslint/no-var-requires -- plain CommonJS, loaded before ts-node */
11
+ const { execSync } = require('child_process');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const dbPath = path.join(os.tmpdir(), `xenon-test-run-${process.pid}-${Date.now()}.db`);
16
+ const url = `file:${dbPath}`;
17
+ execSync('npx prisma migrate deploy', {
18
+ cwd: path.resolve(__dirname, '../..'),
19
+ env: Object.assign(Object.assign({}, process.env), { DATABASE_URL: url }),
20
+ stdio: 'pipe',
21
+ });
22
+ process.env.DATABASE_URL = url;
23
+ process.on('exit', () => {
24
+ for (const f of [dbPath, `${dbPath}-journal`]) {
25
+ try {
26
+ fs.unlinkSync(f);
27
+ }
28
+ catch (_a) {
29
+ // already gone
30
+ }
31
+ }
32
+ });
@@ -14,6 +14,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  require("reflect-metadata");
16
16
  const sinon_1 = __importDefault(require("sinon"));
17
+ const AndroidDeviceManager_1 = require("../../src/device-managers/AndroidDeviceManager");
17
18
  const adbkit_1 = __importDefault(require("@devicefarmer/adbkit"));
18
19
  const appium_adb_1 = require("appium-adb");
19
20
  const chai_1 = require("chai");
@@ -165,6 +166,43 @@ describe('Android Device Manager', () => {
165
166
  (0, chai_1.expect)(devices.length).to.be.equal(1);
166
167
  (0, chai_1.expect)(devices[0]).to.have.property('udid', 'emulator-7777');
167
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
+ }));
181
+ // A phone adb reports as plugged (and every phone connected when the server
182
+ // starts) went through onDeviceAdded with no androidDeviceType check: a
183
+ // hub set to emulators only listed a real phone until its next sync pruned
184
+ // it, and could hand it to a session meanwhile.
185
+ describe('a plugged phone', () => {
186
+ const plug = (androidDeviceType, real) => __awaiter(void 0, void 0, void 0, function* () {
187
+ const manager = (0, test_container_1.createTestAndroidManager)({ platform: 'android', androidDeviceType });
188
+ sandbox.stub(manager, 'waitBootComplete').resolves(true);
189
+ sandbox.stub(manager, 'isRealDevice').resolves(real);
190
+ // Stands for the rest of the add: the port lease, the row, the hub.
191
+ const deviceInfo = sandbox.stub(manager, 'deviceInfo').resolves(undefined);
192
+ yield manager.onDeviceAdded(adb, { id: 'plugged-1', type: 'device' });
193
+ return deviceInfo.called;
194
+ });
195
+ it('is ignored when the server doesn’t serve its kind', () => __awaiter(void 0, void 0, void 0, function* () {
196
+ (0, chai_1.expect)(yield plug('simulated', true), 'a real phone, emulators only').to.equal(false);
197
+ (0, chai_1.expect)(yield plug('real', false), 'an emulator, real phones only').to.equal(false);
198
+ }));
199
+ it('is added when the server serves its kind', () => __awaiter(void 0, void 0, void 0, function* () {
200
+ (0, chai_1.expect)(yield plug('simulated', false)).to.equal(true);
201
+ (0, chai_1.expect)(yield plug('real', true)).to.equal(true);
202
+ (0, chai_1.expect)(yield plug('both', true)).to.equal(true);
203
+ (0, chai_1.expect)(yield plug('both', false)).to.equal(true);
204
+ }));
205
+ });
168
206
  it('should handle device never completing boot', () => __awaiter(void 0, void 0, void 0, function* () {
169
207
  const androidDevices = (0, test_container_1.createTestAndroidManager)({ platform: 'android' });
170
208
  // @ts-expect-error - Accessing private member for testing
@@ -183,3 +221,25 @@ describe('Android Device Manager', () => {
183
221
  }).to.not.throw();
184
222
  }));
185
223
  });
224
+ // The one rule discovery and a plugged phone both apply.
225
+ describe('includesAndroidDevice', () => {
226
+ const real = { deviceType: 'real', state: 'device' };
227
+ const emulator = { deviceType: 'emulator', state: 'device' };
228
+ const offlineEmulator = { deviceType: 'emulator', state: 'offline' };
229
+ it('real: real phones only', () => {
230
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(real, 'real')).to.equal(true);
231
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(emulator, 'real')).to.equal(false);
232
+ });
233
+ it('simulated: emulators only, booted ones when bootedEmulators is on', () => {
234
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(real, 'simulated')).to.equal(false);
235
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(emulator, 'simulated')).to.equal(true);
236
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(offlineEmulator, 'simulated')).to.equal(true);
237
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(offlineEmulator, 'simulated', true)).to.equal(false);
238
+ });
239
+ it('both: everything, emulators booted when bootedEmulators is on', () => {
240
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(real, 'both')).to.equal(true);
241
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(emulator, 'both', true)).to.equal(true);
242
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(offlineEmulator, 'both', true)).to.equal(false);
243
+ (0, chai_1.expect)((0, AndroidDeviceManager_1.includesAndroidDevice)(Object.assign(Object.assign({}, real), { state: 'offline' }), 'both', true)).to.equal(true);
244
+ });
245
+ });
@@ -14,6 +14,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  require("reflect-metadata");
16
16
  const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
17
18
  const typedi_1 = require("typedi");
18
19
  const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
19
20
  const PluginContext_1 = require("../../src/PluginContext");
@@ -50,6 +51,23 @@ describe('AndroidH264StreamService', () => {
50
51
  (0, chai_1.expect)(mux).to.equal(svc.getMultiplexer('udid-x'));
51
52
  (0, chai_1.expect)(mux.clientCount).to.equal(0);
52
53
  }));
54
+ it('start() leaves no timer behind once the config packet arrives', () => __awaiter(void 0, void 0, void 0, function* () {
55
+ const clock = sinon_1.default.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
56
+ try {
57
+ const svc = make();
58
+ svc.openCapture = (_udid, onPacket) => __awaiter(void 0, void 0, void 0, function* () {
59
+ onPacket({ type: 'config', data: Buffer.from([0]), ptsMs: 0 });
60
+ return { kill: () => undefined };
61
+ });
62
+ yield svc.start('udid-t');
63
+ // The 3 s wait is only a cap: a start that got its config in time must
64
+ // not leave it pending for the process to wait out.
65
+ (0, chai_1.expect)(clock.countTimers()).to.equal(0);
66
+ }
67
+ finally {
68
+ clock.restore();
69
+ }
70
+ }));
53
71
  it('start() is idempotent per udid (concurrent starts share one capture)', () => __awaiter(void 0, void 0, void 0, function* () {
54
72
  const svc = make();
55
73
  let opens = 0;
@@ -26,6 +26,7 @@ const clipboardErrors_1 = require("../../src/device-managers/clipboardErrors");
26
26
  const authMiddleware_1 = require("../../src/middleware/authMiddleware");
27
27
  const container_registration_1 = require("../helpers/container-registration");
28
28
  const loopbackServer_1 = require("../helpers/loopbackServer");
29
+ const own_node_id_1 = require("../helpers/own-node-id");
29
30
  /**
30
31
  * GET/POST /control/:udid/clipboard answered 200 whatever happened on the
31
32
  * device: the Android manager swallowed its errors, and its "set" had no
@@ -58,6 +59,8 @@ function buildApp() {
58
59
  return app;
59
60
  }
60
61
  describe('GET/POST /control/:udid/clipboard', () => {
62
+ // The fake rows are this server's own phones.
63
+ (0, own_node_id_1.useOwnNodeId)();
61
64
  let manager;
62
65
  let restoreContainer;
63
66
  // Served on 127.0.0.1, not by request(app): that listens on every address
@@ -73,6 +76,7 @@ describe('GET/POST /control/:udid/clipboard', () => {
73
76
  return ({
74
77
  udid: UDID,
75
78
  host: '127.0.0.1',
79
+ nodeId: own_node_id_1.OWN_NODE_ID,
76
80
  platform: 'android',
77
81
  busy: false,
78
82
  session_id: null,
@@ -21,11 +21,11 @@ const typedi_1 = require("typedi");
21
21
  const control_1 = __importDefault(require("../../src/app/routers/control"));
22
22
  const device_store_1 = require("../../src/data-service/device-store");
23
23
  const device_managers_1 = require("../../src/device-managers");
24
- const InternalHttpClient_1 = require("../../src/InternalHttpClient");
25
24
  const PluginContext_1 = require("../../src/PluginContext");
26
25
  const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
27
26
  const authMiddleware_1 = require("../../src/middleware/authMiddleware");
28
27
  const container_registration_1 = require("../helpers/container-registration");
28
+ const loopbackServer_1 = require("../helpers/loopbackServer");
29
29
  /**
30
30
  * Whether a /control action is forwarded to another server is decided by the
31
31
  * phone's own row (its nodeId, else its exact host against this server's own
@@ -64,8 +64,8 @@ function buildApp() {
64
64
  return app;
65
65
  }
66
66
  describe('/control forwards only another server’s phone', () => {
67
+ const loopback = (0, loopbackServer_1.loopbackServers)();
67
68
  let manager;
68
- let post;
69
69
  let restore;
70
70
  let context;
71
71
  let savedContext;
@@ -80,13 +80,13 @@ describe('/control forwards only another server’s phone', () => {
80
80
  sinon_1.default
81
81
  .stub(device_store_1.DeviceStoreFactory, 'getStore')
82
82
  .returns({ findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return (Object.assign({}, device)); }) });
83
- post = sinon_1.default.stub(InternalHttpClient_1.InternalHttpClient, 'post').resolves({ data: {} });
84
83
  });
85
- afterEach(() => {
84
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
86
85
  Object.assign(context, savedContext);
87
86
  sinon_1.default.restore();
88
87
  restore();
89
- });
88
+ yield loopback.closeAll();
89
+ }));
90
90
  const tap = (udid, hostHeader) => (0, loopbackRequest_1.default)(buildApp())
91
91
  .post(`/xenon/api/control/${udid}/tap`)
92
92
  .set('Host', hostHeader)
@@ -96,20 +96,20 @@ describe('/control forwards only another server’s phone', () => {
96
96
  const res = yield tap('own-1', 'localhost:4723');
97
97
  (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
98
98
  (0, chai_1.expect)(manager.taps).to.deep.equal(['own-1']);
99
- (0, chai_1.expect)(post.called, 'never forwarded to itself').to.equal(false);
100
99
  }));
101
- it('forwards a node’s phone once, never retried', () => __awaiter(void 0, void 0, void 0, function* () {
102
- device = {
103
- udid: 'node-phone',
104
- host: 'http://10.0.0.9:4725',
105
- nodeId: 'node-9',
106
- platform: 'android',
107
- };
100
+ it('forwards a node’s phone once, never retried, and relays its answer', () => __awaiter(void 0, void 0, void 0, function* () {
101
+ const node = (0, express_1.default)();
102
+ const hits = [];
103
+ node.use((req, res) => {
104
+ hits.push(req.originalUrl);
105
+ res.status(503).json({ error: 'node busy' });
106
+ });
107
+ const nodeOrigin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
108
+ device = { udid: 'node-phone', host: nodeOrigin, nodeId: 'node-9', platform: 'android' };
108
109
  const res = yield tap('node-phone', '192.168.0.104:4723');
109
- (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
110
+ (0, chai_1.expect)(res.status).to.equal(503);
111
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'node busy' });
110
112
  (0, chai_1.expect)(manager.taps).to.deep.equal([]);
111
- (0, chai_1.expect)(post.calledOnce).to.equal(true);
112
- (0, chai_1.expect)(post.firstCall.args[0]).to.equal('http://10.0.0.9:4725/xenon/api/control/node-phone/tap');
113
- (0, chai_1.expect)(post.firstCall.args[2]).to.include({ retry: false });
113
+ (0, chai_1.expect)(hits).to.deep.equal(['/xenon/api/control/node-phone/tap']);
114
114
  }));
115
115
  });
@@ -261,3 +261,41 @@ describe('deviceAccessGuard', () => {
261
261
  (0, chai_1.expect)(res.body.reached).to.equal(true);
262
262
  }));
263
263
  });
264
+ /**
265
+ * On a hub, a node's phone is busy when its node reports it busy (nodeBusy):
266
+ * a preview the node holds, say. The hub has no holder on record for that:
267
+ * no session and no hold of its own. Failing closed on "busy, owner unknown"
268
+ * refused every non-admin, the preview's own holder included, so the hub
269
+ * leaves that decision to the node, whose own guard knows the holder and
270
+ * judges every call the hub forwards. What the hub holds itself (a claim, a
271
+ * session, a hold) it still judges.
272
+ */
273
+ describe('deviceAccessGuard on a node’s phone', () => {
274
+ const nodePhone = (row) => appWith({ actorUserId: BOB, findDeviceImpl: () => __awaiter(void 0, void 0, void 0, function* () { return row; }), sessionOwner: ALICE });
275
+ it('leaves a phone busy only by its node’s report to the node', () => __awaiter(void 0, void 0, void 0, function* () {
276
+ const app = nodePhone({ busy: true, nodeBusy: true, session_id: null });
277
+ const tap = yield (0, loopbackRequest_1.default)(app).post(`/control/${UDID}/tap`);
278
+ (0, chai_1.expect)(tap.status).to.equal(200);
279
+ (0, chai_1.expect)(tap.body.reached).to.equal(true);
280
+ const logs = yield (0, loopbackRequest_1.default)(app).get(`/control/${UDID}/logs`);
281
+ (0, chai_1.expect)(logs.status).to.equal(200);
282
+ }));
283
+ it('still judges a session this server routed to the node', () => __awaiter(void 0, void 0, void 0, function* () {
284
+ const tap = yield (0, loopbackRequest_1.default)(nodePhone({
285
+ busy: true,
286
+ nodeBusy: true,
287
+ session_id: 'appium-1',
288
+ claimSessionId: 'appium-1',
289
+ })).post(`/control/${UDID}/tap`);
290
+ (0, chai_1.expect)(tap.status).to.equal(409);
291
+ (0, chai_1.expect)(tap.body.error).to.equal('device_in_use_by_session');
292
+ }));
293
+ it('still refuses while this server’s own create for the phone is pending', () => __awaiter(void 0, void 0, void 0, function* () {
294
+ const tap = yield (0, loopbackRequest_1.default)(nodePhone({ busy: true, nodeBusy: true, session_id: null, claimedAt: 1790000000000 })).post(`/control/${UDID}/tap`);
295
+ (0, chai_1.expect)(tap.status).to.equal(409);
296
+ }));
297
+ it('still refuses this server’s own phone busy with no holder on record', () => __awaiter(void 0, void 0, void 0, function* () {
298
+ const tap = yield (0, loopbackRequest_1.default)(nodePhone({ busy: true, session_id: null })).post(`/control/${UDID}/tap`);
299
+ (0, chai_1.expect)(tap.status).to.equal(409);
300
+ }));
301
+ });