@xenon-device-management/xenon 2.16.0 → 2.17.1

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 (135) hide show
  1. package/README.md +2 -2
  2. package/lib/package.json +2 -3
  3. package/lib/public/assets/{AnnotationOverlay-2W4JvEyZ.js → AnnotationOverlay-CB8njQvS.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-GmckX_7r.js → ApiKeyGate-B-804QBS.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-BsDvfjR1.js +2 -0
  6. package/lib/public/assets/{EmptyState-7hETKEWb.js → EmptyState-WzLw1cEV.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-GOgssFEP.js → FieldGroup-CaQNXgcE.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-Yvf6RGMd.js → FilterMenu-C9gAiypL.js} +1 -1
  9. package/lib/public/assets/{Menu-KrPnfdsf.js → Menu-CfTti3rw.js} +1 -1
  10. package/lib/public/assets/{Modal-C6WjZFeI.js → Modal-BcZUFhDm.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CSRIhZEh.js → RecordingPage-B_pbl3UE.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CB8ST3nE.js → RecordingsPage-BkVhplE3.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C9o90KId.js → SegmentedControl-CDLMLNl5.js} +1 -1
  14. package/lib/public/assets/{SettingCard-CPm3w9l2.js → SettingCard-Lyw5CB1F.js} +1 -1
  15. package/lib/public/assets/{Table-DwIgERRt.js → Table-CK414bOU.js} +1 -1
  16. package/lib/public/assets/{activity-DrLbHifn.js → activity-pEyAdE-M.js} +1 -1
  17. package/lib/public/assets/{ai-settings-DU_Ls06P.js → ai-settings-CIhS7MMq.js} +1 -1
  18. package/lib/public/assets/{api-keys-CWYdEzJ_.js → api-keys-D2J44Ebv.js} +1 -1
  19. package/lib/public/assets/{apps-BqNVdln7.js → apps-CCV3RZY8.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BtB0_Jg_.js → arrow-left-DaEUmERk.js} +1 -1
  21. package/lib/public/assets/{arrow-right-C8NhdtyN.js → arrow-right-CnRXHXit.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-gMg0ffvv.js → arrow-up-right-BGCS7zhi.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CiYZ3N4M.js → auth-shell-CpW1zsxD.js} +2 -2
  24. package/lib/public/assets/{builds-page-EaaqDmLN.js → builds-page-B-xdzjMx.js} +1 -1
  25. package/lib/public/assets/{button-DDau4Axv.js → button-B5QkOVa4.js} +1 -1
  26. package/lib/public/assets/{calendar-DQ83B1ed.js → calendar-gX_1EKue.js} +1 -1
  27. package/lib/public/assets/{check-K1ovTQam.js → check-MIM4THvH.js} +1 -1
  28. package/lib/public/assets/{chevron-right-Biq00pMt.js → chevron-right-BZIp3-9t.js} +1 -1
  29. package/lib/public/assets/{circle-check-CBv88cH7.js → circle-check-Crt-G_uy.js} +1 -1
  30. package/lib/public/assets/{circle-x-Bmltailn.js → circle-x-C8GvvXVt.js} +1 -1
  31. package/lib/public/assets/{clock-BPrld-3h.js → clock-MFKV4qGa.js} +1 -1
  32. package/lib/public/assets/{copy-t0QB14JI.js → copy-BU-Z7mBK.js} +1 -1
  33. package/lib/public/assets/device-explorer-D-78Twa0.js +282 -0
  34. package/lib/public/assets/{download-BroHxNOh.js → download-DIoLCCYv.js} +1 -1
  35. package/lib/public/assets/{forgot-password-Bjmf49i6.js → forgot-password-vNYlPnia.js} +1 -1
  36. package/lib/public/assets/{h264Stream-DeD__6wa.js → h264Stream-BLcSplMU.js} +1 -1
  37. package/lib/public/assets/{index-B0gQkEg0.js → index-BpqyclNY.js} +2 -2
  38. package/lib/public/assets/{index-B0JeuZbg.js → index-Hl1OL-k_.js} +1 -1
  39. package/lib/public/assets/{index-72oF89LJ.js → index-QbukVVrl.js} +1 -1
  40. package/lib/public/assets/{input-DIc4MSC7.js → input-DHH3fHer.js} +1 -1
  41. package/lib/public/assets/{line-chart-BcWm2vW8.js → line-chart-PdViEphw.js} +1 -1
  42. package/lib/public/assets/{list-checks-B06IJJad.js → list-checks-DI_heIg9.js} +1 -1
  43. package/lib/public/assets/{lock-pVLvnN30.js → lock-DVP_dL6J.js} +1 -1
  44. package/lib/public/assets/{login-CN77vy--.js → login-B8vtTDcr.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-CSdzQSkY.js → maintenance-settings-BMa5EfgI.js} +1 -1
  46. package/lib/public/assets/{monitor-BtrN5IkI.js → monitor-CtzX_RkD.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CNsQfo6w.js → mouse-pointer-2-BA09sdS9.js} +1 -1
  48. package/lib/public/assets/{network-BSfA8ZxG.js → network-B_F9Nox-.js} +1 -1
  49. package/lib/public/assets/{overview-BfTebCK5.js → overview-kWCYMHMV.js} +1 -1
  50. package/lib/public/assets/{page-header-D8KUX6Qg.js → page-header-CAbH1O1a.js} +1 -1
  51. package/lib/public/assets/{play-Cvc8xzic.js → play-sg95hLqA.js} +1 -1
  52. package/lib/public/assets/{plus-C9Zj84UF.js → plus-BT9mk2gj.js} +1 -1
  53. package/lib/public/assets/{profile-page-Bi27STgs.js → profile-page-DZOz5Ypc.js} +1 -1
  54. package/lib/public/assets/{reset-password-CPsOuwSu.js → reset-password-CqeImlEO.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DEQ-DBOX.js → runbook-page-U_8TSysj.js} +1 -1
  56. package/lib/public/assets/{select-BWpnhWXj.js → select-Cx4JDzsR.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BefSCSpe.js → selector-detail-redirect-DiMRe7DW.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-MSk96Zz7.js → selector-health-page-CKR5URll.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-DPyBNef_.js → session-detail-page-DuIVzTW_.js} +1 -1
  60. package/lib/public/assets/{settings-V6x-cXwy.js → settings-B5fuvgTP.js} +1 -1
  61. package/lib/public/assets/{stat-tile-Duex-GZD.js → stat-tile-CtHX1dkh.js} +1 -1
  62. package/lib/public/assets/{tablet-BBcoqBmL.js → tablet-DCGD0GQt.js} +1 -1
  63. package/lib/public/assets/{teams-ts8p5rEh.js → teams-MzlELlw9.js} +1 -1
  64. package/lib/public/assets/{trash-2-CZ2ew6AZ.js → trash-2-BIRm2KVs.js} +1 -1
  65. package/lib/public/assets/{upload-Dv99y_le.js → upload-C-lgyswH.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-DrYC8JCF.js → use-builds-data-IOydXA8d.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-C7QkBNkP.js → use-password-reset-mode-CIVXG7Me.js} +1 -1
  68. package/lib/public/assets/{users-gJ5Y01yo.js → users-hwK5XGIy.js} +1 -1
  69. package/lib/public/assets/{users-D7tKMESH.js → users-ivNCjeus.js} +1 -1
  70. package/lib/public/assets/{video-off-CqZVuZGs.js → video-off-DvcIEsdZ.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-DI5DSW99.js → webhook-settings-DukgCe1u.js} +1 -1
  72. package/lib/public/assets/{zap-ClcHFxfo.js → zap-BE9gacFj.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/control.yaml +69 -4
  77. package/lib/src/app/openapi/grid.yaml +102 -0
  78. package/lib/src/app/routers/control.js +24 -5
  79. package/lib/src/dashboard/event-manager.js +5 -8
  80. package/lib/src/device-managers/ios/IOSStreamService.js +147 -18
  81. package/lib/src/device-utils.js +61 -24
  82. package/lib/src/gateway/internalCall.js +33 -5
  83. package/lib/src/gateway/nodeAsk.js +57 -0
  84. package/lib/src/gateway/nodeSessionDeviceLogs.js +35 -0
  85. package/lib/src/gateway/sessionGateway.js +9 -4
  86. package/lib/src/helpers/index.js +9 -6
  87. package/lib/src/interceptors/CommandInterceptor.js +10 -0
  88. package/lib/src/plugin.js +4 -1
  89. package/lib/src/scripts/initialize-database.js +0 -6
  90. package/lib/src/scripts/run-migrations.js +123 -880
  91. package/lib/src/services/ServerManager.js +25 -4
  92. package/lib/src/services/SessionLifecycleService.js +20 -5
  93. package/lib/src/services/UserService.js +8 -3
  94. package/lib/src/services/interceptor/CertManager.js +50 -16
  95. package/lib/src/services/logcat/NodeDeviceLogStore.js +99 -0
  96. package/lib/src/services/logcat/NodeDeviceLogsCollector.js +164 -0
  97. package/lib/src/services/logcat/SessionDeviceLogs.js +199 -44
  98. package/lib/src/services/logcat/nodeDeviceLogs.js +91 -0
  99. package/lib/src/services/metrics/nodeMetrics.js +20 -49
  100. package/lib/src/sessions/RemoteSession.js +11 -2
  101. package/lib/src/sessions/SessionManager.js +28 -1
  102. package/lib/src/sessions/appiumUmbrella.js +11 -0
  103. package/lib/src/sessions/sessionMemory.js +11 -2
  104. package/lib/test/helpers/fake-logcat.js +80 -0
  105. package/lib/test/helpers/hub-node-pair.js +227 -0
  106. package/lib/test/unit/UserService.test.js +8 -0
  107. package/lib/test/unit/boot-device-sync.spec.js +195 -0
  108. package/lib/test/unit/dashboard-events-device-logs.spec.js +15 -0
  109. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +21 -0
  110. package/lib/test/unit/hub-node-iphone-readiness.spec.js +134 -0
  111. package/lib/test/unit/hub-node-session-device-logs.spec.js +194 -0
  112. package/lib/test/unit/hub-node-session-metrics.spec.js +24 -192
  113. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +8 -1
  114. package/lib/test/unit/install-scripts.spec.js +98 -0
  115. package/lib/test/unit/interceptor/CertManager.spec.js +89 -0
  116. package/lib/test/unit/internal-call.spec.js +99 -0
  117. package/lib/test/unit/internal-calls-session-record.spec.js +312 -0
  118. package/lib/test/unit/ios-simulator-preview.spec.js +315 -0
  119. package/lib/test/unit/is-xenon-running.spec.js +10 -0
  120. package/lib/test/unit/node-device-log-store.spec.js +102 -0
  121. package/lib/test/unit/node-device-logs-collector.spec.js +343 -0
  122. package/lib/test/unit/node-gateway-create-appium-server.spec.js +8 -1
  123. package/lib/test/unit/node-hub-session-owner.spec.js +8 -1
  124. package/lib/test/unit/node-session-device-logs-route.spec.js +103 -0
  125. package/lib/test/unit/run-migrations-database.spec.js +57 -424
  126. package/lib/test/unit/run-migrations.test.js +74 -674
  127. package/lib/test/unit/session-device-logs-hub-node.spec.js +340 -0
  128. package/lib/test/unit/session-device-logs-lifecycle.spec.js +137 -0
  129. package/lib/test/unit/session-device-logs.spec.js +32 -94
  130. package/lib/test/unit/session-log-indexes.spec.js +15 -0
  131. package/lib/tsconfig.tsbuildinfo +1 -1
  132. package/package.json +2 -3
  133. package/schema.json +1 -1
  134. package/lib/public/assets/DeviceMosaicView-DqODzkyo.js +0 -2
  135. package/lib/public/assets/device-explorer-BgGZ8AvV.js +0 -282
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const chai_1 = require("chai");
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const root = path_1.default.join(__dirname, '..', '..');
10
+ const readJson = (file) => JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
11
+ /**
12
+ * npm 12 runs a dependency's install scripts only when the project installing
13
+ * it allows them (`allowScripts` in its package.json), and skips the rest with
14
+ * a warning. Appium installs a plugin with npm into its own home, whose
15
+ * package.json allows nothing, so every install script in Xenon's tree is
16
+ * skipped there and `appium plugin install` still reports success.
17
+ *
18
+ * Through 2.17 passwords were hashed with `bcrypt`, whose native binary only
19
+ * its `install` script downloads. Installed with npm 12, the plugin then
20
+ * failed to load: "Cannot find module .../bcrypt/lib/binding/napi-v3/bcrypt_lib.node".
21
+ *
22
+ * Each package here has an install script and works without it, for the
23
+ * reason given, checked with npm 12. A new one needs its own reason, checked
24
+ * the same way, or a replacement that needs no script.
25
+ */
26
+ const WORKS_WITHOUT_ITS_SCRIPT = {
27
+ '@ffmpeg-installer/*': 'chmod u+x ffmpeg: every platform package ships ffmpeg executable already',
28
+ '@prisma/client': 'generates the default client in node_modules/.prisma; Xenon imports its own (src/generated/client)',
29
+ '@prisma/engines': "downloads Prisma's engines: the schema engine is fetched on its first use at startup, " +
30
+ 'and the generated client carries its query engines',
31
+ '@scarf/scarf': "swagger-ui-dist's install analytics",
32
+ 'appium-chromedriver': 'downloads a Chromedriver; Xenon downloads its own when needed (ChromeDriverManager)',
33
+ prisma: 'warns about incompatible older Prisma packages',
34
+ protobufjs: 'warns about a dependent pinned to another version scheme',
35
+ sharp: 'checks for its prebuilt binary, which comes in an @img/sharp-* package with no script',
36
+ 'tesseract.js': 'prints an Open Collective notice',
37
+ };
38
+ const matches = (key, name) => key.endsWith('/*') ? name.startsWith(key.slice(0, -1)) : name === key;
39
+ const nameOf = (id) => id.slice(0, id.lastIndexOf('@'));
40
+ /** Where Node would find `name` from `from`: its node_modules, then each parent's. */
41
+ function packageDir(name, from) {
42
+ for (let dir = from;; dir = path_1.default.dirname(dir)) {
43
+ const candidate = path_1.default.join(dir, 'node_modules', name, 'package.json');
44
+ if (fs_1.default.existsSync(candidate))
45
+ return path_1.default.dirname(candidate);
46
+ if (path_1.default.dirname(dir) === dir)
47
+ return undefined;
48
+ }
49
+ }
50
+ /**
51
+ * Every package Xenon runs with (its dependencies, theirs, and so on, as
52
+ * installed) that has an install script: `preinstall`, `install` or
53
+ * `postinstall`, or a `binding.gyp`, which npm builds with `node-gyp rebuild`
54
+ * when no script says otherwise. An optional dependency for another platform
55
+ * isn't installed, and is left out.
56
+ */
57
+ function packagesWithInstallScripts() {
58
+ const found = new Map();
59
+ const seen = new Set();
60
+ const runtimeDeps = (pkg) => Object.keys(Object.assign(Object.assign({}, pkg.dependencies), pkg.optionalDependencies));
61
+ const queue = runtimeDeps(readJson(path_1.default.join(root, 'package.json'))).map((name) => ({
62
+ name,
63
+ from: root,
64
+ }));
65
+ for (let next = queue.shift(); next; next = queue.shift()) {
66
+ const { name, from } = next;
67
+ const dir = packageDir(name, from);
68
+ if (!dir || seen.has(dir))
69
+ continue;
70
+ seen.add(dir);
71
+ const pkg = readJson(path_1.default.join(dir, 'package.json'));
72
+ const scripts = ['preinstall', 'install', 'postinstall']
73
+ .filter((hook) => { var _a; return (_a = pkg.scripts) === null || _a === void 0 ? void 0 : _a[hook]; })
74
+ .map((hook) => `${hook}: ${pkg.scripts[hook]}`);
75
+ if (scripts.length === 0 && fs_1.default.existsSync(path_1.default.join(dir, 'binding.gyp'))) {
76
+ scripts.push('binding.gyp: node-gyp rebuild');
77
+ }
78
+ if (scripts.length > 0)
79
+ found.set(`${pkg.name}@${pkg.version}`, scripts.join(' | '));
80
+ queue.push(...runtimeDeps(pkg).map((dep) => ({ name: dep, from: dir })));
81
+ }
82
+ return found;
83
+ }
84
+ describe('install scripts in the runtime dependency tree', () => {
85
+ const found = packagesWithInstallScripts();
86
+ const keys = Object.keys(WORKS_WITHOUT_ITS_SCRIPT);
87
+ it('belong only to packages that work without them, since npm 12 skips them', () => {
88
+ const unexplained = [...found]
89
+ .filter(([id]) => !keys.some((key) => matches(key, nameOf(id))))
90
+ .map(([id, scripts]) => `${id} (${scripts})`);
91
+ (0, chai_1.expect)(unexplained).to.deep.equal([]);
92
+ });
93
+ it('name no package the tree no longer has', () => {
94
+ const names = [...found.keys()].map(nameOf);
95
+ const stale = keys.filter((key) => !names.some((name) => matches(key, name)));
96
+ (0, chai_1.expect)(stale).to.deep.equal([]);
97
+ });
98
+ });
@@ -46,6 +46,9 @@ const chai_1 = require("chai");
46
46
  const fs = __importStar(require("fs"));
47
47
  const path = __importStar(require("path"));
48
48
  const os = __importStar(require("os"));
49
+ const child_process_1 = require("child_process");
50
+ const crypto_1 = require("crypto");
51
+ const sinon = __importStar(require("sinon"));
49
52
  const node_forge_1 = require("node-forge");
50
53
  const CertManager_1 = require("../../../src/services/interceptor/CertManager");
51
54
  describe('CertManager', () => {
@@ -98,4 +101,90 @@ describe('CertManager', () => {
98
101
  (0, chai_1.expect)(fname).to.match(/^[0-9a-f]{8}\.0$/);
99
102
  (0, chai_1.expect)(typeof ca.subjectHash).to.equal('string');
100
103
  }));
104
+ describe('serial number', () => {
105
+ // The serial was Date.now() in hex padded to 16 digits: always 00 00 01 ...,
106
+ // an INTEGER with illegal leading zeros. OpenSSL 3 (Node's included), Go
107
+ // and BoringSSL refuse such a certificate outright.
108
+ it('is a CA OpenSSL can load', () => __awaiter(void 0, void 0, void 0, function* () {
109
+ const ca = yield new CertManager_1.CertManager(dir).ensure();
110
+ (0, chai_1.expect)(() => new crypto_1.X509Certificate(fs.readFileSync(ca.certPath))).to.not.throw();
111
+ }));
112
+ it('is positive, minimally encoded and at least 64 random bits', () => __awaiter(void 0, void 0, void 0, function* () {
113
+ const ca = yield new CertManager_1.CertManager(dir).ensure();
114
+ const serial = new crypto_1.X509Certificate(fs.readFileSync(ca.certPath)).serialNumber;
115
+ const first = parseInt(serial.slice(0, 2), 16);
116
+ (0, chai_1.expect)(first, 'no leading zero byte').to.be.greaterThan(0);
117
+ (0, chai_1.expect)(first, 'not negative').to.be.lessThan(0x80);
118
+ (0, chai_1.expect)(serial.length / 2)
119
+ .to.be.at.least(8)
120
+ .and.at.most(20);
121
+ }));
122
+ it('differs between two CAs generated in the same millisecond', () => __awaiter(void 0, void 0, void 0, function* () {
123
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), 'xenon-cert-'));
124
+ const clock = sinon.useFakeTimers({ now: 1760000000000, toFake: ['Date'] });
125
+ try {
126
+ const a = yield new CertManager_1.CertManager(dir).ensure();
127
+ const b = yield new CertManager_1.CertManager(other).ensure();
128
+ (0, chai_1.expect)(new crypto_1.X509Certificate(fs.readFileSync(a.certPath)).serialNumber).to.not.equal(new crypto_1.X509Certificate(fs.readFileSync(b.certPath)).serialNumber);
129
+ }
130
+ finally {
131
+ clock.restore();
132
+ fs.rmSync(other, { recursive: true, force: true });
133
+ }
134
+ }));
135
+ it('replaces a CA an earlier version wrote with the bad serial', () => __awaiter(void 0, void 0, void 0, function* () {
136
+ writeCa(dir, '000001a10d75af19');
137
+ const ca = yield new CertManager_1.CertManager(dir).ensure();
138
+ (0, chai_1.expect)(() => new crypto_1.X509Certificate(fs.readFileSync(ca.certPath))).to.not.throw();
139
+ }));
140
+ it('keeps a CA OpenSSL can load', () => __awaiter(void 0, void 0, void 0, function* () {
141
+ writeCa(dir, '1a10d75af19a2b3c');
142
+ const before = fs.readFileSync(path.join(dir, 'certs', 'ca.pem'), 'utf8');
143
+ yield new CertManager_1.CertManager(dir).ensure();
144
+ (0, chai_1.expect)(fs.readFileSync(path.join(dir, 'certs', 'ca.pem'), 'utf8')).to.equal(before);
145
+ }));
146
+ });
147
+ describe('Android file name', () => {
148
+ // Android looks a system CA up by <subject_hash_old>.0. The name was
149
+ // openssl's, else a SHA-1 of the PEM, which names no certificate: with the
150
+ // bad serial openssl couldn't load the CA, so the fallback always ran.
151
+ it("is OpenSSL's subject_hash_old, with no openssl binary on the PATH", function () {
152
+ return __awaiter(this, void 0, void 0, function* () {
153
+ try {
154
+ (0, child_process_1.execFileSync)('openssl', ['version'], { stdio: 'ignore' });
155
+ }
156
+ catch (_a) {
157
+ this.skip();
158
+ }
159
+ const ca = yield new CertManager_1.CertManager(dir).ensure();
160
+ const expected = (0, child_process_1.execFileSync)('openssl', ['x509', '-inform', 'PEM', '-subject_hash_old', '-noout', '-in', ca.certPath], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
161
+ (0, chai_1.expect)(ca.subjectHash).to.equal(expected);
162
+ const pathBefore = process.env.PATH;
163
+ process.env.PATH = '';
164
+ try {
165
+ (0, chai_1.expect)(new CertManager_1.CertManager(dir).androidCertFilename()).to.equal(`${expected}.0`);
166
+ }
167
+ finally {
168
+ process.env.PATH = pathBefore;
169
+ }
170
+ });
171
+ });
172
+ });
101
173
  });
174
+ function writeCa(dir, serialHex) {
175
+ fs.mkdirSync(path.join(dir, 'certs'), { recursive: true });
176
+ fs.mkdirSync(path.join(dir, 'keys'), { recursive: true });
177
+ const keys = node_forge_1.pki.rsa.generateKeyPair(1024);
178
+ const cert = node_forge_1.pki.createCertificate();
179
+ cert.publicKey = keys.publicKey;
180
+ cert.serialNumber = serialHex;
181
+ cert.validity.notBefore = new Date(Date.now() - 60000);
182
+ cert.validity.notAfter = new Date(Date.now() + 365 * 24 * 3600000);
183
+ const attrs = [{ name: 'commonName', value: 'Xenon MITM Root CA' }];
184
+ cert.setSubject(attrs);
185
+ cert.setIssuer(attrs);
186
+ cert.setExtensions([{ name: 'basicConstraints', cA: true, critical: true }]);
187
+ cert.sign(keys.privateKey, node_forge_1.md.sha256.create());
188
+ fs.writeFileSync(path.join(dir, 'certs', 'ca.pem'), node_forge_1.pki.certificateToPem(cert), 'utf8');
189
+ fs.writeFileSync(path.join(dir, 'keys', 'ca.private.key'), node_forge_1.pki.privateKeyToPem(keys.privateKey));
190
+ }
@@ -13,6 +13,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  const chai_1 = require("chai");
16
+ const http_1 = __importDefault(require("http"));
16
17
  const path_1 = __importDefault(require("path"));
17
18
  const sinon_1 = __importDefault(require("sinon"));
18
19
  const express_1 = __importDefault(require("express"));
@@ -166,6 +167,104 @@ describe('internal calls (/wd-internal with the per-process secret)', () => {
166
167
  }
167
168
  (0, chai_1.expect)(ran).to.deep.equal([]);
168
169
  }));
170
+ /**
171
+ * The plugin's handle, deep inside Appium's route, is never given the
172
+ * request, so an accepted call's context is how it knows the command
173
+ * is Xenon's own and not the test's.
174
+ */
175
+ describe('the plugin can tell an internal call from a test’s command', () => {
176
+ let seen;
177
+ let release;
178
+ function labSeeing() {
179
+ return __awaiter(this, void 0, void 0, function* () {
180
+ seen = [];
181
+ release = undefined;
182
+ const app = makeExpress();
183
+ app.use(express_1.default.json());
184
+ // A route that, like Appium's, answers after awaiting the driver.
185
+ app.get('/wd/hub/session/:sessionId/source', (req, res) => __awaiter(this, void 0, void 0, function* () {
186
+ if (req.query.hold)
187
+ yield new Promise((resolve) => (release = resolve));
188
+ yield new Promise((resolve) => setTimeout(resolve, 5));
189
+ seen.push({ url: req.originalUrl, inside: (0, internalCall_1.isInsideInternalCall)() });
190
+ res.json({ value: '<page/>' });
191
+ }));
192
+ (0, registerCommandAuth_1.registerCommandAuth)(app, { basePath: '/wd/hub' }, {
193
+ enabled: () => false,
194
+ authDisabled: () => false,
195
+ verifier: new commandCaller_1.CommandCallerVerifier({
196
+ verifyKeyPair: sinon_1.default.stub().resolves(null),
197
+ verifyBearer: sinon_1.default.stub().resolves(null),
198
+ }),
199
+ ownerOf,
200
+ logger: { info: () => undefined, warn: () => undefined, error: () => undefined },
201
+ });
202
+ app.use((_req, res) => res.status(404).json(UNKNOWN_ROUTE));
203
+ return loopback.serve(app);
204
+ });
205
+ }
206
+ it('inside an accepted call, across the route’s awaits', () => __awaiter(void 0, void 0, void 0, function* () {
207
+ const app = yield labSeeing();
208
+ yield (0, loopbackRequest_1.default)(app)
209
+ .get('/wd/hub/wd-internal/session/s1/source')
210
+ .set((0, internalCall_1.internalCallHeaders)());
211
+ (0, chai_1.expect)(seen).to.deep.equal([{ url: '/wd/hub/session/s1/source', inside: true }]);
212
+ (0, chai_1.expect)((0, internalCall_1.isInsideInternalCall)(), 'outside any request').to.equal(false);
213
+ }));
214
+ it('never for a test’s command, a refused call, or a request after one', () => __awaiter(void 0, void 0, void 0, function* () {
215
+ const app = yield labSeeing();
216
+ yield (0, loopbackRequest_1.default)(app).get('/wd/hub/session/s1/source').set((0, internalCall_1.internalCallHeaders)());
217
+ yield (0, loopbackRequest_1.default)(app)
218
+ .get('/wd/hub/wd-internal/session/s1/source')
219
+ .set((0, internalCall_1.internalCallHeaders)())
220
+ .expect(200);
221
+ yield (0, loopbackRequest_1.default)(app)
222
+ .get('/wd/hub/wd-internal/session/s1/source')
223
+ .set(internalCall_1.INTERNAL_CALL_HEADER, 'not-the-secret')
224
+ .expect(404);
225
+ yield (0, loopbackRequest_1.default)(app).get('/wd/hub/session/s1/source').expect(200);
226
+ (0, chai_1.expect)(seen.map((s) => s.inside)).to.deep.equal([false, true, false]);
227
+ }));
228
+ it('never for a request on the same kept-alive connection after one', () => __awaiter(void 0, void 0, void 0, function* () {
229
+ const server = yield labSeeing();
230
+ const port = server.address().port;
231
+ const agent = new http_1.default.Agent({ keepAlive: true, maxSockets: 1 });
232
+ const get = (path, headers) => new Promise((resolve, reject) => {
233
+ const req = http_1.default.get({ host: '127.0.0.1', port, path, headers, agent }, (res) => {
234
+ res.resume();
235
+ res.on('end', () => resolve(req.reusedSocket));
236
+ });
237
+ req.on('error', reject);
238
+ });
239
+ try {
240
+ yield get('/wd/hub/wd-internal/session/s1/source', (0, internalCall_1.internalCallHeaders)());
241
+ (0, chai_1.expect)(yield get('/wd/hub/session/s1/source', {}), 'the socket was reused').to.equal(true);
242
+ }
243
+ finally {
244
+ agent.destroy();
245
+ }
246
+ (0, chai_1.expect)(seen.map((s) => s.inside)).to.deep.equal([true, false]);
247
+ }));
248
+ it('never for a test’s command running alongside one', () => __awaiter(void 0, void 0, void 0, function* () {
249
+ const app = yield labSeeing();
250
+ const internal = (0, loopbackRequest_1.default)(app)
251
+ .get('/wd/hub/wd-internal/session/s1/source?hold=1')
252
+ .set((0, internalCall_1.internalCallHeaders)())
253
+ .then((r) => r);
254
+ for (let i = 0; i < 100 && !release; i++)
255
+ yield new Promise((r) => setTimeout(r, 10));
256
+ const held = release;
257
+ if (!held)
258
+ throw new Error('the internal call never reached its route');
259
+ yield (0, loopbackRequest_1.default)(app).get('/wd/hub/session/s1/source').expect(200);
260
+ held();
261
+ yield internal;
262
+ (0, chai_1.expect)(seen).to.deep.equal([
263
+ { url: '/wd/hub/session/s1/source', inside: false },
264
+ { url: '/wd/hub/session/s1/source?hold=1', inside: true },
265
+ ]);
266
+ }));
267
+ });
169
268
  it('puts the internal-call layer ahead of the session layer, both ahead of the routes', () => __awaiter(void 0, void 0, void 0, function* () {
170
269
  const { raw } = yield lab();
171
270
  const stack = (0, insertBeforeRoutes_1.routerStackOf)(raw);
@@ -0,0 +1,312 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ require("reflect-metadata");
49
+ const chai_1 = require("chai");
50
+ const http_1 = __importDefault(require("http"));
51
+ const sinon_1 = __importDefault(require("sinon"));
52
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
53
+ const plugin_1 = require("../../src/plugin");
54
+ const registerCommandAuth_1 = require("../../src/app/registerCommandAuth");
55
+ const SessionManager_1 = require("../../src/sessions/SessionManager");
56
+ const LocalSession_1 = require("../../src/sessions/LocalSession");
57
+ const event_manager_1 = require("../../src/dashboard/event-manager");
58
+ const NotificationService_1 = require("../../src/services/NotificationService");
59
+ const AIService_1 = require("../../src/services/AIService");
60
+ const failure_analysis_service_1 = require("../../src/dashboard/services/failure-analysis-service");
61
+ const deviceService = __importStar(require("../../src/data-service/device-service"));
62
+ const SessionStatus_1 = require("../../src/types/SessionStatus");
63
+ const scratch_database_1 = require("../helpers/scratch-database");
64
+ const loki_stores_1 = require("../helpers/loki-stores");
65
+ const appium_umbrella_1 = require("../helpers/appium-umbrella");
66
+ /**
67
+ * Xenon's own calls to a session it runs, in Appium 3's own server(): Appium's
68
+ * umbrella driver with Xenon's plugin and the production session gateway, on
69
+ * a dashboard-on server, over a scratch database.
70
+ *
71
+ * A LocalSession asks its driver in-process first. When that fails it asks
72
+ * again over HTTP, at `<basePath>/wd-internal/session/<id>/...` with the
73
+ * per-process secret (gateway/internalCall.ts), and that request goes through
74
+ * Appium's route, the umbrella and the plugin's `handle` like a test's.
75
+ * Through 2.16 the plugin couldn't tell: the marker was on the Express request,
76
+ * which `handle` is never given. So a performance recording's stop the driver
77
+ * refused at the end of a session was recorded as a failed `execute` of the
78
+ * session, and the session, whose own commands had all passed, ended failed:
79
+ * "marked as FAILED due to error in command: execute", the driver's refusal
80
+ * as its reason, failure analysis, a `session_failed` webhook. Observed on a
81
+ * simulator on 2026-10-05 (2.16 no longer stops one there, #503); on an
82
+ * iPhone whenever the recording's start failed.
83
+ */
84
+ const SESSION = 'internal-calls-session';
85
+ const UDID = 'iphone-1';
86
+ const BASE = '/wd/hub';
87
+ const REFUSAL = 'No Time Profiler recording was started on this device';
88
+ const quiet = { info: () => undefined, warn: () => undefined, error: () => undefined };
89
+ /** An iPhone driver as far as these calls go: it refuses the performance recording. */
90
+ function iPhoneLikeDriverClass() {
91
+ const { BaseDriver, errors } = (0, appium_umbrella_1.appiumBaseDriver)();
92
+ return class IPhoneLikeDriver extends BaseDriver {
93
+ constructor() {
94
+ super(...arguments);
95
+ this.calls = [];
96
+ this.pageSourceFailures = 0;
97
+ }
98
+ getUrl() {
99
+ return __awaiter(this, void 0, void 0, function* () {
100
+ this.calls.push('getUrl');
101
+ return 'https://app.example/';
102
+ });
103
+ }
104
+ execute(script) {
105
+ return __awaiter(this, void 0, void 0, function* () {
106
+ this.calls.push(`execute ${script}`);
107
+ if (script === 'mobile: startPerfRecord' || script === 'mobile: stopPerfRecord') {
108
+ throw new errors.UnknownError(REFUSAL);
109
+ }
110
+ return null;
111
+ });
112
+ }
113
+ getPageSource() {
114
+ return __awaiter(this, void 0, void 0, function* () {
115
+ this.calls.push('getPageSource');
116
+ if (this.pageSourceFailures > 0) {
117
+ this.pageSourceFailures--;
118
+ throw new errors.UnknownError('The page source took too long');
119
+ }
120
+ if (this.held)
121
+ yield this.held;
122
+ return '<XCUIElementTypeApplication name="Shop"/>';
123
+ });
124
+ }
125
+ /** Commands waiting in base-driver's queue, read as base-driver reads it. */
126
+ queued() {
127
+ var _a, _b, _c, _d;
128
+ return (_d = (_c = (_b = (_a = this.commandsQueueGuard) === null || _a === void 0 ? void 0 : _a.queues) === null || _b === void 0 ? void 0 : _b[BaseDriver.name]) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0;
129
+ }
130
+ };
131
+ }
132
+ describe('Xenon’s own calls to a session (/wd-internal), in Appium 3’s own server()', function () {
133
+ this.timeout(60000);
134
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
135
+ (0, loki_stores_1.usePrismaStores)();
136
+ let appiumLogs;
137
+ let server;
138
+ let url;
139
+ let inner;
140
+ let session;
141
+ let notified;
142
+ let isHubBefore;
143
+ before(() => {
144
+ appiumLogs = (0, appium_umbrella_1.quietAppiumLogs)();
145
+ });
146
+ after(() => appiumLogs.restore());
147
+ beforeEach(() => __awaiter(this, void 0, void 0, function* () {
148
+ // A hub or a standalone server, with the dashboard recording its sessions.
149
+ isHubBefore = plugin_1.XenonPlugin.IS_HUB;
150
+ plugin_1.XenonPlugin.IS_HUB = true;
151
+ notified = sinon_1.default.stub(NotificationService_1.NotificationService.prototype, 'notifySessionFailed').resolves();
152
+ // A failed session's analysis never reaches an AI provider from here.
153
+ sinon_1.default.stub(AIService_1.AI_SERVICE, 'isEnabled').returns(false);
154
+ yield scratch.db.sessionLog.deleteMany({});
155
+ yield scratch.db.log.deleteMany({});
156
+ yield scratch.db.session.deleteMany({});
157
+ const baseDriver = (0, appium_umbrella_1.appiumBaseDriver)();
158
+ const umbrella = (0, appium_umbrella_1.umbrellaWith)([[plugin_1.XenonPlugin, 'xenon']]);
159
+ umbrella.args.plugin = { xenon: { enableDashboard: true } };
160
+ server = yield baseDriver.server({
161
+ routeConfiguringFunction: baseDriver.routeConfiguringFunction(umbrella),
162
+ port: 0,
163
+ hostname: '127.0.0.1',
164
+ basePath: BASE,
165
+ cliArgs: { basePath: BASE },
166
+ serverUpdaters: [
167
+ (app) => (0, registerCommandAuth_1.registerSessionGateway)(app, { basePath: BASE }, (0, registerCommandAuth_1.commandAuthDeps)({ enabled: () => false, authDisabled: () => false, logger: quiet })),
168
+ ],
169
+ });
170
+ const port = server.address().port;
171
+ url = `http://127.0.0.1:${port}`;
172
+ // Where a LocalSession's loopback calls go: the server it runs on.
173
+ Object.assign(umbrella.opts, { address: '127.0.0.1', port, basePath: BASE });
174
+ // The session as Appium holds it once created.
175
+ const Driver = iPhoneLikeDriverClass();
176
+ inner = new Driver({}, false);
177
+ inner.sessionId = SESSION;
178
+ inner.caps = { platformName: 'iOS', automationName: appium_umbrella_1.FAKE_AUTOMATION, udid: UDID };
179
+ inner.newCommandTimeoutMs = 0;
180
+ umbrella.sessions[SESSION] = inner;
181
+ // ... and as Xenon records it.
182
+ yield scratch.db.session.create({
183
+ data: {
184
+ id: SESSION,
185
+ desired_capabilities: '{}',
186
+ session_capabilities: '{}',
187
+ node_id: 'node-1',
188
+ has_live_video: false,
189
+ device_udid: UDID,
190
+ device_platform: 'ios',
191
+ device_version: '18.0',
192
+ },
193
+ });
194
+ session = new LocalSession_1.LocalSession({
195
+ sessionId: SESSION,
196
+ device: { udid: UDID, platform: 'ios', realDevice: true, host: url },
197
+ sessionResponse: {},
198
+ xenonOption: {},
199
+ driver: umbrella,
200
+ });
201
+ SessionManager_1.SESSION_MANAGER.addSession(SESSION, session);
202
+ }));
203
+ afterEach(() => __awaiter(this, void 0, void 0, function* () {
204
+ SessionManager_1.SESSION_MANAGER.removeSession(SESSION);
205
+ plugin_1.XenonPlugin.IS_HUB = isHubBefore;
206
+ sinon_1.default.restore();
207
+ server.closeAllConnections();
208
+ yield new Promise((resolve) => http_1.default.Server.prototype.close.call(server, () => resolve()));
209
+ }));
210
+ /** The session's recorded commands, oldest first. */
211
+ function recorded() {
212
+ return __awaiter(this, void 0, void 0, function* () {
213
+ const rows = yield scratch.db.sessionLog.findMany({
214
+ where: { session_id: SESSION },
215
+ orderBy: { createdAt: 'asc' },
216
+ });
217
+ return rows.map((row) => ({ command: row.command_name, failed: row.is_error }));
218
+ });
219
+ }
220
+ /** The session's end as every ending reaches it, and what its row says then. */
221
+ function end() {
222
+ return __awaiter(this, void 0, void 0, function* () {
223
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(SESSION);
224
+ const row = yield scratch.db.session.findUniqueOrThrow({ where: { id: SESSION } });
225
+ return { status: row.status, reason: row.failure_reason };
226
+ });
227
+ }
228
+ /** Waits for a condition the test can't be told about. */
229
+ function until(condition) {
230
+ return __awaiter(this, void 0, void 0, function* () {
231
+ for (let i = 0; i < 200 && !condition(); i++)
232
+ yield new Promise((r) => setTimeout(r, 10));
233
+ (0, chai_1.expect)(condition(), 'waited 2 s').to.equal(true);
234
+ });
235
+ }
236
+ /** A command the test sends, through the public route. */
237
+ const testCommand = () => (0, loopbackRequest_1.default)(url).get(`${BASE}/session/${SESSION}/url`);
238
+ it('a performance recording’s stop the driver refuses doesn’t fail a session whose commands all passed', () => __awaiter(this, void 0, void 0, function* () {
239
+ yield testCommand().expect(200);
240
+ (0, chai_1.expect)(yield session.stopPerformanceRecording()).to.equal(null);
241
+ // The stop was asked in-process, then over the loopback, which reached the driver.
242
+ (0, chai_1.expect)(inner.calls).to.deep.equal([
243
+ 'getUrl',
244
+ 'execute mobile: stopPerfRecord',
245
+ 'execute mobile: stopPerfRecord',
246
+ ]);
247
+ (0, chai_1.expect)(yield recorded()).to.deep.equal([{ command: 'getUrl', failed: false }]);
248
+ (0, chai_1.expect)(yield end()).to.deep.equal({ status: SessionStatus_1.SessionStatus.SUCCESS, reason: null });
249
+ (0, chai_1.expect)(notified.called, 'a session_failed webhook').to.equal(false);
250
+ }));
251
+ it('nor does a refused start of the recording, at the session’s first moments', () => __awaiter(this, void 0, void 0, function* () {
252
+ yield session.startPerformanceRecording();
253
+ (0, chai_1.expect)(inner.calls).to.deep.equal([
254
+ 'execute mobile: startPerfRecord',
255
+ 'execute mobile: startPerfRecord',
256
+ ]);
257
+ yield testCommand().expect(200);
258
+ (0, chai_1.expect)(yield recorded()).to.deep.equal([{ command: 'getUrl', failed: false }]);
259
+ (0, chai_1.expect)(yield end()).to.deep.equal({ status: SessionStatus_1.SessionStatus.SUCCESS, reason: null });
260
+ }));
261
+ it('a page source read over the loopback is not put in the session’s command log', () => __awaiter(this, void 0, void 0, function* () {
262
+ inner.pageSourceFailures = 1;
263
+ (0, chai_1.expect)(yield session.getPageSource()).to.equal('<XCUIElementTypeApplication name="Shop"/>');
264
+ (0, chai_1.expect)(inner.calls).to.deep.equal(['getPageSource', 'getPageSource']);
265
+ (0, chai_1.expect)(yield recorded()).to.deep.equal([]);
266
+ }));
267
+ it('a page source read that fails over the loopback too is not a failed command of the session', () => __awaiter(this, void 0, void 0, function* () {
268
+ inner.pageSourceFailures = 2;
269
+ yield session.getPageSource().then(() => chai_1.expect.fail('the read succeeded'), (error) => { var _a; return (0, chai_1.expect)((_a = error.response) === null || _a === void 0 ? void 0 : _a.status).to.equal(500); });
270
+ (0, chai_1.expect)(inner.calls).to.deep.equal(['getPageSource', 'getPageSource']);
271
+ (0, chai_1.expect)(yield recorded()).to.deep.equal([]);
272
+ (0, chai_1.expect)(yield end()).to.deep.equal({ status: SessionStatus_1.SessionStatus.SUCCESS, reason: null });
273
+ }));
274
+ it('isn’t the session’s activity: only the test’s commands reset its idle clock', () => __awaiter(this, void 0, void 0, function* () {
275
+ const touched = sinon_1.default.stub(deviceService, 'updateCmdExecutedTime').resolves();
276
+ inner.pageSourceFailures = 1;
277
+ yield session.stopPerformanceRecording();
278
+ yield session.getPageSource();
279
+ (0, chai_1.expect)(inner.calls).to.have.length(4);
280
+ (0, chai_1.expect)(touched.called).to.equal(false);
281
+ yield testCommand().expect(200);
282
+ (0, chai_1.expect)(touched.calledOnceWith(SESSION)).to.equal(true);
283
+ }));
284
+ it('a test’s command queued in the driver behind one is recorded, as the test’s', () => __awaiter(this, void 0, void 0, function* () {
285
+ let release = () => { };
286
+ inner.held = new Promise((resolve) => (release = resolve));
287
+ inner.pageSourceFailures = 1;
288
+ // Xenon's read reaches the driver over the loopback and holds its queue.
289
+ const read = session.getPageSource();
290
+ yield until(() => inner.calls.length === 2);
291
+ // The test's command waits in the driver's queue behind it, and runs when
292
+ // Xenon's read lets go.
293
+ const command = testCommand().then((res) => res);
294
+ yield until(() => inner.queued() === 1);
295
+ release();
296
+ (0, chai_1.expect)(yield read).to.equal('<XCUIElementTypeApplication name="Shop"/>');
297
+ (0, chai_1.expect)((yield command).status).to.equal(200);
298
+ (0, chai_1.expect)(inner.calls).to.deep.equal(['getPageSource', 'getPageSource', 'getUrl']);
299
+ (0, chai_1.expect)(yield recorded()).to.deep.equal([{ command: 'getUrl', failed: false }]);
300
+ }));
301
+ it('the same refusal of the test’s own command is recorded and fails the session, as before', () => __awaiter(this, void 0, void 0, function* () {
302
+ yield (0, loopbackRequest_1.default)(url)
303
+ .post(`${BASE}/session/${SESSION}/execute/sync`)
304
+ .send({ script: 'mobile: stopPerfRecord', args: [{ profileName: 'Time Profiler' }] })
305
+ .expect(500);
306
+ (0, chai_1.expect)(yield recorded()).to.deep.equal([{ command: 'execute', failed: true }]);
307
+ (0, chai_1.expect)(yield end()).to.deep.equal({ status: SessionStatus_1.SessionStatus.FAILED, reason: REFUSAL });
308
+ (0, chai_1.expect)(notified.calledOnce, 'a session_failed webhook').to.equal(true);
309
+ // The analysis the failure started ends with the test, not after it.
310
+ yield (0, failure_analysis_service_1.explainSessionFailure)(SESSION);
311
+ }));
312
+ });