@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
@@ -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
- ? { udid, host: 'h', platform: 'android', screenWidth: '1080', screenHeight: '1920' }
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
+ });