@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
|
@@ -0,0 +1,357 @@
|
|
|
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 http_1 = __importDefault(require("http"));
|
|
18
|
+
const events_1 = require("events");
|
|
19
|
+
const express_1 = __importDefault(require("express"));
|
|
20
|
+
const typedi_1 = require("typedi");
|
|
21
|
+
const ws_1 = require("ws");
|
|
22
|
+
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
23
|
+
const PluginContext_1 = require("../../src/PluginContext");
|
|
24
|
+
const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
|
|
25
|
+
const nodeSocketRelay_1 = require("../../src/app/ws/nodeSocketRelay");
|
|
26
|
+
const h264StreamWs_1 = require("../../src/app/ws/h264StreamWs");
|
|
27
|
+
const logcatWs_1 = require("../../src/app/ws/logcatWs");
|
|
28
|
+
/**
|
|
29
|
+
* The H.264 and logcat sockets for a node's phone, on a hub: the hub checks
|
|
30
|
+
* its own ticket, opens the same socket on the node with a ticket of the
|
|
31
|
+
* node's own, and relays it. Frames and close codes pass unchanged, and a
|
|
32
|
+
* viewer that falls behind holds the node back rather than growing the
|
|
33
|
+
* hub's memory, so the node's own slow-viewer rule applies (dropped H.264
|
|
34
|
+
* frames, a visible "lines dropped" record in logcat).
|
|
35
|
+
*/
|
|
36
|
+
const opened = (ws) => new Promise((resolve, reject) => {
|
|
37
|
+
ws.once('open', () => resolve());
|
|
38
|
+
ws.once('error', reject);
|
|
39
|
+
});
|
|
40
|
+
const closed = (ws) => new Promise((resolve) => ws.once('close', (code, reason) => resolve({ code, reason: reason.toString() })));
|
|
41
|
+
const nextMessage = (ws) => new Promise((resolve) => ws.once('message', (data, isBinary) => resolve({ data: data, isBinary })));
|
|
42
|
+
/** An upstream socket as openNodeSocket hands one over: open and paused. */
|
|
43
|
+
const nodeSocketAt = (url) => __awaiter(void 0, void 0, void 0, function* () {
|
|
44
|
+
const up = new ws_1.WebSocket(url);
|
|
45
|
+
yield new Promise((resolve, reject) => {
|
|
46
|
+
up.once('open', () => {
|
|
47
|
+
up.pause();
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
up.once('error', reject);
|
|
51
|
+
});
|
|
52
|
+
return up;
|
|
53
|
+
});
|
|
54
|
+
describe('nodeSocketRelay', () => {
|
|
55
|
+
const loopback = (0, loopbackServer_1.loopbackServers)();
|
|
56
|
+
const sockets = [];
|
|
57
|
+
let context;
|
|
58
|
+
let saved;
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
61
|
+
saved = Object.assign({}, context);
|
|
62
|
+
context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { bindHostOrIp: '127.0.0.1' }), 1, 'hub-1', '');
|
|
63
|
+
});
|
|
64
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
65
|
+
Object.assign(context, saved);
|
|
66
|
+
for (const wss of sockets.splice(0)) {
|
|
67
|
+
for (const client of wss.clients)
|
|
68
|
+
client.terminate();
|
|
69
|
+
wss.close();
|
|
70
|
+
}
|
|
71
|
+
yield loopback.closeAll();
|
|
72
|
+
}));
|
|
73
|
+
/** A node: a ticket route and a socket on the same server. */
|
|
74
|
+
function fakeNode() {
|
|
75
|
+
return __awaiter(this, arguments, void 0, function* (opts = {}) {
|
|
76
|
+
const seen = [];
|
|
77
|
+
const app = (0, express_1.default)();
|
|
78
|
+
app.post('/xenon/api/control/:udid/stream/ticket', (req, res) => {
|
|
79
|
+
var _a;
|
|
80
|
+
seen.push({ url: req.originalUrl, hubToken: req.header('x-xenon-hub-token') });
|
|
81
|
+
res.status((_a = opts.ticketStatus) !== null && _a !== void 0 ? _a : 200).json({ ticket: 'node-ticket', expiresIn: 60 });
|
|
82
|
+
});
|
|
83
|
+
const server = yield loopback.serve(app);
|
|
84
|
+
const wss = new ws_1.WebSocketServer({ server });
|
|
85
|
+
sockets.push(wss);
|
|
86
|
+
wss.on('connection', (ws, req) => {
|
|
87
|
+
var _a;
|
|
88
|
+
seen.push({ url: req.url });
|
|
89
|
+
(_a = opts.onSocket) === null || _a === void 0 ? void 0 : _a.call(opts, ws, req.url);
|
|
90
|
+
});
|
|
91
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
92
|
+
return { origin, seen, wss };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** A hub socket whose connections are relayed to `upstream()`. */
|
|
96
|
+
function relayingHub(upstream, highWaterBytes) {
|
|
97
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
98
|
+
const server = yield loopback.serve((0, express_1.default)());
|
|
99
|
+
const wss = new ws_1.WebSocketServer({ server });
|
|
100
|
+
sockets.push(wss);
|
|
101
|
+
wss.on('connection', (client) => __awaiter(this, void 0, void 0, function* () {
|
|
102
|
+
(0, nodeSocketRelay_1.relaySocket)(client, yield upstream(), highWaterBytes);
|
|
103
|
+
}));
|
|
104
|
+
return `ws://127.0.0.1:${server.address().port}`;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
describe('relaySocket', () => {
|
|
108
|
+
it('passes messages both ways, binary and text as they were', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
109
|
+
let atNode;
|
|
110
|
+
const node = yield fakeNode({ onSocket: (ws) => (atNode = ws) });
|
|
111
|
+
const hub = yield relayingHub(() => nodeSocketAt(`${node.origin.replace('http', 'ws')}/x`));
|
|
112
|
+
const viewer = new ws_1.WebSocket(hub);
|
|
113
|
+
yield opened(viewer);
|
|
114
|
+
yield new Promise((r) => setTimeout(r, 50));
|
|
115
|
+
const binary = nextMessage(viewer);
|
|
116
|
+
atNode.send(Buffer.from([1, 2, 3]));
|
|
117
|
+
(0, chai_1.expect)(yield binary).to.deep.equal({ data: Buffer.from([1, 2, 3]), isBinary: true });
|
|
118
|
+
const text = nextMessage(viewer);
|
|
119
|
+
atNode.send('{"message":"a line"}');
|
|
120
|
+
const got = yield text;
|
|
121
|
+
(0, chai_1.expect)(got.isBinary).to.equal(false);
|
|
122
|
+
(0, chai_1.expect)(got.data.toString()).to.equal('{"message":"a line"}');
|
|
123
|
+
const fromViewer = nextMessage(atNode);
|
|
124
|
+
viewer.send('hello');
|
|
125
|
+
(0, chai_1.expect)((yield fromViewer).data.toString()).to.equal('hello');
|
|
126
|
+
viewer.close();
|
|
127
|
+
}));
|
|
128
|
+
it('closes the viewer with the node’s own code and reason', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
129
|
+
for (const [code, reason] of [
|
|
130
|
+
[1012, 'stream ended'],
|
|
131
|
+
[1008, 'device held by another user'],
|
|
132
|
+
[1011, 'stream failed'],
|
|
133
|
+
]) {
|
|
134
|
+
const node = yield fakeNode({
|
|
135
|
+
onSocket: (ws) => setTimeout(() => ws.close(code, reason), 30),
|
|
136
|
+
});
|
|
137
|
+
const hub = yield relayingHub(() => nodeSocketAt(`${node.origin.replace('http', 'ws')}/x`));
|
|
138
|
+
const viewer = new ws_1.WebSocket(hub);
|
|
139
|
+
(0, chai_1.expect)(yield closed(viewer)).to.deep.equal({ code, reason });
|
|
140
|
+
}
|
|
141
|
+
}));
|
|
142
|
+
it('a node connection that drops is 1011 to the viewer, who retries', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
143
|
+
const node = yield fakeNode({ onSocket: (ws) => setTimeout(() => ws.terminate(), 30) });
|
|
144
|
+
const hub = yield relayingHub(() => nodeSocketAt(`${node.origin.replace('http', 'ws')}/x`));
|
|
145
|
+
const viewer = new ws_1.WebSocket(hub);
|
|
146
|
+
(0, chai_1.expect)((yield closed(viewer)).code).to.equal(1011);
|
|
147
|
+
}));
|
|
148
|
+
it('the viewer leaving closes the node’s socket', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
149
|
+
let atNode;
|
|
150
|
+
const node = yield fakeNode({ onSocket: (ws) => (atNode = ws) });
|
|
151
|
+
const hub = yield relayingHub(() => nodeSocketAt(`${node.origin.replace('http', 'ws')}/x`));
|
|
152
|
+
const viewer = new ws_1.WebSocket(hub);
|
|
153
|
+
yield opened(viewer);
|
|
154
|
+
yield new Promise((r) => setTimeout(r, 50));
|
|
155
|
+
const nodeClosed = closed(atNode);
|
|
156
|
+
viewer.close();
|
|
157
|
+
yield nodeClosed;
|
|
158
|
+
}));
|
|
159
|
+
it('stops reading from the node while the viewer is behind, and resumes once it catches up', () => {
|
|
160
|
+
class FakeSocket extends events_1.EventEmitter {
|
|
161
|
+
constructor() {
|
|
162
|
+
super(...arguments);
|
|
163
|
+
this.readyState = ws_1.WebSocket.OPEN;
|
|
164
|
+
this.paused = false;
|
|
165
|
+
this.pending = [];
|
|
166
|
+
}
|
|
167
|
+
send(_data, _opts, cb) {
|
|
168
|
+
this.pending.push(cb);
|
|
169
|
+
}
|
|
170
|
+
pause() {
|
|
171
|
+
this.paused = true;
|
|
172
|
+
}
|
|
173
|
+
resume() {
|
|
174
|
+
this.paused = false;
|
|
175
|
+
}
|
|
176
|
+
close() { }
|
|
177
|
+
terminate() { }
|
|
178
|
+
}
|
|
179
|
+
const viewer = new FakeSocket();
|
|
180
|
+
const node = new FakeSocket();
|
|
181
|
+
(0, nodeSocketRelay_1.relaySocket)(viewer, node, 100);
|
|
182
|
+
node.emit('message', Buffer.alloc(60), true);
|
|
183
|
+
(0, chai_1.expect)(node.paused).to.equal(false);
|
|
184
|
+
node.emit('message', Buffer.alloc(60), true);
|
|
185
|
+
(0, chai_1.expect)(node.paused, '120 bytes unwritten').to.equal(true);
|
|
186
|
+
const written = () => viewer.pending.shift()();
|
|
187
|
+
written();
|
|
188
|
+
(0, chai_1.expect)(node.paused, '60 bytes unwritten').to.equal(true);
|
|
189
|
+
written();
|
|
190
|
+
(0, chai_1.expect)(node.paused, 'all written').to.equal(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('openNodeSocket', () => {
|
|
194
|
+
const actor = { actorId: 'alice', isAdmin: false };
|
|
195
|
+
it('is null for this server’s own phone, and asks nothing of anyone', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
196
|
+
const node = yield fakeNode();
|
|
197
|
+
const socket = yield (0, nodeSocketRelay_1.openNodeSocket)({ udid: 'p', actor, path: 'stream/h264' }, { findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: node.origin, nodeId: 'hub-1' }); }) });
|
|
198
|
+
(0, chai_1.expect)(socket).to.equal(null);
|
|
199
|
+
(0, chai_1.expect)(node.seen).to.deep.equal([]);
|
|
200
|
+
}));
|
|
201
|
+
it('opens the node’s socket with a ticket of the node’s own, asked for with the hub’s token', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
202
|
+
const node = yield fakeNode();
|
|
203
|
+
const socket = yield (0, nodeSocketRelay_1.openNodeSocket)({
|
|
204
|
+
udid: 'a b',
|
|
205
|
+
actor,
|
|
206
|
+
path: 'logcat',
|
|
207
|
+
query: new URLSearchParams({ levels: 'error', process: 'App' }),
|
|
208
|
+
}, {
|
|
209
|
+
findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'a b', host: node.origin, nodeId: 'node-1' }); }),
|
|
210
|
+
controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return 'signed-for-alice'; }),
|
|
211
|
+
});
|
|
212
|
+
(0, chai_1.expect)(socket).to.be.instanceOf(ws_1.WebSocket);
|
|
213
|
+
socket.close();
|
|
214
|
+
(0, chai_1.expect)(node.seen).to.deep.equal([
|
|
215
|
+
{ url: '/xenon/api/control/a%20b/stream/ticket', hubToken: 'signed-for-alice' },
|
|
216
|
+
{ url: '/xenon/api/control/a%20b/logcat?levels=error&process=App&ticket=node-ticket' },
|
|
217
|
+
]);
|
|
218
|
+
}));
|
|
219
|
+
it('holds what the node sends first until something reads it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
220
|
+
// H.264's config packet and logcat's replay go out the moment the
|
|
221
|
+
// node's socket opens; lost, the preview never decodes.
|
|
222
|
+
const node = yield fakeNode({ onSocket: (ws) => ws.send('first frame') });
|
|
223
|
+
const socket = yield (0, nodeSocketRelay_1.openNodeSocket)({ udid: 'p', actor, path: 'stream/h264' }, {
|
|
224
|
+
findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: node.origin, nodeId: 'node-1' }); }),
|
|
225
|
+
controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
226
|
+
});
|
|
227
|
+
const up = socket;
|
|
228
|
+
yield new Promise((r) => setTimeout(r, 100));
|
|
229
|
+
const first = nextMessage(up);
|
|
230
|
+
up.resume();
|
|
231
|
+
(0, chai_1.expect)((yield first).data.toString()).to.equal('first frame');
|
|
232
|
+
up.close();
|
|
233
|
+
}));
|
|
234
|
+
it('a node that refuses the ticket is a refusal, one that can’t be reached is not', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
235
|
+
const refusing = yield fakeNode({ ticketStatus: 409 });
|
|
236
|
+
const find = (host) => () => __awaiter(void 0, void 0, void 0, function* () {
|
|
237
|
+
return ({
|
|
238
|
+
udid: 'p',
|
|
239
|
+
host,
|
|
240
|
+
nodeId: 'node-1',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
let error;
|
|
244
|
+
yield (0, nodeSocketRelay_1.openNodeSocket)({ udid: 'p', actor, path: 'stream/h264' }, { findDevice: find(refusing.origin), controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) }).catch((e) => (error = e));
|
|
245
|
+
(0, chai_1.expect)(error).to.be.instanceOf(nodeSocketRelay_1.NodeSocketRefused);
|
|
246
|
+
error = undefined;
|
|
247
|
+
yield (0, nodeSocketRelay_1.openNodeSocket)({ udid: 'p', actor, path: 'stream/h264' }, { findDevice: find('http://127.0.0.1:1'), controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) }).catch((e) => (error = e));
|
|
248
|
+
(0, chai_1.expect)(error).to.be.instanceOf(Error);
|
|
249
|
+
(0, chai_1.expect)(error).to.not.be.instanceOf(nodeSocketRelay_1.NodeSocketRefused);
|
|
250
|
+
}));
|
|
251
|
+
it('a cloud provider’s phone is refused without a request', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
252
|
+
const node = yield fakeNode();
|
|
253
|
+
let error;
|
|
254
|
+
yield (0, nodeSocketRelay_1.openNodeSocket)({ udid: 'p', actor, path: 'stream/h264' }, {
|
|
255
|
+
findDevice: () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: node.origin, cloud: '{"cloudName":"x"}' }); }),
|
|
256
|
+
}).catch((e) => (error = e));
|
|
257
|
+
(0, chai_1.expect)(error).to.be.instanceOf(nodeSocketRelay_1.NodeSocketRefused);
|
|
258
|
+
(0, chai_1.expect)(node.seen).to.deep.equal([]);
|
|
259
|
+
}));
|
|
260
|
+
});
|
|
261
|
+
describe('the hub’s own sockets', () => {
|
|
262
|
+
function hubWith(attach) {
|
|
263
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
264
|
+
const server = http_1.default.createServer();
|
|
265
|
+
attach(server);
|
|
266
|
+
yield new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
267
|
+
loopbackServersCleanup.push(server);
|
|
268
|
+
return `ws://127.0.0.1:${server.address().port}`;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const loopbackServersCleanup = [];
|
|
272
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
273
|
+
var _a;
|
|
274
|
+
for (const s of loopbackServersCleanup.splice(0)) {
|
|
275
|
+
(_a = s.closeAllConnections) === null || _a === void 0 ? void 0 : _a.call(s);
|
|
276
|
+
yield new Promise((r) => s.close(r));
|
|
277
|
+
}
|
|
278
|
+
}));
|
|
279
|
+
it('H.264: a node’s phone is relayed from its node, with the ticket’s user', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
280
|
+
const node = yield fakeNode({ onSocket: (ws) => ws.send(Buffer.from([0, 9, 9])) });
|
|
281
|
+
const asked = [];
|
|
282
|
+
const hub = yield hubWith((server) => (0, h264StreamWs_1.attachH264Ws)(server, {
|
|
283
|
+
redeem: () => __awaiter(void 0, void 0, void 0, function* () { return ({ actorId: 'alice', isAdmin: true }); }),
|
|
284
|
+
startStream: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
285
|
+
throw new Error('not this server’s phone');
|
|
286
|
+
}),
|
|
287
|
+
nodeSocket: (udid, who) => __awaiter(void 0, void 0, void 0, function* () {
|
|
288
|
+
asked.push({ udid, who });
|
|
289
|
+
return nodeSocketAt(`${node.origin.replace('http', 'ws')}/h264`);
|
|
290
|
+
}),
|
|
291
|
+
}));
|
|
292
|
+
const viewer = new ws_1.WebSocket(`${hub}/xenon/api/control/p/stream/h264?ticket=t`);
|
|
293
|
+
(0, chai_1.expect)((yield nextMessage(viewer)).data).to.deep.equal(Buffer.from([0, 9, 9]));
|
|
294
|
+
(0, chai_1.expect)(asked).to.deep.equal([{ udid: 'p', who: { actorId: 'alice', isAdmin: true } }]);
|
|
295
|
+
viewer.close();
|
|
296
|
+
}));
|
|
297
|
+
it('H.264: a refusal from the node is 1008, anything else 1011', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
298
|
+
for (const [thrown, code] of [
|
|
299
|
+
[new nodeSocketRelay_1.NodeSocketRefused('the node refused the ticket'), 1008],
|
|
300
|
+
[new Error('ECONNREFUSED'), 1011],
|
|
301
|
+
]) {
|
|
302
|
+
const hub = yield hubWith((server) => (0, h264StreamWs_1.attachH264Ws)(server, {
|
|
303
|
+
redeem: () => __awaiter(void 0, void 0, void 0, function* () { return ({ actorId: 'alice' }); }),
|
|
304
|
+
startStream: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
305
|
+
throw new Error('not this server’s phone');
|
|
306
|
+
}),
|
|
307
|
+
nodeSocket: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
308
|
+
throw thrown;
|
|
309
|
+
}),
|
|
310
|
+
}));
|
|
311
|
+
const viewer = new ws_1.WebSocket(`${hub}/xenon/api/control/p/stream/h264?ticket=t`);
|
|
312
|
+
(0, chai_1.expect)((yield closed(viewer)).code).to.equal(code);
|
|
313
|
+
}
|
|
314
|
+
}));
|
|
315
|
+
it('logcat: relayed after this server’s own ownership check, with the filter', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
316
|
+
const node = yield fakeNode({ onSocket: (ws) => ws.send('{"message":"from the node"}') });
|
|
317
|
+
const asked = [];
|
|
318
|
+
let authorized = 0;
|
|
319
|
+
const hub = yield hubWith((server) => (0, logcatWs_1.attachLogcatWs)(server, {
|
|
320
|
+
redeem: () => __awaiter(void 0, void 0, void 0, function* () { return ({ actorId: 'alice' }); }),
|
|
321
|
+
authorize: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
322
|
+
authorized++;
|
|
323
|
+
return true;
|
|
324
|
+
}),
|
|
325
|
+
startStream: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
326
|
+
throw new Error('not this server’s phone');
|
|
327
|
+
}),
|
|
328
|
+
nodeSocket: (udid, who, filter) => __awaiter(void 0, void 0, void 0, function* () {
|
|
329
|
+
asked.push({ udid, filter });
|
|
330
|
+
return nodeSocketAt(`${node.origin.replace('http', 'ws')}/logcat`);
|
|
331
|
+
}),
|
|
332
|
+
}));
|
|
333
|
+
const viewer = new ws_1.WebSocket(`${hub}/xenon/api/control/p/logcat?ticket=t&levels=error&process=App`);
|
|
334
|
+
(0, chai_1.expect)((yield nextMessage(viewer)).data.toString()).to.equal('{"message":"from the node"}');
|
|
335
|
+
(0, chai_1.expect)(authorized).to.equal(1);
|
|
336
|
+
(0, chai_1.expect)(asked).to.deep.equal([{ udid: 'p', filter: { levels: ['error'], process: 'App' } }]);
|
|
337
|
+
viewer.close();
|
|
338
|
+
}));
|
|
339
|
+
it('logcat: a phone this server may not show isn’t asked of the node', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
340
|
+
let asked = 0;
|
|
341
|
+
const hub = yield hubWith((server) => (0, logcatWs_1.attachLogcatWs)(server, {
|
|
342
|
+
redeem: () => __awaiter(void 0, void 0, void 0, function* () { return ({ actorId: 'bob' }); }),
|
|
343
|
+
authorize: () => __awaiter(void 0, void 0, void 0, function* () { return false; }),
|
|
344
|
+
startStream: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
345
|
+
throw new Error('unused');
|
|
346
|
+
}),
|
|
347
|
+
nodeSocket: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
348
|
+
asked++;
|
|
349
|
+
return null;
|
|
350
|
+
}),
|
|
351
|
+
}));
|
|
352
|
+
const viewer = new ws_1.WebSocket(`${hub}/xenon/api/control/p/logcat?ticket=t`);
|
|
353
|
+
(0, chai_1.expect)((yield closed(viewer)).code).to.equal(1008);
|
|
354
|
+
(0, chai_1.expect)(asked).to.equal(0);
|
|
355
|
+
}));
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -62,6 +62,7 @@ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-man
|
|
|
62
62
|
const authMiddleware_1 = require("../../src/middleware/authMiddleware");
|
|
63
63
|
const container_registration_1 = require("../helpers/container-registration");
|
|
64
64
|
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
65
|
+
const own_node_id_1 = require("../helpers/own-node-id");
|
|
65
66
|
const UDID = 'DEV-1';
|
|
66
67
|
const ALICE = 'usr_alice';
|
|
67
68
|
const BOB = 'usr_bob';
|
|
@@ -84,6 +85,8 @@ function buildApp(userId) {
|
|
|
84
85
|
return app;
|
|
85
86
|
}
|
|
86
87
|
describe('POST /control/:udid/stream/leave', () => {
|
|
88
|
+
// The fake rows are this server's own phones.
|
|
89
|
+
(0, own_node_id_1.useOwnNodeId)();
|
|
87
90
|
let deviceRow;
|
|
88
91
|
let unblock;
|
|
89
92
|
let androidStop;
|
|
@@ -101,6 +104,7 @@ describe('POST /control/:udid/stream/leave', () => {
|
|
|
101
104
|
deviceRow = {
|
|
102
105
|
udid: UDID,
|
|
103
106
|
host: '127.0.0.1',
|
|
107
|
+
nodeId: own_node_id_1.OWN_NODE_ID,
|
|
104
108
|
platform: 'android',
|
|
105
109
|
busy: true,
|
|
106
110
|
session_id: `manual_${ALICE}_${UDID}`,
|
|
@@ -62,6 +62,7 @@ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-man
|
|
|
62
62
|
const authMiddleware_1 = require("../../src/middleware/authMiddleware");
|
|
63
63
|
const container_registration_1 = require("../helpers/container-registration");
|
|
64
64
|
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
65
|
+
const own_node_id_1 = require("../helpers/own-node-id");
|
|
65
66
|
/**
|
|
66
67
|
* Round-trip guard for POST /control/:udid/stream/stop.
|
|
67
68
|
*
|
|
@@ -119,6 +120,8 @@ function buildApp(caller) {
|
|
|
119
120
|
return app;
|
|
120
121
|
}
|
|
121
122
|
describe('POST /control/:udid/stream/stop — lock ownership round-trip', () => {
|
|
123
|
+
// The fake rows are this server's own phones.
|
|
124
|
+
(0, own_node_id_1.useOwnNodeId)();
|
|
122
125
|
let unblock;
|
|
123
126
|
let androidStop;
|
|
124
127
|
let h264Stop;
|
|
@@ -131,6 +134,7 @@ describe('POST /control/:udid/stream/stop — lock ownership round-trip', () =>
|
|
|
131
134
|
deviceRow = {
|
|
132
135
|
udid: UDID,
|
|
133
136
|
host: '127.0.0.1',
|
|
137
|
+
nodeId: own_node_id_1.OWN_NODE_ID,
|
|
134
138
|
platform: 'android',
|
|
135
139
|
busy: sessionId !== null,
|
|
136
140
|
session_id: sessionId,
|
|
@@ -62,6 +62,7 @@ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-man
|
|
|
62
62
|
const authMiddleware_1 = require("../../src/middleware/authMiddleware");
|
|
63
63
|
const container_registration_1 = require("../helpers/container-registration");
|
|
64
64
|
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
65
|
+
const own_node_id_1 = require("../helpers/own-node-id");
|
|
65
66
|
/**
|
|
66
67
|
* POST /control/:udid/stream/stop must not stop the stream under a live
|
|
67
68
|
* recording. The recording reads from that stream: measured 2026-09-26 on a
|
|
@@ -94,6 +95,8 @@ function buildApp(caller) {
|
|
|
94
95
|
return app;
|
|
95
96
|
}
|
|
96
97
|
describe('POST /control/:udid/stream/stop — a device being recorded', () => {
|
|
98
|
+
// The fake rows are this server's own phones.
|
|
99
|
+
(0, own_node_id_1.useOwnNodeId)();
|
|
97
100
|
let unblock;
|
|
98
101
|
let androidStop;
|
|
99
102
|
let h264Stop;
|
|
@@ -110,6 +113,7 @@ describe('POST /control/:udid/stream/stop — a device being recorded', () => {
|
|
|
110
113
|
return ({
|
|
111
114
|
udid: UDID,
|
|
112
115
|
host: '127.0.0.1',
|
|
116
|
+
nodeId: own_node_id_1.OWN_NODE_ID,
|
|
113
117
|
platform: 'android',
|
|
114
118
|
busy: true,
|
|
115
119
|
session_id: `manual_${ALICE}_${UDID}`,
|
|
@@ -31,6 +31,7 @@ const UserSessionService_1 = require("../../src/services/UserSessionService");
|
|
|
31
31
|
const device_store_1 = require("../../src/data-service/device-store");
|
|
32
32
|
const container_registration_1 = require("../helpers/container-registration");
|
|
33
33
|
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
34
|
+
const own_node_id_1 = require("../helpers/own-node-id");
|
|
34
35
|
/**
|
|
35
36
|
* `req.auth.userId` must always hold a User id.
|
|
36
37
|
*
|
|
@@ -51,6 +52,8 @@ const UDID = 'UDID-1';
|
|
|
51
52
|
const ALICE_USER = 'usr_alice';
|
|
52
53
|
const ALICE_KEY = 'key_abc';
|
|
53
54
|
describe('stream/ticket carries the user identity, not the credential', () => {
|
|
55
|
+
// The fake rows are this server's own phones.
|
|
56
|
+
(0, own_node_id_1.useOwnNodeId)();
|
|
54
57
|
let dir;
|
|
55
58
|
let ticketSvc;
|
|
56
59
|
function buildApp(caller = {}) {
|
|
@@ -103,7 +106,14 @@ describe('stream/ticket carries the user identity, not the credential', () => {
|
|
|
103
106
|
sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
|
|
104
107
|
findDevice: (_a) => __awaiter(void 0, [_a], void 0, function* ({ udid }) {
|
|
105
108
|
return udid === UDID
|
|
106
|
-
? {
|
|
109
|
+
? {
|
|
110
|
+
udid,
|
|
111
|
+
host: 'h',
|
|
112
|
+
nodeId: own_node_id_1.OWN_NODE_ID,
|
|
113
|
+
platform: 'android',
|
|
114
|
+
screenWidth: '1080',
|
|
115
|
+
screenHeight: '1920',
|
|
116
|
+
}
|
|
107
117
|
: null;
|
|
108
118
|
}),
|
|
109
119
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
require("reflect-metadata");
|
|
7
|
+
const chai_1 = require("chai");
|
|
8
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
9
|
+
const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
|
|
10
|
+
const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
|
|
11
|
+
const IOSStreamService_1 = __importDefault(require("../../src/device-managers/ios/IOSStreamService"));
|
|
12
|
+
/**
|
|
13
|
+
* The stream services start their watchdogs in their constructors. On a
|
|
14
|
+
* server the HTTP listener keeps the process alive, so those intervals only
|
|
15
|
+
* need to fire while it runs; referenced, they also kept a test run (or any
|
|
16
|
+
* script that merely constructs a service) alive after its work was done.
|
|
17
|
+
*/
|
|
18
|
+
describe('stream service watchdogs', () => {
|
|
19
|
+
let created;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
created = [];
|
|
22
|
+
const realSetInterval = global.setInterval;
|
|
23
|
+
sinon_1.default.stub(global, 'setInterval').callsFake(((fn, ms) => {
|
|
24
|
+
const timer = realSetInterval(fn, ms);
|
|
25
|
+
created.push(timer);
|
|
26
|
+
return timer;
|
|
27
|
+
}));
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
sinon_1.default.restore();
|
|
31
|
+
for (const timer of created)
|
|
32
|
+
clearInterval(timer);
|
|
33
|
+
});
|
|
34
|
+
for (const [name, Service] of [
|
|
35
|
+
['AndroidStreamService', AndroidStreamService_1.default],
|
|
36
|
+
['AndroidH264StreamService', AndroidH264StreamService_1.default],
|
|
37
|
+
['IOSStreamService', IOSStreamService_1.default],
|
|
38
|
+
]) {
|
|
39
|
+
it(`${name}'s intervals don't keep the process alive`, () => {
|
|
40
|
+
new Service();
|
|
41
|
+
(0, chai_1.expect)(created.length, 'the constructor starts its watchdog').to.be.greaterThan(0);
|
|
42
|
+
for (const timer of created)
|
|
43
|
+
(0, chai_1.expect)(timer.hasRef(), 'unref()ed').to.equal(false);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|