@xenon-device-management/xenon 2.8.2 → 2.9.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 (85) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-BqBip3BK.js → AnnotationOverlay-DhRFPZ9-.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DUvekoqc.js → ApiKeyGate-BmZy1Srn.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-BG0CrInp.js → BugReportButton-ByLSqwSk.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-B0SnOzKM.js → DeviceMosaicView-DVfXSoXH.js} +2 -2
  6. package/lib/public/assets/{EmptyState-Dq-Bq8TT.js → EmptyState-Bib6wal8.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-BIgXTGgC.js → FieldGroup-D5sTaKGR.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-CeJOFd6_.js → FilterMenu-D3XXKTwF.js} +1 -1
  9. package/lib/public/assets/{Menu-B4H6_Xk9.js → Menu-BiZhr0F7.js} +1 -1
  10. package/lib/public/assets/{Modal-DJG1w3KG.js → Modal-BxGNa3Zp.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CuzbuRlh.js → RecordingPage-Czml77Wb.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CW2IMjO4.js → RecordingsPage-Di7nyHqE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-DhQ3uYdc.js → SegmentedControl-XEKvCRwO.js} +1 -1
  14. package/lib/public/assets/{SettingCard-C5Gmj98i.js → SettingCard-YIpOQAjO.js} +1 -1
  15. package/lib/public/assets/{Table-D-WtrnfJ.js → Table-CqwX6v-1.js} +1 -1
  16. package/lib/public/assets/{activity-CvT65tfF.js → activity-Cur7Syd6.js} +1 -1
  17. package/lib/public/assets/{ai-settings-DuCpE_dA.js → ai-settings-B1OGdSKu.js} +1 -1
  18. package/lib/public/assets/{api-keys-DEIUB9Bz.js → api-keys-abTul2Oa.js} +1 -1
  19. package/lib/public/assets/{apps-B0z6NLY9.js → apps-yQL1MLfG.js} +1 -1
  20. package/lib/public/assets/{arrow-left-CCUPd12W.js → arrow-left-D0ipNN6o.js} +1 -1
  21. package/lib/public/assets/{arrow-right-CrLsc2tN.js → arrow-right-DwVz_Fly.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-CiQmHVQo.js → arrow-up-right-xKuKJfZf.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CHgiSQ9B.js → auth-shell-00zHLFCk.js} +2 -2
  24. package/lib/public/assets/{builds-page-BvxRnAet.js → builds-page-D4RJAbOZ.js} +1 -1
  25. package/lib/public/assets/{button-pgE9Zb7y.js → button-Cic7S8s2.js} +1 -1
  26. package/lib/public/assets/{calendar-CNJBqo5s.js → calendar-DyNZ2bq9.js} +1 -1
  27. package/lib/public/assets/{check-BOAfxviA.js → check-CXGZdojb.js} +1 -1
  28. package/lib/public/assets/{chevron-left-DAYay1pY.js → chevron-left-BcNiIt1C.js} +1 -1
  29. package/lib/public/assets/{chevron-right-B0FDOdzU.js → chevron-right-BxDjkb1I.js} +1 -1
  30. package/lib/public/assets/{circle-check-DOIAC8XC.js → circle-check-9isFAWQ6.js} +1 -1
  31. package/lib/public/assets/{circle-x-z8FaSlcA.js → circle-x-CfnPCFLK.js} +1 -1
  32. package/lib/public/assets/{clock-BtaZSeEx.js → clock-Dc4TvnFF.js} +1 -1
  33. package/lib/public/assets/{copy-D60KmcdV.js → copy-TFQpalJ2.js} +1 -1
  34. package/lib/public/assets/{device-explorer-Cph43t8H.js → device-explorer-CB4bSJtl.js} +1 -1
  35. package/lib/public/assets/{download-s-xM4AGY.js → download-1_bq_Ikd.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CKw6RVHP.js → forgot-password-B92TZ1GF.js} +1 -1
  37. package/lib/public/assets/{funnel-BvgEGRdQ.js → funnel-DC7DsjHe.js} +1 -1
  38. package/lib/public/assets/{index-BB9sO7Nt.js → index-BSsnx09Q.js} +1 -1
  39. package/lib/public/assets/{index-BpIf5nE2.js → index-CC2N_uMU.js} +2 -2
  40. package/lib/public/assets/{input-CPS4hChq.js → input-D2DBpxK2.js} +1 -1
  41. package/lib/public/assets/{keycodes-D5ZobFRW.js → keycodes-BQuIOMw0.js} +1 -1
  42. package/lib/public/assets/{layers-CrAsrOG4.js → layers-DxtoRlvi.js} +1 -1
  43. package/lib/public/assets/{list-checks-BnEjyKfv.js → list-checks-jQ7YUgdE.js} +1 -1
  44. package/lib/public/assets/{lock-Dm0hfbuM.js → lock-WHSsj2dj.js} +1 -1
  45. package/lib/public/assets/{login-DdlbwQwy.js → login-BB_xyUWw.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-kocnslUP.js → maintenance-settings-Dwadp8IQ.js} +1 -1
  47. package/lib/public/assets/{monitor-CGU-u9zq.js → monitor-DIWQjUlh.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-CWu4a7Ez.js → mouse-pointer-2-D11Re9Gj.js} +1 -1
  49. package/lib/public/assets/{network-TV7kV1jl.js → network-BYBgSBjv.js} +1 -1
  50. package/lib/public/assets/{overview-C3pWhCtz.js → overview-BISAwoyi.js} +1 -1
  51. package/lib/public/assets/{page-header-DCNc9cYT.js → page-header-ChX4YiMP.js} +1 -1
  52. package/lib/public/assets/{plus-C6Uuyu5Z.js → plus-BWNj1XSQ.js} +1 -1
  53. package/lib/public/assets/{profile-page-CjUENuCw.js → profile-page-VZD3gRjV.js} +1 -1
  54. package/lib/public/assets/{reset-password-rpbIm1Go.js → reset-password-Dz9LzFeX.js} +1 -1
  55. package/lib/public/assets/{runbook-page-BGkOmIkY.js → runbook-page-Bl2FodZf.js} +1 -1
  56. package/lib/public/assets/{select-6Q8XgEfq.js → select-Bs9CQu5Z.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-BPIr14Yp.js → selector-detail-page-By7ihhbY.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-Vycvp0ED.js → selector-health-page-Y4CTbOLH.js} +1 -1
  59. package/lib/public/assets/{selector-health-DhZ490H5.js → selector-health-xiBb8vNz.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-D2rvJIgL.js → session-detail-page-DqeKRP6c.js} +1 -1
  61. package/lib/public/assets/{settings-bF2S7k19.js → settings-DDhV_Dnz.js} +1 -1
  62. package/lib/public/assets/stat-tile-DMBTDbwt.js +8 -0
  63. package/lib/public/assets/{tablet-1Gmx6FV4.js → tablet-Cr5bOgng.js} +1 -1
  64. package/lib/public/assets/{teams-DHBNN_a7.js → teams-Bm0UQkR6.js} +1 -1
  65. package/lib/public/assets/{trash-2-D3idPlqv.js → trash-2-Di_lMeiP.js} +1 -1
  66. package/lib/public/assets/{upload-HOW_42FA.js → upload-RvXDBmCX.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-CVPBPGSb.js → use-password-reset-mode-C7pUQ7xe.js} +1 -1
  68. package/lib/public/assets/{users-DjGgZFe_.js → users-CIILdbvy.js} +1 -1
  69. package/lib/public/assets/{users-BnKiArxK.js → users-DZ7B5ePh.js} +1 -1
  70. package/lib/public/assets/{video-off-CuipXFJK.js → video-off-aYil8B-X.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-7_s2Zky-.js → webhook-settings-17JYW1ZR.js} +1 -1
  72. package/lib/public/assets/{zap-Gl0D1sD4.js → zap-D9hchm-a.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/app/routers/dashboard.js +6 -0
  75. package/lib/src/app/routers/sessionInsights.js +23 -1
  76. package/lib/src/device-managers/ios/IOSStreamService.js +12 -0
  77. package/lib/src/device-managers/ios/IOSTunnels.js +147 -23
  78. package/lib/src/device-managers/ios/WDAClient.js +18 -4
  79. package/lib/test/unit/ios-go-ios-tunnel-env.spec.js +38 -1
  80. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +10 -0
  81. package/lib/test/unit/ios-stream-port-leases.spec.js +26 -0
  82. package/lib/test/unit/ios-tunnels.spec.js +90 -0
  83. package/lib/test/unit/session-list-insights.spec.js +57 -0
  84. package/package.json +1 -1
  85. package/lib/public/assets/stat-tile-DOrnbGTr.js +0 -8
@@ -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
@@ -283,6 +283,10 @@ let IOSStreamService = class IOSStreamService {
283
283
  return __awaiter(this, void 0, void 0, function* () {
284
284
  if (yield this.appiumSessionMayUse(udid, 'go-ios tunnels'))
285
285
  return;
286
+ // A tunnel opened for screenshots is live and tracked, not a leftover:
287
+ // the start's ensure takes it over (IOSTunnels.borrow).
288
+ if (this.tunnels().isOnDemand(udid))
289
+ return;
286
290
  logger_1.default.debug(`Cleaning up orphan tunnels for ${udid}...`);
287
291
  yield this.tunnels().stop(udid);
288
292
  // Reap the tunnel process *group* for this udid so the self-forking go-ios
@@ -853,6 +857,14 @@ let IOSStreamService = class IOSStreamService {
853
857
  session.status = 'error';
854
858
  session.lastError = error.message;
855
859
  }
860
+ // A start that got its tunnel up and then failed (WDA didn't launch)
861
+ // stops that tunnel, so it doesn't run on, holding its ports, until
862
+ // the phone's next start. Not while an Appium session may use it.
863
+ if ((session === null || session === void 0 ? void 0 : session.tunnelPort) != null &&
864
+ !(yield this.appiumSessionMayUse(udid, "the failed start's go-ios tunnel"))) {
865
+ yield this.tunnels().stop(udid);
866
+ session.tunnelPort = null;
867
+ }
856
868
  // Back off before the next attempt so a polling client can't drive a
857
869
  // restart loop against a device that keeps failing to start.
858
870
  this.markRecoveryAttempt(udid);
@@ -18,8 +18,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.IOSTunnels = exports.TUNNEL_CHECK_MS = exports.TUNNEL_LEASE_TTL_MS = exports.TUNNEL_READY_POLL_MS = exports.TUNNEL_READY_TIMEOUT_MS = void 0;
21
+ exports.IOSTunnels = exports.TUNNEL_BORROW_IDLE_MS = 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
  *
@@ -68,12 +69,53 @@ exports.TUNNEL_READY_POLL_MS = 500;
68
69
  exports.TUNNEL_LEASE_TTL_MS = 90 * 60 * 1000;
69
70
  /** How often running tunnels are checked for a phone that went away. */
70
71
  exports.TUNNEL_CHECK_MS = 5000;
72
+ /**
73
+ * How long a tunnel opened for screenshots stays after the last one: long
74
+ * enough that a client taking screenshots in a loop pays the start once.
75
+ */
76
+ exports.TUNNEL_BORROW_IDLE_MS = 2 * 60 * 1000;
77
+ /** How long a phone's iOS version is trusted before `ios info` is asked again. */
78
+ const VERSION_CACHE_MS = 10 * 60 * 1000;
71
79
  /** The iOS version `ios info` reports, as a number: 17.2 for "17.2.1" or "iOS 17.2", 0 for none. */
72
80
  function iosVersionOf(info) {
73
81
  const versionStr = String(info.ProductVersion || info.HumanReadableProductVersionString || '0');
74
82
  const match = versionStr.match(/(\d+\.?\d*)/);
75
83
  return match ? parseFloat(match[0]) : 0;
76
84
  }
85
+ /** The longest go-ios reason kept for an error message. */
86
+ const REASON_MAX = 200;
87
+ /**
88
+ * The last error or warning in a chunk of go-ios's stderr, for an error
89
+ * message. go-ios logs JSON lines: an ERROR or WARN line gives its `msg` and
90
+ * `error`, an INFO line gives nothing, and a line that isn't JSON (a panic)
91
+ * is kept as it is.
92
+ */
93
+ function goIosReason(text) {
94
+ var _a;
95
+ let reason;
96
+ for (const raw of text.split('\n')) {
97
+ const line = raw.trim();
98
+ if (!line)
99
+ continue;
100
+ let entry;
101
+ try {
102
+ entry = JSON.parse(line);
103
+ }
104
+ catch (_b) {
105
+ entry = undefined;
106
+ }
107
+ if (entry && typeof entry === 'object') {
108
+ const level = String((_a = entry.level) !== null && _a !== void 0 ? _a : '').toUpperCase();
109
+ if ((level === 'ERROR' || level === 'WARN') && entry.msg) {
110
+ reason = entry.error ? `${entry.msg}: ${entry.error}` : String(entry.msg);
111
+ }
112
+ }
113
+ else {
114
+ reason = line;
115
+ }
116
+ }
117
+ return reason === null || reason === void 0 ? void 0 : reason.slice(0, REASON_MAX);
118
+ }
77
119
  /** An answer that puts the phone's traffic anywhere but its leased P + 1. */
78
120
  function moved(info, port) {
79
121
  return info.state === 'up' && info.userspacePort !== undefined && info.userspacePort !== port + 1;
@@ -83,29 +125,48 @@ let IOSTunnels = class IOSTunnels {
83
125
  this.log = logger_1.default.scope('IOSTunnels');
84
126
  this.tunnels = new Map();
85
127
  this.checking = false;
128
+ this.versionCache = new Map();
86
129
  this.goIOSPath = path_1.default.join((0, helpers_1.cachePath)('goIOS'), 'ios');
87
130
  }
88
131
  /**
89
- * The phone's tunnel-info port, starting its tunnel if it has none. Null
90
- * for a phone below iOS 17, or whose version can't be read: it needs no
91
- * tunnel. Throws when no pair of ports is left, or when the tunnel exits
92
- * before it is ready.
132
+ * The phone's tunnel-info port for its stream, starting its tunnel if it
133
+ * has none. A tunnel opened for screenshots is taken over: from then on
134
+ * only the stream's stop ends it. Null for a phone below iOS 17, or whose
135
+ * version can't be read: it needs no tunnel. Throws when no pair of ports
136
+ * is left, or when the tunnel exits before it is ready.
93
137
  */
94
138
  ensure(udid) {
95
139
  return __awaiter(this, void 0, void 0, function* () {
96
- var _a;
97
- const running = this.portFor(udid);
98
- if (running !== undefined)
99
- return running;
100
- let version;
101
- try {
102
- version = yield this.iosVersion(udid);
103
- }
104
- catch (e) {
105
- this.log.warn(`[${udid}] Could not read the iOS version, so no go-ios tunnel: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
106
- return null;
140
+ return this.open(udid, 'stream');
141
+ });
142
+ }
143
+ /**
144
+ * The phone's tunnel-info port for a screenshot, which on iOS 17+ is
145
+ * reached only through the tunnel. A phone that isn't streaming has none,
146
+ * so one is started; it stays while screenshots keep coming and stops
147
+ * {@link TUNNEL_BORROW_IDLE_MS} after the last one (checkTunnels). Null and
148
+ * throws as ensure.
149
+ */
150
+ borrow(udid) {
151
+ return __awaiter(this, void 0, void 0, function* () {
152
+ return this.open(udid, 'screenshot');
153
+ });
154
+ }
155
+ /** Whether the phone's tunnel was opened for screenshots and no stream has taken it. */
156
+ isOnDemand(udid) {
157
+ const tunnel = this.tunnels.get(udid);
158
+ return !!tunnel && tunnel.process.exitCode === null && !tunnel.streamOwned;
159
+ }
160
+ open(udid, use) {
161
+ return __awaiter(this, void 0, void 0, function* () {
162
+ const running = this.tunnels.get(udid);
163
+ if (running && running.process.exitCode === null) {
164
+ this.claim(running, use);
165
+ return running.whenReady;
107
166
  }
108
- if (version < 17)
167
+ // A screenshot also asks on a simulator, which has no version to read.
168
+ const version = yield this.versionOf(udid, use === 'screenshot');
169
+ if (version === null || version < 17)
109
170
  return null;
110
171
  const port = yield this.allocator().acquirePair('tunnel', udid, {
111
172
  ttlMs: exports.TUNNEL_LEASE_TTL_MS,
@@ -126,7 +187,15 @@ let IOSTunnels = class IOSTunnels {
126
187
  yield this.releasePair(port, udid);
127
188
  throw e;
128
189
  }
129
- const tunnel = { port, process: proc, ready: false };
190
+ const tunnel = {
191
+ port,
192
+ process: proc,
193
+ ready: false,
194
+ whenReady: Promise.resolve(port),
195
+ streamOwned: false,
196
+ borrowedUntil: 0,
197
+ };
198
+ this.claim(tunnel, use);
130
199
  this.tunnels.set(udid, tunnel);
131
200
  this.watch();
132
201
  // A tunnel whose process ends (go-ios crashed, or something killed it)
@@ -144,14 +213,30 @@ let IOSTunnels = class IOSTunnels {
144
213
  this.log.warn(`[${udid}] go-ios tunnel failed: ${err.message}`);
145
214
  ended();
146
215
  });
147
- this.logOutput(udid, proc);
148
- this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})`);
216
+ this.logOutput(udid, tunnel);
217
+ this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})` +
218
+ (use === 'screenshot' ? ' for screenshots' : ''));
219
+ // Whoever asks while it starts waits for the same start.
220
+ tunnel.whenReady = this.waitUntilReady(udid, tunnel);
221
+ return tunnel.whenReady;
222
+ });
223
+ }
224
+ claim(tunnel, use) {
225
+ if (use === 'stream')
226
+ tunnel.streamOwned = true;
227
+ else
228
+ tunnel.borrowedUntil = this.now() + exports.TUNNEL_BORROW_IDLE_MS;
229
+ }
230
+ waitUntilReady(udid, tunnel) {
231
+ return __awaiter(this, void 0, void 0, function* () {
232
+ const { port, process: proc } = tunnel;
149
233
  const polls = exports.TUNNEL_READY_TIMEOUT_MS / exports.TUNNEL_READY_POLL_MS;
150
234
  for (let poll = 0; poll <= polls; poll++) {
151
235
  if (poll > 0)
152
236
  yield this.sleep(exports.TUNNEL_READY_POLL_MS);
153
237
  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})`);
238
+ throw new Error(`The go-ios tunnel for ${udid} exited before it was ready (exit code ${proc.exitCode})` +
239
+ (tunnel.reason ? `: ${tunnel.reason}` : ''));
155
240
  }
156
241
  const info = yield this.tunnelInfo(port, udid);
157
242
  if (moved(info, port)) {
@@ -170,6 +255,33 @@ let IOSTunnels = class IOSTunnels {
170
255
  return port;
171
256
  });
172
257
  }
258
+ /**
259
+ * The phone's iOS version, from `ios info`, kept for VERSION_CACHE_MS: a
260
+ * screenshot on a phone below iOS 17 would otherwise ask every time. Null
261
+ * when it can't be read (not cached, so a phone still pairing is asked
262
+ * again); logged at debug when `quiet`.
263
+ */
264
+ versionOf(udid_1) {
265
+ return __awaiter(this, arguments, void 0, function* (udid, quiet = false) {
266
+ var _a;
267
+ const cached = this.versionCache.get(udid);
268
+ if (cached && this.now() - cached.at < VERSION_CACHE_MS)
269
+ return cached.version;
270
+ try {
271
+ const version = yield this.iosVersion(udid);
272
+ this.versionCache.set(udid, { version, at: this.now() });
273
+ return version;
274
+ }
275
+ catch (e) {
276
+ const message = `[${udid}] Could not read the iOS version, so no go-ios tunnel: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`;
277
+ if (quiet)
278
+ this.log.debug(message);
279
+ else
280
+ this.log.warn(message);
281
+ return null;
282
+ }
283
+ });
284
+ }
173
285
  /** The phone's tunnel-info port, while its tunnel runs. */
174
286
  portFor(udid) {
175
287
  const tunnel = this.tunnels.get(udid);
@@ -217,6 +329,11 @@ let IOSTunnels = class IOSTunnels {
217
329
  this.checking = true;
218
330
  try {
219
331
  for (const [udid, tunnel] of [...this.tunnels]) {
332
+ if (!tunnel.streamOwned && tunnel.borrowedUntil <= this.now()) {
333
+ this.log.info(`[${udid}] go-ios tunnel on ${tunnel.port}, opened for screenshots, is idle; stopping it`);
334
+ yield this.stop(udid);
335
+ continue;
336
+ }
220
337
  const info = yield this.tunnelInfo(tunnel.port, udid);
221
338
  if (this.tunnels.get(udid) !== tunnel)
222
339
  continue;
@@ -278,13 +395,16 @@ let IOSTunnels = class IOSTunnels {
278
395
  }
279
396
  /**
280
397
  * 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.
398
+ * pre-iOS 17 phone (which needs no tunnel) is logged once per phone. Its
399
+ * last error or warning is kept, for the error if the tunnel fails.
282
400
  */
283
- logOutput(udid, proc) {
401
+ logOutput(udid, tunnel) {
284
402
  var _a, _b;
403
+ const proc = tunnel.process;
285
404
  (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => this.log.debug(`Tunnel [${udid}]: ${data}`));
286
405
  const loggedUnsupported = new Set();
287
406
  (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => {
407
+ var _a;
288
408
  const text = String(data);
289
409
  const { unsupported, udid: targetUdid } = (0, iosStreamDiagnostics_1.classifyTunnelStderr)(text);
290
410
  if (unsupported) {
@@ -295,6 +415,7 @@ let IOSTunnels = class IOSTunnels {
295
415
  }
296
416
  return;
297
417
  }
418
+ tunnel.reason = (_a = goIosReason(text)) !== null && _a !== void 0 ? _a : tunnel.reason;
298
419
  this.log.debug(`Tunnel Err [${udid}]: ${text}`);
299
420
  });
300
421
  }
@@ -360,6 +481,9 @@ let IOSTunnels = class IOSTunnels {
360
481
  req.on('error', () => resolve({ state: 'unknown' }));
361
482
  });
362
483
  }
484
+ now() {
485
+ return Date.now();
486
+ }
363
487
  sleep(ms) {
364
488
  return new Promise((resolve) => setTimeout(resolve, ms));
365
489
  }
@@ -395,12 +395,20 @@ let WDAClient = WDAClient_1 = class WDAClient {
395
395
  return __awaiter(this, void 0, void 0, function* () {
396
396
  var _a, _b, _c;
397
397
  const s = typedi_1.Container.get(IOSStreamService_1.default);
398
+ let tunnelError;
398
399
  if (yield s.isGoIOSAvailable()) {
399
400
  try {
401
+ // On iOS 17+ go-ios reaches the screenshot service only through the
402
+ // phone's own tunnel, and a phone that isn't streaming has none: open
403
+ // one, kept while screenshots keep coming (see IOSTunnels.borrow).
404
+ const tunnels = typedi_1.Container.get(IOSTunnels_1.IOSTunnels);
405
+ yield tunnels.borrow(udid).catch((e) => {
406
+ tunnelError = e;
407
+ throw e;
408
+ });
400
409
  const p = path_1.default.join(os_1.default.tmpdir(), `screenshot-${udid}.png`);
401
410
  yield execFilePromise(s.goIOSPath, ['screenshot', '--udid', udid, '--output', p], {
402
- // This phone's own go-ios tunnel on iOS 17+ (see IOSTunnels).
403
- env: typedi_1.Container.get(IOSTunnels_1.IOSTunnels).envFor(udid),
411
+ env: tunnels.envFor(udid),
404
412
  });
405
413
  const b = yield fs_extra_1.default.readFile(p);
406
414
  yield fs_extra_1.default.remove(p);
@@ -410,8 +418,14 @@ let WDAClient = WDAClient_1 = class WDAClient {
410
418
  this.log.debug(`go-ios screenshot failed for ${udid}: ${e.message}\n${e.stack}`);
411
419
  }
412
420
  }
413
- const res = yield this.sendWDACommand(udid, 'get', '/screenshot');
414
- return ((_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.screenshot) || ((_c = res.data) === null || _c === void 0 ? void 0 : _c.value) || '';
421
+ try {
422
+ const res = yield this.sendWDACommand(udid, 'get', '/screenshot');
423
+ return ((_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.screenshot) || ((_c = res.data) === null || _c === void 0 ? void 0 : _c.value) || '';
424
+ }
425
+ catch (e) {
426
+ // WebDriverAgent couldn't answer either; the tunnel's reason says more.
427
+ throw tunnelError !== null && tunnelError !== void 0 ? tunnelError : e;
428
+ }
415
429
  });
416
430
  }
417
431
  getClipboard(udid) {
@@ -43,13 +43,33 @@ case "$1" in
43
43
  syslog) printf '{"msg":"port=%s"}\\n' "$GO_IOS_AGENT_PORT"; exec sleep 5 ;;
44
44
  esac
45
45
  `;
46
- /** IOSTunnels as if WITH_TUNNEL's tunnel ran on 12100 and NO_TUNNEL had none. */
46
+ /** iOS 17+, not streaming: it has a tunnel only once one is opened for it. */
47
+ const NEEDS_TUNNEL = 'test-iphone-idle-00008120';
48
+ /**
49
+ * IOSTunnels as if WITH_TUNNEL's tunnel ran on 12100, NO_TUNNEL needed none
50
+ * (below iOS 17), and NEEDS_TUNNEL got one on 12102 once borrowed.
51
+ */
47
52
  const tunnels = {
53
+ borrowed: new Set(),
54
+ borrowCalls: [],
55
+ borrowError: undefined,
56
+ borrow(udid) {
57
+ return __awaiter(this, void 0, void 0, function* () {
58
+ this.borrowCalls.push(udid);
59
+ if (this.borrowError)
60
+ throw this.borrowError;
61
+ if (udid === NEEDS_TUNNEL)
62
+ this.borrowed.add(udid);
63
+ return udid === WITH_TUNNEL ? 12100 : udid === NEEDS_TUNNEL ? 12102 : null;
64
+ });
65
+ },
48
66
  envFor(udid) {
49
67
  const env = Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' });
50
68
  delete env.GO_IOS_AGENT_PORT;
51
69
  if (udid === WITH_TUNNEL)
52
70
  env.GO_IOS_AGENT_PORT = '12100';
71
+ if (this.borrowed.has(udid))
72
+ env.GO_IOS_AGENT_PORT = '12102';
53
73
  return env;
54
74
  },
55
75
  };
@@ -63,6 +83,9 @@ describe("go-ios commands find their own phone's tunnel (GO_IOS_AGENT_PORT)", ()
63
83
  });
64
84
  after(() => fs_1.default.rmSync(dir, { recursive: true, force: true }));
65
85
  beforeEach(() => {
86
+ tunnels.borrowed.clear();
87
+ tunnels.borrowCalls = [];
88
+ tunnels.borrowError = undefined;
66
89
  const real = typedi_1.Container.get.bind(typedi_1.Container);
67
90
  sinon_1.default.stub(typedi_1.Container, 'get').callsFake((token) => {
68
91
  if (token === IOSTunnels_1.IOSTunnels)
@@ -88,6 +111,20 @@ describe("go-ios commands find their own phone's tunnel (GO_IOS_AGENT_PORT)", ()
88
111
  const png = yield new WDAClient_1.WDAClient().getScreenshot(NO_TUNNEL);
89
112
  (0, chai_1.expect)(Buffer.from(png, 'base64').toString()).to.equal('port=');
90
113
  }));
114
+ // On iOS 17+ the screenshot service is reached only through the tunnel,
115
+ // and a phone that isn't streaming has none.
116
+ it("opens the phone's tunnel for a screenshot when it has none", () => __awaiter(void 0, void 0, void 0, function* () {
117
+ const png = yield new WDAClient_1.WDAClient().getScreenshot(NEEDS_TUNNEL);
118
+ (0, chai_1.expect)(tunnels.borrowCalls).to.deep.equal([NEEDS_TUNNEL]);
119
+ (0, chai_1.expect)(Buffer.from(png, 'base64').toString()).to.equal('port=12102');
120
+ }));
121
+ it("says why, when the tunnel can't start and WebDriverAgent can't answer either", () => __awaiter(void 0, void 0, void 0, function* () {
122
+ tunnels.borrowError = new Error('The go-ios tunnel for X exited before it was ready (exit code 1): failed to start tunnel: EOF');
123
+ const client = new WDAClient_1.WDAClient();
124
+ sinon_1.default.stub(client, 'sendWDACommand').rejects(new Error('connect ECONNREFUSED'));
125
+ const err = yield client.getScreenshot(NEEDS_TUNNEL).then(() => null, (e) => e);
126
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message).to.match(/exited before it was ready .*failed to start tunnel: EOF/);
127
+ }));
91
128
  });
92
129
  describe('the logs read (WDAClient syslog)', () => {
93
130
  let client;
@@ -198,6 +198,7 @@ describe('go-ios tunnels under a live Appium session (fake exec, fake processes)
198
198
  envFor: sinon_1.default.stub().callsFake(() => (Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }))),
199
199
  stop: sinon_1.default.stub().resolves(),
200
200
  touch: sinon_1.default.stub().resolves(),
201
+ isOnDemand: sinon_1.default.stub().returns(false),
201
202
  };
202
203
  svc = iosService();
203
204
  const real = typedi_1.Container.get.bind(typedi_1.Container);
@@ -363,6 +364,15 @@ describe('go-ios tunnels under a live Appium session (fake exec, fake processes)
363
364
  (0, chai_1.expect)(defaultPorts()).to.deep.equal([]);
364
365
  }));
365
366
  });
367
+ describe('a start on a phone whose tunnel was opened for screenshots', () => {
368
+ it('takes that tunnel over, neither stopping nor reaping it', () => __awaiter(void 0, void 0, void 0, function* () {
369
+ Object.assign(device, { busy: false, session_id: null });
370
+ tunnels.isOnDemand.withArgs(IPHONE).returns(true);
371
+ yield svc.cleanupOrphanTunnels(IPHONE);
372
+ (0, chai_1.expect)(tunnels.stop.called, 'the tracked tunnel').to.equal(false);
373
+ (0, chai_1.expect)(swept(), 'no pgrep reap of its process').to.deep.equal([]);
374
+ }));
375
+ });
366
376
  describe('boot', () => {
367
377
  it('reaps every go-ios process, then drops every tunnel port lease', () => __awaiter(void 0, void 0, void 0, function* () {
368
378
  yield svc.reapOrphanTunnels();
@@ -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.
@@ -80,10 +80,16 @@ class TestTunnels extends IOSTunnels_1.IOSTunnels {
80
80
  /** Each tunnel-info poll: true is the phone's tunnel on P + 1, false a 404. */
81
81
  this.answers = () => true;
82
82
  this.ports = fakeAllocator();
83
+ this.clock = 1000000;
84
+ this.versionReads = 0;
83
85
  this.nextPid = 7000;
84
86
  }
87
+ now() {
88
+ return this.clock;
89
+ }
85
90
  iosVersion(udid) {
86
91
  return __awaiter(this, void 0, void 0, function* () {
92
+ this.versionReads += 1;
87
93
  const version = this.versions.get(udid);
88
94
  if (version === undefined)
89
95
  throw new Error(`no such phone: ${udid}`);
@@ -181,6 +187,18 @@ describe('IOSTunnels: a go-ios tunnel per iPhone', () => {
181
187
  (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(undefined);
182
188
  (0, chai_1.expect)([...t.ports.leased.keys()]).to.deep.equal([]);
183
189
  }));
190
+ it("says why the tunnel exited before it was ready, in go-ios's words", () => __awaiter(void 0, void 0, void 0, function* () {
191
+ t.answers = () => {
192
+ const proc = t.spawned[0].proc;
193
+ proc.stderr.emit('data', Buffer.from('{"level":"INFO","msg":"Using userspace networking"}\n' +
194
+ '{"level":"ERROR","msg":"failed to start tunnel","error":"listen tcp 127.0.0.1:12101: bind: address already in use"}\n'));
195
+ proc.exit(1);
196
+ return false;
197
+ };
198
+ const err = yield t.ensure(PHONE_A).then(() => null, (e) => e);
199
+ (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): ` +
200
+ 'failed to start tunnel: listen tcp 127.0.0.1:12101: bind: address already in use');
201
+ }));
184
202
  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
203
  t.answers = () => ({ state: 'up', userspacePort: 12102 });
186
204
  const err = yield t.ensure(PHONE_A).then(() => null, (e) => e);
@@ -320,6 +338,78 @@ describe('IOSTunnels: a phone that goes away (go-ios keeps its agent running)',
320
338
  }
321
339
  }));
322
340
  });
341
+ // A screenshot on iOS 17+ goes through the phone's tunnel, and a phone that
342
+ // isn't streaming has none: one is opened for screenshots and kept while they
343
+ // keep coming.
344
+ describe('IOSTunnels: a tunnel opened for screenshots', () => {
345
+ let t;
346
+ beforeEach(() => {
347
+ sinon_1.default.stub(process, 'kill');
348
+ t = new TestTunnels();
349
+ t.versions.set(PHONE_A, 17.4);
350
+ });
351
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
352
+ yield t.stop(PHONE_A);
353
+ sinon_1.default.restore();
354
+ }));
355
+ it('opens one when the phone has none, and the next screenshot reuses it', () => __awaiter(void 0, void 0, void 0, function* () {
356
+ (0, chai_1.expect)(yield t.borrow(PHONE_A)).to.equal(12100);
357
+ (0, chai_1.expect)(yield t.borrow(PHONE_A)).to.equal(12100);
358
+ (0, chai_1.expect)(t.spawned.length).to.equal(1);
359
+ (0, chai_1.expect)(t.envFor(PHONE_A).GO_IOS_AGENT_PORT).to.equal('12100');
360
+ }));
361
+ it('stops it 2 minutes after the last screenshot, and gives its ports back', () => __awaiter(void 0, void 0, void 0, function* () {
362
+ yield t.borrow(PHONE_A);
363
+ t.clock += IOSTunnels_1.TUNNEL_BORROW_IDLE_MS - 1000;
364
+ yield t.borrow(PHONE_A); // another screenshot: the 2 minutes start again
365
+ t.clock += IOSTunnels_1.TUNNEL_BORROW_IDLE_MS - 1000;
366
+ yield t.checkTunnels();
367
+ (0, chai_1.expect)(t.portFor(PHONE_A), 'last used 119 s ago').to.equal(12100);
368
+ t.clock += 2000;
369
+ yield t.checkTunnels();
370
+ yield settle();
371
+ (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(undefined);
372
+ (0, chai_1.expect)(t.killed).to.deep.equal([t.spawned[0].proc.pid]);
373
+ (0, chai_1.expect)([...t.ports.leased.keys()]).to.deep.equal([]);
374
+ }));
375
+ it('never stops one a stream took over', () => __awaiter(void 0, void 0, void 0, function* () {
376
+ yield t.borrow(PHONE_A);
377
+ (0, chai_1.expect)(t.isOnDemand(PHONE_A)).to.equal(true);
378
+ (0, chai_1.expect)(yield t.ensure(PHONE_A), 'the stream takes it').to.equal(12100);
379
+ (0, chai_1.expect)(t.isOnDemand(PHONE_A)).to.equal(false);
380
+ t.clock += 10 * IOSTunnels_1.TUNNEL_BORROW_IDLE_MS;
381
+ yield t.checkTunnels();
382
+ (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(12100);
383
+ (0, chai_1.expect)(t.spawned.length).to.equal(1);
384
+ }));
385
+ it("never stops a stream's own tunnel for being idle", () => __awaiter(void 0, void 0, void 0, function* () {
386
+ yield t.ensure(PHONE_A);
387
+ yield t.borrow(PHONE_A); // a screenshot while the stream runs
388
+ t.clock += 10 * IOSTunnels_1.TUNNEL_BORROW_IDLE_MS;
389
+ yield t.checkTunnels();
390
+ (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(12100);
391
+ (0, chai_1.expect)(t.isOnDemand(PHONE_A)).to.equal(false);
392
+ }));
393
+ it('opens none for a phone below iOS 17, and reads its version once', () => __awaiter(void 0, void 0, void 0, function* () {
394
+ t.versions.set('old-iphone', 16.7);
395
+ (0, chai_1.expect)(yield t.borrow('old-iphone')).to.equal(null);
396
+ (0, chai_1.expect)(yield t.borrow('old-iphone')).to.equal(null);
397
+ (0, chai_1.expect)(t.versionReads).to.equal(1);
398
+ (0, chai_1.expect)(t.spawned).to.deep.equal([]);
399
+ }));
400
+ });
401
+ describe('goIosReason', () => {
402
+ it("reads go-ios's last error or warning", () => {
403
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('{"level":"INFO","msg":"Tunnel server started"}\n')).to.equal(undefined);
404
+ (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');
405
+ (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');
406
+ });
407
+ it('keeps a line that is not JSON, trimmed and at most 200 characters', () => {
408
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)(' panic: runtime error \n')).to.equal('panic: runtime error');
409
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('x'.repeat(500))).to.have.length(200);
410
+ (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('\n \n')).to.equal(undefined);
411
+ });
412
+ });
323
413
  describe('iosVersionOf', () => {
324
414
  it('reads the version ios info reports', () => {
325
415
  (0, chai_1.expect)((0, IOSTunnels_1.iosVersionOf)({ ProductVersion: '17.2.1' })).to.equal(17.2);