@xenon-device-management/xenon 2.1.0 → 2.2.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 (110) hide show
  1. package/lib/package.json +3 -3
  2. package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-XpewX5nn.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-C7OmS61B.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-D-rGOBbJ.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-fPxujqqE.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-5bCutWrs.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-owsBiDT1.js} +1 -1
  9. package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-DFhqzPSt.js} +1 -1
  10. package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-Bw-a-E-e.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-WtByf5bC.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-FpWWFNgT.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-Jzn9c7o8.js} +1 -1
  14. package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-BR7nOhpT.js} +1 -1
  15. package/lib/public/assets/{Table-ucR4bnmc.js → Table-B_Va3V05.js} +1 -1
  16. package/lib/public/assets/{activity-K-hdylwq.js → activity-DxRFWvkN.js} +1 -1
  17. package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-CQf-9_V6.js} +1 -1
  18. package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-SoE1Kd7N.js} +1 -1
  19. package/lib/public/assets/{apps-Dez1petz.js → apps-CgGXTlgn.js} +1 -1
  20. package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-uQ6WIwiC.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-C9RyfzXS.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-DFHi_8yG.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Bii1t4qc.js} +2 -2
  24. package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-Bnv8Z9hi.js} +1 -1
  25. package/lib/public/assets/{button-Dd-IhMC1.js → button-_Y78wXiP.js} +1 -1
  26. package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-D4MqSOc2.js} +1 -1
  27. package/lib/public/assets/{check-C4Jrjg8f.js → check-uGHCXe-L.js} +1 -1
  28. package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-Dy1OqpzB.js} +1 -1
  29. package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-BUsldEli.js} +1 -1
  30. package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-D3Qq0tKp.js} +1 -1
  31. package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-B4IOD1DE.js} +1 -1
  32. package/lib/public/assets/{clock-B8vmwkHj.js → clock-BfYfzvSg.js} +1 -1
  33. package/lib/public/assets/{copy-BAqnFI-F.js → copy-DZwYE5Va.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-CRG4Utt5.js} +1 -1
  35. package/lib/public/assets/{download-B6UEAp_1.js → download-CCDYo_TN.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-DuLCmVFb.js} +1 -1
  37. package/lib/public/assets/{funnel-Da90BBbT.js → funnel-CtKNPqzb.js} +1 -1
  38. package/lib/public/assets/{index-BuIIcmGp.js → index-3uxl3o7K.js} +43 -43
  39. package/lib/public/assets/{index-MX7IzqyW.js → index-Zy1Op5C1.js} +1 -1
  40. package/lib/public/assets/{input-Bq9aY-We.js → input-CzTWvUXZ.js} +1 -1
  41. package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-CI-nYDFM.js} +1 -1
  42. package/lib/public/assets/{layers-bXjpswQI.js → layers-YZZGFyTX.js} +1 -1
  43. package/lib/public/assets/{lock-pJ72y5Fy.js → lock-L8tzWgDo.js} +1 -1
  44. package/lib/public/assets/{login-UOcHALV2.js → login-DBbX-fu8.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-AJwkOIsE.js} +1 -1
  46. package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-Di_66F2m.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
  48. package/lib/public/assets/{network-CKVqcyp6.js → network-DiSumY-1.js} +1 -1
  49. package/lib/public/assets/{overview-BORGkN3N.js → overview-CwE4gXl5.js} +1 -1
  50. package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-CfR4Sm9V.js} +1 -1
  51. package/lib/public/assets/{plus-BpFKVdC8.js → plus-DOS8P9CQ.js} +1 -1
  52. package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-BYpHwQIH.js} +1 -1
  53. package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-BRVJU_qV.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-Bi3JvAFZ.js} +1 -1
  55. package/lib/public/assets/{select-DIZWIoVR.js → select-Cqx7pEou.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-C0DsncKj.js} +1 -1
  57. package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-CAhpdilF.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CrCVeW2m.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-F_hwYqEt.js} +1 -1
  60. package/lib/public/assets/{settings-BcRBKUXd.js → settings-C-s5pypL.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-DY5uzv6A.js} +1 -1
  62. package/lib/public/assets/{tablet-_sXqZztt.js → tablet-Dli7gwyg.js} +1 -1
  63. package/lib/public/assets/{teams-DGHT8MKm.js → teams-ui4IhHNC.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-BV4hSmXJ.js} +1 -1
  65. package/lib/public/assets/{upload-CIDH9wkj.js → upload-DkGy_J-L.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
  67. package/lib/public/assets/{users-BUE7MoTq.js → users-BU0nXIVe.js} +1 -1
  68. package/lib/public/assets/{users-7yviqflc.js → users-Nb_rzRmF.js} +1 -1
  69. package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-CUNzD2kH.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Ca3UFXZe.js} +1 -1
  71. package/lib/public/assets/{zap-CWE94qcK.js → zap-B2bH2vKX.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +5 -142
  74. package/lib/src/app/routers/nodePhoneControl.js +301 -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 +11 -0
  79. package/lib/src/data-service/deviceClaims.js +18 -0
  80. package/lib/src/data-service/prisma-store.js +8 -0
  81. package/lib/src/device-managers/AndroidDeviceManager.js +27 -27
  82. package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
  83. package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
  84. package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
  85. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  86. package/lib/src/services/ServerManager.js +13 -0
  87. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  88. package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
  89. package/lib/test/helpers/own-node-id.js +29 -0
  90. package/lib/test/setup/scratch-default-database.js +32 -0
  91. package/lib/test/unit/AndroidDeviceManager.spec.js +48 -0
  92. package/lib/test/unit/android-h264-service.spec.js +18 -0
  93. package/lib/test/unit/clipboard-route.spec.js +4 -0
  94. package/lib/test/unit/control-forward-decision.spec.js +17 -17
  95. package/lib/test/unit/device-access-guard.spec.js +38 -0
  96. package/lib/test/unit/hub-node-control-forward.spec.js +318 -1
  97. package/lib/test/unit/hub-node-race.spec.js +25 -0
  98. package/lib/test/unit/install-repository-app-team.spec.js +4 -0
  99. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
  100. package/lib/test/unit/logcat-ws.spec.js +8 -0
  101. package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
  102. package/lib/test/unit/node-phone-control.spec.js +131 -0
  103. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  104. package/lib/test/unit/stream-leave-route.spec.js +4 -0
  105. package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
  106. package/lib/test/unit/stream-stop-recording.spec.js +4 -0
  107. package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
  108. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  109. package/lib/tsconfig.tsbuildinfo +1 -1
  110. package/package.json +3 -3
@@ -72,6 +72,10 @@ 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 http_1 = __importDefault(require("http"));
77
+ const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
78
+ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
75
79
  /**
76
80
  * A hub forwards five of its /control actions on a node's phone (tap, swipe,
77
81
  * text, keyevent, touchAndHold) to the phone's node. It sent them with no
@@ -86,12 +90,50 @@ const loopbackServer_1 = require("../helpers/loopbackServer");
86
90
  * knows (LiveSessionOwners).
87
91
  */
88
92
  // Named like the real manager: /control picks one by constructor.name.
93
+ // Hub and node share this process, so each call records the server it ran
94
+ // on: an action the hub ran on its own would show up as `hub-1:...`.
89
95
  class AndroidDeviceManager {
90
96
  constructor() {
91
97
  this.taps = [];
98
+ this.calls = [];
99
+ this.failLogs = false;
92
100
  this.tap = (udid) => __awaiter(this, void 0, void 0, function* () {
93
101
  this.taps.push(udid);
94
102
  });
103
+ this.getScreenshot = () => __awaiter(this, void 0, void 0, function* () {
104
+ this.at('getScreenshot');
105
+ return 'A'.repeat(200);
106
+ });
107
+ this.getClipboard = () => __awaiter(this, void 0, void 0, function* () {
108
+ this.at('getClipboard');
109
+ return 'copied';
110
+ });
111
+ this.setClipboard = () => __awaiter(this, void 0, void 0, function* () { return this.at('setClipboard'); });
112
+ this.lock = () => __awaiter(this, void 0, void 0, function* () { return this.at('lock'); });
113
+ this.unlock = () => __awaiter(this, void 0, void 0, function* () { return this.at('unlock'); });
114
+ this.getDisplayState = () => __awaiter(this, void 0, void 0, function* () {
115
+ this.at('getDisplayState');
116
+ return 'on';
117
+ });
118
+ this.uninstallApp = () => __awaiter(this, void 0, void 0, function* () { return this.at('uninstallApp'); });
119
+ this.listApps = () => __awaiter(this, void 0, void 0, function* () {
120
+ this.at('listApps');
121
+ return [{ bundleId: 'com.example' }];
122
+ });
123
+ this.getLogs = () => __awaiter(this, void 0, void 0, function* () {
124
+ this.at('getLogs');
125
+ if (this.failLogs)
126
+ throw new Error('logcat failed on the node');
127
+ return 'a log line';
128
+ });
129
+ this.executeShell = () => __awaiter(this, void 0, void 0, function* () {
130
+ this.at('executeShell');
131
+ return 'uid=2000(shell)';
132
+ });
133
+ this.installApp = () => __awaiter(this, void 0, void 0, function* () { return this.at('installApp'); });
134
+ }
135
+ at(name) {
136
+ this.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:${name}`);
95
137
  }
96
138
  }
97
139
  describe('the hub’s device control on a node’s phone', () => {
@@ -108,17 +150,25 @@ describe('the hub’s device control on a node’s phone', () => {
108
150
  let nodeOrigin;
109
151
  let nodePort;
110
152
  let nodeArgs;
153
+ let nodeRequests;
111
154
  beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
112
155
  dirs = [fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-control-keys-'))];
113
156
  hubKeys = new JwtKeyService_1.JwtKeyService();
114
157
  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);
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);
116
159
  typedi_1.Container.set(JwtKeyService_1.JwtKeyService, hubKeys);
117
160
  typedi_1.Container.set(hubSessionToken_1.HubSessionTokenIssuer, new hubSessionToken_1.HubSessionTokenIssuer());
118
161
  typedi_1.Container.set(SessionOwnerResolver_1.SessionOwnerResolver, new SessionOwnerResolver_1.SessionOwnerResolver());
119
162
  typedi_1.Container.set(LiveSessionOwners_1.LiveSessionOwners, new LiveSessionOwners_1.LiveSessionOwners());
120
163
  manager = new AndroidDeviceManager();
121
164
  typedi_1.Container.set(device_managers_1.XenonManager, { deviceInstances: () => __awaiter(void 0, void 0, void 0, function* () { return [manager]; }) });
165
+ typedi_1.Container.set(InspectorService_1.InspectorService, {
166
+ getSnapshot: () => __awaiter(void 0, void 0, void 0, function* () {
167
+ manager.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:getSnapshot`);
168
+ return { source: '<hierarchy/>' };
169
+ }),
170
+ });
171
+ nodeRequests = [];
122
172
  context = typedi_1.Container.get(PluginContext_1.PluginContext);
123
173
  saved = {
124
174
  context: Object.assign({}, context),
@@ -159,6 +209,15 @@ describe('the hub’s device control on a node’s phone', () => {
159
209
  hubApp.use('/xenon/api', hubApi);
160
210
  // The node: its CSRF check, its login and its /control, as ServerManager mounts them.
161
211
  const node = (0, express_1.default)();
212
+ node.use((req, _res, next) => {
213
+ nodeRequests.push({
214
+ method: req.method,
215
+ path: req.path,
216
+ url: req.originalUrl,
217
+ headers: Object.assign({}, req.headers),
218
+ });
219
+ next();
220
+ });
162
221
  node.use((_req, _res, next) => {
163
222
  context.setContext(nodeArgs, nodePort, 'node-1', '');
164
223
  next();
@@ -236,6 +295,264 @@ describe('the hub’s device control on a node’s phone', () => {
236
295
  yield (0, auth_1.issueToken)({ userId: 'alice', role: 'ADMIN', scopes: 'admin' }, { audience: hubSessionToken_1.HUB_CONTROL_AUDIENCE }).catch((error) => (refused = error));
237
296
  (0, chai_1.expect)(refused === null || refused === void 0 ? void 0 : refused.message).to.include('unsupported audience');
238
297
  }));
298
+ describe('which actions reach the node', () => {
299
+ const asAlice = (method, action, body) => {
300
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/phone-1/${action}`);
301
+ req.set('x-test-user', 'alice');
302
+ return method === 'post' ? req.send(body !== null && body !== void 0 ? body : {}) : req;
303
+ };
304
+ const nodeSaw = (action) => nodeRequests.filter((r) => r.path === `/xenon/api/control/phone-1/${action}`);
305
+ // Each runs on the node and answers with the node's own reply.
306
+ const FORWARDED = [
307
+ {
308
+ method: 'get',
309
+ action: 'screenshot',
310
+ call: 'getScreenshot',
311
+ reply: { screenshot: 'A'.repeat(200) },
312
+ },
313
+ { method: 'get', action: 'clipboard', call: 'getClipboard', reply: { content: 'copied' } },
314
+ {
315
+ method: 'post',
316
+ action: 'clipboard',
317
+ send: { content: 'x' },
318
+ call: 'setClipboard',
319
+ reply: { success: true },
320
+ },
321
+ { method: 'post', action: 'lock', call: 'lock', reply: { success: true } },
322
+ { method: 'post', action: 'unlock', call: 'unlock', reply: { success: true } },
323
+ { method: 'get', action: 'display', call: 'getDisplayState', reply: { state: 'on' } },
324
+ {
325
+ method: 'post',
326
+ action: 'uninstall',
327
+ send: { bundleId: 'com.example' },
328
+ call: 'uninstallApp',
329
+ reply: { success: true },
330
+ },
331
+ { method: 'get', action: 'apps', call: 'listApps', reply: [{ bundleId: 'com.example' }] },
332
+ { method: 'get', action: 'logs', call: 'getLogs', reply: { logs: 'a log line' } },
333
+ {
334
+ method: 'post',
335
+ action: 'shell',
336
+ send: { command: 'id' },
337
+ call: 'executeShell',
338
+ reply: { output: 'uid=2000(shell)' },
339
+ },
340
+ {
341
+ method: 'get',
342
+ action: 'inspector/snapshot',
343
+ call: 'getSnapshot',
344
+ reply: { source: '<hierarchy/>' },
345
+ },
346
+ ];
347
+ for (const { method, action, send, call, reply } of FORWARDED) {
348
+ it(`${method.toUpperCase()} ${action} runs on the node and answers with the node's reply`, () => __awaiter(void 0, void 0, void 0, function* () {
349
+ const res = yield asAlice(method, action, send);
350
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
351
+ (0, chai_1.expect)(res.body).to.deep.equal(reply);
352
+ (0, chai_1.expect)(manager.calls).to.deep.equal([`node-1:${call}`]);
353
+ (0, chai_1.expect)(nodeSaw(action)).to.have.length(1);
354
+ }));
355
+ }
356
+ it('relays the node’s own error answer unchanged, sent once', () => __awaiter(void 0, void 0, void 0, function* () {
357
+ manager.failLogs = true;
358
+ const res = yield asAlice('get', 'logs');
359
+ (0, chai_1.expect)(res.status).to.equal(500);
360
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'logcat failed on the node' });
361
+ (0, chai_1.expect)(nodeSaw('logs')).to.have.length(1);
362
+ }));
363
+ it('sends the node none of the caller’s credentials, only its own token', () => __awaiter(void 0, void 0, void 0, function* () {
364
+ yield (0, loopbackRequest_1.default)(hubApp)
365
+ .post('/xenon/api/control/phone-1/lock')
366
+ .set('x-test-user', 'alice')
367
+ .set('cookie', 'xenon_dashboard_session=secret-session')
368
+ .set('authorization', 'Bearer secret-jwt')
369
+ .set('x-xenon-access-key', 'secret-key')
370
+ .set('x-xenon-token', 'secret-token')
371
+ .send({})
372
+ .expect(200);
373
+ const [sent] = nodeSaw('lock');
374
+ for (const name of [
375
+ 'cookie',
376
+ 'authorization',
377
+ 'x-xenon-access-key',
378
+ 'x-xenon-token',
379
+ 'x-test-user',
380
+ ]) {
381
+ (0, chai_1.expect)(sent.headers, name).to.not.have.property(name);
382
+ }
383
+ (0, chai_1.expect)(sent.headers[hubSessionToken_1.HUB_TOKEN_HEADER]).to.be.a('string');
384
+ }));
385
+ it('answers appium-session itself: the session is routed through the hub', () => __awaiter(void 0, void 0, void 0, function* () {
386
+ const res = yield asAlice('get', 'appium-session');
387
+ (0, chai_1.expect)(res.status).to.equal(200);
388
+ (0, chai_1.expect)(res.body).to.include({ status: 'success', sessionId: 's-1' });
389
+ (0, chai_1.expect)(nodeSaw('appium-session')).to.have.length(0);
390
+ }));
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.
395
+ const REFUSED = [
396
+ ['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
+ ['post', 'an-action-added-later'],
402
+ ];
403
+ describe('the live preview', () => {
404
+ // phone-2 is free: a preview holds it on the node.
405
+ const preview = (method, action) => {
406
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/phone-2/${action}`);
407
+ req.set('x-test-user', 'alice');
408
+ return method === 'post' ? req.send({}) : req;
409
+ };
410
+ const nodeSawPreview = (action) => nodeRequests.filter((r) => r.path === `/xenon/api/control/phone-2/${action}`);
411
+ let started;
412
+ let stopped;
413
+ let mjpegPort;
414
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
415
+ started = [];
416
+ stopped = [];
417
+ // The camera the node's MJPEG fan-out reads: a frame every 50 ms.
418
+ const camera = (0, express_1.default)();
419
+ camera.get('/', (_req, res) => {
420
+ res.writeHead(200, { 'content-type': 'multipart/x-mixed-replace; boundary=f' });
421
+ const timer = setInterval(() => res.write('--f\r\nFAKE-JPEG-FRAME\r\n'), 50);
422
+ res.on('close', () => clearInterval(timer));
423
+ });
424
+ mjpegPort = (yield loopback.serve(camera)).address().port;
425
+ const mjpeg = typedi_1.Container.get(AndroidStreamService_1.default);
426
+ const h264 = typedi_1.Container.get(AndroidH264StreamService_1.default);
427
+ const where = () => typedi_1.Container.get(PluginContext_1.PluginContext).nodeId;
428
+ sinon_1.default.stub(mjpeg, 'startStream').callsFake((udid) => __awaiter(void 0, void 0, void 0, function* () {
429
+ started.push(`${where()}:${udid}`);
430
+ return { mjpegPort };
431
+ }));
432
+ sinon_1.default.stub(mjpeg, 'stopStream').callsFake((udid) => __awaiter(void 0, void 0, void 0, function* () {
433
+ stopped.push(`${where()}:${udid}`);
434
+ }));
435
+ sinon_1.default.stub(mjpeg, 'getStreamStatus').returns(undefined);
436
+ sinon_1.default.stub(mjpeg, 'updateViewerCount');
437
+ sinon_1.default.stub(h264, 'stop').resolves();
438
+ sinon_1.default.stub(h264, 'getMultiplexer').returns(undefined);
439
+ }));
440
+ 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* () {
441
+ const res = yield preview('post', 'stream/start');
442
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
443
+ (0, chai_1.expect)(res.body).to.include({ success: true, type: 'mjpeg' });
444
+ (0, chai_1.expect)(started).to.deep.equal(['node-1:phone-2']);
445
+ const row = yield scratch.db.device.findFirst({ where: { udid: 'phone-2' } });
446
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.session_id).to.equal('manual_alice_phone-2');
447
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.nodeBusy).to.equal(true);
448
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.busy).to.equal(true);
449
+ }));
450
+ it('status, leave and stop are the node’s answers', () => __awaiter(void 0, void 0, void 0, function* () {
451
+ yield preview('post', 'stream/start').expect(200);
452
+ const status = yield preview('get', 'stream/status');
453
+ (0, chai_1.expect)(status.status).to.equal(200);
454
+ (0, chai_1.expect)(status.body).to.include({ udid: 'phone-2', status: 'stopped' });
455
+ yield preview('post', 'stream/leave').expect(202);
456
+ yield preview('post', 'stream/stop').expect(200);
457
+ (0, chai_1.expect)(stopped).to.deep.equal(['node-1:phone-2']);
458
+ for (const action of ['stream/status', 'stream/leave', 'stream/stop']) {
459
+ (0, chai_1.expect)(nodeSawPreview(action), action).to.have.length(1);
460
+ }
461
+ }));
462
+ it('mints the viewer’s ticket here, where the team check ran', () => __awaiter(void 0, void 0, void 0, function* () {
463
+ const res = yield preview('post', 'stream/ticket');
464
+ (0, chai_1.expect)(res.status).to.equal(200);
465
+ (0, chai_1.expect)(res.body.ticket).to.be.a('string');
466
+ (0, chai_1.expect)(nodeSawPreview('stream/ticket')).to.have.length(0);
467
+ }));
468
+ it('relays the node’s MJPEG stream to the viewer, without the viewer’s ticket', () => __awaiter(void 0, void 0, void 0, function* () {
469
+ const hubPort = (yield loopback.serve(hubApp)).address().port;
470
+ const got = yield new Promise((resolve, reject) => {
471
+ const req = http_1.default.get({
472
+ host: '127.0.0.1',
473
+ port: hubPort,
474
+ path: '/xenon/api/control/phone-2/stream?ticket=spent&t=1',
475
+ headers: { 'x-test-user': 'alice' },
476
+ }, (res) => {
477
+ let body = '';
478
+ res.on('data', (chunk) => {
479
+ body += chunk.toString();
480
+ if (body.split('FAKE-JPEG-FRAME').length > 3) {
481
+ req.destroy();
482
+ resolve({ type: String(res.headers['content-type']), body });
483
+ }
484
+ });
485
+ });
486
+ req.on('error', (e) => (e.code === 'ECONNRESET' ? undefined : reject(e)));
487
+ });
488
+ (0, chai_1.expect)(got.type).to.include('multipart/x-mixed-replace');
489
+ (0, chai_1.expect)(started).to.deep.equal(['node-1:phone-2']);
490
+ (0, chai_1.expect)(nodeSawPreview('stream').map((r) => r.url)).to.deep.equal([
491
+ '/xenon/api/control/phone-2/stream?t=1',
492
+ ]);
493
+ yield preview('post', 'stream/stop').expect(200);
494
+ }));
495
+ });
496
+ it('refuses the rest, naming the node, and runs none of it on the hub', () => __awaiter(void 0, void 0, void 0, function* () {
497
+ for (const [method, action, body] of REFUSED) {
498
+ const res = yield asAlice(method, action, body);
499
+ (0, chai_1.expect)(res.status, `${method} ${action}`).to.equal(501);
500
+ (0, chai_1.expect)(res.body.error, `${method} ${action}`).to.equal('not_available_through_hub');
501
+ (0, chai_1.expect)(res.body.message, `${method} ${action}`).to.include(nodeOrigin);
502
+ }
503
+ (0, chai_1.expect)(nodeRequests).to.have.length(0);
504
+ (0, chai_1.expect)(manager.calls).to.deep.equal([]);
505
+ }));
506
+ it('sends nothing to a cloud provider’s phone', () => __awaiter(void 0, void 0, void 0, function* () {
507
+ const provider = (0, express_1.default)();
508
+ let reached = 0;
509
+ provider.use((_req, res) => {
510
+ reached++;
511
+ res.status(200).json({});
512
+ });
513
+ const providerOrigin = `http://127.0.0.1:${(yield loopback.serve(provider)).address().port}`;
514
+ yield scratch.db.device.create({
515
+ data: {
516
+ udid: 'cloud-1',
517
+ host: providerOrigin,
518
+ platform: 'android',
519
+ name: 'cloud-1',
520
+ cloud: JSON.stringify({ cloudName: 'example-cloud' }),
521
+ },
522
+ });
523
+ for (const [method, action, body] of [
524
+ ['post', 'tap', { x: 1, y: 2 }],
525
+ ['get', 'screenshot'],
526
+ ['post', 'text', { text: 'a secret' }],
527
+ ]) {
528
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/cloud-1/${action}`)
529
+ .set('x-test-user', 'alice');
530
+ const res = yield (method === 'post' ? req.send(body !== null && body !== void 0 ? body : {}) : req);
531
+ (0, chai_1.expect)(res.status, action).to.equal(501);
532
+ (0, chai_1.expect)(res.body.error, action).to.equal('not_available_for_cloud_phone');
533
+ }
534
+ (0, chai_1.expect)(reached).to.equal(0);
535
+ (0, chai_1.expect)(manager.taps).to.deep.equal([]);
536
+ }));
537
+ it('a node that can’t be reached: 502, naming the node', () => __awaiter(void 0, void 0, void 0, function* () {
538
+ yield scratch.db.device.create({
539
+ data: {
540
+ udid: 'gone-1',
541
+ host: 'http://127.0.0.1:1',
542
+ nodeId: 'node-2',
543
+ platform: 'android',
544
+ name: 'gone-1',
545
+ },
546
+ });
547
+ const res = yield (0, loopbackRequest_1.default)(hubApp)
548
+ .get('/xenon/api/control/gone-1/screenshot')
549
+ .set('x-test-user', 'alice');
550
+ (0, chai_1.expect)(res.status).to.equal(502);
551
+ (0, chai_1.expect)(res.body.error).to.equal('node_unreachable');
552
+ (0, chai_1.expect)(res.body.message).to.include('http://127.0.0.1:1');
553
+ (0, chai_1.expect)(manager.calls).to.deep.equal([]);
554
+ }));
555
+ });
239
556
  describe('on the node', () => {
240
557
  it('the node’s own ownership guard judges the user the hub named', () => __awaiter(void 0, void 0, void 0, function* () {
241
558
  const asBob = yield controlToken({ userId: 'bob' });
@@ -376,6 +376,31 @@ 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);
385
+ (0, chai_1.expect)(yield row('s9')).to.include({ busy: true, nodeBusy: true, session_id: null });
386
+ let session;
387
+ try {
388
+ session = yield (0, device_utils_1.allocateDeviceForSession)(caps('s9'), 200, 50, pluginArgs);
389
+ }
390
+ catch (err) {
391
+ session = err;
392
+ }
393
+ (0, chai_1.expect)(session, 'not handed to a session').to.be.an('error');
394
+ // The preview ended, and the node says so.
395
+ yield report(nodePhone('s9', { busy: false }));
396
+ (0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false });
397
+ }));
398
+ it('touches no other server’s row for the same udid', () => __awaiter(this, void 0, void 0, function* () {
399
+ yield scratch.db.device.create({ data: nodePhone('s9') });
400
+ yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('s9', 'http://10.9.9.9:4725');
401
+ (0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false });
402
+ }));
403
+ });
379
404
  describe('a standalone server', () => {
380
405
  it('allocates, runs and frees its own phone as before', () => __awaiter(this, void 0, void 0, function* () {
381
406
  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,53 @@
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);
42
+ const rows = model.find({ udid: 'mark-node-busy-s9' });
43
+ const byHost = Object.fromEntries(rows.map((r) => [r.host, [r.busy, r.nodeBusy]]));
44
+ (0, chai_1.expect)(byHost).to.deep.equal({
45
+ [NODE]: [true, true],
46
+ 'http://10.0.0.8:4725': [false, false],
47
+ });
48
+ }));
49
+ it('does nothing for a phone it doesn’t have', () => __awaiter(void 0, void 0, void 0, function* () {
50
+ yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE);
51
+ (0, chai_1.expect)((yield db_1.XenonDatabase.DeviceModel).find({ udid: 'mark-node-busy-s9' })).to.deep.equal([]);
52
+ }));
53
+ });
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const express_1 = __importDefault(require("express"));
18
+ const typedi_1 = require("typedi");
19
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
20
+ const loopbackServer_1 = require("../helpers/loopbackServer");
21
+ const PluginContext_1 = require("../../src/PluginContext");
22
+ const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
23
+ const nodePhoneControl_1 = require("../../src/app/routers/nodePhoneControl");
24
+ /**
25
+ * The gate in front of /control's handlers, on its own: what it does when the
26
+ * phone's node is slow or its row can't be read. The actions themselves are
27
+ * in hub-node-control-forward.spec.ts.
28
+ */
29
+ describe('nodePhoneControl', () => {
30
+ const loopback = (0, loopbackServer_1.loopbackServers)();
31
+ let context;
32
+ let saved;
33
+ let findDevice;
34
+ let held;
35
+ beforeEach(() => {
36
+ context = typedi_1.Container.get(PluginContext_1.PluginContext);
37
+ saved = Object.assign({}, context);
38
+ context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { bindHostOrIp: '127.0.0.1' }), 1, 'hub-1', '');
39
+ held = [];
40
+ });
41
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
42
+ Object.assign(context, saved);
43
+ yield loopback.closeAll();
44
+ }));
45
+ const app = (timeoutMs = 60000) => {
46
+ const a = (0, express_1.default)();
47
+ a.use(express_1.default.json());
48
+ a.use('/xenon/api/control', (0, nodePhoneControl_1.nodePhoneControl)({ findDevice: (udid) => findDevice(udid), timeoutMs }), (_req, res) => res.status(299).json({ handledHere: true }));
49
+ return a;
50
+ };
51
+ it('a node that doesn’t answer in time: 504, naming it', () => __awaiter(void 0, void 0, void 0, function* () {
52
+ const node = (0, express_1.default)();
53
+ node.use((req) => held.push(req)); // never answers
54
+ const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
55
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
56
+ const res = yield (0, loopbackRequest_1.default)(app(150)).get('/xenon/api/control/p/screenshot');
57
+ (0, chai_1.expect)(res.status).to.equal(504);
58
+ (0, chai_1.expect)(res.body.error).to.equal('node_timeout');
59
+ (0, chai_1.expect)(res.body.message).to.include(origin);
60
+ (0, chai_1.expect)(held).to.have.length(1);
61
+ }));
62
+ it('a device row that can’t be read: 503, and no handler runs it here', () => __awaiter(void 0, void 0, void 0, function* () {
63
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () {
64
+ throw new Error('database is locked');
65
+ });
66
+ const res = yield (0, loopbackRequest_1.default)(app()).post('/xenon/api/control/p/tap').send({ x: 1, y: 2 });
67
+ (0, chai_1.expect)(res.status).to.equal(503);
68
+ (0, chai_1.expect)(res.body.error).to.equal('device_ownership_unavailable');
69
+ }));
70
+ it('this server’s own phone, and an unknown one, go to the handlers', () => __awaiter(void 0, void 0, void 0, function* () {
71
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: 'http://10.0.0.9:4725', nodeId: 'hub-1' }); });
72
+ yield (0, loopbackRequest_1.default)(app()).post('/xenon/api/control/p/tap').send({}).expect(299);
73
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return null; });
74
+ yield (0, loopbackRequest_1.default)(app()).post('/xenon/api/control/p/tap').send({}).expect(299);
75
+ }));
76
+ it('a stream outlives the timeout once the node has answered', () => __awaiter(void 0, void 0, void 0, function* () {
77
+ const node = (0, express_1.default)();
78
+ node.get('/xenon/api/control/p/stream', (_req, res) => {
79
+ // Text, so the test's client doesn't try to parse it; an MJPEG
80
+ // answer is relayed the same way.
81
+ res.writeHead(200, { 'content-type': 'text/plain' });
82
+ res.write('frame one;');
83
+ setTimeout(() => res.end('frame two;'), 400);
84
+ });
85
+ const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
86
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
87
+ const res = yield (0, loopbackRequest_1.default)(app(150)).get('/xenon/api/control/p/stream');
88
+ (0, chai_1.expect)(res.status).to.equal(200);
89
+ (0, chai_1.expect)(res.text).to.equal('frame one;frame two;');
90
+ }));
91
+ it('sends the node none of the caller’s ticket, and the rest of the query', () => __awaiter(void 0, void 0, void 0, function* () {
92
+ const node = (0, express_1.default)();
93
+ const seen = [];
94
+ node.use((req, res) => {
95
+ seen.push(req.originalUrl);
96
+ res.json({ status: 'stopped' });
97
+ });
98
+ const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
99
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
100
+ yield (0, loopbackRequest_1.default)(app()).get('/xenon/api/control/p/stream/status?ticket=spent&t=1&r=2').expect(200);
101
+ (0, chai_1.expect)(seen).to.deep.equal(['/xenon/api/control/p/stream/status?t=1&r=2']);
102
+ }));
103
+ it('marks the phone busy for its node once the node has started a preview, and only then', () => __awaiter(void 0, void 0, void 0, function* () {
104
+ let answer = 200;
105
+ const node = (0, express_1.default)();
106
+ node.use((_req, res) => res.status(answer).json({}));
107
+ const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
108
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
109
+ const marked = [];
110
+ const a = (0, express_1.default)();
111
+ a.use(express_1.default.json());
112
+ a.use('/xenon/api/control', (0, nodePhoneControl_1.nodePhoneControl)({
113
+ findDevice: (udid) => findDevice(udid),
114
+ markNodeBusy: (udid, host) => __awaiter(void 0, void 0, void 0, function* () {
115
+ marked.push(`${udid}@${host}`);
116
+ }),
117
+ }));
118
+ yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/start').send({}).expect(200);
119
+ answer = 409;
120
+ yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/start').send({}).expect(409);
121
+ answer = 200;
122
+ yield (0, loopbackRequest_1.default)(a).get('/xenon/api/control/p/stream/status').expect(200);
123
+ (0, chai_1.expect)(marked).to.deep.equal([`p@${origin}`]);
124
+ }));
125
+ it('refuses a host it must never call', () => __awaiter(void 0, void 0, void 0, function* () {
126
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: 'http://169.254.169.254', nodeId: 'node-1' }); });
127
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/xenon/api/control/p/screenshot');
128
+ (0, chai_1.expect)(res.status).to.equal(400);
129
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'Unsafe device host' });
130
+ }));
131
+ });