@xenon-device-management/xenon 2.16.0 → 2.17.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 (114) hide show
  1. package/README.md +2 -2
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-2W4JvEyZ.js → AnnotationOverlay-CjmMCS0S.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-GmckX_7r.js → ApiKeyGate-B4Ob0v3C.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DqODzkyo.js → DeviceMosaicView-DTVOB_NO.js} +1 -1
  6. package/lib/public/assets/{EmptyState-7hETKEWb.js → EmptyState-DcEG-oAi.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-GOgssFEP.js → FieldGroup-CtnCDshE.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-Yvf6RGMd.js → FilterMenu-BK0dE9cc.js} +1 -1
  9. package/lib/public/assets/{Menu-KrPnfdsf.js → Menu-eKsmLpKS.js} +1 -1
  10. package/lib/public/assets/{Modal-C6WjZFeI.js → Modal-DPwop3Lz.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CSRIhZEh.js → RecordingPage-CfgSkZkB.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CB8ST3nE.js → RecordingsPage-Bzl9pTUY.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C9o90KId.js → SegmentedControl-B_TZTrPm.js} +1 -1
  14. package/lib/public/assets/{SettingCard-CPm3w9l2.js → SettingCard-DlmvZJPM.js} +1 -1
  15. package/lib/public/assets/{Table-DwIgERRt.js → Table-BigPWKXi.js} +1 -1
  16. package/lib/public/assets/{activity-DrLbHifn.js → activity-CvuZdQYu.js} +1 -1
  17. package/lib/public/assets/{ai-settings-DU_Ls06P.js → ai-settings-lI2UJ2n7.js} +1 -1
  18. package/lib/public/assets/{api-keys-CWYdEzJ_.js → api-keys-CPgf9XJM.js} +1 -1
  19. package/lib/public/assets/{apps-BqNVdln7.js → apps-DIzOvtUp.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BtB0_Jg_.js → arrow-left-DiP5ywBT.js} +1 -1
  21. package/lib/public/assets/{arrow-right-C8NhdtyN.js → arrow-right-DtW_8hm5.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-gMg0ffvv.js → arrow-up-right-C89FbNU0.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CiYZ3N4M.js → auth-shell-B7sUDb--.js} +2 -2
  24. package/lib/public/assets/{builds-page-EaaqDmLN.js → builds-page-BBhvrMfA.js} +1 -1
  25. package/lib/public/assets/{button-DDau4Axv.js → button-DugsdUw1.js} +1 -1
  26. package/lib/public/assets/{calendar-DQ83B1ed.js → calendar-grKd08ZH.js} +1 -1
  27. package/lib/public/assets/{check-K1ovTQam.js → check-ORjyNfnX.js} +1 -1
  28. package/lib/public/assets/{chevron-right-Biq00pMt.js → chevron-right-Ei2FxUXq.js} +1 -1
  29. package/lib/public/assets/{circle-check-CBv88cH7.js → circle-check-Day0HjW4.js} +1 -1
  30. package/lib/public/assets/{circle-x-Bmltailn.js → circle-x-CHxSRLHp.js} +1 -1
  31. package/lib/public/assets/{clock-BPrld-3h.js → clock-BDHewWTx.js} +1 -1
  32. package/lib/public/assets/{copy-t0QB14JI.js → copy-CTwT4I3D.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BgGZ8AvV.js → device-explorer-DlhrKJ-g.js} +1 -1
  34. package/lib/public/assets/{download-BroHxNOh.js → download-D3bjz_vr.js} +1 -1
  35. package/lib/public/assets/{forgot-password-Bjmf49i6.js → forgot-password-BNBLi023.js} +1 -1
  36. package/lib/public/assets/{h264Stream-DeD__6wa.js → h264Stream-C51Au2i_.js} +1 -1
  37. package/lib/public/assets/{index-B0gQkEg0.js → index-Dk5J6HB1.js} +2 -2
  38. package/lib/public/assets/{index-B0JeuZbg.js → index-DncSz5LG.js} +1 -1
  39. package/lib/public/assets/{index-72oF89LJ.js → index-Z7_BY3pJ.js} +1 -1
  40. package/lib/public/assets/{input-DIc4MSC7.js → input-bTXM4l09.js} +1 -1
  41. package/lib/public/assets/{line-chart-BcWm2vW8.js → line-chart-B3YmVYyr.js} +1 -1
  42. package/lib/public/assets/{list-checks-B06IJJad.js → list-checks-BClKEj1-.js} +1 -1
  43. package/lib/public/assets/{lock-pVLvnN30.js → lock-CZsenX3D.js} +1 -1
  44. package/lib/public/assets/{login-CN77vy--.js → login-B6A6tOp9.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-CSdzQSkY.js → maintenance-settings-CmCJGy7O.js} +1 -1
  46. package/lib/public/assets/{monitor-BtrN5IkI.js → monitor-BdTNEglU.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CNsQfo6w.js → mouse-pointer-2-BM8CaLtt.js} +1 -1
  48. package/lib/public/assets/{network-BSfA8ZxG.js → network-BS6vUc1x.js} +1 -1
  49. package/lib/public/assets/{overview-BfTebCK5.js → overview-Yfk_Y7g3.js} +1 -1
  50. package/lib/public/assets/{page-header-D8KUX6Qg.js → page-header-DhXCwpml.js} +1 -1
  51. package/lib/public/assets/{play-Cvc8xzic.js → play-D08gP1td.js} +1 -1
  52. package/lib/public/assets/{plus-C9Zj84UF.js → plus-BKboorfa.js} +1 -1
  53. package/lib/public/assets/{profile-page-Bi27STgs.js → profile-page-Cd9NodrA.js} +1 -1
  54. package/lib/public/assets/{reset-password-CPsOuwSu.js → reset-password-acLDyyDW.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DEQ-DBOX.js → runbook-page-CWLkC1Fd.js} +1 -1
  56. package/lib/public/assets/{select-BWpnhWXj.js → select-DZoRKNti.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BefSCSpe.js → selector-detail-redirect-DS4qrt84.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-MSk96Zz7.js → selector-health-page-TpmN61G3.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-DPyBNef_.js → session-detail-page-C0wYaXqy.js} +1 -1
  60. package/lib/public/assets/{settings-V6x-cXwy.js → settings-CxPGEY2j.js} +1 -1
  61. package/lib/public/assets/{stat-tile-Duex-GZD.js → stat-tile-DUpyYgl-.js} +1 -1
  62. package/lib/public/assets/{tablet-BBcoqBmL.js → tablet-SA02BJNQ.js} +1 -1
  63. package/lib/public/assets/{teams-ts8p5rEh.js → teams-jamD0Kxr.js} +1 -1
  64. package/lib/public/assets/{trash-2-CZ2ew6AZ.js → trash-2-DO8fv6w4.js} +1 -1
  65. package/lib/public/assets/{upload-Dv99y_le.js → upload-CaiGCStb.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-DrYC8JCF.js → use-builds-data-jS-fcvVf.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-C7QkBNkP.js → use-password-reset-mode-BzoenegM.js} +1 -1
  68. package/lib/public/assets/{users-gJ5Y01yo.js → users-Bnw7Vs_B.js} +1 -1
  69. package/lib/public/assets/{users-D7tKMESH.js → users-DHvDrriG.js} +1 -1
  70. package/lib/public/assets/{video-off-CqZVuZGs.js → video-off-FEvy0w5U.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-DI5DSW99.js → webhook-settings-CBVB5lxi.js} +1 -1
  72. package/lib/public/assets/{zap-ClcHFxfo.js → zap-BMrTJO6M.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +1 -1
  75. package/lib/src/app/index.js +2 -0
  76. package/lib/src/app/openapi/grid.yaml +102 -0
  77. package/lib/src/dashboard/event-manager.js +5 -8
  78. package/lib/src/device-utils.js +11 -2
  79. package/lib/src/gateway/nodeAsk.js +57 -0
  80. package/lib/src/gateway/nodeSessionDeviceLogs.js +35 -0
  81. package/lib/src/plugin.js +4 -1
  82. package/lib/src/scripts/initialize-database.js +0 -6
  83. package/lib/src/scripts/run-migrations.js +123 -880
  84. package/lib/src/services/SessionLifecycleService.js +20 -5
  85. package/lib/src/services/interceptor/CertManager.js +50 -16
  86. package/lib/src/services/logcat/NodeDeviceLogStore.js +99 -0
  87. package/lib/src/services/logcat/NodeDeviceLogsCollector.js +164 -0
  88. package/lib/src/services/logcat/SessionDeviceLogs.js +199 -44
  89. package/lib/src/services/logcat/nodeDeviceLogs.js +91 -0
  90. package/lib/src/services/metrics/nodeMetrics.js +20 -49
  91. package/lib/src/sessions/RemoteSession.js +11 -2
  92. package/lib/src/sessions/SessionManager.js +28 -1
  93. package/lib/test/helpers/fake-logcat.js +80 -0
  94. package/lib/test/helpers/hub-node-pair.js +227 -0
  95. package/lib/test/unit/dashboard-events-device-logs.spec.js +15 -0
  96. package/lib/test/unit/hub-node-iphone-readiness.spec.js +134 -0
  97. package/lib/test/unit/hub-node-session-device-logs.spec.js +194 -0
  98. package/lib/test/unit/hub-node-session-metrics.spec.js +24 -192
  99. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +8 -1
  100. package/lib/test/unit/interceptor/CertManager.spec.js +89 -0
  101. package/lib/test/unit/node-device-log-store.spec.js +102 -0
  102. package/lib/test/unit/node-device-logs-collector.spec.js +343 -0
  103. package/lib/test/unit/node-gateway-create-appium-server.spec.js +8 -1
  104. package/lib/test/unit/node-hub-session-owner.spec.js +8 -1
  105. package/lib/test/unit/node-session-device-logs-route.spec.js +103 -0
  106. package/lib/test/unit/run-migrations-database.spec.js +57 -424
  107. package/lib/test/unit/run-migrations.test.js +74 -674
  108. package/lib/test/unit/session-device-logs-hub-node.spec.js +340 -0
  109. package/lib/test/unit/session-device-logs-lifecycle.spec.js +137 -0
  110. package/lib/test/unit/session-device-logs.spec.js +32 -94
  111. package/lib/test/unit/session-log-indexes.spec.js +15 -0
  112. package/lib/tsconfig.tsbuildinfo +1 -1
  113. package/package.json +1 -1
  114. package/schema.json +1 -1
@@ -79,6 +79,7 @@ const appVisibility_1 = require("./device-access/appVisibility");
79
79
  const LiveSessionOwners_1 = require("./device-access/LiveSessionOwners");
80
80
  const sessionMemory_1 = require("../sessions/sessionMemory");
81
81
  const SessionMetricsService_1 = require("./metrics/SessionMetricsService");
82
+ const SessionDeviceLogs_1 = require("./logcat/SessionDeviceLogs");
82
83
  const appCapability_1 = require("./session/appCapability");
83
84
  const CircuitBreaker_1 = require("../data-service/CircuitBreaker");
84
85
  const nodeWebDriverUrl_1 = require("../gateway/nodeWebDriverUrl");
@@ -904,15 +905,26 @@ let SessionLifecycleService = class SessionLifecycleService {
904
905
  if (sessionInstance instanceof LocalSession_1.LocalSession) {
905
906
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).record(sessionId, userId);
906
907
  }
907
- // A node samples its own phones for its hub, whatever its dashboard
908
- // setting (the hub collects the figures). A hub and a standalone server
909
- // start sampling in EventManager.onSessionStarted, once the row exists.
908
+ // A node samples its own phones for its hub, and records their device
909
+ // log unless the session turned it off, whatever its dashboard setting
910
+ // (the hub collects both). A hub and a standalone server start them in
911
+ // EventManager.onSessionStarted, once the row exists.
910
912
  if (!this.isHub(context.pluginArgs) && sessionInstance instanceof LocalSession_1.LocalSession) {
911
913
  typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).start({
912
914
  sessionId,
913
915
  device: freshDevice,
914
916
  capabilities: sessionResponse,
915
917
  });
918
+ if (xenonCapabilities[XenonCapabilityManager_1.XENON_CAPABILITIES.SAVE_DEVICE_LOGS] !== false) {
919
+ void typedi_1.Container.get(SessionDeviceLogs_1.SessionDeviceLogs).start({
920
+ sessionId,
921
+ device: freshDevice,
922
+ since: allocatedAt,
923
+ // An iPhone's or simulator's: the log its driver captures.
924
+ driverLog: sessionInstance.deviceLog(),
925
+ appUnderTest: sessionInstance.appUnderTest(),
926
+ });
927
+ }
916
928
  }
917
929
  yield this.applyPostSessionLogic(sessionInstance, xenonCapabilities, freshDevice);
918
930
  });
@@ -1218,9 +1230,11 @@ let SessionLifecycleService = class SessionLifecycleService {
1218
1230
  reason,
1219
1231
  });
1220
1232
  // Before the lock's "still in memory?" check, which a dashboard-off
1221
- // node fails. On a node this ends the figures it holds for its hub;
1222
- // on a hub it collects a node session's last ones. Idempotent.
1233
+ // node fails. On a node this ends the figures and device log it
1234
+ // holds for its hub; on a hub it collects a node session's last ones,
1235
+ // before onSessionStopped's failure analysis reads the log. Idempotent.
1223
1236
  yield typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).stop(sessionId);
1237
+ yield typedi_1.Container.get(SessionDeviceLogs_1.SessionDeviceLogs).stop(sessionId);
1224
1238
  yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
1225
1239
  const session = SessionManager_1.SESSION_MANAGER.getSession(sessionId);
1226
1240
  if (!session) {
@@ -1338,6 +1352,7 @@ let SessionLifecycleService = class SessionLifecycleService {
1338
1352
  (0, sessionMemory_1.forgetSessionMemory)(sessionId);
1339
1353
  typedi_1.Container.get(TracingService_1.TracingService).endSessionSpan(sessionId, { failed: true, reason });
1340
1354
  yield typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).stop(sessionId);
1355
+ yield typedi_1.Container.get(SessionDeviceLogs_1.SessionDeviceLogs).stop(sessionId);
1341
1356
  }));
1342
1357
  });
1343
1358
  }
@@ -41,13 +41,19 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
41
41
  step((generator = generator.apply(thisArg, _arguments || [])).next());
42
42
  });
43
43
  };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
44
47
  Object.defineProperty(exports, "__esModule", { value: true });
45
48
  exports.CertManager = void 0;
49
+ exports.caSerialNumber = caSerialNumber;
50
+ exports.subjectHashOld = subjectHashOld;
46
51
  const fs = __importStar(require("fs"));
47
52
  const path = __importStar(require("path"));
48
- const child_process_1 = require("child_process");
49
53
  const crypto_1 = require("crypto");
50
54
  const node_forge_1 = require("node-forge");
55
+ const logger_1 = __importDefault(require("../../logger"));
56
+ const logger = logger_1.default.scope('CertManager');
51
57
  class CertManager {
52
58
  constructor(dir) {
53
59
  this.dir = dir;
@@ -71,6 +77,12 @@ class CertManager {
71
77
  if (!fs.existsSync(this.certPath) || !fs.existsSync(this.keyPath)) {
72
78
  this.generateCa();
73
79
  }
80
+ else if (!loadsInOpenSsl(this.certPath)) {
81
+ // Through 2.15 the serial had illegal leading zeros, so OpenSSL, Go and
82
+ // a phone's BoringSSL refused the CA. It never worked; make a new one.
83
+ logger.warn(`Replacing the interceptor CA at ${this.certPath}: it can't be loaded`);
84
+ this.generateCa();
85
+ }
74
86
  const subjectHash = this.computeAndroidHash(this.certPath);
75
87
  return { certPath: this.certPath, keyPath: this.keyPath, subjectHash };
76
88
  });
@@ -83,7 +95,7 @@ class CertManager {
83
95
  const keys = node_forge_1.pki.rsa.generateKeyPair(2048);
84
96
  const cert = node_forge_1.pki.createCertificate();
85
97
  cert.publicKey = keys.publicKey;
86
- cert.serialNumber = Date.now().toString(16).padStart(16, '0');
98
+ cert.serialNumber = caSerialNumber();
87
99
  const now = new Date();
88
100
  cert.validity.notBefore = new Date(now.getTime() - 24 * 60 * 60 * 1000);
89
101
  cert.validity.notAfter = new Date(now.getTime() + 10 * 365 * 24 * 60 * 60 * 1000);
@@ -112,20 +124,42 @@ class CertManager {
112
124
  fs.writeFileSync(this.publicKeyPath, node_forge_1.pki.publicKeyToPem(keys.publicKey), 'utf8');
113
125
  }
114
126
  computeAndroidHash(certPath) {
115
- try {
116
- const out = (0, child_process_1.execSync)(`openssl x509 -inform PEM -subject_hash_old -noout -in "${certPath}"`, {
117
- encoding: 'utf8',
118
- timeout: 5000,
119
- stdio: ['ignore', 'pipe', 'ignore'],
120
- }).trim();
121
- if (/^[0-9a-f]{8}$/.test(out))
122
- return out;
123
- }
124
- catch (e) {
125
- /* openssl unavailable — fall through */
126
- }
127
- const pem = fs.readFileSync(certPath, 'utf8');
128
- return (0, crypto_1.createHash)('sha1').update(pem).digest('hex').slice(0, 8);
127
+ return subjectHashOld(fs.readFileSync(certPath, 'utf8'));
129
128
  }
130
129
  }
131
130
  exports.CertManager = CertManager;
131
+ /**
132
+ * 16 random bytes, positive and minimally encoded as DER wants: the first
133
+ * byte is 0x01..0x7f (no leading zero, no sign bit), as node-forge writes the
134
+ * hex as the INTEGER's bytes unchanged.
135
+ */
136
+ function caSerialNumber() {
137
+ const bytes = (0, crypto_1.randomBytes)(16);
138
+ bytes[0] = bytes[0] & 0x7f || 0x01;
139
+ return bytes.toString('hex');
140
+ }
141
+ function loadsInOpenSsl(certPath) {
142
+ try {
143
+ new crypto_1.X509Certificate(fs.readFileSync(certPath));
144
+ return true;
145
+ }
146
+ catch (_a) {
147
+ return false;
148
+ }
149
+ }
150
+ /**
151
+ * OpenSSL's `x509 -subject_hash_old`, the name Android files a system CA
152
+ * under: MD5 of the subject's DER as the certificate holds it, the first four
153
+ * bytes read little-endian.
154
+ */
155
+ function subjectHashOld(pem) {
156
+ const der = node_forge_1.pki.pemToDer(pem).getBytes();
157
+ const tbs = node_forge_1.asn1.fromDer(der).value[0];
158
+ const fields = tbs.value;
159
+ const hasVersion = fields[0].tagClass === node_forge_1.asn1.Class.CONTEXT_SPECIFIC;
160
+ const subject = fields[hasVersion ? 5 : 4];
161
+ const digest = (0, crypto_1.createHash)('md5')
162
+ .update(Buffer.from(node_forge_1.asn1.toDer(subject).getBytes(), 'binary'))
163
+ .digest();
164
+ return digest.readUInt32LE(0).toString(16).padStart(8, '0');
165
+ }
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.NodeDeviceLogStore = void 0;
10
+ const typedi_1 = require("typedi");
11
+ const nodeDeviceLogs_1 = require("./nodeDeviceLogs");
12
+ /**
13
+ * On a node: each recorded session's device log lines, held in memory until
14
+ * the hub collects them. A node has no Session row for a session its hub
15
+ * created, so it can't write them to Log. The session's DeviceLogBook bounds
16
+ * how many there can be; what the hub has collected is dropped; an ended
17
+ * session is kept NODE_DEVICE_LOGS_KEEP_AFTER_END_MS, then forgotten,
18
+ * whether or not a hub asked.
19
+ */
20
+ let NodeDeviceLogStore = class NodeDeviceLogStore {
21
+ constructor() {
22
+ this.now = () => Date.now();
23
+ this.entries = new Map();
24
+ }
25
+ begin(sessionId) {
26
+ this.prune();
27
+ this.entries.set(sessionId, {
28
+ lines: [],
29
+ nextSeq: 1,
30
+ state: 'recording',
31
+ claimed: false,
32
+ endedAt: null,
33
+ });
34
+ }
35
+ add(sessionId, rows) {
36
+ const entry = this.entries.get(sessionId);
37
+ if (!entry)
38
+ return;
39
+ for (const row of rows) {
40
+ entry.lines.push({
41
+ seq: entry.nextSeq++,
42
+ message: row.message,
43
+ timestamp: row.timestamp.getTime(),
44
+ });
45
+ }
46
+ }
47
+ end(sessionId) {
48
+ var _a;
49
+ const entry = this.entries.get(sessionId);
50
+ if (!entry)
51
+ return;
52
+ entry.state = 'ended';
53
+ entry.endedAt = this.now();
54
+ // Forgotten on time even if nothing else happens on this node: an ended
55
+ // session can hold the book's whole limit.
56
+ const timer = setTimeout(() => this.prune(), nodeDeviceLogs_1.NODE_DEVICE_LOGS_KEEP_AFTER_END_MS);
57
+ (_a = timer.unref) === null || _a === void 0 ? void 0 : _a.call(timer);
58
+ }
59
+ /** Drops the session at once: it isn't recorded any more. */
60
+ forget(sessionId) {
61
+ this.entries.delete(sessionId);
62
+ }
63
+ /** Whether a hub has asked about the session. */
64
+ claimed(sessionId) {
65
+ var _a;
66
+ return ((_a = this.entries.get(sessionId)) === null || _a === void 0 ? void 0 : _a.claimed) === true;
67
+ }
68
+ /**
69
+ * Up to `limit` lines after `after` (from the first, without it). Those at
70
+ * or before it the hub has, so they go.
71
+ */
72
+ read(sessionId, after, limit = nodeDeviceLogs_1.NODE_DEVICE_LOG_PAGE) {
73
+ this.prune();
74
+ const entry = this.entries.get(sessionId);
75
+ if (!entry)
76
+ return { state: 'off', lines: [], more: false };
77
+ entry.claimed = true;
78
+ if (after !== null) {
79
+ const keep = entry.lines.findIndex((l) => l.seq > after);
80
+ entry.lines.splice(0, keep === -1 ? entry.lines.length : keep);
81
+ }
82
+ return {
83
+ state: entry.state,
84
+ lines: entry.lines.slice(0, limit),
85
+ more: entry.lines.length > limit,
86
+ };
87
+ }
88
+ prune() {
89
+ const cutoff = this.now() - nodeDeviceLogs_1.NODE_DEVICE_LOGS_KEEP_AFTER_END_MS;
90
+ for (const [id, entry] of this.entries) {
91
+ if (entry.endedAt !== null && entry.endedAt <= cutoff)
92
+ this.entries.delete(id);
93
+ }
94
+ }
95
+ };
96
+ exports.NodeDeviceLogStore = NodeDeviceLogStore;
97
+ exports.NodeDeviceLogStore = NodeDeviceLogStore = __decorate([
98
+ (0, typedi_1.Service)()
99
+ ], NodeDeviceLogStore);
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.NodeDeviceLogsCollector = exports.OUTAGE_AFTER_FAILURES = exports.MAX_PAGES_PER_ROUND = exports.COLLECT_INTERVAL_MS = void 0;
13
+ /** How often the hub asks a node for a session's new lines. */
14
+ exports.COLLECT_INTERVAL_MS = 10000;
15
+ /** Answers asked for in one round while the node says it holds more. */
16
+ exports.MAX_PAGES_PER_ROUND = 10;
17
+ /**
18
+ * Failed asks in a row after which the node is taken to be gone, and not
19
+ * asked once more when the session ends. One failure is a busy node, often at
20
+ * the end of a test, and the last ask brings the test's last lines.
21
+ */
22
+ exports.OUTAGE_AFTER_FAILURES = 2;
23
+ const rowOf = (l) => ({
24
+ message: l.message,
25
+ timestamp: new Date(l.timestamp),
26
+ });
27
+ /**
28
+ * On a hub: a session on a node's phone, its device log lines collected by
29
+ * asking the node (GET /node/sessions/:id/device-logs) at once, then every
30
+ * COLLECT_INTERVAL_MS, instead of reading the phone. Asking at once tells the
31
+ * node a hub wants the session's lines (it stops recording a session nobody
32
+ * asks about). While the node holds more than one answer, it is asked again at
33
+ * once. Asks never overlap. An unreachable node is asked again; whether the
34
+ * session is alive is the heartbeat's call. When the session ends it asks
35
+ * once more, for the last lines, unless the node stopped answering
36
+ * (OUTAGE_AFTER_FAILURES).
37
+ */
38
+ class NodeDeviceLogsCollector {
39
+ constructor(o) {
40
+ var _a;
41
+ this.o = o;
42
+ /** The newest line's `seq` the hub has. */
43
+ this.after = null;
44
+ this.done = false;
45
+ this.asking = null;
46
+ this.outage = false;
47
+ /** Failed asks in a row. */
48
+ this.failures = 0;
49
+ this.storedTail = (_a = o.storedTail) !== null && _a !== void 0 ? _a : [];
50
+ }
51
+ start() {
52
+ var _a, _b, _c;
53
+ const origin = this.o.source.nodeOrigin();
54
+ if (!origin || !this.o.support.shouldAsk(origin)) {
55
+ this.finish();
56
+ return;
57
+ }
58
+ this.timer = setInterval(() => void this.tick(), (_a = this.o.intervalMs) !== null && _a !== void 0 ? _a : exports.COLLECT_INTERVAL_MS);
59
+ (_c = (_b = this.timer).unref) === null || _c === void 0 ? void 0 : _c.call(_b);
60
+ void this.tick();
61
+ }
62
+ stop() {
63
+ return __awaiter(this, void 0, void 0, function* () {
64
+ if (this.timer)
65
+ clearInterval(this.timer);
66
+ if (this.asking)
67
+ yield this.asking;
68
+ // No last ask of a node that stopped answering: ending each of a dark
69
+ // node's sessions would wait out another timeout for nothing.
70
+ if (!this.done && this.failures < exports.OUTAGE_AFTER_FAILURES)
71
+ yield this.round();
72
+ this.done = true;
73
+ });
74
+ }
75
+ tick() {
76
+ if (this.done || this.asking)
77
+ return undefined;
78
+ this.asking = this.round().finally(() => {
79
+ this.asking = null;
80
+ });
81
+ return this.asking;
82
+ }
83
+ /** Asks until the node has nothing more to give, at most MAX_PAGES_PER_ROUND times. */
84
+ round() {
85
+ return __awaiter(this, void 0, void 0, function* () {
86
+ for (let page = 0; page < exports.MAX_PAGES_PER_ROUND; page++) {
87
+ if (!(yield this.ask()))
88
+ return;
89
+ }
90
+ });
91
+ }
92
+ /** One ask; true when the node holds more, to ask for at once. */
93
+ ask() {
94
+ return __awaiter(this, void 0, void 0, function* () {
95
+ var _a, _b;
96
+ const reply = yield this.o.source.nodeDeviceLogs(this.after);
97
+ switch (reply.kind) {
98
+ case 'answer': {
99
+ this.failures = 0;
100
+ if (this.outage) {
101
+ this.outage = false;
102
+ this.o.logger.info(`${this.o.source.nodeOrigin()} answers again`);
103
+ }
104
+ const after = this.after;
105
+ const lines = reply.answer.lines.filter((l) => after === null || l.seq > after);
106
+ for (const l of lines)
107
+ this.after = Math.max((_a = this.after) !== null && _a !== void 0 ? _a : 0, l.seq);
108
+ const fresh = this.pastStored(lines);
109
+ if (fresh.length > 0)
110
+ yield this.o.onLines(fresh.map(rowOf));
111
+ if (reply.answer.more && lines.length > 0)
112
+ return true;
113
+ if (reply.answer.state !== 'recording')
114
+ this.finish();
115
+ return false;
116
+ }
117
+ case 'unsupported':
118
+ this.o.support.unsupported((_b = this.o.source.nodeOrigin()) !== null && _b !== void 0 ? _b : '', reply.status);
119
+ this.finish();
120
+ return false;
121
+ case 'refused':
122
+ this.finish();
123
+ return false;
124
+ case 'unavailable':
125
+ this.failures += 1;
126
+ if (!this.outage) {
127
+ this.outage = true;
128
+ this.o.logger.warn(`Can't collect device logs from ${this.o.source.nodeOrigin()}: ${reply.reason}. Asking again.`);
129
+ }
130
+ return false;
131
+ }
132
+ });
133
+ }
134
+ /**
135
+ * After a hub restart the node still holds the last page the hub received
136
+ * (it drops lines only when the next ask names them), so the first answer
137
+ * can repeat lines the hub stored. They end where the answer's lines match
138
+ * the stored tail, line for line back from there: logcat often prints the
139
+ * same line twice in one millisecond, so one line alone can't say which
140
+ * copy the hub has.
141
+ */
142
+ pastStored(lines) {
143
+ const tail = this.storedTail;
144
+ if (tail.length === 0)
145
+ return lines;
146
+ this.storedTail = [];
147
+ const same = (l, t) => l.message === t.message && l.timestamp === t.timestamp;
148
+ for (let end = lines.length - 1; end >= 0; end -= 1) {
149
+ const span = Math.min(tail.length, end + 1);
150
+ let k = 0;
151
+ while (k < span && same(lines[end - k], tail[tail.length - 1 - k]))
152
+ k += 1;
153
+ if (k === span)
154
+ return lines.slice(end + 1);
155
+ }
156
+ return lines;
157
+ }
158
+ finish() {
159
+ this.done = true;
160
+ if (this.timer)
161
+ clearInterval(this.timer);
162
+ }
163
+ }
164
+ exports.NodeDeviceLogsCollector = NodeDeviceLogsCollector;