@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
@@ -72,6 +72,11 @@ const config_1 = require("../../src/config");
72
72
  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
+ const InspectorService_1 = require("../../src/services/InspectorService");
76
+ const OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
77
+ const http_1 = __importDefault(require("http"));
78
+ const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
79
+ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
75
80
  /**
76
81
  * A hub forwards five of its /control actions on a node's phone (tap, swipe,
77
82
  * text, keyevent, touchAndHold) to the phone's node. It sent them with no
@@ -86,12 +91,54 @@ const loopbackServer_1 = require("../helpers/loopbackServer");
86
91
  * knows (LiveSessionOwners).
87
92
  */
88
93
  // Named like the real manager: /control picks one by constructor.name.
94
+ // Hub and node share this process, so each call records the server it ran
95
+ // on: an action the hub ran on its own would show up as `hub-1:...`.
89
96
  class AndroidDeviceManager {
90
97
  constructor() {
91
98
  this.taps = [];
99
+ this.calls = [];
100
+ this.failLogs = false;
92
101
  this.tap = (udid) => __awaiter(this, void 0, void 0, function* () {
93
102
  this.taps.push(udid);
94
103
  });
104
+ this.getScreenshot = () => __awaiter(this, void 0, void 0, function* () {
105
+ this.at('getScreenshot');
106
+ return 'A'.repeat(200);
107
+ });
108
+ this.getClipboard = () => __awaiter(this, void 0, void 0, function* () {
109
+ this.at('getClipboard');
110
+ return 'copied';
111
+ });
112
+ this.setClipboard = () => __awaiter(this, void 0, void 0, function* () { return this.at('setClipboard'); });
113
+ this.lock = () => __awaiter(this, void 0, void 0, function* () { return this.at('lock'); });
114
+ this.unlock = () => __awaiter(this, void 0, void 0, function* () { return this.at('unlock'); });
115
+ this.getDisplayState = () => __awaiter(this, void 0, void 0, function* () {
116
+ this.at('getDisplayState');
117
+ return 'on';
118
+ });
119
+ this.uninstallApp = () => __awaiter(this, void 0, void 0, function* () { return this.at('uninstallApp'); });
120
+ this.listApps = () => __awaiter(this, void 0, void 0, function* () {
121
+ this.at('listApps');
122
+ return [{ bundleId: 'com.example' }];
123
+ });
124
+ this.getLogs = () => __awaiter(this, void 0, void 0, function* () {
125
+ this.at('getLogs');
126
+ if (this.failLogs)
127
+ throw new Error('logcat failed on the node');
128
+ return 'a log line';
129
+ });
130
+ this.executeShell = () => __awaiter(this, void 0, void 0, function* () {
131
+ this.at('executeShell');
132
+ return 'uid=2000(shell)';
133
+ });
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
+ });
139
+ }
140
+ at(name) {
141
+ this.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:${name}`);
95
142
  }
96
143
  }
97
144
  describe('the hub’s device control on a node’s phone', () => {
@@ -108,17 +155,25 @@ describe('the hub’s device control on a node’s phone', () => {
108
155
  let nodeOrigin;
109
156
  let nodePort;
110
157
  let nodeArgs;
158
+ let nodeRequests;
111
159
  beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
112
160
  dirs = [fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-control-keys-'))];
113
161
  hubKeys = new JwtKeyService_1.JwtKeyService();
114
162
  yield hubKeys.init(dirs[0]);
115
- restore = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService, hubSessionToken_1.HubSessionTokenIssuer, device_managers_1.XenonManager, SessionOwnerResolver_1.SessionOwnerResolver, LiveSessionOwners_1.LiveSessionOwners);
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);
116
164
  typedi_1.Container.set(JwtKeyService_1.JwtKeyService, hubKeys);
117
165
  typedi_1.Container.set(hubSessionToken_1.HubSessionTokenIssuer, new hubSessionToken_1.HubSessionTokenIssuer());
118
166
  typedi_1.Container.set(SessionOwnerResolver_1.SessionOwnerResolver, new SessionOwnerResolver_1.SessionOwnerResolver());
119
167
  typedi_1.Container.set(LiveSessionOwners_1.LiveSessionOwners, new LiveSessionOwners_1.LiveSessionOwners());
120
168
  manager = new AndroidDeviceManager();
121
169
  typedi_1.Container.set(device_managers_1.XenonManager, { deviceInstances: () => __awaiter(void 0, void 0, void 0, function* () { return [manager]; }) });
170
+ typedi_1.Container.set(InspectorService_1.InspectorService, {
171
+ getSnapshot: () => __awaiter(void 0, void 0, void 0, function* () {
172
+ manager.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:getSnapshot`);
173
+ return { source: '<hierarchy/>' };
174
+ }),
175
+ });
176
+ nodeRequests = [];
122
177
  context = typedi_1.Container.get(PluginContext_1.PluginContext);
123
178
  saved = {
124
179
  context: Object.assign({}, context),
@@ -159,6 +214,15 @@ describe('the hub’s device control on a node’s phone', () => {
159
214
  hubApp.use('/xenon/api', hubApi);
160
215
  // The node: its CSRF check, its login and its /control, as ServerManager mounts them.
161
216
  const node = (0, express_1.default)();
217
+ node.use((req, _res, next) => {
218
+ nodeRequests.push({
219
+ method: req.method,
220
+ path: req.path,
221
+ url: req.originalUrl,
222
+ headers: Object.assign({}, req.headers),
223
+ });
224
+ next();
225
+ });
162
226
  node.use((_req, _res, next) => {
163
227
  context.setContext(nodeArgs, nodePort, 'node-1', '');
164
228
  next();
@@ -236,6 +300,315 @@ describe('the hub’s device control on a node’s phone', () => {
236
300
  yield (0, auth_1.issueToken)({ userId: 'alice', role: 'ADMIN', scopes: 'admin' }, { audience: hubSessionToken_1.HUB_CONTROL_AUDIENCE }).catch((error) => (refused = error));
237
301
  (0, chai_1.expect)(refused === null || refused === void 0 ? void 0 : refused.message).to.include('unsupported audience');
238
302
  }));
303
+ describe('which actions reach the node', () => {
304
+ const asAlice = (method, action, body) => {
305
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/phone-1/${action}`);
306
+ req.set('x-test-user', 'alice');
307
+ return method === 'post' ? req.send(body !== null && body !== void 0 ? body : {}) : req;
308
+ };
309
+ const nodeSaw = (action) => nodeRequests.filter((r) => r.path === `/xenon/api/control/phone-1/${action}`);
310
+ // Each runs on the node and answers with the node's own reply.
311
+ const FORWARDED = [
312
+ {
313
+ method: 'get',
314
+ action: 'screenshot',
315
+ call: 'getScreenshot',
316
+ reply: { screenshot: 'A'.repeat(200) },
317
+ },
318
+ { method: 'get', action: 'clipboard', call: 'getClipboard', reply: { content: 'copied' } },
319
+ {
320
+ method: 'post',
321
+ action: 'clipboard',
322
+ send: { content: 'x' },
323
+ call: 'setClipboard',
324
+ reply: { success: true },
325
+ },
326
+ { method: 'post', action: 'lock', call: 'lock', reply: { success: true } },
327
+ { method: 'post', action: 'unlock', call: 'unlock', reply: { success: true } },
328
+ { method: 'get', action: 'display', call: 'getDisplayState', reply: { state: 'on' } },
329
+ {
330
+ method: 'post',
331
+ action: 'uninstall',
332
+ send: { bundleId: 'com.example' },
333
+ call: 'uninstallApp',
334
+ reply: { success: true },
335
+ },
336
+ { method: 'get', action: 'apps', call: 'listApps', reply: [{ bundleId: 'com.example' }] },
337
+ { method: 'get', action: 'logs', call: 'getLogs', reply: { logs: 'a log line' } },
338
+ {
339
+ method: 'post',
340
+ action: 'shell',
341
+ send: { command: 'id' },
342
+ call: 'executeShell',
343
+ reply: { output: 'uid=2000(shell)' },
344
+ },
345
+ {
346
+ method: 'get',
347
+ action: 'inspector/snapshot',
348
+ call: 'getSnapshot',
349
+ reply: { source: '<hierarchy/>' },
350
+ },
351
+ ];
352
+ for (const { method, action, send, call, reply } of FORWARDED) {
353
+ it(`${method.toUpperCase()} ${action} runs on the node and answers with the node's reply`, () => __awaiter(void 0, void 0, void 0, function* () {
354
+ const res = yield asAlice(method, action, send);
355
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
356
+ (0, chai_1.expect)(res.body).to.deep.equal(reply);
357
+ (0, chai_1.expect)(manager.calls).to.deep.equal([`node-1:${call}`]);
358
+ (0, chai_1.expect)(nodeSaw(action)).to.have.length(1);
359
+ }));
360
+ }
361
+ it('relays the node’s own error answer unchanged, sent once', () => __awaiter(void 0, void 0, void 0, function* () {
362
+ manager.failLogs = true;
363
+ const res = yield asAlice('get', 'logs');
364
+ (0, chai_1.expect)(res.status).to.equal(500);
365
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'logcat failed on the node' });
366
+ (0, chai_1.expect)(nodeSaw('logs')).to.have.length(1);
367
+ }));
368
+ it('sends the node none of the caller’s credentials, only its own token', () => __awaiter(void 0, void 0, void 0, function* () {
369
+ yield (0, loopbackRequest_1.default)(hubApp)
370
+ .post('/xenon/api/control/phone-1/lock')
371
+ .set('x-test-user', 'alice')
372
+ .set('cookie', 'xenon_dashboard_session=secret-session')
373
+ .set('authorization', 'Bearer secret-jwt')
374
+ .set('x-xenon-access-key', 'secret-key')
375
+ .set('x-xenon-token', 'secret-token')
376
+ .send({})
377
+ .expect(200);
378
+ const [sent] = nodeSaw('lock');
379
+ for (const name of [
380
+ 'cookie',
381
+ 'authorization',
382
+ 'x-xenon-access-key',
383
+ 'x-xenon-token',
384
+ 'x-test-user',
385
+ ]) {
386
+ (0, chai_1.expect)(sent.headers, name).to.not.have.property(name);
387
+ }
388
+ (0, chai_1.expect)(sent.headers[hubSessionToken_1.HUB_TOKEN_HEADER]).to.be.a('string');
389
+ }));
390
+ it('answers appium-session itself: the session is routed through the hub', () => __awaiter(void 0, void 0, void 0, function* () {
391
+ const res = yield asAlice('get', 'appium-session');
392
+ (0, chai_1.expect)(res.status).to.equal(200);
393
+ (0, chai_1.expect)(res.body).to.include({ status: 'success', sessionId: 's-1' });
394
+ (0, chai_1.expect)(nodeSaw('appium-session')).to.have.length(0);
395
+ }));
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.
398
+ const REFUSED = [
399
+ ['post', 'install', { appPath: '/tmp/app.apk' }],
400
+ ['post', 'an-action-added-later'],
401
+ ];
402
+ describe('the live preview', () => {
403
+ // phone-2 is free: a preview holds it on the node.
404
+ const preview = (method, action) => {
405
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/phone-2/${action}`);
406
+ req.set('x-test-user', 'alice');
407
+ return method === 'post' ? req.send({}) : req;
408
+ };
409
+ const nodeSawPreview = (action) => nodeRequests.filter((r) => r.path === `/xenon/api/control/phone-2/${action}`);
410
+ let started;
411
+ let stopped;
412
+ let mjpegPort;
413
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
414
+ started = [];
415
+ stopped = [];
416
+ // The camera the node's MJPEG fan-out reads: a frame every 50 ms.
417
+ const camera = (0, express_1.default)();
418
+ camera.get('/', (_req, res) => {
419
+ res.writeHead(200, { 'content-type': 'multipart/x-mixed-replace; boundary=f' });
420
+ const timer = setInterval(() => res.write('--f\r\nFAKE-JPEG-FRAME\r\n'), 50);
421
+ res.on('close', () => clearInterval(timer));
422
+ });
423
+ mjpegPort = (yield loopback.serve(camera)).address().port;
424
+ const mjpeg = typedi_1.Container.get(AndroidStreamService_1.default);
425
+ const h264 = typedi_1.Container.get(AndroidH264StreamService_1.default);
426
+ const where = () => typedi_1.Container.get(PluginContext_1.PluginContext).nodeId;
427
+ sinon_1.default.stub(mjpeg, 'startStream').callsFake((udid) => __awaiter(void 0, void 0, void 0, function* () {
428
+ started.push(`${where()}:${udid}`);
429
+ return { mjpegPort };
430
+ }));
431
+ sinon_1.default.stub(mjpeg, 'stopStream').callsFake((udid) => __awaiter(void 0, void 0, void 0, function* () {
432
+ stopped.push(`${where()}:${udid}`);
433
+ }));
434
+ sinon_1.default.stub(mjpeg, 'getStreamStatus').returns(undefined);
435
+ sinon_1.default.stub(mjpeg, 'updateViewerCount');
436
+ sinon_1.default.stub(h264, 'stop').resolves();
437
+ sinon_1.default.stub(h264, 'getMultiplexer').returns(undefined);
438
+ }));
439
+ it('starts on the node, which holds the phone, and the hub marks it busy for the node at once', () => __awaiter(void 0, void 0, void 0, function* () {
440
+ const res = yield preview('post', 'stream/start');
441
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
442
+ (0, chai_1.expect)(res.body).to.include({ success: true, type: 'mjpeg' });
443
+ (0, chai_1.expect)(started).to.deep.equal(['node-1:phone-2']);
444
+ const row = yield scratch.db.device.findFirst({ where: { udid: 'phone-2' } });
445
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.session_id).to.equal('manual_alice_phone-2');
446
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.nodeBusy).to.equal(true);
447
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.busy).to.equal(true);
448
+ }));
449
+ it('status, leave and stop are the node’s answers', () => __awaiter(void 0, void 0, void 0, function* () {
450
+ yield preview('post', 'stream/start').expect(200);
451
+ const status = yield preview('get', 'stream/status');
452
+ (0, chai_1.expect)(status.status).to.equal(200);
453
+ (0, chai_1.expect)(status.body).to.include({ udid: 'phone-2', status: 'stopped' });
454
+ yield preview('post', 'stream/leave').expect(202);
455
+ yield preview('post', 'stream/stop').expect(200);
456
+ (0, chai_1.expect)(stopped).to.deep.equal(['node-1:phone-2']);
457
+ for (const action of ['stream/status', 'stream/leave', 'stream/stop']) {
458
+ (0, chai_1.expect)(nodeSawPreview(action), action).to.have.length(1);
459
+ }
460
+ }));
461
+ it('mints the viewer’s ticket here, where the team check ran', () => __awaiter(void 0, void 0, void 0, function* () {
462
+ const res = yield preview('post', 'stream/ticket');
463
+ (0, chai_1.expect)(res.status).to.equal(200);
464
+ (0, chai_1.expect)(res.body.ticket).to.be.a('string');
465
+ (0, chai_1.expect)(nodeSawPreview('stream/ticket')).to.have.length(0);
466
+ }));
467
+ it('relays the node’s MJPEG stream to the viewer, without the viewer’s ticket', () => __awaiter(void 0, void 0, void 0, function* () {
468
+ const hubPort = (yield loopback.serve(hubApp)).address().port;
469
+ const got = yield new Promise((resolve, reject) => {
470
+ const req = http_1.default.get({
471
+ host: '127.0.0.1',
472
+ port: hubPort,
473
+ path: '/xenon/api/control/phone-2/stream?ticket=spent&t=1',
474
+ headers: { 'x-test-user': 'alice' },
475
+ }, (res) => {
476
+ let body = '';
477
+ res.on('data', (chunk) => {
478
+ body += chunk.toString();
479
+ if (body.split('FAKE-JPEG-FRAME').length > 3) {
480
+ req.destroy();
481
+ resolve({ type: String(res.headers['content-type']), body });
482
+ }
483
+ });
484
+ });
485
+ req.on('error', (e) => (e.code === 'ECONNRESET' ? undefined : reject(e)));
486
+ });
487
+ (0, chai_1.expect)(got.type).to.include('multipart/x-mixed-replace');
488
+ (0, chai_1.expect)(started).to.deep.equal(['node-1:phone-2']);
489
+ (0, chai_1.expect)(nodeSawPreview('stream').map((r) => r.url)).to.deep.equal([
490
+ '/xenon/api/control/phone-2/stream?t=1',
491
+ ]);
492
+ yield preview('post', 'stream/stop').expect(200);
493
+ }));
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
+ });
552
+ it('refuses the rest, naming the node, and runs none of it on the hub', () => __awaiter(void 0, void 0, void 0, function* () {
553
+ for (const [method, action, body] of REFUSED) {
554
+ const res = yield asAlice(method, action, body);
555
+ (0, chai_1.expect)(res.status, `${method} ${action}`).to.equal(501);
556
+ (0, chai_1.expect)(res.body.error, `${method} ${action}`).to.equal('not_available_through_hub');
557
+ (0, chai_1.expect)(res.body.message, `${method} ${action}`).to.include(nodeOrigin);
558
+ }
559
+ (0, chai_1.expect)(nodeRequests).to.have.length(0);
560
+ (0, chai_1.expect)(manager.calls).to.deep.equal([]);
561
+ }));
562
+ it('sends nothing to a cloud provider’s phone', () => __awaiter(void 0, void 0, void 0, function* () {
563
+ const provider = (0, express_1.default)();
564
+ let reached = 0;
565
+ provider.use((_req, res) => {
566
+ reached++;
567
+ res.status(200).json({});
568
+ });
569
+ const providerOrigin = `http://127.0.0.1:${(yield loopback.serve(provider)).address().port}`;
570
+ yield scratch.db.device.create({
571
+ data: {
572
+ udid: 'cloud-1',
573
+ host: providerOrigin,
574
+ platform: 'android',
575
+ name: 'cloud-1',
576
+ cloud: JSON.stringify({ cloudName: 'example-cloud' }),
577
+ },
578
+ });
579
+ for (const [method, action, body] of [
580
+ ['post', 'tap', { x: 1, y: 2 }],
581
+ ['get', 'screenshot'],
582
+ ['post', 'text', { text: 'a secret' }],
583
+ ]) {
584
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/cloud-1/${action}`)
585
+ .set('x-test-user', 'alice');
586
+ const res = yield (method === 'post' ? req.send(body !== null && body !== void 0 ? body : {}) : req);
587
+ (0, chai_1.expect)(res.status, action).to.equal(501);
588
+ (0, chai_1.expect)(res.body.error, action).to.equal('not_available_for_cloud_phone');
589
+ }
590
+ (0, chai_1.expect)(reached).to.equal(0);
591
+ (0, chai_1.expect)(manager.taps).to.deep.equal([]);
592
+ }));
593
+ it('a node that can’t be reached: 502, naming the node', () => __awaiter(void 0, void 0, void 0, function* () {
594
+ yield scratch.db.device.create({
595
+ data: {
596
+ udid: 'gone-1',
597
+ host: 'http://127.0.0.1:1',
598
+ nodeId: 'node-2',
599
+ platform: 'android',
600
+ name: 'gone-1',
601
+ },
602
+ });
603
+ const res = yield (0, loopbackRequest_1.default)(hubApp)
604
+ .get('/xenon/api/control/gone-1/screenshot')
605
+ .set('x-test-user', 'alice');
606
+ (0, chai_1.expect)(res.status).to.equal(502);
607
+ (0, chai_1.expect)(res.body.error).to.equal('node_unreachable');
608
+ (0, chai_1.expect)(res.body.message).to.include('http://127.0.0.1:1');
609
+ (0, chai_1.expect)(manager.calls).to.deep.equal([]);
610
+ }));
611
+ });
239
612
  describe('on the node', () => {
240
613
  it('the node’s own ownership guard judges the user the hub named', () => __awaiter(void 0, void 0, void 0, function* () {
241
614
  const asBob = yield controlToken({ userId: 'bob' });
@@ -376,6 +376,57 @@ describe('The hub/node busy race', function () {
376
376
  (0, chai_1.expect)(alive.calledWith(HUB)).to.equal(false);
377
377
  }));
378
378
  });
379
+ describe('a preview the node has just started', () => {
380
+ it('is busy on the hub at once, and the node’s next report settles it', () => __awaiter(this, void 0, void 0, function* () {
381
+ yield scratch.db.device.create({ data: nodePhone('s9') });
382
+ // The node answered a forwarded stream/start: it holds the phone now,
383
+ // though its last report said free.
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
+ });
391
+ let session;
392
+ try {
393
+ session = yield (0, device_utils_1.allocateDeviceForSession)(caps('s9'), 200, 50, pluginArgs);
394
+ }
395
+ catch (err) {
396
+ session = err;
397
+ }
398
+ (0, chai_1.expect)(session, 'not handed to a session').to.be.an('error');
399
+ // The preview ended, and the node says so.
400
+ yield report(nodePhone('s9', { busy: false }));
401
+ (0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false, nodeHold: null });
402
+ }));
403
+ it('touches no other server’s row for the same udid', () => __awaiter(this, void 0, void 0, function* () {
404
+ yield scratch.db.device.create({ data: nodePhone('s9') });
405
+ yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('s9', 'http://10.9.9.9:4725');
406
+ (0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false });
407
+ }));
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
+ });
379
430
  describe('a standalone server', () => {
380
431
  it('allocates, runs and frees its own phone as before', () => __awaiter(this, void 0, void 0, function* () {
381
432
  yield scratch.db.device.create({
@@ -25,6 +25,7 @@ const device_managers_1 = require("../../src/device-managers");
25
25
  const app_service_1 = require("../../src/dashboard/services/app-service");
26
26
  const authMiddleware_1 = require("../../src/middleware/authMiddleware");
27
27
  const container_registration_1 = require("../helpers/container-registration");
28
+ const own_node_id_1 = require("../helpers/own-node-id");
28
29
  /**
29
30
  * Installing an uploaded app onto a phone hands over its binary as surely as
30
31
  * downloading it does, so POST /control/:udid/install-repository-app follows
@@ -63,6 +64,8 @@ function buildApp(teamIds) {
63
64
  return app;
64
65
  }
65
66
  describe('POST /control/:udid/install-repository-app follows the app team rule', () => {
67
+ // The fake rows are this server's own phones.
68
+ (0, own_node_id_1.useOwnNodeId)();
66
69
  let manager;
67
70
  let restore;
68
71
  beforeEach(() => {
@@ -74,6 +77,7 @@ describe('POST /control/:udid/install-repository-app follows the app team rule',
74
77
  return ({
75
78
  udid: UDID,
76
79
  host: '127.0.0.1',
80
+ nodeId: own_node_id_1.OWN_NODE_ID,
77
81
  platform: 'android',
78
82
  busy: false,
79
83
  session_id: null,
@@ -90,3 +90,18 @@ describe('LeaseOrphanSweeper: a lease ends at expiresAt (scratch DB)', () => {
90
90
  (0, chai_1.expect)(releaseLeaseLock.called).to.equal(false);
91
91
  }));
92
92
  });
93
+ describe('leaseReapReason', () => {
94
+ // Past expiresAt, heartbeat and extend refuse the lease (410), so by the next
95
+ // sweep its heartbeats are missed too. The end time is the cause; the lab's
96
+ // log said "missed heartbeats" for every such lease.
97
+ it('names the end time for a lease past expiresAt, even when its heartbeats are missed too', () => {
98
+ const now = Date.now();
99
+ const reason = (0, LeaseOrphanSweeper_1.leaseReapReason)({ heartbeatSeconds: 10, lastHeartbeatAt: now - 60000, expiresAt: now - 1000 }, now);
100
+ (0, chai_1.expect)(reason).to.match(/^expired at /);
101
+ });
102
+ it('names missed heartbeats for a lease before its end time', () => {
103
+ const now = Date.now();
104
+ const reason = (0, LeaseOrphanSweeper_1.leaseReapReason)({ heartbeatSeconds: 10, lastHeartbeatAt: now - 60000, expiresAt: now + 60000 }, now);
105
+ (0, chai_1.expect)(reason).to.equal('missed heartbeats');
106
+ });
107
+ });
@@ -419,6 +419,14 @@ describe('makeTicketActorAuthorizer', () => {
419
419
  });
420
420
  return { fn, calls };
421
421
  };
422
+ it('leaves a node’s phone busy only by its node’s report to the node', () => __awaiter(void 0, void 0, void 0, function* () {
423
+ // The hub relays this socket from the node, whose own check knows the
424
+ // holder (deviceAccessGuard's spec says why).
425
+ const { fn } = authorizer({ busy: true, nodeBusy: true, session_id: null }, ALICE);
426
+ (0, chai_1.expect)(yield fn(UDID, { actorId: BOB })).to.equal(true);
427
+ const routed = authorizer({ busy: true, nodeBusy: true, session_id: 'appium-1', claimSessionId: 'appium-1' }, ALICE);
428
+ (0, chai_1.expect)(yield routed.fn(UDID, { actorId: BOB })).to.equal(false);
429
+ }));
422
430
  it('denies a device the store cannot resolve', () => __awaiter(void 0, void 0, void 0, function* () {
423
431
  // Fail-closed, and deliberately unlike deviceAccessGuard's fall-through:
424
432
  // there is no 404 behind this call, and getAdbForDevice happily runs
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ require("reflect-metadata");
13
+ const chai_1 = require("chai");
14
+ const db_1 = require("../../src/data-service/db");
15
+ const device_store_1 = require("../../src/data-service/device-store");
16
+ const loki_stores_1 = require("../helpers/loki-stores");
17
+ /**
18
+ * markNodeBusy on the in-memory store: the hub's note that a node has just
19
+ * taken its phone for a preview. The Prisma store is covered with the hub's
20
+ * allocation in hub-node-race.spec.ts.
21
+ */
22
+ describe('markNodeBusy (in-memory store)', () => {
23
+ (0, loki_stores_1.useLokiStores)();
24
+ const NODE = 'http://10.0.0.9:4725';
25
+ const phone = (host) => ({
26
+ udid: 'mark-node-busy-s9',
27
+ host,
28
+ platform: 'android',
29
+ busy: false,
30
+ nodeBusy: false,
31
+ offline: false,
32
+ userBlocked: false,
33
+ });
34
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
35
+ (yield db_1.XenonDatabase.DeviceModel).findAndRemove({ udid: 'mark-node-busy-s9' });
36
+ }));
37
+ it('sets nodeBusy and busy on that phone of that node only', () => __awaiter(void 0, void 0, void 0, function* () {
38
+ const model = yield db_1.XenonDatabase.DeviceModel;
39
+ model.insert(phone(NODE));
40
+ model.insert(phone('http://10.0.0.8:4725'));
41
+ yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE, 'manual_u1_s9');
42
+ const rows = model.find({ udid: 'mark-node-busy-s9' });
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
+ (0, chai_1.expect)(byHost).to.deep.equal({
45
+ [NODE]: [true, true, 'manual_u1_s9'],
46
+ 'http://10.0.0.8:4725': [false, false, null],
47
+ });
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
+ }));
62
+ it('does nothing for a phone it doesn’t have', () => __awaiter(void 0, void 0, void 0, function* () {
63
+ yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE);
64
+ (0, chai_1.expect)((yield db_1.XenonDatabase.DeviceModel).find({ udid: 'mark-node-busy-s9' })).to.deep.equal([]);
65
+ }));
66
+ });