@xenon-device-management/xenon 2.8.1 → 2.8.3

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 (85) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-D9bdTRcw.js → AnnotationOverlay-DZrFxYPZ.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-CHrLtNyc.js → ApiKeyGate-DkyW0B7G.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-Cvnfp_lP.js → BugReportButton-TyYrNWYM.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-B--dBAh0.js → DeviceMosaicView-DY__bWHx.js} +2 -2
  6. package/lib/public/assets/{EmptyState-Bd-GNhqS.js → EmptyState-CfkN-nUG.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-B6TJlJGM.js → FieldGroup-DQu6Zo15.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-Cp_hdlhf.js → FilterMenu-BT_Q7-4t.js} +1 -1
  9. package/lib/public/assets/{Menu-shaiCwvG.js → Menu-DfwEC8pG.js} +1 -1
  10. package/lib/public/assets/{Modal-CVlHheGn.js → Modal-DXyaClqh.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BglT6aA7.js → RecordingPage-j7H4_iT1.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-y3OOMfJ2.js → RecordingsPage-ST7B99o1.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Btui_BrO.js → SegmentedControl-DaqYH9BW.js} +1 -1
  14. package/lib/public/assets/{SettingCard-BojNKc9f.js → SettingCard-CWTz_r_8.js} +1 -1
  15. package/lib/public/assets/{Table-CgoNnHi5.js → Table-BDO5qlOf.js} +1 -1
  16. package/lib/public/assets/{activity-BB1GKieH.js → activity-CGdR3Ff6.js} +1 -1
  17. package/lib/public/assets/{ai-settings-DabayswA.js → ai-settings-C8I0KSUg.js} +1 -1
  18. package/lib/public/assets/{api-keys-azY7czw6.js → api-keys-BhbZQpGV.js} +1 -1
  19. package/lib/public/assets/{apps-DFvDvrJh.js → apps-DaBl9nMx.js} +1 -1
  20. package/lib/public/assets/{arrow-left-D2R0-wu3.js → arrow-left-WRIrign1.js} +1 -1
  21. package/lib/public/assets/{arrow-right-XChCsVs4.js → arrow-right-BuIztKwL.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DMqbmzLo.js → arrow-up-right-Cz072oos.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DXDgAjsz.js → auth-shell-BS884uL2.js} +2 -2
  24. package/lib/public/assets/{builds-page-3L3rEp7Q.js → builds-page-B05iZMmN.js} +1 -1
  25. package/lib/public/assets/{button-BZ8Paskr.js → button-DjeIU-Ty.js} +1 -1
  26. package/lib/public/assets/{calendar-N2T0cT9m.js → calendar-DUF9u2PF.js} +1 -1
  27. package/lib/public/assets/{check-iKGL7VPu.js → check-DUR92DEE.js} +1 -1
  28. package/lib/public/assets/{chevron-left-C0H3PyOa.js → chevron-left-CRvlIuad.js} +1 -1
  29. package/lib/public/assets/{chevron-right-jSMYhKfu.js → chevron-right-iRDZ5v7C.js} +1 -1
  30. package/lib/public/assets/{circle-check-8drTpet1.js → circle-check-CqDvbZtW.js} +1 -1
  31. package/lib/public/assets/{circle-x-dWikpR-a.js → circle-x-CsYtRLC8.js} +1 -1
  32. package/lib/public/assets/{clock-Gn9P_xeq.js → clock-DB9ihtTE.js} +1 -1
  33. package/lib/public/assets/{copy-CRO4NZQZ.js → copy-BufiyibK.js} +1 -1
  34. package/lib/public/assets/{device-explorer-Dt31Uik2.js → device-explorer-BapnPGYt.js} +1 -1
  35. package/lib/public/assets/{download-DPxWk60W.js → download-D14AYHRg.js} +1 -1
  36. package/lib/public/assets/{forgot-password-vNOHTq63.js → forgot-password-2PqpH7ph.js} +1 -1
  37. package/lib/public/assets/{funnel-0f0ehxyn.js → funnel-CgV-CZex.js} +1 -1
  38. package/lib/public/assets/{index-D_T9qS1E.js → index-BE8OC-Wj.js} +1 -1
  39. package/lib/public/assets/{index-BKtEL_K4.js → index-CXxjosDd.js} +2 -2
  40. package/lib/public/assets/{input-CC4-VE9G.js → input-DPn63eF4.js} +1 -1
  41. package/lib/public/assets/{keycodes-BWUMrNPy.js → keycodes-BYgvJDTE.js} +1 -1
  42. package/lib/public/assets/{layers-C8yrdLpc.js → layers-DJZQUOez.js} +1 -1
  43. package/lib/public/assets/{list-checks-C2BNGDYf.js → list-checks-YM2_VNMv.js} +1 -1
  44. package/lib/public/assets/{lock-CrwjpPiF.js → lock-Cy-A3o47.js} +1 -1
  45. package/lib/public/assets/{login-eVu1fnMN.js → login-CRZ2J7Up.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-CMFadU0m.js → maintenance-settings-4HkUk10V.js} +1 -1
  47. package/lib/public/assets/{monitor-Dpxo7IUi.js → monitor-Cyvro78e.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-Bd9UBsUB.js → mouse-pointer-2-26YTYUOD.js} +1 -1
  49. package/lib/public/assets/{network-BeTXa8Ww.js → network-D-W1VWM_.js} +1 -1
  50. package/lib/public/assets/{overview-cVHbQJBa.js → overview-pVmHrKhk.js} +1 -1
  51. package/lib/public/assets/{page-header-Dtt6ySD7.js → page-header-DsTokPlH.js} +1 -1
  52. package/lib/public/assets/{plus-Dalpx132.js → plus-OjIdQkG_.js} +1 -1
  53. package/lib/public/assets/{profile-page-CLVVG6gR.js → profile-page-D0jy_p5L.js} +1 -1
  54. package/lib/public/assets/{reset-password-BCBz06iw.js → reset-password-DptmkgSN.js} +1 -1
  55. package/lib/public/assets/{runbook-page-2nwa4t1z.js → runbook-page-9QcpCPUo.js} +1 -1
  56. package/lib/public/assets/{select-DP8zFEM7.js → select-CHKzkHre.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-Cbh2njcH.js → selector-detail-page-P5zDXMZC.js} +1 -1
  58. package/lib/public/assets/{selector-health-Bvsjfwv0.js → selector-health-Buz4eRW1.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-DaUsNk74.js → selector-health-page-BHZfbxGg.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-Bxvmu6zZ.js → session-detail-page-JHU-GKSX.js} +1 -1
  61. package/lib/public/assets/{settings-DhK4nDW3.js → settings-CRTRggHW.js} +1 -1
  62. package/lib/public/assets/stat-tile-Be2twadZ.js +8 -0
  63. package/lib/public/assets/{tablet-BxwMEQ5P.js → tablet-C7QkOkHs.js} +1 -1
  64. package/lib/public/assets/{teams-CoHMZ77q.js → teams-CwVhnDoL.js} +1 -1
  65. package/lib/public/assets/{trash-2-BBdLg7FK.js → trash-2-yjlULOrf.js} +1 -1
  66. package/lib/public/assets/{upload-Bph2BQlh.js → upload-BcrYG7kK.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-NQmLPUN0.js → use-password-reset-mode-BhRH2TTL.js} +1 -1
  68. package/lib/public/assets/{users-TdXvYiCq.js → users-BSZL_J7x.js} +1 -1
  69. package/lib/public/assets/{users-D8F7_TeC.js → users-D3jRTB5K.js} +1 -1
  70. package/lib/public/assets/{video-off-Dw7aZgDU.js → video-off-BwbiJtmA.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-BqcYFTth.js → webhook-settings-CTIdd7DN.js} +1 -1
  72. package/lib/public/assets/{zap-Z2pU9Fzi.js → zap-CZK3Cbup.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/app/routers/control.js +11 -0
  75. package/lib/src/app/routers/dashboard.js +6 -0
  76. package/lib/src/app/routers/sessionInsights.js +23 -1
  77. package/lib/src/device-managers/ios/IOSStreamService.js +8 -0
  78. package/lib/src/device-managers/ios/IOSTunnels.js +44 -4
  79. package/lib/test/unit/ios-stream-port-leases.spec.js +26 -0
  80. package/lib/test/unit/ios-tunnels.spec.js +24 -0
  81. package/lib/test/unit/session-list-insights.spec.js +57 -0
  82. package/lib/test/unit/stream-viewer-hangup.spec.js +151 -0
  83. package/lib/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +1 -1
  85. package/lib/public/assets/stat-tile-C6UElbbT.js +0 -8
@@ -963,6 +963,14 @@ router.get('/:udid/stream/status', (req, res) => __awaiter(void 0, void 0, void
963
963
  */
964
964
  router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
965
965
  const { udid } = req.params;
966
+ // Note a hang-up from the start. Starting the stream below can take
967
+ // seconds, and the viewer is counted only after it. A browser that left in
968
+ // the meantime had already fired `close`, so its viewer was counted and
969
+ // never uncounted, holding the phone's preview for nobody.
970
+ let hungUp = false;
971
+ res.once('close', () => {
972
+ hungUp = true;
973
+ });
966
974
  const device = yield getDeviceInfo(udid);
967
975
  if (!device)
968
976
  return res.status(404).send('Device not found');
@@ -1015,6 +1023,9 @@ router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, func
1015
1023
  return res.status(503).send({ error: 'Android stream failed', message: err.message });
1016
1024
  }
1017
1025
  }
1026
+ // Nobody is left to serve: count no viewer and open no proxy.
1027
+ if (hungUp)
1028
+ return;
1018
1029
  if (!mjpegPort) {
1019
1030
  return res.status(404).send({
1020
1031
  error: 'MJPEG port not found for device',
@@ -129,7 +129,13 @@ function getSessions(request, response) {
129
129
  const cursor = readQuery(response, () => (0, sessionInsights_1.cursorWhere)(request.query.before, request.query.beforeId));
130
130
  if (cursor === undefined)
131
131
  return;
132
+ const ids = readQuery(response, () => (0, sessionInsights_1.parseIds)(request.query.ids));
133
+ if (ids === undefined)
134
+ return;
132
135
  const where = {};
136
+ if (ids) {
137
+ where.id = { in: ids };
138
+ }
133
139
  if (since) {
134
140
  where.createdAt = { gte: since };
135
141
  }
@@ -9,10 +9,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.SESSION_PAGE_MAX = exports.SESSION_PAGE_DEFAULT = exports.InvalidQueryError = void 0;
12
+ exports.SESSION_IDS_MAX = exports.SESSION_PAGE_MAX = exports.SESSION_PAGE_DEFAULT = exports.InvalidQueryError = void 0;
13
13
  exports.sessionOutcome = sessionOutcome;
14
14
  exports.parseSince = parseSince;
15
15
  exports.parseLimit = parseLimit;
16
+ exports.parseIds = parseIds;
16
17
  exports.cursorWhere = cursorWhere;
17
18
  exports.describeSessions = describeSessions;
18
19
  exports.percentile = percentile;
@@ -77,6 +78,27 @@ function parseLimit(raw) {
77
78
  }
78
79
  return Math.min(n, exports.SESSION_PAGE_MAX);
79
80
  }
81
+ /** The most ids one GET /session takes. */
82
+ exports.SESSION_IDS_MAX = 200;
83
+ /**
84
+ * The `ids` query parameter: null when absent; else the comma-separated
85
+ * session ids, 1 to SESSION_IDS_MAX of them, or InvalidQueryError. The
86
+ * Sessions page re-reads older rows still running by id.
87
+ */
88
+ function parseIds(raw) {
89
+ if (raw === undefined)
90
+ return null;
91
+ const ids = typeof raw === 'string'
92
+ ? raw
93
+ .split(',')
94
+ .map((id) => id.trim())
95
+ .filter(Boolean)
96
+ : [];
97
+ if (ids.length === 0 || ids.length > exports.SESSION_IDS_MAX) {
98
+ throw new InvalidQueryError('invalid_ids', `ids must list 1 to ${exports.SESSION_IDS_MAX} session ids, separated by commas`);
99
+ }
100
+ return ids;
101
+ }
80
102
  /**
81
103
  * Where the next page starts: after the last row of the page before, given
82
104
  * as its `createdAt` (`before`) and `id` (`beforeId`). Pages go newest first
@@ -853,6 +853,14 @@ let IOSStreamService = class IOSStreamService {
853
853
  session.status = 'error';
854
854
  session.lastError = error.message;
855
855
  }
856
+ // A start that got its tunnel up and then failed (WDA didn't launch)
857
+ // stops that tunnel, so it doesn't run on, holding its ports, until
858
+ // the phone's next start. Not while an Appium session may use it.
859
+ if ((session === null || session === void 0 ? void 0 : session.tunnelPort) != null &&
860
+ !(yield this.appiumSessionMayUse(udid, "the failed start's go-ios tunnel"))) {
861
+ yield this.tunnels().stop(udid);
862
+ session.tunnelPort = null;
863
+ }
856
864
  // Back off before the next attempt so a polling client can't drive a
857
865
  // restart loop against a device that keeps failing to start.
858
866
  this.markRecoveryAttempt(udid);
@@ -20,6 +20,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.IOSTunnels = exports.TUNNEL_CHECK_MS = exports.TUNNEL_LEASE_TTL_MS = exports.TUNNEL_READY_POLL_MS = exports.TUNNEL_READY_TIMEOUT_MS = void 0;
22
22
  exports.iosVersionOf = iosVersionOf;
23
+ exports.goIosReason = goIosReason;
23
24
  /**
24
25
  * One go-ios tunnel per iOS 17+ phone, each on its own leased pair of ports.
25
26
  *
@@ -74,6 +75,40 @@ function iosVersionOf(info) {
74
75
  const match = versionStr.match(/(\d+\.?\d*)/);
75
76
  return match ? parseFloat(match[0]) : 0;
76
77
  }
78
+ /** The longest go-ios reason kept for an error message. */
79
+ const REASON_MAX = 200;
80
+ /**
81
+ * The last error or warning in a chunk of go-ios's stderr, for an error
82
+ * message. go-ios logs JSON lines: an ERROR or WARN line gives its `msg` and
83
+ * `error`, an INFO line gives nothing, and a line that isn't JSON (a panic)
84
+ * is kept as it is.
85
+ */
86
+ function goIosReason(text) {
87
+ var _a;
88
+ let reason;
89
+ for (const raw of text.split('\n')) {
90
+ const line = raw.trim();
91
+ if (!line)
92
+ continue;
93
+ let entry;
94
+ try {
95
+ entry = JSON.parse(line);
96
+ }
97
+ catch (_b) {
98
+ entry = undefined;
99
+ }
100
+ if (entry && typeof entry === 'object') {
101
+ const level = String((_a = entry.level) !== null && _a !== void 0 ? _a : '').toUpperCase();
102
+ if ((level === 'ERROR' || level === 'WARN') && entry.msg) {
103
+ reason = entry.error ? `${entry.msg}: ${entry.error}` : String(entry.msg);
104
+ }
105
+ }
106
+ else {
107
+ reason = line;
108
+ }
109
+ }
110
+ return reason === null || reason === void 0 ? void 0 : reason.slice(0, REASON_MAX);
111
+ }
77
112
  /** An answer that puts the phone's traffic anywhere but its leased P + 1. */
78
113
  function moved(info, port) {
79
114
  return info.state === 'up' && info.userspacePort !== undefined && info.userspacePort !== port + 1;
@@ -144,14 +179,15 @@ let IOSTunnels = class IOSTunnels {
144
179
  this.log.warn(`[${udid}] go-ios tunnel failed: ${err.message}`);
145
180
  ended();
146
181
  });
147
- this.logOutput(udid, proc);
182
+ this.logOutput(udid, tunnel);
148
183
  this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})`);
149
184
  const polls = exports.TUNNEL_READY_TIMEOUT_MS / exports.TUNNEL_READY_POLL_MS;
150
185
  for (let poll = 0; poll <= polls; poll++) {
151
186
  if (poll > 0)
152
187
  yield this.sleep(exports.TUNNEL_READY_POLL_MS);
153
188
  if (this.tunnels.get(udid) !== tunnel) {
154
- throw new Error(`The go-ios tunnel for ${udid} exited before it was ready (exit code ${proc.exitCode})`);
189
+ throw new Error(`The go-ios tunnel for ${udid} exited before it was ready (exit code ${proc.exitCode})` +
190
+ (tunnel.reason ? `: ${tunnel.reason}` : ''));
155
191
  }
156
192
  const info = yield this.tunnelInfo(port, udid);
157
193
  if (moved(info, port)) {
@@ -278,13 +314,16 @@ let IOSTunnels = class IOSTunnels {
278
314
  }
279
315
  /**
280
316
  * go-ios's output, at debug. Its repeated warning about a connected
281
- * pre-iOS 17 phone (which needs no tunnel) is logged once per phone.
317
+ * pre-iOS 17 phone (which needs no tunnel) is logged once per phone. Its
318
+ * last error or warning is kept, for the error if the tunnel fails.
282
319
  */
283
- logOutput(udid, proc) {
320
+ logOutput(udid, tunnel) {
284
321
  var _a, _b;
322
+ const proc = tunnel.process;
285
323
  (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => this.log.debug(`Tunnel [${udid}]: ${data}`));
286
324
  const loggedUnsupported = new Set();
287
325
  (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => {
326
+ var _a;
288
327
  const text = String(data);
289
328
  const { unsupported, udid: targetUdid } = (0, iosStreamDiagnostics_1.classifyTunnelStderr)(text);
290
329
  if (unsupported) {
@@ -295,6 +334,7 @@ let IOSTunnels = class IOSTunnels {
295
334
  }
296
335
  return;
297
336
  }
337
+ tunnel.reason = (_a = goIosReason(text)) !== null && _a !== void 0 ? _a : tunnel.reason;
298
338
  this.log.debug(`Tunnel Err [${udid}]: ${text}`);
299
339
  });
300
340
  }
@@ -108,6 +108,8 @@ describe('iOS stream port leases (fake processes, real sockets, scratch DB)', ()
108
108
  let appiumWdaPorts;
109
109
  let ios;
110
110
  let android;
111
+ /** The stream's runwda exits at once, as when WebDriverAgent fails to launch. */
112
+ let runwdaExits;
111
113
  let tunnels;
112
114
  beforeEach(() => {
113
115
  allocator = new PortAllocator_1.PortAllocator();
@@ -123,6 +125,7 @@ describe('iOS stream port leases (fake processes, real sockets, scratch DB)', ()
123
125
  spawned = [];
124
126
  extraServers = [];
125
127
  appiumWdaPorts = new Set();
128
+ runwdaExits = false;
126
129
  tunnels = {
127
130
  ensure: sinon_1.default.stub().resolves(null),
128
131
  envFor: sinon_1.default.stub().callsFake(() => (Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }))),
@@ -150,6 +153,8 @@ describe('iOS stream port leases (fake processes, real sockets, scratch DB)', ()
150
153
  server = listenNow(Number(args[args.length - 1].split(':')[0]));
151
154
  }
152
155
  const proc = new FakeProcess(command, args, server, opts);
156
+ if (runwdaExits && args[0] === 'runwda')
157
+ proc.exitCode = 1;
153
158
  spawned.push(proc);
154
159
  return proc;
155
160
  }));
@@ -236,6 +241,27 @@ describe('iOS stream port leases (fake processes, real sockets, scratch DB)', ()
236
241
  const runwda = spawned.find((p) => p.command === ios.goIOSPath && p.args[0] === 'runwda');
237
242
  (0, chai_1.expect)((_c = (_b = runwda === null || runwda === void 0 ? void 0 : runwda.opts) === null || _b === void 0 ? void 0 : _b.env) === null || _c === void 0 ? void 0 : _c.GO_IOS_AGENT_PORT, "runwda finds this phone's tunnel").to.equal('12100');
238
243
  }));
244
+ describe("a start whose WebDriverAgent fails after the iPhone's tunnel came up", () => {
245
+ beforeEach(() => {
246
+ tunnels.ensure.resolves(12100);
247
+ runwdaExits = true;
248
+ sinon_1.default.stub(ios, 'isGoIOSAvailable').resolves(true);
249
+ });
250
+ it('stops that tunnel, so it neither lingers nor holds its ports', () => __awaiter(void 0, void 0, void 0, function* () {
251
+ var _a, _b;
252
+ const err = yield ios.startStream(IPHONE).then(() => null, (e) => e);
253
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message).to.match(/WDA process exited/);
254
+ (0, chai_1.expect)(tunnels.stop.calledWith(IPHONE)).to.equal(true);
255
+ (0, chai_1.expect)((_a = ios.getStreamStatus(IPHONE)) === null || _a === void 0 ? void 0 : _a.status).to.equal('error');
256
+ (0, chai_1.expect)((_b = ios.getStreamStatus(IPHONE)) === null || _b === void 0 ? void 0 : _b.tunnelPort).to.equal(null);
257
+ }));
258
+ it('leaves it running while an Appium session holds the iPhone', () => __awaiter(void 0, void 0, void 0, function* () {
259
+ Object.assign(devices.get(IPHONE), { busy: true, session_id: '7f0c5a52-appium-session' });
260
+ const err = yield ios.startStream(IPHONE).then(() => null, (e) => e);
261
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message).to.match(/WDA process exited/);
262
+ (0, chai_1.expect)(tunnels.stop.called).to.equal(false);
263
+ }));
264
+ });
239
265
  it('stopping a stale iOS session never deletes a lease another device holds', () => __awaiter(void 0, void 0, void 0, function* () {
240
266
  var _a;
241
267
  // The S9+ has since been leased the port the stale iPhone session names.
@@ -181,6 +181,18 @@ describe('IOSTunnels: a go-ios tunnel per iPhone', () => {
181
181
  (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(undefined);
182
182
  (0, chai_1.expect)([...t.ports.leased.keys()]).to.deep.equal([]);
183
183
  }));
184
+ it("says why the tunnel exited before it was ready, in go-ios's words", () => __awaiter(void 0, void 0, void 0, function* () {
185
+ t.answers = () => {
186
+ const proc = t.spawned[0].proc;
187
+ proc.stderr.emit('data', Buffer.from('{"level":"INFO","msg":"Using userspace networking"}\n' +
188
+ '{"level":"ERROR","msg":"failed to start tunnel","error":"listen tcp 127.0.0.1:12101: bind: address already in use"}\n'));
189
+ proc.exit(1);
190
+ return false;
191
+ };
192
+ const err = yield t.ensure(PHONE_A).then(() => null, (e) => e);
193
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message).to.equal(`The go-ios tunnel for ${PHONE_A} exited before it was ready (exit code 1): ` +
194
+ 'failed to start tunnel: listen tcp 127.0.0.1:12101: bind: address already in use');
195
+ }));
184
196
  it('fails the start when the tunnel comes up on another traffic port, and stops it', () => __awaiter(void 0, void 0, void 0, function* () {
185
197
  t.answers = () => ({ state: 'up', userspacePort: 12102 });
186
198
  const err = yield t.ensure(PHONE_A).then(() => null, (e) => e);
@@ -320,6 +332,18 @@ describe('IOSTunnels: a phone that goes away (go-ios keeps its agent running)',
320
332
  }
321
333
  }));
322
334
  });
335
+ describe('goIosReason', () => {
336
+ it("reads go-ios's last error or warning", () => {
337
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('{"level":"INFO","msg":"Tunnel server started"}\n')).to.equal(undefined);
338
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('{"level":"WARN","msg":"failed to get tunnel info","udid":"X"}')).to.equal('failed to get tunnel info');
339
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('{"level":"ERROR","msg":"first"}\n{"level":"ERROR","msg":"failed to start tunnel","error":"EOF"}\n')).to.equal('failed to start tunnel: EOF');
340
+ });
341
+ it('keeps a line that is not JSON, trimmed and at most 200 characters', () => {
342
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)(' panic: runtime error \n')).to.equal('panic: runtime error');
343
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('x'.repeat(500))).to.have.length(200);
344
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('\n \n')).to.equal(undefined);
345
+ });
346
+ });
323
347
  describe('iosVersionOf', () => {
324
348
  it('reads the version ios info reports', () => {
325
349
  (0, chai_1.expect)((0, IOSTunnels_1.iosVersionOf)({ ProductVersion: '17.2.1' })).to.equal(17.2);
@@ -111,6 +111,63 @@ describe('Sessions page data', () => {
111
111
  (0, chai_1.expect)(ids(res)).to.deep.equal(['a', 'c', 'b', 'd']);
112
112
  }));
113
113
  });
114
+ // The Sessions page re-reads, by id, the rows of its older pages that were
115
+ // still running when loaded. Those pages aren't polled, so a session that
116
+ // had finished kept showing "running" there.
117
+ describe('by id', () => {
118
+ const ids = (res) => res.body.map((r) => r.id);
119
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
120
+ // A shared-pool phone, so a member may see these sessions too.
121
+ yield scratch.db.device.create({
122
+ data: {
123
+ udid: 'phone-1',
124
+ host: 'http://127.0.0.1:4723',
125
+ nodeId: 'hub-1',
126
+ platform: 'android',
127
+ },
128
+ });
129
+ yield session({ id: 'a', createdAt: ago(1 * MIN) });
130
+ yield session({ id: 'b', createdAt: ago(2 * MIN), status: 'running' });
131
+ yield session({ id: 'c', createdAt: ago(3 * MIN) });
132
+ }));
133
+ it('answers just the sessions asked for, newest first', () => __awaiter(void 0, void 0, void 0, function* () {
134
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session?ids=c,b');
135
+ (0, chai_1.expect)(res.status).to.equal(200);
136
+ (0, chai_1.expect)(ids(res)).to.deep.equal(['b', 'c']);
137
+ }));
138
+ it('leaves out an id it does not know', () => __awaiter(void 0, void 0, void 0, function* () {
139
+ (0, chai_1.expect)(ids(yield (0, loopbackRequest_1.default)(app()).get('/session?ids=b,nope'))).to.deep.equal(['b']);
140
+ }));
141
+ it('refuses an empty list, or more than 200 ids', () => __awaiter(void 0, void 0, void 0, function* () {
142
+ const many = Array.from({ length: 201 }, (_, i) => `x${i}`).join(',');
143
+ for (const q of ['ids=', 'ids=,', `ids=${many}`]) {
144
+ const res = yield (0, loopbackRequest_1.default)(app()).get(`/session?${q}`);
145
+ (0, chai_1.expect)(res.status, q.slice(0, 12)).to.equal(400);
146
+ (0, chai_1.expect)(res.body.error, q.slice(0, 12)).to.equal('invalid_ids');
147
+ }
148
+ }));
149
+ it('answers only the sessions the caller may see', () => __awaiter(void 0, void 0, void 0, function* () {
150
+ const team = yield scratch.db.team.create({ data: { name: 'by-id-team' } });
151
+ yield scratch.db.device.create({
152
+ data: {
153
+ udid: 'team-b',
154
+ host: 'http://127.0.0.1:4723',
155
+ nodeId: 'hub-1',
156
+ platform: 'android',
157
+ teamId: team.id,
158
+ },
159
+ });
160
+ yield session({ id: 'hidden', device_udid: 'team-b', status: 'running' });
161
+ caller = {
162
+ kind: 'user-session',
163
+ userId: 'member-1',
164
+ role: 'MEMBER',
165
+ scopes: 'sessions',
166
+ teamIds: [],
167
+ };
168
+ (0, chai_1.expect)(ids(yield (0, loopbackRequest_1.default)(app()).get('/session?ids=b,hidden'))).to.deep.equal(['b']);
169
+ }));
170
+ });
114
171
  it('keeps the sessions created since the given time, newest first', () => __awaiter(void 0, void 0, void 0, function* () {
115
172
  yield session({ id: 'old', createdAt: ago(3 * HOUR) });
116
173
  yield session({ id: 'recent', createdAt: ago(30 * MIN) });
@@ -0,0 +1,151 @@
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 sinon_1 = __importDefault(require("sinon"));
19
+ const express_1 = __importDefault(require("express"));
20
+ const typedi_1 = require("typedi");
21
+ const control_1 = __importDefault(require("../../src/app/routers/control"));
22
+ const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
23
+ const device_store_1 = require("../../src/data-service/device-store");
24
+ const authMiddleware_1 = require("../../src/middleware/authMiddleware");
25
+ const loopbackServer_1 = require("../helpers/loopbackServer");
26
+ const own_node_id_1 = require("../helpers/own-node-id");
27
+ /**
28
+ * GET /control/:udid/stream counted its viewer only once the stream had
29
+ * started, which takes seconds on a cold start, and only then listened for
30
+ * `close`. A browser that hung up during that wait had already fired `close`,
31
+ * so its viewer was counted and never uncounted, and the phone's preview and
32
+ * hold were never released.
33
+ *
34
+ * Seen on the lab (2.8.1): an Android tile with H.264 preview did this every
35
+ * time. It opens an MJPEG warm-up and drops it as soon as H.264 is ready, and
36
+ * the phone stayed held for "1 other viewer(s)" with nothing connected.
37
+ */
38
+ function buildApp() {
39
+ const app = (0, express_1.default)();
40
+ const apiRouter = express_1.default.Router();
41
+ apiRouter.use((req, _res, next) => {
42
+ req.auth = {
43
+ kind: 'user-session',
44
+ userId: 'usr_me',
45
+ role: 'MEMBER',
46
+ scopes: (0, authMiddleware_1.scopesForRole)('MEMBER'),
47
+ rateLimit: 100,
48
+ };
49
+ next();
50
+ });
51
+ control_1.default.register(apiRouter);
52
+ app.use('/xenon/api', apiRouter);
53
+ return app;
54
+ }
55
+ /** An MJPEG source that answers and then stays quiet, like a phone at rest. */
56
+ function mjpegSource() {
57
+ const server = http_1.default.createServer((_req, res) => {
58
+ res.writeHead(200, { 'Content-Type': 'multipart/x-mixed-replace; boundary=frame' });
59
+ });
60
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
61
+ }
62
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
63
+ function until(check_1) {
64
+ return __awaiter(this, arguments, void 0, function* (check, ms = 2000) {
65
+ for (const end = Date.now() + ms; Date.now() < end && !check();)
66
+ yield pause(20);
67
+ });
68
+ }
69
+ describe('GET /control/:udid/stream counts only viewers who are still there', () => {
70
+ // The fake rows are this server's own phones.
71
+ (0, own_node_id_1.useOwnNodeId)();
72
+ const loopback = (0, loopbackServer_1.loopbackServers)();
73
+ let source;
74
+ let startCalled;
75
+ let finishStart;
76
+ let viewers;
77
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
78
+ source = yield mjpegSource();
79
+ const sourcePort = source.address().port;
80
+ let markCalled;
81
+ startCalled = new Promise((resolve) => (markCalled = resolve));
82
+ let release;
83
+ const held = new Promise((resolve) => (release = resolve));
84
+ finishStart = release;
85
+ // A cold start: the stream is ready only when the test says so.
86
+ const android = typedi_1.Container.get(AndroidStreamService_1.default);
87
+ sinon_1.default.stub(android, 'startStream').callsFake(() => __awaiter(void 0, void 0, void 0, function* () {
88
+ markCalled();
89
+ yield held;
90
+ return { mjpegPort: sourcePort };
91
+ }));
92
+ viewers = 0;
93
+ sinon_1.default.stub(android, 'updateViewerCount').callsFake((_udid, delta) => {
94
+ viewers += delta;
95
+ });
96
+ sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
97
+ findDevice: (_a) => __awaiter(void 0, [_a], void 0, function* ({ udid }) {
98
+ return ({
99
+ udid,
100
+ host: '127.0.0.1',
101
+ nodeId: own_node_id_1.OWN_NODE_ID,
102
+ platform: 'android',
103
+ busy: false,
104
+ session_id: null,
105
+ screenWidth: '1080',
106
+ screenHeight: '2220',
107
+ });
108
+ }),
109
+ });
110
+ }));
111
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
112
+ var _a;
113
+ sinon_1.default.restore();
114
+ yield loopback.closeAll();
115
+ (_a = source.closeAllConnections) === null || _a === void 0 ? void 0 : _a.call(source);
116
+ yield new Promise((resolve) => source.close(() => resolve(undefined)));
117
+ }));
118
+ function watch(udid) {
119
+ return __awaiter(this, void 0, void 0, function* () {
120
+ const server = yield loopback.serve(buildApp());
121
+ const { port } = server.address();
122
+ const req = http_1.default.get({
123
+ host: '127.0.0.1',
124
+ port,
125
+ path: `/xenon/api/control/${udid}/stream?t=0`,
126
+ agent: false,
127
+ });
128
+ req.on('error', () => undefined);
129
+ return req;
130
+ });
131
+ }
132
+ it('never counts a viewer who hung up while the stream was starting', () => __awaiter(void 0, void 0, void 0, function* () {
133
+ const req = yield watch('HANGUP-DURING-START');
134
+ yield startCalled;
135
+ req.destroy(); // the tile switched to H.264, or was closed
136
+ yield pause(100);
137
+ finishStart();
138
+ yield pause(300);
139
+ (0, chai_1.expect)(viewers).to.equal(0);
140
+ }));
141
+ it('counts a viewer who stays, and uncounts them when they leave', () => __awaiter(void 0, void 0, void 0, function* () {
142
+ const req = yield watch('STAYS-THEN-LEAVES');
143
+ yield startCalled;
144
+ finishStart();
145
+ yield until(() => viewers === 1);
146
+ (0, chai_1.expect)(viewers, 'watching').to.equal(1);
147
+ req.destroy();
148
+ yield until(() => viewers === 0);
149
+ (0, chai_1.expect)(viewers, 'left').to.equal(0);
150
+ }));
151
+ });