@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.
- package/lib/package.json +3 -3
- package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-XpewX5nn.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
- package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-C7OmS61B.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-D-rGOBbJ.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
- package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-fPxujqqE.js} +1 -1
- package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-5bCutWrs.js} +1 -1
- package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-owsBiDT1.js} +1 -1
- package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-DFhqzPSt.js} +1 -1
- package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-Bw-a-E-e.js} +1 -1
- package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-WtByf5bC.js} +1 -1
- package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-FpWWFNgT.js} +1 -1
- package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-Jzn9c7o8.js} +1 -1
- package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-BR7nOhpT.js} +1 -1
- package/lib/public/assets/{Table-ucR4bnmc.js → Table-B_Va3V05.js} +1 -1
- package/lib/public/assets/{activity-K-hdylwq.js → activity-DxRFWvkN.js} +1 -1
- package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-CQf-9_V6.js} +1 -1
- package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-SoE1Kd7N.js} +1 -1
- package/lib/public/assets/{apps-Dez1petz.js → apps-CgGXTlgn.js} +1 -1
- package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-uQ6WIwiC.js} +1 -1
- package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-C9RyfzXS.js} +1 -1
- package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-DFHi_8yG.js} +1 -1
- package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Bii1t4qc.js} +2 -2
- package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-Bnv8Z9hi.js} +1 -1
- package/lib/public/assets/{button-Dd-IhMC1.js → button-_Y78wXiP.js} +1 -1
- package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-D4MqSOc2.js} +1 -1
- package/lib/public/assets/{check-C4Jrjg8f.js → check-uGHCXe-L.js} +1 -1
- package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-Dy1OqpzB.js} +1 -1
- package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-BUsldEli.js} +1 -1
- package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-D3Qq0tKp.js} +1 -1
- package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-B4IOD1DE.js} +1 -1
- package/lib/public/assets/{clock-B8vmwkHj.js → clock-BfYfzvSg.js} +1 -1
- package/lib/public/assets/{copy-BAqnFI-F.js → copy-DZwYE5Va.js} +1 -1
- package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-CRG4Utt5.js} +1 -1
- package/lib/public/assets/{download-B6UEAp_1.js → download-CCDYo_TN.js} +1 -1
- package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-DuLCmVFb.js} +1 -1
- package/lib/public/assets/{funnel-Da90BBbT.js → funnel-CtKNPqzb.js} +1 -1
- package/lib/public/assets/{index-BuIIcmGp.js → index-3uxl3o7K.js} +43 -43
- package/lib/public/assets/{index-MX7IzqyW.js → index-Zy1Op5C1.js} +1 -1
- package/lib/public/assets/{input-Bq9aY-We.js → input-CzTWvUXZ.js} +1 -1
- package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-CI-nYDFM.js} +1 -1
- package/lib/public/assets/{layers-bXjpswQI.js → layers-YZZGFyTX.js} +1 -1
- package/lib/public/assets/{lock-pJ72y5Fy.js → lock-L8tzWgDo.js} +1 -1
- package/lib/public/assets/{login-UOcHALV2.js → login-DBbX-fu8.js} +1 -1
- package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-AJwkOIsE.js} +1 -1
- package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-Di_66F2m.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
- package/lib/public/assets/{network-CKVqcyp6.js → network-DiSumY-1.js} +1 -1
- package/lib/public/assets/{overview-BORGkN3N.js → overview-CwE4gXl5.js} +1 -1
- package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-CfR4Sm9V.js} +1 -1
- package/lib/public/assets/{plus-BpFKVdC8.js → plus-DOS8P9CQ.js} +1 -1
- package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-BYpHwQIH.js} +1 -1
- package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-BRVJU_qV.js} +1 -1
- package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-Bi3JvAFZ.js} +1 -1
- package/lib/public/assets/{select-DIZWIoVR.js → select-Cqx7pEou.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-C0DsncKj.js} +1 -1
- package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-CAhpdilF.js} +1 -1
- package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CrCVeW2m.js} +1 -1
- package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-F_hwYqEt.js} +1 -1
- package/lib/public/assets/{settings-BcRBKUXd.js → settings-C-s5pypL.js} +1 -1
- package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-DY5uzv6A.js} +1 -1
- package/lib/public/assets/{tablet-_sXqZztt.js → tablet-Dli7gwyg.js} +1 -1
- package/lib/public/assets/{teams-DGHT8MKm.js → teams-ui4IhHNC.js} +1 -1
- package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-BV4hSmXJ.js} +1 -1
- package/lib/public/assets/{upload-CIDH9wkj.js → upload-DkGy_J-L.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
- package/lib/public/assets/{users-BUE7MoTq.js → users-BU0nXIVe.js} +1 -1
- package/lib/public/assets/{users-7yviqflc.js → users-Nb_rzRmF.js} +1 -1
- package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-CUNzD2kH.js} +1 -1
- package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Ca3UFXZe.js} +1 -1
- package/lib/public/assets/{zap-CWE94qcK.js → zap-B2bH2vKX.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +5 -142
- package/lib/src/app/routers/nodePhoneControl.js +301 -0
- package/lib/src/app/ws/h264StreamWs.js +30 -4
- package/lib/src/app/ws/logcatWs.js +36 -7
- package/lib/src/app/ws/nodeSocketRelay.js +167 -0
- package/lib/src/data-service/device-store.js +11 -0
- package/lib/src/data-service/deviceClaims.js +18 -0
- package/lib/src/data-service/prisma-store.js +8 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +27 -27
- package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
- package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
- package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
- package/lib/src/middleware/deviceAccessGuard.js +4 -1
- package/lib/src/services/ServerManager.js +13 -0
- package/lib/src/services/device-access/ticketActorAccess.js +3 -1
- package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
- package/lib/test/helpers/own-node-id.js +29 -0
- package/lib/test/setup/scratch-default-database.js +32 -0
- package/lib/test/unit/AndroidDeviceManager.spec.js +48 -0
- package/lib/test/unit/android-h264-service.spec.js +18 -0
- package/lib/test/unit/clipboard-route.spec.js +4 -0
- package/lib/test/unit/control-forward-decision.spec.js +17 -17
- package/lib/test/unit/device-access-guard.spec.js +38 -0
- package/lib/test/unit/hub-node-control-forward.spec.js +318 -1
- package/lib/test/unit/hub-node-race.spec.js +25 -0
- package/lib/test/unit/install-repository-app-team.spec.js +4 -0
- package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
- package/lib/test/unit/logcat-ws.spec.js +8 -0
- package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
- package/lib/test/unit/node-phone-control.spec.js +131 -0
- package/lib/test/unit/node-socket-relay.spec.js +357 -0
- package/lib/test/unit/stream-leave-route.spec.js +4 -0
- package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
- package/lib/test/unit/stream-stop-recording.spec.js +4 -0
- package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
- package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- 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
|
+
});
|