@xenon-device-management/xenon 2.15.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 (206) hide show
  1. package/README.md +38 -26
  2. package/lib/package.json +2 -1
  3. package/lib/public/assets/{AnnotationOverlay-Btq3dZRI.js → AnnotationOverlay-CjmMCS0S.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-C9OoAgg_.js → ApiKeyGate-B4Ob0v3C.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DBC_xczB.js → DeviceMosaicView-DTVOB_NO.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DqWU2-vM.js → EmptyState-DcEG-oAi.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CveQ3xrA.js → FieldGroup-CtnCDshE.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BfKDSfHz.js → FilterMenu-BK0dE9cc.js} +1 -1
  9. package/lib/public/assets/{Menu-CtdCybzA.js → Menu-eKsmLpKS.js} +1 -1
  10. package/lib/public/assets/{Modal-Bsvj1tm2.js → Modal-DPwop3Lz.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CRofOiyx.js → RecordingPage-CfgSkZkB.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-jNQHb2IR.js → RecordingsPage-Bzl9pTUY.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C8HtTha-.js → SegmentedControl-B_TZTrPm.js} +1 -1
  14. package/lib/public/assets/{SettingCard-D8K1LZNo.js → SettingCard-DlmvZJPM.js} +1 -1
  15. package/lib/public/assets/{Table-zNTX92Pn.js → Table-BigPWKXi.js} +1 -1
  16. package/lib/public/assets/{activity-CJ8LwZq9.js → activity-CvuZdQYu.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CgxQZqrc.js → ai-settings-lI2UJ2n7.js} +1 -1
  18. package/lib/public/assets/{api-keys-ECpo-zTo.js → api-keys-CPgf9XJM.js} +1 -1
  19. package/lib/public/assets/{apps-BjGymyf3.js → apps-DIzOvtUp.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BWfCbeHP.js → arrow-left-DiP5ywBT.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DC7UGf_n.js → arrow-right-DtW_8hm5.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-BOjwybDk.js → arrow-up-right-C89FbNU0.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CxMTo9aH.js → auth-shell-B7sUDb--.js} +2 -2
  24. package/lib/public/assets/{builds-page-DOCm4HkA.js → builds-page-BBhvrMfA.js} +1 -1
  25. package/lib/public/assets/{button-Cg08-EMT.js → button-DugsdUw1.js} +1 -1
  26. package/lib/public/assets/{calendar-CA1ikLJs.js → calendar-grKd08ZH.js} +1 -1
  27. package/lib/public/assets/{check-BMTaH72W.js → check-ORjyNfnX.js} +1 -1
  28. package/lib/public/assets/{chevron-right-B2QOv9Nx.js → chevron-right-Ei2FxUXq.js} +1 -1
  29. package/lib/public/assets/{circle-check-CYXBoUqT.js → circle-check-Day0HjW4.js} +1 -1
  30. package/lib/public/assets/{circle-x-DTwnSAnl.js → circle-x-CHxSRLHp.js} +1 -1
  31. package/lib/public/assets/{clock-C8_HViQP.js → clock-BDHewWTx.js} +1 -1
  32. package/lib/public/assets/{copy-Be7LNMXV.js → copy-CTwT4I3D.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BOqtfGXz.js → device-explorer-DlhrKJ-g.js} +1 -1
  34. package/lib/public/assets/{download-B56lwxWP.js → download-D3bjz_vr.js} +1 -1
  35. package/lib/public/assets/{forgot-password-CCU6bzLn.js → forgot-password-BNBLi023.js} +1 -1
  36. package/lib/public/assets/{h264Stream-C-OtmIuk.js → h264Stream-C51Au2i_.js} +1 -1
  37. package/lib/public/assets/index-Dk5J6HB1.js +219 -0
  38. package/lib/public/assets/{index-B9KQ6LsY.js → index-DncSz5LG.js} +1 -1
  39. package/lib/public/assets/{index-CfxsUslp.js → index-Z7_BY3pJ.js} +1 -1
  40. package/lib/public/assets/{input-CZHFjbSg.js → input-bTXM4l09.js} +1 -1
  41. package/lib/public/assets/{line-chart-Gl_Dz3X_.js → line-chart-B3YmVYyr.js} +1 -1
  42. package/lib/public/assets/{list-checks-CCgjeyGY.js → list-checks-BClKEj1-.js} +1 -1
  43. package/lib/public/assets/{lock-CkOwgDcx.js → lock-CZsenX3D.js} +1 -1
  44. package/lib/public/assets/{login-D5cc4w7M.js → login-B6A6tOp9.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-t7qr3fvX.js → maintenance-settings-CmCJGy7O.js} +1 -1
  46. package/lib/public/assets/{monitor-DEJixKQO.js → monitor-BdTNEglU.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CtOrk4M8.js → mouse-pointer-2-BM8CaLtt.js} +1 -1
  48. package/lib/public/assets/{network-Dii9qdvk.js → network-BS6vUc1x.js} +1 -1
  49. package/lib/public/assets/{overview-rolAACrO.js → overview-Yfk_Y7g3.js} +1 -1
  50. package/lib/public/assets/{page-header-COQZxJfR.js → page-header-DhXCwpml.js} +1 -1
  51. package/lib/public/assets/{play-DrN1Se_R.js → play-D08gP1td.js} +1 -1
  52. package/lib/public/assets/{plus-5aq-9uRx.js → plus-BKboorfa.js} +1 -1
  53. package/lib/public/assets/{profile-page-6i5kY6kF.js → profile-page-Cd9NodrA.js} +1 -1
  54. package/lib/public/assets/{reset-password-sp2IBrc7.js → reset-password-acLDyyDW.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJDjeopU.js → runbook-page-CWLkC1Fd.js} +153 -102
  56. package/lib/public/assets/{select-Bpyn7rWO.js → select-DZoRKNti.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BH74vmKv.js → selector-detail-redirect-DS4qrt84.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-Cw_g218H.js → selector-health-page-TpmN61G3.js} +1 -1
  59. package/lib/public/assets/session-detail-page-C0wYaXqy.js +4 -0
  60. package/lib/public/assets/{settings-C3uyGhyc.js → settings-CxPGEY2j.js} +1 -1
  61. package/lib/public/assets/{stat-tile-xVqAKIlI.js → stat-tile-DUpyYgl-.js} +1 -1
  62. package/lib/public/assets/{tablet-BxUScAeO.js → tablet-SA02BJNQ.js} +1 -1
  63. package/lib/public/assets/{teams-B5ljGkY2.js → teams-jamD0Kxr.js} +1 -1
  64. package/lib/public/assets/{trash-2-B701i8sy.js → trash-2-DO8fv6w4.js} +1 -1
  65. package/lib/public/assets/{upload-C2gr44RS.js → upload-CaiGCStb.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-NeQOFEoy.js → use-builds-data-jS-fcvVf.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DEzJq5ef.js → use-password-reset-mode-BzoenegM.js} +1 -1
  68. package/lib/public/assets/{users-MY6eNe6s.js → users-Bnw7Vs_B.js} +1 -1
  69. package/lib/public/assets/{users-D_vv_yz4.js → users-DHvDrriG.js} +1 -1
  70. package/lib/public/assets/{video-off-OK1lNZQn.js → video-off-FEvy0w5U.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-BU-C9lBf.js → webhook-settings-CBVB5lxi.js} +1 -1
  72. package/lib/public/assets/{zap-DO9ry1iz.js → zap-BMrTJO6M.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/index.js +2 -0
  78. package/lib/src/app/openapi/grid.yaml +102 -0
  79. package/lib/src/app/openapi/sessions.yaml +7 -5
  80. package/lib/src/app/routers/auth.js +2 -1
  81. package/lib/src/app/routers/users.js +2 -1
  82. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  83. package/lib/src/dashboard/commandLogFields.js +42 -0
  84. package/lib/src/dashboard/event-manager.js +23 -154
  85. package/lib/src/dashboard/services/failure-analysis-service.js +8 -86
  86. package/lib/src/dashboard/services/failureCategories.js +264 -0
  87. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  88. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  89. package/lib/src/device-utils.js +25 -8
  90. package/lib/src/gateway/forwardToNode.js +4 -11
  91. package/lib/src/gateway/hubSessionToken.js +27 -4
  92. package/lib/src/gateway/nodeAsk.js +57 -0
  93. package/lib/src/gateway/nodeSessionDeviceLogs.js +35 -0
  94. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  95. package/lib/src/helpers/outboundProxy.js +219 -0
  96. package/lib/src/index.js +21 -0
  97. package/lib/src/interceptors/CommandInterceptor.js +13 -15
  98. package/lib/src/interceptors/xenonScripts.js +28 -7
  99. package/lib/src/logger.js +3 -1
  100. package/lib/src/plugin.js +12 -4
  101. package/lib/src/scripts/database-check.js +3 -3
  102. package/lib/src/scripts/initialize-database.js +29 -33
  103. package/lib/src/scripts/run-migrations.js +320 -25
  104. package/lib/src/services/ApiKeyService.js +16 -5
  105. package/lib/src/services/InterceptorService.js +6 -2
  106. package/lib/src/services/OrphanSweeper.js +6 -0
  107. package/lib/src/services/SelectorStateService.js +11 -7
  108. package/lib/src/services/SelectorVerificationJob.js +6 -2
  109. package/lib/src/services/SessionLifecycleService.js +52 -23
  110. package/lib/src/services/SocketClient.js +11 -0
  111. package/lib/src/services/SocketServer.js +361 -42
  112. package/lib/src/services/TeamService.js +5 -1
  113. package/lib/src/services/TracingService.js +171 -60
  114. package/lib/src/services/UserService.js +14 -3
  115. package/lib/src/services/UserSessionService.js +16 -4
  116. package/lib/src/services/healing/HealingOrchestrator.js +3 -1
  117. package/lib/src/services/identity/identityChanges.js +41 -0
  118. package/lib/src/services/interceptor/CertManager.js +50 -16
  119. package/lib/src/services/logcat/NodeDeviceLogStore.js +99 -0
  120. package/lib/src/services/logcat/NodeDeviceLogsCollector.js +164 -0
  121. package/lib/src/services/logcat/SessionDeviceLogs.js +279 -37
  122. package/lib/src/services/logcat/deviceLogBook.js +5 -4
  123. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  124. package/lib/src/services/logcat/nodeDeviceLogs.js +91 -0
  125. package/lib/src/services/metrics/nodeMetrics.js +20 -49
  126. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  127. package/lib/src/services/token/JwtKeyService.js +8 -2
  128. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  129. package/lib/src/sessions/LocalSession.js +29 -1
  130. package/lib/src/sessions/RemoteSession.js +23 -4
  131. package/lib/src/sessions/SessionManager.js +33 -9
  132. package/lib/test/helpers/driver-log.js +38 -0
  133. package/lib/test/helpers/fake-http-proxy.js +106 -0
  134. package/lib/test/helpers/fake-logcat.js +80 -0
  135. package/lib/test/helpers/hub-node-pair.js +227 -0
  136. package/lib/test/helpers/otlp-probe.js +107 -0
  137. package/lib/test/integration/androidDevices.spec.js +1 -1
  138. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  139. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  140. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  141. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  142. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  143. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  144. package/lib/test/unit/dashboard-events-device-logs.spec.js +74 -11
  145. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  146. package/lib/test/unit/database-check.spec.js +1 -0
  147. package/lib/test/unit/device-utils.spec.js +13 -4
  148. package/lib/test/unit/event-log-session-data.spec.js +11 -4
  149. package/lib/test/unit/failure-analysis.spec.js +22 -8
  150. package/lib/test/unit/failure-categories.spec.js +24 -22
  151. package/lib/test/unit/failure-categorization.spec.js +393 -0
  152. package/lib/test/unit/healing-tier-gate.spec.js +3 -2
  153. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +72 -1
  154. package/lib/test/unit/hub-node-iphone-readiness.spec.js +134 -0
  155. package/lib/test/unit/hub-node-session-device-logs.spec.js +194 -0
  156. package/lib/test/unit/hub-node-session-metrics.spec.js +24 -192
  157. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +8 -1
  158. package/lib/test/unit/interceptor/CertManager.spec.js +89 -0
  159. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  160. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  161. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  162. package/lib/test/unit/logcat-ws.spec.js +78 -37
  163. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  164. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  165. package/lib/test/unit/node-device-log-store.spec.js +102 -0
  166. package/lib/test/unit/node-device-logs-collector.spec.js +343 -0
  167. package/lib/test/unit/node-gateway-create-appium-server.spec.js +8 -1
  168. package/lib/test/unit/node-hub-session-owner.spec.js +8 -1
  169. package/lib/test/unit/node-session-device-logs-route.spec.js +103 -0
  170. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  171. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  172. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  173. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  174. package/lib/test/unit/proxy-option.spec.js +358 -0
  175. package/lib/test/unit/run-migrations-database.spec.js +440 -0
  176. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  177. package/lib/test/unit/run-migrations.test.js +264 -0
  178. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  179. package/lib/test/unit/selector-state-service.spec.js +72 -23
  180. package/lib/test/unit/selector-verification-clean-build.spec.js +1 -1
  181. package/lib/test/unit/selector-verification-job.spec.js +15 -6
  182. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  183. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  184. package/lib/test/unit/session-device-logs-hub-node.spec.js +340 -0
  185. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  186. package/lib/test/unit/session-device-logs-lifecycle.spec.js +137 -0
  187. package/lib/test/unit/session-device-logs.spec.js +43 -96
  188. package/lib/test/unit/session-end-failure-analysis.spec.js +5 -5
  189. package/lib/test/unit/session-log-indexes.spec.js +16 -1
  190. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  191. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  192. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  193. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  194. package/lib/test/unit/unexpected-shutdown-reason.spec.js +2 -2
  195. package/lib/tsconfig.tsbuildinfo +1 -1
  196. package/package.json +2 -1
  197. package/schema.json +11 -7
  198. package/scripts/dev/readme-screenshots.js +276 -23
  199. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  200. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  201. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  202. package/scripts/export-openapi.js +32 -0
  203. package/scripts/lib/openapi-for-site.js +79 -0
  204. package/lib/public/assets/index-BOxe43p1.js +0 -219
  205. package/lib/public/assets/session-detail-page-AZJE6XXE.js +0 -4
  206. package/lib/src/scripts/prepare-prisma.js +0 -89
@@ -13,6 +13,9 @@ 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 fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
16
19
  const sinon_1 = __importDefault(require("sinon"));
17
20
  const run_migrations_1 = require("../../src/scripts/run-migrations");
18
21
  const config_1 = require("../../src/config");
@@ -20,6 +23,7 @@ const logger_1 = __importDefault(require("../../src/logger"));
20
23
  describe('runMigrations', () => {
21
24
  afterEach(() => sinon_1.default.restore());
22
25
  it('skips with an info log when XENON_AUTO_MIGRATE=false (autoMigrate=false)', () => __awaiter(void 0, void 0, void 0, function* () {
26
+ var _a;
23
27
  const orig = config_1.config.autoMigrate;
24
28
  config_1.config.autoMigrate = false;
25
29
  const info = sinon_1.default.spy(logger_1.default, 'info');
@@ -29,6 +33,10 @@ describe('runMigrations', () => {
29
33
  yield (0, run_migrations_1.runMigrations)();
30
34
  const calls = info.getCalls().map((c) => c.args.join(' '));
31
35
  (0, chai_1.expect)(calls.some((m) => /Auto-migrate disabled/i.test(m))).to.be.true;
36
+ // It says how to choose the command, as the server would.
37
+ const line = (_a = calls.find((m) => /Auto-migrate disabled/i.test(m))) !== null && _a !== void 0 ? _a : '';
38
+ (0, chai_1.expect)(line).to.match(/matches its tables/);
39
+ (0, chai_1.expect)(line).to.match(/P3018/);
32
40
  }
33
41
  finally {
34
42
  config_1.config.autoMigrate = orig;
@@ -36,3 +44,259 @@ describe('runMigrations', () => {
36
44
  }
37
45
  }));
38
46
  });
47
+ describe('planSchemaSync', () => {
48
+ const LOCAL = ['20250101_a', '20250102_b', '20250103_c'];
49
+ const unasked = () => {
50
+ throw new Error('the tables only need comparing when a migration is left to apply');
51
+ };
52
+ it('pushes a database with no migration history, accepting data loss as the default always did', () => {
53
+ const plan = (0, run_migrations_1.planSchemaSync)(null, LOCAL, unasked);
54
+ (0, chai_1.expect)(plan.command).to.equal('db push');
55
+ (0, chai_1.expect)(plan.acceptDataLoss).to.equal(true);
56
+ });
57
+ it('deploys to a database whose history has every migration', () => {
58
+ const history = { applied: [...LOCAL], failed: [] };
59
+ (0, chai_1.expect)((0, run_migrations_1.planSchemaSync)(history, LOCAL, unasked).command).to.equal('migrate deploy');
60
+ });
61
+ it('deploys the missing migrations when the tables are what the history says', () => {
62
+ const history = { applied: LOCAL.slice(0, 2), failed: [] };
63
+ const compared = [];
64
+ const plan = (0, run_migrations_1.planSchemaSync)(history, LOCAL, (applied) => {
65
+ compared.push(applied);
66
+ return true;
67
+ });
68
+ (0, chai_1.expect)(plan.command).to.equal('migrate deploy');
69
+ (0, chai_1.expect)(compared).to.deep.equal([LOCAL.slice(0, 2)]);
70
+ });
71
+ it('pushes when the tables are ahead of the history, so deploy would redo a change', () => {
72
+ const history = { applied: LOCAL.slice(0, 1), failed: [] };
73
+ const plan = (0, run_migrations_1.planSchemaSync)(history, LOCAL, () => false);
74
+ (0, chai_1.expect)(plan.command).to.equal('db push');
75
+ (0, chai_1.expect)(plan.acceptDataLoss).to.equal(false);
76
+ (0, chai_1.expect)(plan.reason).to.match(/differ from what its recorded migrations make/);
77
+ // The failure message depends on why: see schemaSyncFailure.
78
+ (0, chai_1.expect)(plan.basis).to.equal('tables-differ');
79
+ });
80
+ it('deploys, as the history says, when the tables could not be compared', () => {
81
+ const history = { applied: LOCAL.slice(0, 1), failed: [] };
82
+ const plan = (0, run_migrations_1.planSchemaSync)(history, LOCAL, () => null);
83
+ (0, chai_1.expect)(plan.command).to.equal('migrate deploy');
84
+ (0, chai_1.expect)(plan.reason).to.match(/could not be compared/);
85
+ });
86
+ it('pushes a database whose history records a failed migration', () => {
87
+ const history = { applied: LOCAL.slice(0, 2), failed: [LOCAL[2]] };
88
+ const plan = (0, run_migrations_1.planSchemaSync)(history, LOCAL, unasked);
89
+ (0, chai_1.expect)(plan.command).to.equal('db push');
90
+ (0, chai_1.expect)(plan.acceptDataLoss).to.equal(false);
91
+ (0, chai_1.expect)(plan.reason).to.include(LOCAL[2]);
92
+ (0, chai_1.expect)(plan.basis).to.equal('failed-migration');
93
+ (0, chai_1.expect)(plan.failed).to.deep.equal([LOCAL[2]]);
94
+ });
95
+ });
96
+ describe('runMigrations chooses by the database, not by databaseProvider', () => {
97
+ const scratchUrl = `file:${path_1.default.join(os_1.default.tmpdir(), `xenon-choose-${process.pid}-absent.db`)}`;
98
+ const everyMigration = fs_1.default
99
+ .readdirSync(path_1.default.resolve(__dirname, '../../prisma/migrations'))
100
+ .filter((name) => !name.endsWith('.toml'));
101
+ let saved;
102
+ beforeEach(() => {
103
+ saved = {
104
+ autoMigrate: config_1.config.autoMigrate,
105
+ databaseProvider: config_1.config.databaseProvider,
106
+ databaseUrl: config_1.config.databaseUrl,
107
+ };
108
+ config_1.config.autoMigrate = true;
109
+ config_1.config.databaseUrl = scratchUrl;
110
+ });
111
+ afterEach(() => {
112
+ Object.assign(config_1.config, saved);
113
+ });
114
+ /** The prisma subcommand runMigrations ran, given the history the database reports. */
115
+ function commandFor(provider, history) {
116
+ return __awaiter(this, void 0, void 0, function* () {
117
+ config_1.config.databaseProvider = provider;
118
+ const commands = [];
119
+ yield (0, run_migrations_1.runMigrations)((_cmd, args) => {
120
+ commands.push(args.filter((a) => !a.startsWith('-') && !a.includes(path_1.default.sep)).join(' '));
121
+ return Buffer.from('ok');
122
+ }, () => __awaiter(this, void 0, void 0, function* () { return history; }));
123
+ return commands[commands.length - 1];
124
+ });
125
+ }
126
+ for (const provider of ['sqlite', 'postgresql']) {
127
+ it(`pushes a database with no history when databaseProvider is ${provider}`, () => __awaiter(void 0, void 0, void 0, function* () {
128
+ (0, chai_1.expect)(yield commandFor(provider, null)).to.equal('db push');
129
+ }));
130
+ it(`deploys to a database with a complete history when databaseProvider is ${provider}`, () => __awaiter(void 0, void 0, void 0, function* () {
131
+ const history = { applied: everyMigration, failed: [] };
132
+ (0, chai_1.expect)(yield commandFor(provider, history)).to.equal('migrate deploy');
133
+ }));
134
+ }
135
+ });
136
+ // `db push --accept-data-loss` drops whatever the schema doesn't have. On a
137
+ // database with no history that is what the default always did. On one with a
138
+ // history it would drop what the history doesn't explain, such as a table added
139
+ // by hand or a half-finished table a failed migration left, where `migrate
140
+ // deploy` (the old `postgresql` path) kept them.
141
+ describe('runMigrations: what each plan runs', () => {
142
+ const scratchUrl = `file:${path_1.default.join(os_1.default.tmpdir(), `xenon-flags-${process.pid}-absent.db`)}`;
143
+ const everyMigration = fs_1.default
144
+ .readdirSync(path_1.default.resolve(__dirname, '../../prisma/migrations'))
145
+ .filter((name) => !name.endsWith('.toml'))
146
+ .sort();
147
+ let saved;
148
+ beforeEach(() => {
149
+ saved = { autoMigrate: config_1.config.autoMigrate, databaseUrl: config_1.config.databaseUrl };
150
+ config_1.config.autoMigrate = true;
151
+ config_1.config.databaseUrl = scratchUrl;
152
+ });
153
+ afterEach(() => {
154
+ Object.assign(config_1.config, saved);
155
+ sinon_1.default.restore();
156
+ });
157
+ /** Every prisma call's arguments, with `diff` and the update answered by the given steps. */
158
+ function run(history_1) {
159
+ return __awaiter(this, arguments, void 0, function* (history, steps = {}) {
160
+ const calls = [];
161
+ try {
162
+ yield (0, run_migrations_1.runMigrations)((_cmd, args) => {
163
+ calls.push(args);
164
+ const step = args.includes('diff') ? steps.diff : steps.update;
165
+ return step ? step(args) : Buffer.from('ok');
166
+ }, () => __awaiter(this, void 0, void 0, function* () { return history; }));
167
+ return { calls };
168
+ }
169
+ catch (error) {
170
+ return { calls, error };
171
+ }
172
+ });
173
+ }
174
+ const fail = (status, stderr, stdout = '') => () => {
175
+ throw Object.assign(new Error('Command failed'), {
176
+ status,
177
+ stderr: Buffer.from(stderr),
178
+ stdout: Buffer.from(stdout),
179
+ });
180
+ };
181
+ const update = (calls) => calls[calls.length - 1];
182
+ it('accepts data loss only for a database with no history, as the default always did', () => __awaiter(void 0, void 0, void 0, function* () {
183
+ const { calls } = yield run(null);
184
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push', '--accept-data-loss']);
185
+ }));
186
+ it('pushes without accepting data loss when the tables differ from the history', () => __awaiter(void 0, void 0, void 0, function* () {
187
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
188
+ const { calls } = yield run(history, { diff: fail(2, '') });
189
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
190
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
191
+ }));
192
+ it('pushes without accepting data loss when the history records a failed migration', () => __awaiter(void 0, void 0, void 0, function* () {
193
+ const history = { applied: everyMigration.slice(0, -1), failed: everyMigration.slice(-1) };
194
+ const { calls } = yield run(history);
195
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
196
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
197
+ }));
198
+ it('deploys to a database whose history matches its tables', () => __awaiter(void 0, void 0, void 0, function* () {
199
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
200
+ const { calls } = yield run(history);
201
+ (0, chai_1.expect)(update(calls)).to.include.members(['migrate', 'deploy']);
202
+ }));
203
+ // What `db push` without --accept-data-loss prints when it would drop a table
204
+ // that holds rows: the list on stdout, the refusal on stderr.
205
+ const refusesDataLoss = fail(1, 'Error: Use the --accept-data-loss flag to ignore the data loss warnings like prisma db push --accept-data-loss', '⚠️ There might be data loss when applying the changes:\n\n' +
206
+ ' • You are about to drop the `LabNotes` table, which is not empty (1 rows).');
207
+ const root = path_1.default.resolve(__dirname, '../..');
208
+ const prisma = path_1.default.join(root, 'node_modules', '.bin', 'prisma');
209
+ const schema = path_1.default.join(root, 'prisma', 'schema.prisma');
210
+ const scratchFile = scratchUrl.slice('file:'.length);
211
+ /** The commands a failure message gives, one to an indented line, in its order. */
212
+ const commandsIn = (message) => message
213
+ .split('\n')
214
+ .filter((line) => /^ {2}(DATABASE_URL=|echo )/.test(line))
215
+ .map((line) => line.trim());
216
+ const letGo = `DATABASE_URL=file:${scratchFile} ${prisma} db push --skip-generate --accept-data-loss ` +
217
+ `--schema ${schema}`;
218
+ // A history database whose tables differ from its migrations: `migrate
219
+ // deploy` works on it when something was added by hand, and fails (P3018)
220
+ // when db push moved the tables past the history. The message can't tell
221
+ // which, so it never sends migrate deploy to the file before a copy.
222
+ it('says how to let go of what db push would delete, or keep it by trying migrate deploy on a copy first', () => __awaiter(void 0, void 0, void 0, function* () {
223
+ var _a;
224
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
225
+ const { error } = yield run(history, { diff: fail(2, ''), update: refusesDataLoss });
226
+ (0, chai_1.expect)(error, 'the start stops').to.be.an('Error');
227
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
228
+ (0, chai_1.expect)(message).to.match(/stopped rather than delete/);
229
+ (0, chai_1.expect)(message).to.match(/differ from what its recorded migrations make/);
230
+ // Prisma's own list of what it would drop, which is only on stdout.
231
+ (0, chai_1.expect)(message).to.include('LabNotes');
232
+ (0, chai_1.expect)(message).to.not.match(/required column/);
233
+ (0, chai_1.expect)(message, 'no plain advice to migrate deploy the file').to.not.match(/by hand with prisma migrate deploy/);
234
+ const commands = commandsIn(message);
235
+ const backup = commands.findIndex((c) => c.includes(`VACUUM INTO '${scratchFile}.backup-`) &&
236
+ c.endsWith(`| ${prisma} db execute --url file:${scratchFile} --stdin`));
237
+ (0, chai_1.expect)(backup, `a backup first:\n${message}`).to.equal(0);
238
+ (0, chai_1.expect)(commands).to.include(letGo);
239
+ const onCopy = commands.findIndex((c) => c.startsWith(`DATABASE_URL=file:${scratchFile}.copy ${prisma} migrate deploy --schema ${schema}`));
240
+ const copied = commands.findIndex((c) => c.includes(`VACUUM INTO '${scratchFile}.copy'`));
241
+ const onFile = commands.indexOf(`DATABASE_URL=file:${scratchFile} ${prisma} migrate deploy --schema ${schema}`);
242
+ (0, chai_1.expect)(copied, 'the copy is made').to.be.greaterThan(backup);
243
+ (0, chai_1.expect)(onCopy, 'migrate deploy runs on the copy').to.be.greaterThan(copied);
244
+ (0, chai_1.expect)(onFile, 'and on the file only after the copy').to.be.greaterThan(onCopy);
245
+ }));
246
+ // With a failed migration recorded, `migrate deploy` stops at once (P3009),
247
+ // whatever the tables hold, so the message never suggests it.
248
+ it('names the failed migration and never suggests migrate deploy when the history records one', () => __awaiter(void 0, void 0, void 0, function* () {
249
+ var _a;
250
+ const failed = everyMigration[everyMigration.length - 1];
251
+ const history = { applied: everyMigration.slice(0, -1), failed: [failed] };
252
+ const { error } = yield run(history, { update: refusesDataLoss });
253
+ (0, chai_1.expect)(error, 'the start stops').to.be.an('Error');
254
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
255
+ (0, chai_1.expect)(message).to.match(/stopped rather than delete/);
256
+ (0, chai_1.expect)(message).to.include(failed);
257
+ (0, chai_1.expect)(message).to.match(/records a failed migration/);
258
+ (0, chai_1.expect)(message).to.not.match(/differ from what its recorded migrations make/);
259
+ (0, chai_1.expect)(message).to.include('LabNotes');
260
+ const commands = commandsIn(message);
261
+ (0, chai_1.expect)(commands[0]).to.include(`VACUUM INTO '${scratchFile}.backup-`);
262
+ (0, chai_1.expect)(commands).to.include(letGo);
263
+ (0, chai_1.expect)(commands.filter((c) => c.includes('migrate deploy'))).to.deep.equal([]);
264
+ (0, chai_1.expect)(message).to.not.match(/by hand with prisma migrate deploy/);
265
+ }));
266
+ // A migration can fail on what is already in the tables, which a later
267
+ // start deals with, or on the rows (a unique index over duplicate values),
268
+ // which no start gets past until someone changes them.
269
+ it('says what to check when migrate deploy fails on a migration (P3018), without promising the next start recovers', () => __awaiter(void 0, void 0, void 0, function* () {
270
+ var _a;
271
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
272
+ const { error } = yield run(history, {
273
+ diff: fail(1, 'simulated diff failure'),
274
+ update: fail(1, 'Error: P3018\n\nA migration failed to apply.\n\nMigration name: 20261101000000_uniq\n\n' +
275
+ 'Database error:\nUNIQUE constraint failed: LocatorEtalon.strategy, LocatorEtalon.nodeName'),
276
+ });
277
+ (0, chai_1.expect)(error, 'the start stops').to.be.an('Error');
278
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
279
+ (0, chai_1.expect)(message).to.include('20261101000000_uniq');
280
+ (0, chai_1.expect)(message).to.include('UNIQUE constraint failed');
281
+ (0, chai_1.expect)(message).to.not.match(/next start brings the database up to date/);
282
+ (0, chai_1.expect)(message).to.not.match(/start again, or first/);
283
+ // Both things to check: a change already there, and rows the migration can't take.
284
+ (0, chai_1.expect)(message).to.match(/already there/);
285
+ (0, chai_1.expect)(message).to.match(/duplicate values/);
286
+ (0, chai_1.expect)(message).to.match(/change those rows/);
287
+ }));
288
+ it("says it couldn't read the history without blaming the file outright", () => __awaiter(void 0, void 0, void 0, function* () {
289
+ let error;
290
+ try {
291
+ yield (0, run_migrations_1.runMigrations)(() => Buffer.from('ok'), () => __awaiter(void 0, void 0, void 0, function* () {
292
+ throw new Error('database is locked');
293
+ }));
294
+ }
295
+ catch (e) {
296
+ error = e;
297
+ }
298
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/could not read the migration history/);
299
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/another server/);
300
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.include('database is locked');
301
+ }));
302
+ });
@@ -25,7 +25,9 @@ describe('POST /healing/selector/state (real queries)', function () {
25
25
  before(() => __awaiter(this, void 0, void 0, function* () {
26
26
  yield (0, selector_health_fixture_1.seedSelectorHealth)(scratch.db);
27
27
  // The service's transactions use the scratch database's own client.
28
- typedi_1.Container.set(SelectorStateService_1.SelectorStateService, new SelectorStateService_1.SelectorStateService(scratch.db, { emitToDashboard: () => undefined }));
28
+ typedi_1.Container.set(SelectorStateService_1.SelectorStateService, new SelectorStateService_1.SelectorStateService(scratch.db, {
29
+ emitToDashboardForSelector: () => __awaiter(this, void 0, void 0, function* () { return undefined; }),
30
+ }));
29
31
  }));
30
32
  after(() => {
31
33
  typedi_1.Container.remove(SelectorStateService_1.SelectorStateService);
@@ -38,7 +38,7 @@ function buildPrismaStub() {
38
38
  }
39
39
  function buildSocketStub() {
40
40
  return {
41
- emitToDashboard: sinon_1.default.stub(),
41
+ emitToDashboardForSelector: sinon_1.default.stub().resolves(),
42
42
  };
43
43
  }
44
44
  const STRATEGY = 'accessibility id';
@@ -105,8 +105,8 @@ describe('SelectorStateService', () => {
105
105
  selector: SELECTOR,
106
106
  apiKeyId: API_KEY_ID,
107
107
  });
108
- (0, chai_1.expect)(socketStub.emitToDashboard.calledOnce).to.equal(true);
109
- const [event, payload] = socketStub.emitToDashboard.firstCall.args;
108
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.calledOnce).to.equal(true);
109
+ const [event, payload] = socketStub.emitToDashboardForSelector.firstCall.args;
110
110
  (0, chai_1.expect)(event).to.equal(SocketEvents_1.SocketEvents.SELECTOR_FIXED);
111
111
  (0, chai_1.expect)(payload).to.include({
112
112
  original_strategy: STRATEGY,
@@ -133,7 +133,7 @@ describe('SelectorStateService', () => {
133
133
  (0, chai_1.expect)(err).to.be.instanceOf(SelectorStateService_1.SelectorStateConflictError);
134
134
  (0, chai_1.expect)(err.currentStatus).to.equal('muted');
135
135
  (0, chai_1.expect)(prismaStub.selectorState.upsert.called).to.equal(false);
136
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
136
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
137
137
  }));
138
138
  });
139
139
  describe('mute', () => {
@@ -170,8 +170,8 @@ describe('SelectorStateService', () => {
170
170
  selector: SELECTOR,
171
171
  apiKeyId: API_KEY_ID,
172
172
  });
173
- (0, chai_1.expect)(socketStub.emitToDashboard.calledOnce).to.equal(true);
174
- const [event, payload] = socketStub.emitToDashboard.firstCall.args;
173
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.calledOnce).to.equal(true);
174
+ const [event, payload] = socketStub.emitToDashboardForSelector.firstCall.args;
175
175
  (0, chai_1.expect)(event).to.equal(SocketEvents_1.SocketEvents.SELECTOR_MUTED);
176
176
  (0, chai_1.expect)(payload.status).to.equal('muted');
177
177
  (0, chai_1.expect)(payload.muted_at).to.equal('2026-04-25T02:00:00.000Z');
@@ -195,8 +195,8 @@ describe('SelectorStateService', () => {
195
195
  });
196
196
  (0, chai_1.expect)(prismaStub.selectorState.delete.calledOnce).to.equal(true);
197
197
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
198
- (0, chai_1.expect)(socketStub.emitToDashboard.calledOnce).to.equal(true);
199
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_UNMUTED);
198
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.calledOnce).to.equal(true);
199
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_UNMUTED);
200
200
  }));
201
201
  it('keeps the row and sets status=active when history exists', () => __awaiter(void 0, void 0, void 0, function* () {
202
202
  const muted = makeRow({
@@ -217,8 +217,8 @@ describe('SelectorStateService', () => {
217
217
  const args = prismaStub.selectorState.update.firstCall.args[0];
218
218
  (0, chai_1.expect)(args.data.status).to.equal('active');
219
219
  (0, chai_1.expect)(args.data.muted_at).to.equal(null);
220
- (0, chai_1.expect)(socketStub.emitToDashboard.calledOnce).to.equal(true);
221
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_UNMUTED);
220
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.calledOnce).to.equal(true);
221
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_UNMUTED);
222
222
  }));
223
223
  it('is a no-op when the row exists but is not muted', () => __awaiter(void 0, void 0, void 0, function* () {
224
224
  prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'active' }));
@@ -229,7 +229,7 @@ describe('SelectorStateService', () => {
229
229
  });
230
230
  (0, chai_1.expect)(prismaStub.selectorState.delete.called).to.equal(false);
231
231
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
232
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
232
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
233
233
  }));
234
234
  it('is a no-op when no row exists', () => __awaiter(void 0, void 0, void 0, function* () {
235
235
  prismaStub.selectorState.findUnique.resolves(null);
@@ -240,7 +240,7 @@ describe('SelectorStateService', () => {
240
240
  });
241
241
  (0, chai_1.expect)(prismaStub.selectorState.delete.called).to.equal(false);
242
242
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
243
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
243
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
244
244
  }));
245
245
  });
246
246
  describe('cancelVerification', () => {
@@ -261,7 +261,7 @@ describe('SelectorStateService', () => {
261
261
  (0, chai_1.expect)(err.currentStatus).to.equal('active');
262
262
  (0, chai_1.expect)(prismaStub.selectorState.delete.called).to.equal(false);
263
263
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
264
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
264
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
265
265
  }));
266
266
  it('throws SelectorStateConflictError when no row exists', () => __awaiter(void 0, void 0, void 0, function* () {
267
267
  prismaStub.selectorState.findUnique.resolves(null);
@@ -294,8 +294,8 @@ describe('SelectorStateService', () => {
294
294
  });
295
295
  (0, chai_1.expect)(prismaStub.selectorState.delete.calledOnce).to.equal(true);
296
296
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
297
- (0, chai_1.expect)(socketStub.emitToDashboard.calledOnce).to.equal(true);
298
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_CANCELLED);
297
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.calledOnce).to.equal(true);
298
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_CANCELLED);
299
299
  }));
300
300
  it('keeps row and clears fixed state when history exists (resolved_at not null)', () => __awaiter(void 0, void 0, void 0, function* () {
301
301
  const pending = makeRow({
@@ -318,7 +318,7 @@ describe('SelectorStateService', () => {
318
318
  (0, chai_1.expect)(args.data.status).to.equal('active');
319
319
  (0, chai_1.expect)(args.data.fixed_at).to.equal(null);
320
320
  (0, chai_1.expect)(args.data.clean_builds_count).to.equal(0);
321
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_CANCELLED);
321
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_CANCELLED);
322
322
  }));
323
323
  });
324
324
  describe('onHealRecorded', () => {
@@ -344,8 +344,8 @@ describe('SelectorStateService', () => {
344
344
  (0, chai_1.expect)(args.data.resolved_at).to.equal(null);
345
345
  (0, chai_1.expect)(args.data.regression_count).to.deep.equal({ increment: 1 });
346
346
  (0, chai_1.expect)(args.data.clean_builds_count).to.equal(0);
347
- (0, chai_1.expect)(socketStub.emitToDashboard.calledOnce).to.equal(true);
348
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_REGRESSED);
347
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.calledOnce).to.equal(true);
348
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_REGRESSED);
349
349
  }));
350
350
  it('flips resolved → active, clears resolved_at, and increments regression_count', () => __awaiter(void 0, void 0, void 0, function* () {
351
351
  const resolved = makeRow({
@@ -368,7 +368,7 @@ describe('SelectorStateService', () => {
368
368
  (0, chai_1.expect)(args.data.resolved_at).to.equal(null);
369
369
  (0, chai_1.expect)(args.data.fixed_at).to.equal(null);
370
370
  (0, chai_1.expect)(args.data.regression_count).to.deep.equal({ increment: 1 });
371
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_REGRESSED);
371
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal(SocketEvents_1.SocketEvents.SELECTOR_REGRESSED);
372
372
  }));
373
373
  it('is a no-op when the row is muted', () => __awaiter(void 0, void 0, void 0, function* () {
374
374
  prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'muted' }));
@@ -378,7 +378,7 @@ describe('SelectorStateService', () => {
378
378
  sessionId: SESSION_ID,
379
379
  });
380
380
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
381
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
381
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
382
382
  }));
383
383
  it('is a no-op when no row exists', () => __awaiter(void 0, void 0, void 0, function* () {
384
384
  prismaStub.selectorState.findUnique.resolves(null);
@@ -388,7 +388,7 @@ describe('SelectorStateService', () => {
388
388
  sessionId: SESSION_ID,
389
389
  });
390
390
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
391
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
391
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
392
392
  }));
393
393
  it('is a no-op when row is already active', () => __awaiter(void 0, void 0, void 0, function* () {
394
394
  prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'active' }));
@@ -398,7 +398,7 @@ describe('SelectorStateService', () => {
398
398
  sessionId: SESSION_ID,
399
399
  });
400
400
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.equal(false);
401
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
401
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
402
402
  }));
403
403
  });
404
404
  describe('getState', () => {
@@ -443,7 +443,7 @@ describe('SelectorStateService', () => {
443
443
  thrown = e;
444
444
  }
445
445
  (0, chai_1.expect)(thrown.message).to.equal('disk full');
446
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.equal(false);
446
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.equal(false);
447
447
  }));
448
448
  it('records the mute with its reason', () => __awaiter(void 0, void 0, void 0, function* () {
449
449
  prismaStub.selectorState.upsert.resolves(makeRow({ status: 'muted', muted_at: new Date() }));
@@ -483,4 +483,53 @@ describe('SelectorStateService', () => {
483
483
  (0, chai_1.expect)(prismaStub.selectorEvent.create.called).to.equal(false);
484
484
  }));
485
485
  });
486
+ // The socket server sends a selector's event only to the dashboards whose
487
+ // caller may see the selector, so every event names it.
488
+ describe('each event names its selector, for the socket server to scope it', () => {
489
+ const KEY = { strategy: STRATEGY, selector: SELECTOR };
490
+ const ctx = { strategy: STRATEGY, selector: SELECTOR, apiKeyId: API_KEY_ID };
491
+ const sent = () => socketStub.emitToDashboardForSelector.getCalls().map((c) => [c.args[0], c.args[2]]);
492
+ it('markFixed and mute', () => __awaiter(void 0, void 0, void 0, function* () {
493
+ prismaStub.selectorState.findUnique.resolves(null);
494
+ prismaStub.selectorState.upsert.resolves(makeRow({ status: 'pending' }));
495
+ yield svc.markFixed(ctx);
496
+ prismaStub.selectorState.upsert.resolves(makeRow({ status: 'muted' }));
497
+ yield svc.mute(ctx);
498
+ (0, chai_1.expect)(sent()).to.deep.equal([
499
+ [SocketEvents_1.SocketEvents.SELECTOR_FIXED, KEY],
500
+ [SocketEvents_1.SocketEvents.SELECTOR_MUTED, KEY],
501
+ ]);
502
+ }));
503
+ it('unmute and cancelVerification, whether the row is kept or deleted', () => __awaiter(void 0, void 0, void 0, function* () {
504
+ prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'muted' }));
505
+ yield svc.unmute(ctx);
506
+ prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'muted', regression_count: 1 }));
507
+ prismaStub.selectorState.update.resolves(makeRow({ status: 'active', regression_count: 1 }));
508
+ yield svc.unmute(ctx);
509
+ prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'pending' }));
510
+ yield svc.cancelVerification(ctx);
511
+ prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'pending', regression_count: 1 }));
512
+ yield svc.cancelVerification(ctx);
513
+ (0, chai_1.expect)(sent()).to.deep.equal([
514
+ [SocketEvents_1.SocketEvents.SELECTOR_UNMUTED, KEY],
515
+ [SocketEvents_1.SocketEvents.SELECTOR_UNMUTED, KEY],
516
+ [SocketEvents_1.SocketEvents.SELECTOR_CANCELLED, KEY],
517
+ [SocketEvents_1.SocketEvents.SELECTOR_CANCELLED, KEY],
518
+ ]);
519
+ }));
520
+ it('onHealRecorded', () => __awaiter(void 0, void 0, void 0, function* () {
521
+ prismaStub.selectorState.findUnique.resolves(makeRow({ status: 'pending' }));
522
+ prismaStub.selectorState.update.resolves(makeRow({ status: 'active', regression_count: 1 }));
523
+ yield svc.onHealRecorded({ strategy: STRATEGY, selector: SELECTOR, sessionId: SESSION_ID });
524
+ (0, chai_1.expect)(sent()).to.deep.equal([[SocketEvents_1.SocketEvents.SELECTOR_REGRESSED, KEY]]);
525
+ }));
526
+ it('a heal recorded with no strategy names strategy ""', () => __awaiter(void 0, void 0, void 0, function* () {
527
+ prismaStub.selectorState.findUnique.resolves(null);
528
+ prismaStub.selectorState.upsert.resolves(makeRow({ status: 'muted', original_strategy: '' }));
529
+ yield svc.mute({ strategy: '', selector: SELECTOR, apiKeyId: API_KEY_ID });
530
+ (0, chai_1.expect)(sent()).to.deep.equal([
531
+ [SocketEvents_1.SocketEvents.SELECTOR_MUTED, { strategy: '', selector: SELECTOR }],
532
+ ]);
533
+ }));
534
+ });
486
535
  });
@@ -59,7 +59,7 @@ describe('SelectorVerificationJob counts only builds where the selector was foun
59
59
  yield scratch.db.selectorEvent.deleteMany();
60
60
  yield scratch.db.selectorState.deleteMany();
61
61
  yield scratch.db.sessionLog.deleteMany();
62
- socket = { emitToDashboard: sinon_1.default.stub() };
62
+ socket = { emitToDashboardForSelector: sinon_1.default.stub().resolves() };
63
63
  yield scratch.db.selectorState.create({
64
64
  data: {
65
65
  original_strategy: 'xpath',
@@ -49,7 +49,7 @@ describe('SelectorVerificationJob.run', () => {
49
49
  prismaStub.$transaction = sinon_1.default
50
50
  .stub()
51
51
  .callsFake((fn) => fn(prismaStub));
52
- socketStub = { emitToDashboard: sinon_1.default.stub() };
52
+ socketStub = { emitToDashboardForSelector: sinon_1.default.stub().resolves() };
53
53
  job = new SelectorVerificationJob_1.SelectorVerificationJob(prismaStub, socketStub);
54
54
  });
55
55
  afterEach(() => sinon_1.default.restore());
@@ -84,8 +84,13 @@ describe('SelectorVerificationJob.run', () => {
84
84
  (0, chai_1.expect)(updateArg.data.status).to.equal('resolved');
85
85
  (0, chai_1.expect)(updateArg.data.resolved_at).to.be.instanceOf(Date);
86
86
  (0, chai_1.expect)(updateArg.data.clean_builds_count).to.equal(3);
87
- (0, chai_1.expect)(socketStub.emitToDashboard.calledOnce).to.be.true;
88
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal('selector_resolved');
87
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.calledOnce).to.be.true;
88
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal('selector_resolved');
89
+ // Named, so only the dashboards that may see the selector get it.
90
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[2]).to.deep.equal({
91
+ strategy: 'xpath',
92
+ selector: '//x',
93
+ });
89
94
  }));
90
95
  it('updates clean_builds_count and emits PROGRESS when below 3 and value changed', () => __awaiter(void 0, void 0, void 0, function* () {
91
96
  prismaStub.selectorState.findMany.resolves([
@@ -111,7 +116,11 @@ describe('SelectorVerificationJob.run', () => {
111
116
  const updateArg = prismaStub.selectorState.update.firstCall.args[0];
112
117
  (0, chai_1.expect)(updateArg.data.status).to.be.undefined;
113
118
  (0, chai_1.expect)(updateArg.data.clean_builds_count).to.equal(1);
114
- (0, chai_1.expect)(socketStub.emitToDashboard.firstCall.args[0]).to.equal('selector_progress');
119
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[0]).to.equal('selector_progress');
120
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.firstCall.args[2]).to.deep.equal({
121
+ strategy: 'xpath',
122
+ selector: '//x',
123
+ });
115
124
  }));
116
125
  it('does not write or emit when clean_builds_count is unchanged and not promoting', () => __awaiter(void 0, void 0, void 0, function* () {
117
126
  prismaStub.selectorState.findMany.resolves([
@@ -130,7 +139,7 @@ describe('SelectorVerificationJob.run', () => {
130
139
  ]);
131
140
  yield job.run();
132
141
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.be.false;
133
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.be.false;
142
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.be.false;
134
143
  }));
135
144
  it('SQL filters out builds with NULL build_id and only counts findElement(s) commands', () => __awaiter(void 0, void 0, void 0, function* () {
136
145
  prismaStub.selectorState.findMany.resolves([
@@ -160,7 +169,7 @@ describe('SelectorVerificationJob.run', () => {
160
169
  yield job.run();
161
170
  (0, chai_1.expect)(prismaStub.$queryRaw.called).to.be.false;
162
171
  (0, chai_1.expect)(prismaStub.selectorState.update.called).to.be.false;
163
- (0, chai_1.expect)(socketStub.emitToDashboard.called).to.be.false;
172
+ (0, chai_1.expect)(socketStub.emitToDashboardForSelector.called).to.be.false;
164
173
  }));
165
174
  it('continues processing remaining rows if one fails', () => __awaiter(void 0, void 0, void 0, function* () {
166
175
  prismaStub.selectorState.findMany.resolves([
@@ -53,7 +53,7 @@ describe('SelectorVerificationJob catches a heal the live check missed', functio
53
53
  yield scratch.db.selectorEvent.deleteMany();
54
54
  yield scratch.db.selectorState.deleteMany();
55
55
  yield scratch.db.sessionLog.deleteMany();
56
- socket = { emitToDashboard: sinon_1.default.stub() };
56
+ socket = { emitToDashboardForSelector: sinon_1.default.stub().resolves() };
57
57
  }));
58
58
  const find = (build, selector, healed, at) => scratch.db.sessionLog.create({
59
59
  data: {
@@ -105,7 +105,7 @@ describe('SelectorVerificationJob catches a heal the live check missed', functio
105
105
  (0, chai_1.expect)(row).to.include({ status: 'active', regression_count: 1, clean_builds_count: 0 });
106
106
  (0, chai_1.expect)(row.fixed_at).to.equal(null);
107
107
  (0, chai_1.expect)(yield actionsOf('//pending/broke')).to.deep.equal(['broke_again']);
108
- (0, chai_1.expect)(socket.emitToDashboard.firstCall.args[0]).to.equal('selector_regressed');
108
+ (0, chai_1.expect)(socket.emitToDashboardForSelector.firstCall.args[0]).to.equal('selector_regressed');
109
109
  }));
110
110
  it('sends a fixed selector back to fix', () => __awaiter(this, void 0, void 0, function* () {
111
111
  yield state('//resolved/broke', 'resolved');