@xenon-device-management/xenon 2.15.0 → 2.16.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 (183) hide show
  1. package/README.md +38 -26
  2. package/lib/package.json +2 -1
  3. package/lib/public/assets/{AnnotationOverlay-Btq3dZRI.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-C9OoAgg_.js → ApiKeyGate-GmckX_7r.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DBC_xczB.js → DeviceMosaicView-DqODzkyo.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DqWU2-vM.js → EmptyState-7hETKEWb.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CveQ3xrA.js → FieldGroup-GOgssFEP.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BfKDSfHz.js → FilterMenu-Yvf6RGMd.js} +1 -1
  9. package/lib/public/assets/{Menu-CtdCybzA.js → Menu-KrPnfdsf.js} +1 -1
  10. package/lib/public/assets/{Modal-Bsvj1tm2.js → Modal-C6WjZFeI.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CRofOiyx.js → RecordingPage-CSRIhZEh.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-jNQHb2IR.js → RecordingsPage-CB8ST3nE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C8HtTha-.js → SegmentedControl-C9o90KId.js} +1 -1
  14. package/lib/public/assets/{SettingCard-D8K1LZNo.js → SettingCard-CPm3w9l2.js} +1 -1
  15. package/lib/public/assets/{Table-zNTX92Pn.js → Table-DwIgERRt.js} +1 -1
  16. package/lib/public/assets/{activity-CJ8LwZq9.js → activity-DrLbHifn.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CgxQZqrc.js → ai-settings-DU_Ls06P.js} +1 -1
  18. package/lib/public/assets/{api-keys-ECpo-zTo.js → api-keys-CWYdEzJ_.js} +1 -1
  19. package/lib/public/assets/{apps-BjGymyf3.js → apps-BqNVdln7.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BWfCbeHP.js → arrow-left-BtB0_Jg_.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DC7UGf_n.js → arrow-right-C8NhdtyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-BOjwybDk.js → arrow-up-right-gMg0ffvv.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CxMTo9aH.js → auth-shell-CiYZ3N4M.js} +2 -2
  24. package/lib/public/assets/{builds-page-DOCm4HkA.js → builds-page-EaaqDmLN.js} +1 -1
  25. package/lib/public/assets/{button-Cg08-EMT.js → button-DDau4Axv.js} +1 -1
  26. package/lib/public/assets/{calendar-CA1ikLJs.js → calendar-DQ83B1ed.js} +1 -1
  27. package/lib/public/assets/{check-BMTaH72W.js → check-K1ovTQam.js} +1 -1
  28. package/lib/public/assets/{chevron-right-B2QOv9Nx.js → chevron-right-Biq00pMt.js} +1 -1
  29. package/lib/public/assets/{circle-check-CYXBoUqT.js → circle-check-CBv88cH7.js} +1 -1
  30. package/lib/public/assets/{circle-x-DTwnSAnl.js → circle-x-Bmltailn.js} +1 -1
  31. package/lib/public/assets/{clock-C8_HViQP.js → clock-BPrld-3h.js} +1 -1
  32. package/lib/public/assets/{copy-Be7LNMXV.js → copy-t0QB14JI.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BOqtfGXz.js → device-explorer-BgGZ8AvV.js} +1 -1
  34. package/lib/public/assets/{download-B56lwxWP.js → download-BroHxNOh.js} +1 -1
  35. package/lib/public/assets/{forgot-password-CCU6bzLn.js → forgot-password-Bjmf49i6.js} +1 -1
  36. package/lib/public/assets/{h264Stream-C-OtmIuk.js → h264Stream-DeD__6wa.js} +1 -1
  37. package/lib/public/assets/{index-CfxsUslp.js → index-72oF89LJ.js} +1 -1
  38. package/lib/public/assets/{index-B9KQ6LsY.js → index-B0JeuZbg.js} +1 -1
  39. package/lib/public/assets/index-B0gQkEg0.js +219 -0
  40. package/lib/public/assets/{input-CZHFjbSg.js → input-DIc4MSC7.js} +1 -1
  41. package/lib/public/assets/{line-chart-Gl_Dz3X_.js → line-chart-BcWm2vW8.js} +1 -1
  42. package/lib/public/assets/{list-checks-CCgjeyGY.js → list-checks-B06IJJad.js} +1 -1
  43. package/lib/public/assets/{lock-CkOwgDcx.js → lock-pVLvnN30.js} +1 -1
  44. package/lib/public/assets/{login-D5cc4w7M.js → login-CN77vy--.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-t7qr3fvX.js → maintenance-settings-CSdzQSkY.js} +1 -1
  46. package/lib/public/assets/{monitor-DEJixKQO.js → monitor-BtrN5IkI.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CtOrk4M8.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
  48. package/lib/public/assets/{network-Dii9qdvk.js → network-BSfA8ZxG.js} +1 -1
  49. package/lib/public/assets/{overview-rolAACrO.js → overview-BfTebCK5.js} +1 -1
  50. package/lib/public/assets/{page-header-COQZxJfR.js → page-header-D8KUX6Qg.js} +1 -1
  51. package/lib/public/assets/{play-DrN1Se_R.js → play-Cvc8xzic.js} +1 -1
  52. package/lib/public/assets/{plus-5aq-9uRx.js → plus-C9Zj84UF.js} +1 -1
  53. package/lib/public/assets/{profile-page-6i5kY6kF.js → profile-page-Bi27STgs.js} +1 -1
  54. package/lib/public/assets/{reset-password-sp2IBrc7.js → reset-password-CPsOuwSu.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJDjeopU.js → runbook-page-DEQ-DBOX.js} +153 -102
  56. package/lib/public/assets/{select-Bpyn7rWO.js → select-BWpnhWXj.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BH74vmKv.js → selector-detail-redirect-BefSCSpe.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-Cw_g218H.js → selector-health-page-MSk96Zz7.js} +1 -1
  59. package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
  60. package/lib/public/assets/{settings-C3uyGhyc.js → settings-V6x-cXwy.js} +1 -1
  61. package/lib/public/assets/{stat-tile-xVqAKIlI.js → stat-tile-Duex-GZD.js} +1 -1
  62. package/lib/public/assets/{tablet-BxUScAeO.js → tablet-BBcoqBmL.js} +1 -1
  63. package/lib/public/assets/{teams-B5ljGkY2.js → teams-ts8p5rEh.js} +1 -1
  64. package/lib/public/assets/{trash-2-B701i8sy.js → trash-2-CZ2ew6AZ.js} +1 -1
  65. package/lib/public/assets/{upload-C2gr44RS.js → upload-Dv99y_le.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-NeQOFEoy.js → use-builds-data-DrYC8JCF.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DEzJq5ef.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
  68. package/lib/public/assets/{users-D_vv_yz4.js → users-D7tKMESH.js} +1 -1
  69. package/lib/public/assets/{users-MY6eNe6s.js → users-gJ5Y01yo.js} +1 -1
  70. package/lib/public/assets/{video-off-OK1lNZQn.js → video-off-CqZVuZGs.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-BU-C9lBf.js → webhook-settings-DI5DSW99.js} +1 -1
  72. package/lib/public/assets/{zap-DO9ry1iz.js → zap-ClcHFxfo.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +11 -7
  75. package/lib/src/InternalHttpClient.js +27 -5
  76. package/lib/src/XenonCapabilityManager.js +10 -3
  77. package/lib/src/app/openapi/sessions.yaml +7 -5
  78. package/lib/src/app/routers/auth.js +2 -1
  79. package/lib/src/app/routers/users.js +2 -1
  80. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  81. package/lib/src/dashboard/commandLogFields.js +42 -0
  82. package/lib/src/dashboard/event-manager.js +26 -154
  83. package/lib/src/dashboard/services/failure-analysis-service.js +8 -86
  84. package/lib/src/dashboard/services/failureCategories.js +264 -0
  85. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  86. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  87. package/lib/src/device-utils.js +14 -6
  88. package/lib/src/gateway/forwardToNode.js +4 -11
  89. package/lib/src/gateway/hubSessionToken.js +27 -4
  90. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  91. package/lib/src/helpers/outboundProxy.js +219 -0
  92. package/lib/src/index.js +21 -0
  93. package/lib/src/interceptors/CommandInterceptor.js +13 -15
  94. package/lib/src/interceptors/xenonScripts.js +28 -7
  95. package/lib/src/logger.js +3 -1
  96. package/lib/src/plugin.js +8 -3
  97. package/lib/src/scripts/database-check.js +3 -3
  98. package/lib/src/scripts/initialize-database.js +35 -33
  99. package/lib/src/scripts/run-migrations.js +1079 -27
  100. package/lib/src/services/ApiKeyService.js +16 -5
  101. package/lib/src/services/InterceptorService.js +6 -2
  102. package/lib/src/services/OrphanSweeper.js +6 -0
  103. package/lib/src/services/SelectorStateService.js +11 -7
  104. package/lib/src/services/SelectorVerificationJob.js +6 -2
  105. package/lib/src/services/SessionLifecycleService.js +32 -18
  106. package/lib/src/services/SocketClient.js +11 -0
  107. package/lib/src/services/SocketServer.js +361 -42
  108. package/lib/src/services/TeamService.js +5 -1
  109. package/lib/src/services/TracingService.js +171 -60
  110. package/lib/src/services/UserService.js +14 -3
  111. package/lib/src/services/UserSessionService.js +16 -4
  112. package/lib/src/services/healing/HealingOrchestrator.js +3 -1
  113. package/lib/src/services/identity/identityChanges.js +41 -0
  114. package/lib/src/services/logcat/SessionDeviceLogs.js +97 -10
  115. package/lib/src/services/logcat/deviceLogBook.js +5 -4
  116. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  117. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  118. package/lib/src/services/token/JwtKeyService.js +8 -2
  119. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  120. package/lib/src/sessions/LocalSession.js +29 -1
  121. package/lib/src/sessions/RemoteSession.js +12 -2
  122. package/lib/src/sessions/SessionManager.js +5 -8
  123. package/lib/test/helpers/driver-log.js +38 -0
  124. package/lib/test/helpers/fake-http-proxy.js +106 -0
  125. package/lib/test/helpers/otlp-probe.js +107 -0
  126. package/lib/test/integration/androidDevices.spec.js +1 -1
  127. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  128. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  129. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  130. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  131. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  132. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  133. package/lib/test/unit/dashboard-events-device-logs.spec.js +59 -11
  134. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  135. package/lib/test/unit/database-check.spec.js +1 -0
  136. package/lib/test/unit/device-utils.spec.js +13 -4
  137. package/lib/test/unit/event-log-session-data.spec.js +11 -4
  138. package/lib/test/unit/failure-analysis.spec.js +22 -8
  139. package/lib/test/unit/failure-categories.spec.js +24 -22
  140. package/lib/test/unit/failure-categorization.spec.js +393 -0
  141. package/lib/test/unit/healing-tier-gate.spec.js +3 -2
  142. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +72 -1
  143. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  144. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  145. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  146. package/lib/test/unit/logcat-ws.spec.js +78 -37
  147. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  148. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  149. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  150. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  151. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  152. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  153. package/lib/test/unit/proxy-option.spec.js +358 -0
  154. package/lib/test/unit/run-migrations-database.spec.js +807 -0
  155. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  156. package/lib/test/unit/run-migrations.test.js +864 -0
  157. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  158. package/lib/test/unit/selector-state-service.spec.js +72 -23
  159. package/lib/test/unit/selector-verification-clean-build.spec.js +1 -1
  160. package/lib/test/unit/selector-verification-job.spec.js +15 -6
  161. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  162. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  163. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  164. package/lib/test/unit/session-device-logs.spec.js +11 -2
  165. package/lib/test/unit/session-end-failure-analysis.spec.js +5 -5
  166. package/lib/test/unit/session-log-indexes.spec.js +1 -1
  167. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  168. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  169. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  170. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  171. package/lib/test/unit/unexpected-shutdown-reason.spec.js +2 -2
  172. package/lib/tsconfig.tsbuildinfo +1 -1
  173. package/package.json +2 -1
  174. package/schema.json +11 -7
  175. package/scripts/dev/readme-screenshots.js +276 -23
  176. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  177. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  178. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  179. package/scripts/export-openapi.js +32 -0
  180. package/scripts/lib/openapi-for-site.js +79 -0
  181. package/lib/public/assets/index-BOxe43p1.js +0 -219
  182. package/lib/public/assets/session-detail-page-AZJE6XXE.js +0 -4
  183. package/lib/src/scripts/prepare-prisma.js +0 -89
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const express_1 = __importDefault(require("express"));
19
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
20
+ const dashboard_1 = __importDefault(require("../../src/app/routers/dashboard"));
21
+ const event_manager_1 = require("../../src/dashboard/event-manager");
22
+ const commandLogFields_1 = require("../../src/dashboard/commandLogFields");
23
+ const SessionManager_1 = require("../../src/sessions/SessionManager");
24
+ const SelectorStateService_1 = require("../../src/services/SelectorStateService");
25
+ const SocketServer_1 = require("../../src/services/SocketServer");
26
+ const typedi_1 = require("typedi");
27
+ const scratch_database_1 = require("../helpers/scratch-database");
28
+ const container_registration_1 = require("../helpers/container-registration");
29
+ /**
30
+ * A session's network capture (its requests, its HAR, its mocks) is for admins
31
+ * only: the /interceptor routes, the Network panel and the live events. The
32
+ * command log is read by anyone who can see the session (`session_log`, bug
33
+ * reports). A hub records every command it forwards to a node, so a test's own
34
+ * `xenon: exportHar` put the whole capture, sign-in headers included, in front
35
+ * of every member of the phone's team.
36
+ */
37
+ describe('The command log keeps no network capture', () => {
38
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
39
+ const SESSION = 'cl-session-1';
40
+ const SECRET = 'eyJ-app-access-token';
41
+ const HAR = {
42
+ log: {
43
+ entries: [{ request: { headers: [{ name: 'authorization', value: `Bearer ${SECRET}` }] } }],
44
+ },
45
+ };
46
+ let restore;
47
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
48
+ restore = (0, container_registration_1.saveRegistrations)(SocketServer_1.SocketServer);
49
+ typedi_1.Container.set(SocketServer_1.SocketServer, {
50
+ emitToDashboardForDevices: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }),
51
+ hasScopedDashboard: () => false,
52
+ });
53
+ yield scratch.db.sessionLog.deleteMany({});
54
+ yield scratch.db.session.deleteMany({});
55
+ yield scratch.db.device.deleteMany({});
56
+ yield scratch.db.team.deleteMany({});
57
+ yield scratch.db.team.create({ data: { id: 'team-a', name: 'Team A' } });
58
+ yield scratch.db.device.create({
59
+ data: {
60
+ udid: 'cl-phone-a',
61
+ host: 'http://node:4723',
62
+ platform: 'android',
63
+ teamId: 'team-a',
64
+ },
65
+ });
66
+ yield scratch.db.session.create({
67
+ data: {
68
+ id: SESSION,
69
+ desired_capabilities: '{}',
70
+ session_capabilities: '{}',
71
+ node_id: 'node-1',
72
+ has_live_video: false,
73
+ device_udid: 'cl-phone-a',
74
+ device_platform: 'android',
75
+ device_version: '14',
76
+ },
77
+ });
78
+ sinon_1.default.stub(SessionManager_1.SESSION_MANAGER, 'getSession').returns({
79
+ getId: () => SESSION,
80
+ getDevice: () => ({ udid: 'cl-phone-a', name: 'P', platform: 'android' }),
81
+ getXenonOption: () => undefined,
82
+ getScreenShot: () => __awaiter(void 0, void 0, void 0, function* () { return ''; }),
83
+ });
84
+ sinon_1.default.stub(SelectorStateService_1.SelectorStateService.prototype, 'onHealRecorded').resolves(null);
85
+ }));
86
+ afterEach(() => {
87
+ sinon_1.default.restore();
88
+ restore();
89
+ });
90
+ /** A command as the hub's gateway hands it over: the client's W3C body and the node's answer. */
91
+ function forwarded(script, args, answer) {
92
+ return event_manager_1.DASHBORD_EVENT_MANAGER.afterSessionCommand(SESSION, 'execute', null, {
93
+ body: { script, args },
94
+ method: 'POST',
95
+ originalUrl: `/wd/hub/session/${SESSION}/execute/sync`,
96
+ }, {}, JSON.stringify(answer));
97
+ }
98
+ /** What a member of the phone's team reads. */
99
+ function sessionLogAsMember() {
100
+ return __awaiter(this, void 0, void 0, function* () {
101
+ const app = (0, express_1.default)();
102
+ app.use((req, _res, next) => {
103
+ req.auth = {
104
+ kind: 'user-session',
105
+ userId: 'cl-u-member',
106
+ role: 'MEMBER',
107
+ scopes: 'devices,sessions,read',
108
+ teamIds: ['team-a'],
109
+ };
110
+ next();
111
+ });
112
+ dashboard_1.default.register(app);
113
+ const res = yield (0, loopbackRequest_1.default)(app).get(`/session/${SESSION}/session_log`);
114
+ (0, chai_1.expect)(res.status).to.equal(200);
115
+ return res.body;
116
+ });
117
+ }
118
+ it('records which network script ran and whether it worked, never its arguments or its answer', () => __awaiter(void 0, void 0, void 0, function* () {
119
+ var _a, _b;
120
+ yield forwarded('xenon: exportHar', [], { value: HAR });
121
+ yield forwarded('xe:getRequests', [], { value: [HAR] });
122
+ yield forwarded('getMocks', [], { value: [{ respond: { body: { token: SECRET } } }] });
123
+ yield forwarded('xenon: addMock', [{ respond: { body: { token: SECRET } } }], {
124
+ value: { id: 'm1' },
125
+ });
126
+ yield forwarded('xenon: removeMock', [{ id: 'm1' }], { value: { removed: true } });
127
+ yield forwarded('xenon: clearMocks', [], { value: { ok: true } });
128
+ const rows = yield sessionLogAsMember();
129
+ (0, chai_1.expect)(rows).to.have.length(6);
130
+ for (const row of rows) {
131
+ (0, chai_1.expect)(`${row.body}${row.response}`).to.not.include(SECRET);
132
+ (0, chai_1.expect)(row.is_success).to.equal(true);
133
+ // Every one, removeMock and clearMocks included, whose answers hold no secret.
134
+ (0, chai_1.expect)(JSON.parse(row.response), (_a = row.body) !== null && _a !== void 0 ? _a : '').to.deep.equal({
135
+ value: commandLogFields_1.NETWORK_CAPTURE_NOT_KEPT,
136
+ });
137
+ (0, chai_1.expect)(JSON.parse((_b = row.body) !== null && _b !== void 0 ? _b : '{}').args).to.equal(commandLogFields_1.NETWORK_CAPTURE_NOT_KEPT);
138
+ }
139
+ (0, chai_1.expect)(rows.map((r) => { var _a; return JSON.parse((_a = r.body) !== null && _a !== void 0 ? _a : '{}').script; })).to.have.members([
140
+ 'xenon: exportHar',
141
+ 'xe:getRequests',
142
+ 'getMocks',
143
+ 'xenon: addMock',
144
+ 'xenon: removeMock',
145
+ 'xenon: clearMocks',
146
+ ]);
147
+ }));
148
+ it('keeps a failed network script’s error, which carries no capture', () => __awaiter(void 0, void 0, void 0, function* () {
149
+ const error = {
150
+ value: {
151
+ error: 'unknown error',
152
+ message: `Interceptor not active for session ${SESSION}`,
153
+ stacktrace: '',
154
+ },
155
+ };
156
+ yield forwarded('xenon: exportHar', [], error);
157
+ const [row] = yield sessionLogAsMember();
158
+ (0, chai_1.expect)(row.is_success).to.equal(false);
159
+ (0, chai_1.expect)(JSON.parse(row.response)).to.deep.equal(error);
160
+ }));
161
+ it('on the server that drives the phone, a failed network script keeps its error but not its arguments', () => __awaiter(void 0, void 0, void 0, function* () {
162
+ var _a;
163
+ // CommandInterceptor answers these scripts before its hooks; only a throw
164
+ // (no capture running) reaches the log, from its catch, with the
165
+ // command's arguments as they came: [script, args].
166
+ const error = {
167
+ value: { error: `Interceptor not active for session ${SESSION}` },
168
+ sessionId: SESSION,
169
+ };
170
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.afterSessionCommand(SESSION, 'execute', null, {
171
+ body: ['xenon: addMock', [{ respond: { body: { token: SECRET } } }]],
172
+ method: 'POST',
173
+ originalUrl: '/execute',
174
+ }, {}, JSON.stringify(error));
175
+ const [row] = yield sessionLogAsMember();
176
+ (0, chai_1.expect)(row.body).to.not.include(SECRET);
177
+ (0, chai_1.expect)(JSON.parse((_a = row.body) !== null && _a !== void 0 ? _a : '[]')[0]).to.equal('xenon: addMock');
178
+ (0, chai_1.expect)(JSON.parse(row.response)).to.deep.equal(error);
179
+ (0, chai_1.expect)(row.is_success).to.equal(false);
180
+ }));
181
+ it('logs every other script, and a script the node’s interceptor doesn’t answer, whole', () => __awaiter(void 0, void 0, void 0, function* () {
182
+ // `plugin:` is only the autowait commands' prefix: `plugin: exportHar` is
183
+ // never a network script, and its answer is the driver's.
184
+ yield forwarded('mobile: shell', [{ command: 'echo' }], { value: SECRET });
185
+ yield forwarded('plugin: exportHar', [], { value: SECRET });
186
+ const rows = yield sessionLogAsMember();
187
+ (0, chai_1.expect)(rows).to.have.length(2);
188
+ for (const row of rows)
189
+ (0, chai_1.expect)(row.response).to.include(SECRET);
190
+ }));
191
+ });
@@ -50,6 +50,7 @@ const chai_1 = require("chai");
50
50
  const sinon_1 = __importDefault(require("sinon"));
51
51
  const typedi_1 = require("typedi");
52
52
  const event_manager_1 = require("../../src/dashboard/event-manager");
53
+ const XenonCapabilityManager_1 = require("../../src/XenonCapabilityManager");
53
54
  const assets = __importStar(require("../../src/dashboard/asset-manager"));
54
55
  const sessionService = __importStar(require("../../src/dashboard/services/session-service"));
55
56
  const SessionManager_1 = require("../../src/sessions/SessionManager");
@@ -84,6 +85,7 @@ describe('EventManager: an Android session’s device log', () => {
84
85
  deviceLogs = {
85
86
  start: sinon_1.default.stub().callsFake(() => __awaiter(void 0, void 0, void 0, function* () { return order.push('device logs'); })),
86
87
  stop: sinon_1.default.stub().resolves(),
88
+ noteOff: sinon_1.default.stub().resolves(),
87
89
  };
88
90
  typedi_1.Container.set(SessionDeviceLogs_1.SessionDeviceLogs, deviceLogs);
89
91
  typedi_1.Container.set(SessionMetricsService_1.SessionMetricsService, {
@@ -101,32 +103,78 @@ describe('EventManager: an Android session’s device log', () => {
101
103
  sinon_1.default.restore();
102
104
  restore();
103
105
  });
104
- it("starts once the session's row is written, from when the phone was given to it", () => __awaiter(void 0, void 0, void 0, function* () {
106
+ function startSession(platform, deviceLog) {
105
107
  sinon_1.default.stub(assets, 'prepareDirectory');
106
108
  sinon_1.default.stub(sessionService, 'getOrCreateNewBuild').resolves({ id: 'b-logs' });
107
109
  sinon_1.default.stub(TracingService_1.TracingService.prototype, 'getTraceId').returns('t-1');
108
110
  sinon_1.default.stub(MetricsService_1.MetricsService.prototype, 'incrementSessionStart');
109
- sinon_1.default.stub(prisma_1.prisma.session, 'create').callsFake(() => __awaiter(void 0, void 0, void 0, function* () {
111
+ sinon_1.default.stub(prisma_1.prisma.session, 'create').callsFake(() => __awaiter(this, void 0, void 0, function* () {
110
112
  order.push('row');
111
113
  return {};
112
114
  }));
113
- const session = {
114
- getId: () => 's-logs-1',
115
- getCapabilities: () => ({ platformName: 'Android' }),
116
- getLiveVideoUrl: () => null,
117
- apiKeyId: null,
118
- userId: null,
119
- allocatedAt: 1700000000000,
120
- };
121
- const device = { udid: 'phone-l', platform: 'android', host: 'h', name: 'S9', sdk: '10' };
115
+ const session = Object.assign({ getId: () => 's-logs-1', getCapabilities: () => ({ platformName: platform }), getLiveVideoUrl: () => null, startPerformanceRecording: sinon_1.default.stub().resolves(), apiKeyId: null, userId: null, allocatedAt: 1700000000000 }, (deviceLog ? { deviceLog } : {}));
116
+ const device = { udid: 'phone-l', platform, host: 'h', name: 'phone', sdk: '10' };
117
+ return { session, device };
118
+ }
119
+ it("starts once the session's row is written, from when the phone was given to it", () => __awaiter(void 0, void 0, void 0, function* () {
120
+ const { session, device } = startSession('android');
122
121
  yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStarted({}, session, device);
123
122
  (0, chai_1.expect)(order).to.deep.equal(['row', 'device logs']);
124
123
  (0, chai_1.expect)(deviceLogs.start.firstCall.args[0]).to.deep.equal({
125
124
  sessionId: 's-logs-1',
126
125
  device,
127
126
  since: 1700000000000,
127
+ driverLog: undefined,
128
+ appUnderTest: undefined,
128
129
  });
129
130
  }));
131
+ // The switch, from the session's capabilities as the create parses them.
132
+ const parsed = (alwaysMatch) => (0, XenonCapabilityManager_1.getXenonCapabilities)({ alwaysMatch, firstMatch: [{}] });
133
+ function stubSessionStart() {
134
+ sinon_1.default.stub(assets, 'prepareDirectory');
135
+ sinon_1.default.stub(sessionService, 'getOrCreateNewBuild').resolves({ id: 'b-logs' });
136
+ sinon_1.default.stub(TracingService_1.TracingService.prototype, 'getTraceId').returns('t-1');
137
+ sinon_1.default.stub(MetricsService_1.MetricsService.prototype, 'incrementSessionStart');
138
+ sinon_1.default.stub(prisma_1.prisma.session, 'create').resolves({});
139
+ return {
140
+ session: {
141
+ getId: () => 's-logs-off',
142
+ getCapabilities: () => ({ platformName: 'Android' }),
143
+ getLiveVideoUrl: () => null,
144
+ apiKeyId: null,
145
+ userId: null,
146
+ },
147
+ device: { udid: 'phone-l', platform: 'android', host: 'h', name: 'S9', sdk: '10' },
148
+ };
149
+ }
150
+ it('records it when the session says nothing', () => __awaiter(void 0, void 0, void 0, function* () {
151
+ const { session, device } = stubSessionStart();
152
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStarted(parsed({}), session, device);
153
+ (0, chai_1.expect)(deviceLogs.start.calledOnce).to.equal(true);
154
+ (0, chai_1.expect)(deviceLogs.noteOff.called).to.equal(false);
155
+ }));
156
+ it('keeps none, and says so, for a session that turned it off', () => __awaiter(void 0, void 0, void 0, function* () {
157
+ const { session, device } = stubSessionStart();
158
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStarted(parsed({ 'xe:save_device_logs': false }), session, device);
159
+ (0, chai_1.expect)(deviceLogs.start.called).to.equal(false);
160
+ (0, chai_1.expect)(deviceLogs.noteOff.calledOnceWithExactly('s-logs-off')).to.equal(true);
161
+ }));
162
+ it("doesn't listen to an iPhone's driver log when the session turned it off", () => __awaiter(void 0, void 0, void 0, function* () {
163
+ const driverLog = { on: sinon_1.default.stub(), removeListener: sinon_1.default.stub() };
164
+ const { session, device } = startSession('ios', () => driverLog);
165
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStarted(parsed({ 'xe:saveDeviceLogs': 'false' }), session, device);
166
+ (0, chai_1.expect)(deviceLogs.start.called).to.equal(false);
167
+ (0, chai_1.expect)(deviceLogs.noteOff.calledOnceWithExactly('s-logs-1')).to.equal(true);
168
+ (0, chai_1.expect)(driverLog.on.called).to.equal(false);
169
+ }));
170
+ it("hands an iPhone's session the log its driver captures, and its app", () => __awaiter(void 0, void 0, void 0, function* () {
171
+ const driverLog = { on() { }, removeListener() { } };
172
+ const { session, device } = startSession('ios', () => driverLog);
173
+ session.appUnderTest = () => 'com.example.shop';
174
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStarted({}, session, device);
175
+ (0, chai_1.expect)(deviceLogs.start.firstCall.args[0].driverLog).to.equal(driverLog);
176
+ (0, chai_1.expect)(deviceLogs.start.firstCall.args[0].appUnderTest).to.equal('com.example.shop');
177
+ }));
130
178
  it('writes it to the end when the session stops, even one no longer in memory', () => __awaiter(void 0, void 0, void 0, function* () {
131
179
  sinon_1.default.stub(SessionManager_1.SESSION_MANAGER, 'getSession').returns(undefined);
132
180
  sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({ getDevices: () => __awaiter(void 0, void 0, void 0, function* () { return []; }) });
@@ -332,7 +332,7 @@ describe('Dashboard events name their phone (team-scoped call sites)', () => {
332
332
  config_1.config.sessionAssetsPath = assets;
333
333
  fs_1.default.rmSync(dir, { recursive: true, force: true });
334
334
  });
335
- it("request, started and stopped: the session's device, even after stop() drops the state", () => __awaiter(void 0, void 0, void 0, function* () {
335
+ it("request, started and stopped: the session's device, admins only, even after stop() drops the state", () => __awaiter(void 0, void 0, void 0, function* () {
336
336
  const svc = new InterceptorService_1.InterceptorService();
337
337
  typedi_1.Container.set(PortAllocator_1.PortAllocator, { releaseForUdid: () => __awaiter(void 0, void 0, void 0, function* () { return undefined; }) });
338
338
  svc.androidAdapter = {
@@ -353,16 +353,19 @@ describe('Dashboard events name their phone (team-scoped call sites)', () => {
353
353
  svc.emit({ type: 'request', sessionId: 's4', payload: { id: 'q1' } }, 'phone-a');
354
354
  svc.emit({ type: 'session_started', sessionId: 's4', port: 1, host: 'h' }, 'phone-a');
355
355
  yield svc.stop('s4');
356
+ // Captured traffic (headers, bodies) is for admins only, as REST's
357
+ // /interceptor routes are; so are the capture's start and stop.
356
358
  (0, chai_1.expect)(scoped(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST)[0].args.slice(1)).to.deep.equal([
357
359
  { id: 'q1' },
358
- { udid: 'phone-a' },
360
+ { udid: 'phone-a', adminOnly: true },
359
361
  ]);
360
362
  (0, chai_1.expect)(scoped(SocketEvents_1.SocketEvents.INTERCEPTOR_SESSION_STARTED)[0].args[2]).to.deep.equal({
361
363
  udid: 'phone-a',
364
+ adminOnly: true,
362
365
  });
363
366
  (0, chai_1.expect)(scoped(SocketEvents_1.SocketEvents.INTERCEPTOR_SESSION_STOPPED)[0].args.slice(1)).to.deep.equal([
364
367
  { sessionId: 's4' },
365
- { udid: 'phone-a' },
368
+ { udid: 'phone-a', adminOnly: true },
366
369
  ]);
367
370
  }));
368
371
  });
@@ -27,6 +27,7 @@ describe('checkDatabase', () => {
27
27
  });
28
28
  (0, chai_1.expect)(warning).to.be.a('string');
29
29
  (0, chai_1.expect)(warning).to.match(/databaseProvider/);
30
+ (0, chai_1.expect)(warning).to.match(/has no effect/);
30
31
  (0, chai_1.expect)(warning).to.match(/SQLite/);
31
32
  (0, chai_1.expect)(warning).to.include('/Users/me/.cache/xenon/xenon.db');
32
33
  });
@@ -192,9 +192,14 @@ describe('Device Utils', () => {
192
192
  }
193
193
  const foundDevice = (yield getFilteredDevice(allocatedDeviceForFirstSession.udid, allocatedDeviceForFirstSession.host))[0];
194
194
  expect(foundDevice.busy).to.be.true;
195
- yield (0, device_utils_1.allocateDeviceForSession)(capabilities, 1000, 1000, pluginArgs).catch((error) => expect(error)
195
+ // The other phone on that host, so nothing matching is left.
196
+ const allocatedDeviceForSecondSession = yield DeviceUtils.allocateDeviceForSession(capabilities, 1000, 1000, pluginArgs);
197
+ expect(allocatedDeviceForSecondSession.host).to.not.equal(allocatedDeviceForFirstSession.host);
198
+ // Resolving must fail the test, not skip the check.
199
+ const refused = yield (0, device_utils_1.allocateDeviceForSession)(capabilities, 1000, 1000, pluginArgs).then(() => null, (error) => error);
200
+ expect(refused, 'the allocation was refused')
196
201
  .to.be.an('error')
197
- .with.property('message', 'Device is busy or blocked.. Device request: {"platform":"android","udid":"emulator-5555","filterByHost":"192.168.0.226"}'));
202
+ .with.property('message', 'Device is busy or blocked. Device request: {"platform":"android","udid":["emulator-5555"],"filterByHost":"192.168.0.226"}');
198
203
  }));
199
204
  it('Allocating device should set device to be busy', function () {
200
205
  return __awaiter(this, void 0, void 0, function* () {
@@ -236,9 +241,13 @@ describe('Device Utils', () => {
236
241
  })
237
242
  .data()[0];
238
243
  expect(foundSecondDevice.busy).to.be.true;
239
- yield (0, device_utils_1.allocateDeviceForSession)(capabilities, 1000, 1000, pluginArgs).catch((error) => expect(error)
244
+ // The first allocation wrote its udid into `capabilities`, so three phones
245
+ // match: take the third, and nothing matching is left.
246
+ yield DeviceUtils.allocateDeviceForSession(capabilities, 1000, 1000, pluginArgs);
247
+ const refused = yield (0, device_utils_1.allocateDeviceForSession)(capabilities, 1000, 1000, pluginArgs).then(() => null, (error) => error);
248
+ expect(refused, 'the allocation was refused')
240
249
  .to.be.an('error')
241
- .with.property('message', `Device is busy or blocked.. Device request: {"platform":"android","udid":"${allocatedDeviceForFirstSession.udid}","filterByHost":"192.168.0.226"}`));
250
+ .with.property('message', `Device is busy or blocked. Device request: {"platform":"android","udid":["${allocatedDeviceForFirstSession.udid}"]}`);
242
251
  });
243
252
  });
244
253
  it('should release blocked devices that have no activity for more than the timeout', () => __awaiter(void 0, void 0, void 0, function* () {
@@ -217,9 +217,11 @@ describe("The event log keeps no copy of a session's own data", () => {
217
217
  }
218
218
  }));
219
219
  });
220
- it("every other event is logged once and whole, by either emit; a command's own fields are named", () => __awaiter(void 0, void 0, void 0, function* () {
220
+ it("every other event is logged once and whole, by every emit; a command's own fields are named", () => __awaiter(void 0, void 0, void 0, function* () {
221
221
  const server = typedi_1.Container.get(SocketServer_1.SocketServer);
222
- server.emitToDashboard(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST, capturedRequest('q3'));
222
+ // The unscoped emit is private (node events only), but logs by the same rule.
223
+ const emitUnscoped = (event, data) => server.emitToDashboard(event, data);
224
+ emitUnscoped(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST, capturedRequest('q3'));
223
225
  yield server.emitToDashboardForDevices(SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST, capturedRequest('q4'), { udid: 'phone-a' });
224
226
  (0, chai_1.expect)(appendSafe.called).to.equal(false);
225
227
  const payload = { session_id: 's', command_name: 'click', body: '["hunter2"]', extra: 1 };
@@ -227,7 +229,11 @@ describe("The event log keeps no copy of a session's own data", () => {
227
229
  for (const event of others) {
228
230
  yield server.emitToDashboardForDevices(event, payload, { udid: 'phone-a' });
229
231
  }
230
- server.emitToDashboard(SocketEvents_1.SocketEvents.SESSION_COMMAND, payload);
232
+ emitUnscoped(SocketEvents_1.SocketEvents.SESSION_COMMAND, payload);
233
+ yield server.emitToDashboardForSelector(SocketEvents_1.SocketEvents.SELECTOR_MUTED, payload, {
234
+ strategy: 'xpath',
235
+ selector: '//x',
236
+ });
231
237
  (0, chai_1.expect)(appendSafe.getCalls().map((c) => c.args[0])).to.deep.equal([
232
238
  ...others.map((event) => ({
233
239
  type: event,
@@ -236,9 +242,10 @@ describe("The event log keeps no copy of a session's own data", () => {
236
242
  : payload,
237
243
  })),
238
244
  { type: SocketEvents_1.SocketEvents.SESSION_COMMAND, payload: { session_id: 's', command_name: 'click' } },
245
+ { type: SocketEvents_1.SocketEvents.SELECTOR_MUTED, payload },
239
246
  ]);
240
247
  // The writes are fire-and-forget: let them land here, or they land in the
241
248
  // next test's table after its beforeEach emptied it.
242
- (0, chai_1.expect)(yield eventLogRows(others.length + 1)).to.have.length(others.length + 1);
249
+ (0, chai_1.expect)(yield eventLogRows(others.length + 2)).to.have.length(others.length + 2);
243
250
  }));
244
251
  });
@@ -110,11 +110,25 @@ describe('failure analysis of a failed session', () => {
110
110
  .stub(AIService_1.AI_SERVICE, 'analyzeFailure')
111
111
  .callsFake(() => new Promise((resolve) => answers.push(resolve)));
112
112
  }));
113
- const settle = () => new Promise((r) => setTimeout(r, 50));
113
+ /**
114
+ * Waits for the analyses to reach the AI. Each first reads its session's
115
+ * logs, which a loaded machine (a parallel suite) makes slow, so a fixed
116
+ * pause isn't enough. Waiting can't hide one too many: which analyses run
117
+ * is decided when they are asked for, and the next only starts when an
118
+ * answer is given.
119
+ */
120
+ const askedTimes = (n) => __awaiter(void 0, void 0, void 0, function* () {
121
+ for (let waited = 0; analyzeFailure.callCount < n; waited += 10) {
122
+ if (waited > 15000)
123
+ throw new Error(`asked ${analyzeFailure.callCount} times, not ${n}`);
124
+ yield new Promise((r) => setTimeout(r, 10));
125
+ }
126
+ });
114
127
  it('asks once for a session whose analysis is already running', () => __awaiter(void 0, void 0, void 0, function* () {
115
128
  const first = (0, failure_analysis_service_1.explainSessionFailure)(ID);
116
129
  const second = (0, failure_analysis_service_1.explainSessionFailure)(ID);
117
- yield settle();
130
+ (0, chai_1.expect)(second).to.equal(first);
131
+ yield askedTimes(1);
118
132
  (0, chai_1.expect)(analyzeFailure.callCount).to.equal(1);
119
133
  answers[0]('Root Cause: once.');
120
134
  yield Promise.all([first, second]);
@@ -138,15 +152,15 @@ describe('failure analysis of a failed session', () => {
138
152
  });
139
153
  }
140
154
  const all = ids.map((id) => (0, failure_analysis_service_1.explainSessionFailure)(id));
141
- yield settle();
155
+ yield askedTimes(failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES);
156
+ // Still that many a moment later: the others wait for a turn.
157
+ yield new Promise((r) => setTimeout(r, 50));
142
158
  (0, chai_1.expect)(analyzeFailure.callCount).to.equal(failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES);
143
159
  answers[0](null);
144
- yield settle();
160
+ yield askedTimes(failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES + 1);
145
161
  (0, chai_1.expect)(analyzeFailure.callCount).to.equal(failure_analysis_service_1.MAX_CONCURRENT_FAILURE_ANALYSES + 1);
146
- for (let i = 0; i < 10 && answers.length < ids.length; i++) {
147
- answers.forEach((answer) => answer(null));
148
- yield settle();
149
- }
162
+ answers.forEach((answer) => answer(null));
163
+ yield askedTimes(ids.length);
150
164
  answers.forEach((answer) => answer(null));
151
165
  yield Promise.all(all);
152
166
  (0, chai_1.expect)(analyzeFailure.callCount).to.equal(ids.length);
@@ -9,18 +9,20 @@ const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const typescript_1 = __importDefault(require("typescript"));
11
11
  const swagger_1 = require("../../src/app/swagger");
12
- const failure_analysis_service_1 = require("../../src/dashboard/services/failure-analysis-service");
13
- const SessionManager_1 = require("../../src/sessions/SessionManager");
12
+ const failureCategories_1 = require("../../src/dashboard/services/failureCategories");
14
13
  /**
15
14
  * A Session's `failure_category` is written in two places: the failure
16
15
  * analysis (one of ANALYSIS_CATEGORIES) and a restart's recovery
17
- * (HUB_RESTART). Both write upper case.
16
+ * (HUB_RESTART). Both write upper case. Sessions filed through 2.15 may also
17
+ * hold a category no longer written (RETIRED_CATEGORIES).
18
18
  *
19
19
  * The API reference showed `element_not_found`, a value the server never
20
20
  * writes, and the dashboard had a runbook for "Infrastructure", a category
21
21
  * nothing ever assigns. These hold both to what the server writes.
22
22
  */
23
- const WRITTEN = [...failure_analysis_service_1.ANALYSIS_CATEGORIES, SessionManager_1.HUB_RESTART_CATEGORY];
23
+ const WRITTEN = [...new Set([...failureCategories_1.ANALYSIS_CATEGORIES, failureCategories_1.HUB_RESTART_CATEGORY])];
24
+ /** Every value a session's row can hold. */
25
+ const STORED = [...WRITTEN, ...failureCategories_1.RETIRED_CATEGORIES];
24
26
  /**
25
27
  * The dashboard's runbooks. web/ is an ES module package, so its .ts can't be
26
28
  * required from here; the file imports nothing, so it is compiled and run.
@@ -66,7 +68,7 @@ function csvExampleCategories() {
66
68
  }
67
69
  describe('failure categories', () => {
68
70
  it('are written upper case, as the API reference and the dashboard read them', () => {
69
- (0, chai_1.expect)(failure_analysis_service_1.ANALYSIS_CATEGORIES).to.include('UNKNOWN');
71
+ (0, chai_1.expect)(failureCategories_1.ANALYSIS_CATEGORIES).to.include('UNKNOWN');
70
72
  for (const category of WRITTEN)
71
73
  (0, chai_1.expect)(category).to.match(/^[A-Z_]+$/);
72
74
  });
@@ -81,46 +83,46 @@ describe('failure categories', () => {
81
83
  for (const value of shown)
82
84
  (0, chai_1.expect)(WRITTEN, String(value)).to.include(value);
83
85
  });
84
- it("the session record's description names every category, and no other", () => {
86
+ it("the session record's description names every category a row can hold, and no other", () => {
85
87
  const described = valuesUnder(swagger_1.swaggerSpec, 'failure_category')
86
88
  .filter((v) => !!(v === null || v === void 0 ? void 0 : v.description))
87
89
  .map((v) => v.description);
88
90
  (0, chai_1.expect)(described).to.have.length(1);
89
91
  const named = Array.from(described[0].matchAll(/`([A-Z_]+)`/g), (m) => m[1]);
90
- (0, chai_1.expect)([...named].sort()).to.deep.equal([...WRITTEN].sort());
92
+ (0, chai_1.expect)([...named].sort()).to.deep.equal([...STORED].sort());
91
93
  });
92
94
  });
93
95
  describe("in the dashboard's runbooks", () => {
94
- it('every category the server writes has a runbook of its own', () => {
95
- for (const category of WRITTEN) {
96
+ it('every category a session can hold has a runbook of its own, the retired ones included', () => {
97
+ for (const category of STORED) {
96
98
  (0, chai_1.expect)(Object.keys(RUNBOOKS), category).to.include(runbookKey(category));
97
99
  }
98
100
  });
99
101
  it("each quotes a text that files a failure under its category, not just the category's name", () => {
100
- // The names mislead: "Xenon command failure" is a "command failed"
101
- // message, rarely Xenon's, and few real crashes are worded the way App
102
- // crash's patterns expect. A runbook has to say what really files a
103
- // failure there, so it must quote one of its category's texts. Only
104
- // quoted text in the body counts: the title ("# WDA failure") and the
105
- // prose around a quote ("says a command failed") would pass anything.
106
- for (const { category, patterns } of failure_analysis_service_1.ERROR_PATTERNS) {
102
+ // A category's name can mislead (a tester's "app crash" on Android is
103
+ // mostly filed Element not found), so a runbook has to say what really
104
+ // files a failure there: it must quote one of its rule's codes or
105
+ // phrases. Only quoted text in the body counts: the title and the prose
106
+ // around a quote would pass anything.
107
+ for (const { category, codes, phrases } of failureCategories_1.FAILURE_RULES) {
107
108
  const { markdown } = RUNBOOKS[runbookKey(category)];
108
109
  const body = markdown.split('\n').slice(1).join(' ').replace(/\s+/g, ' ');
109
- const quoted = Array.from(body.matchAll(/"([^"]+)"/g), (m) => m[1]);
110
- (0, chai_1.expect)(patterns.some((p) => quoted.some((q) => new RegExp(p, 'i').test(q))), `the ${category} runbook quotes none of ${JSON.stringify(patterns)}`).to.equal(true);
110
+ const quoted = Array.from(body.matchAll(/"([^"]+)"/g), (m) => m[1].toLowerCase());
111
+ const texts = [...codes, ...phrases];
112
+ (0, chai_1.expect)(texts.some((t) => quoted.some((q) => q.includes(t.toLowerCase()))), `the ${category} runbook quotes none of ${JSON.stringify(texts)}`).to.equal(true);
111
113
  }
112
114
  });
113
- it('every runbook is for a category the server writes', () => {
114
- const written = WRITTEN.map(runbookKey);
115
+ it('every runbook is for a category a session can hold', () => {
116
+ const stored = STORED.map(runbookKey);
115
117
  for (const key of Object.keys(RUNBOOKS)) {
116
- (0, chai_1.expect)(written, `runbook "${key}"`).to.include(key);
118
+ (0, chai_1.expect)(stored, `runbook "${key}"`).to.include(key);
117
119
  }
118
120
  });
119
121
  it('a stored category opens its own runbook from the "Why it failed" card', () => {
120
122
  var _a, _b;
121
123
  // failure-summary.tsx links /runbooks/<the category, lower case>.
122
124
  const linked = (category) => RUNBOOKS[runbookKey(category.toLowerCase())];
123
- (0, chai_1.expect)((_a = linked(SessionManager_1.HUB_RESTART_CATEGORY)) === null || _a === void 0 ? void 0 : _a.title).to.equal('Hub restart');
125
+ (0, chai_1.expect)((_a = linked(failureCategories_1.HUB_RESTART_CATEGORY)) === null || _a === void 0 ? void 0 : _a.title).to.equal('Hub restart');
124
126
  (0, chai_1.expect)((_b = linked('TIMEOUT')) === null || _b === void 0 ? void 0 : _b.title).to.equal('Timeout');
125
127
  (0, chai_1.expect)(linked('UNKNOWN')).to.equal(RUNBOOKS.unknown);
126
128
  });