@xenon-device-management/xenon 2.14.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 (335) hide show
  1. package/README.md +44 -29
  2. package/lib/package.json +6 -5
  3. package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-GmckX_7r.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-DqODzkyo.js +2 -0
  6. package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-7hETKEWb.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-GOgssFEP.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-Yvf6RGMd.js} +1 -1
  9. package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-KrPnfdsf.js} +1 -1
  10. package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-C6WjZFeI.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CSRIhZEh.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-CB8ST3nE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C9o90KId.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-CPm3w9l2.js} +1 -1
  15. package/lib/public/assets/{Table-BcTm5xV2.js → Table-DwIgERRt.js} +1 -1
  16. package/lib/public/assets/{activity-DOVdsfMQ.js → activity-DrLbHifn.js} +1 -1
  17. package/lib/public/assets/ai-settings-DU_Ls06P.js +16 -0
  18. package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-CWYdEzJ_.js} +1 -1
  19. package/lib/public/assets/{apps-D0SC6uw6.js → apps-BqNVdln7.js} +1 -1
  20. package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BtB0_Jg_.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-C8NhdtyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-gMg0ffvv.js} +1 -1
  23. package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CiYZ3N4M.js} +2 -2
  24. package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-EaaqDmLN.js} +1 -1
  25. package/lib/public/assets/{button-Dda4irn_.js → button-DDau4Axv.js} +1 -1
  26. package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-DQ83B1ed.js} +1 -1
  27. package/lib/public/assets/{check-CZ9iw9-e.js → check-K1ovTQam.js} +1 -1
  28. package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-Biq00pMt.js} +1 -1
  29. package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CBv88cH7.js} +1 -1
  30. package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-Bmltailn.js} +1 -1
  31. package/lib/public/assets/{clock-CpuDKysU.js → clock-BPrld-3h.js} +1 -1
  32. package/lib/public/assets/{copy-CAQydNFe.js → copy-t0QB14JI.js} +1 -1
  33. package/lib/public/assets/derive-UnLRp4r3.js +1 -0
  34. package/lib/public/assets/device-explorer-BgGZ8AvV.js +282 -0
  35. package/lib/public/assets/{download-C9XG6lLv.js → download-BroHxNOh.js} +1 -1
  36. package/lib/public/assets/forgot-password-Bjmf49i6.js +1 -0
  37. package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-DeD__6wa.js} +6 -6
  38. package/lib/public/assets/{index-DJGc1M2f.js → index-72oF89LJ.js} +1 -1
  39. package/lib/public/assets/index-B0JeuZbg.js +15 -0
  40. package/lib/public/assets/index-B0gQkEg0.js +219 -0
  41. package/lib/public/assets/{input-Cm0tmSoc.js → input-DIc4MSC7.js} +1 -1
  42. package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-BcWm2vW8.js} +1 -1
  43. package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-B06IJJad.js} +1 -1
  44. package/lib/public/assets/{lock-CCJv9m_I.js → lock-pVLvnN30.js} +1 -1
  45. package/lib/public/assets/{login-f8vOVW-U.js → login-CN77vy--.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-CSdzQSkY.js} +1 -1
  47. package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-BtrN5IkI.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
  49. package/lib/public/assets/{network-ebT86qoi.js → network-BSfA8ZxG.js} +1 -1
  50. package/lib/public/assets/{overview-C0aykVrd.js → overview-BfTebCK5.js} +1 -1
  51. package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-D8KUX6Qg.js} +1 -1
  52. package/lib/public/assets/{play-DLG9t8FU.js → play-Cvc8xzic.js} +1 -1
  53. package/lib/public/assets/{plus-nBFSMu_X.js → plus-C9Zj84UF.js} +1 -1
  54. package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-Bi27STgs.js} +1 -1
  55. package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-CPsOuwSu.js} +1 -1
  56. package/lib/public/assets/runbook-page-DEQ-DBOX.js +304 -0
  57. package/lib/public/assets/{select-88jHcl-Z.js → select-BWpnhWXj.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BefSCSpe.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-MSk96Zz7.js} +1 -1
  60. package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
  61. package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
  62. package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-V6x-cXwy.js} +1 -1
  63. package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-Duex-GZD.js} +1 -1
  64. package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BBcoqBmL.js} +1 -1
  65. package/lib/public/assets/{teams-B4-jrr-1.js → teams-ts8p5rEh.js} +1 -1
  66. package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-CZ2ew6AZ.js} +1 -1
  67. package/lib/public/assets/{upload-BG7_rMXK.js → upload-Dv99y_le.js} +1 -1
  68. package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-DrYC8JCF.js} +1 -1
  69. package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
  70. package/lib/public/assets/{users-CFba5TSy.js → users-D7tKMESH.js} +1 -1
  71. package/lib/public/assets/{users-Cp0beJm3.js → users-gJ5Y01yo.js} +1 -1
  72. package/lib/public/assets/{video-off-DDsEISjV.js → video-off-CqZVuZGs.js} +1 -1
  73. package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-DI5DSW99.js} +1 -1
  74. package/lib/public/assets/{zap-l4VaXzq8.js → zap-ClcHFxfo.js} +1 -1
  75. package/lib/public/index.html +1 -1
  76. package/lib/schema.json +16 -12
  77. package/lib/src/InternalHttpClient.js +27 -5
  78. package/lib/src/XenonCapabilityManager.js +10 -3
  79. package/lib/src/app/dashboardPluginLink.js +117 -0
  80. package/lib/src/app/index.js +13 -34
  81. package/lib/src/app/openapi/control.yaml +6 -4
  82. package/lib/src/app/openapi/grid.yaml +8 -2
  83. package/lib/src/app/openapi/identity.yaml +103 -31
  84. package/lib/src/app/openapi/platform.yaml +33 -15
  85. package/lib/src/app/openapi/sessions.yaml +12 -3
  86. package/lib/src/app/routers/apikeys.js +14 -2
  87. package/lib/src/app/routers/auth.js +31 -6
  88. package/lib/src/app/routers/control.js +13 -5
  89. package/lib/src/app/routers/dashboard.js +42 -27
  90. package/lib/src/app/routers/grid.js +5 -3
  91. package/lib/src/app/routers/profile.js +21 -0
  92. package/lib/src/app/routers/users.js +12 -6
  93. package/lib/src/app/swagger.js +2 -0
  94. package/lib/src/app/ws/h264StreamWs.js +10 -1
  95. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  96. package/lib/src/config.js +1 -0
  97. package/lib/src/dashboard/commandLogFields.js +42 -0
  98. package/lib/src/dashboard/commands.js +5 -5
  99. package/lib/src/dashboard/event-manager.js +64 -204
  100. package/lib/src/dashboard/services/failure-analysis-service.js +118 -105
  101. package/lib/src/dashboard/services/failureCategories.js +264 -0
  102. package/lib/src/dashboard/sessionCommandSummary.js +39 -0
  103. package/lib/src/data-service/device-store.js +14 -1
  104. package/lib/src/data-service/prisma-store.js +19 -7
  105. package/lib/src/data-service/web-config-service.js +7 -0
  106. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  107. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  108. package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
  109. package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
  110. package/lib/src/device-utils.js +29 -3
  111. package/lib/src/gateway/defaultGateway.js +1 -1
  112. package/lib/src/gateway/forwardToNode.js +6 -11
  113. package/lib/src/gateway/healReport.js +87 -0
  114. package/lib/src/gateway/hubSessionToken.js +27 -4
  115. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  116. package/lib/src/gateway/sessionGateway.js +12 -2
  117. package/lib/src/generated/client/edge.js +4 -3
  118. package/lib/src/generated/client/index-browser.js +1 -0
  119. package/lib/src/generated/client/index.d.ts +32 -0
  120. package/lib/src/generated/client/index.js +4 -3
  121. package/lib/src/generated/client/package.json +1 -1
  122. package/lib/src/generated/client/schema.prisma +3 -0
  123. package/lib/src/generated/client/wasm.js +1 -0
  124. package/lib/src/helpers/outboundProxy.js +219 -0
  125. package/lib/src/index.js +21 -0
  126. package/lib/src/interceptors/CommandInterceptor.js +256 -196
  127. package/lib/src/interceptors/xenonScripts.js +28 -7
  128. package/lib/src/logger.js +3 -1
  129. package/lib/src/middleware/authMiddleware.js +15 -3
  130. package/lib/src/middleware/commandCaller.js +3 -3
  131. package/lib/src/middleware/verifyCredential.js +58 -1
  132. package/lib/src/plugin.js +16 -6
  133. package/lib/src/scripts/database-check.js +3 -3
  134. package/lib/src/scripts/initialize-database.js +35 -33
  135. package/lib/src/scripts/run-migrations.js +1079 -27
  136. package/lib/src/services/AICommandService.js +9 -2
  137. package/lib/src/services/AIService.js +192 -42
  138. package/lib/src/services/ApiKeyService.js +16 -5
  139. package/lib/src/services/EmailService.js +29 -0
  140. package/lib/src/services/InterceptorService.js +6 -2
  141. package/lib/src/services/OrphanSweeper.js +8 -0
  142. package/lib/src/services/SelectorStateService.js +11 -7
  143. package/lib/src/services/SelectorVerificationJob.js +21 -6
  144. package/lib/src/services/ServerManager.js +11 -1
  145. package/lib/src/services/SessionLifecycleService.js +118 -44
  146. package/lib/src/services/SocketClient.js +11 -0
  147. package/lib/src/services/SocketServer.js +380 -45
  148. package/lib/src/services/TeamService.js +5 -1
  149. package/lib/src/services/TracingService.js +171 -60
  150. package/lib/src/services/UserService.js +14 -3
  151. package/lib/src/services/UserSessionService.js +16 -4
  152. package/lib/src/services/bug-report/video-slice.js +4 -1
  153. package/lib/src/services/device-access/leaseOverride.js +7 -5
  154. package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
  155. package/lib/src/services/healing/HealEtalonService.js +3 -2
  156. package/lib/src/services/healing/HealingOrchestrator.js +57 -72
  157. package/lib/src/services/healing/OcrHealingProvider.js +41 -64
  158. package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
  159. package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
  160. package/lib/src/services/healing/fingerprintIdentity.js +126 -0
  161. package/lib/src/services/healing/resilioPath.js +274 -0
  162. package/lib/src/services/identity/identityChanges.js +41 -0
  163. package/lib/src/services/logcat/SessionDeviceLogs.js +358 -0
  164. package/lib/src/services/logcat/deviceLogBook.js +164 -0
  165. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  166. package/lib/src/services/ocr/ocrData.js +118 -0
  167. package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
  168. package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
  169. package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
  170. package/lib/src/services/passwordResetLink.js +50 -9
  171. package/lib/src/services/passwordResetMode.js +3 -1
  172. package/lib/src/services/publicUrl.js +45 -0
  173. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  174. package/lib/src/services/session/sessionIdentity.js +5 -3
  175. package/lib/src/services/session/shutdownReason.js +26 -0
  176. package/lib/src/services/settings/aiEngineSettings.js +219 -0
  177. package/lib/src/services/token/JwtKeyService.js +8 -2
  178. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  179. package/lib/src/services/token/mintedLifetime.js +51 -0
  180. package/lib/src/sessions/LocalSession.js +29 -1
  181. package/lib/src/sessions/RemoteSession.js +12 -2
  182. package/lib/src/sessions/SessionManager.js +4 -1
  183. package/lib/src/sessions/sessionMemory.js +25 -0
  184. package/lib/test/helpers/driver-log.js +38 -0
  185. package/lib/test/helpers/fake-ai-provider.js +188 -0
  186. package/lib/test/helpers/fake-http-proxy.js +106 -0
  187. package/lib/test/helpers/loki-stores.js +33 -0
  188. package/lib/test/helpers/otlp-probe.js +107 -0
  189. package/lib/test/helpers/page-sources.js +164 -0
  190. package/lib/test/helpers/scratch-database.js +57 -13
  191. package/lib/test/integration/androidDevices.spec.js +1 -1
  192. package/lib/test/integration/audit-ingest.spec.js +10 -10
  193. package/lib/test/integration/auth-flow.spec.js +9 -14
  194. package/lib/test/integration/auth-rate-limit.spec.js +6 -3
  195. package/lib/test/integration/bug-report-route.spec.js +22 -2
  196. package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
  197. package/lib/test/integration/forgot-password.spec.js +17 -18
  198. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  199. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  200. package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
  201. package/lib/test/integration/migration.spec.js +6 -1
  202. package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
  203. package/lib/test/integration/ports-router.spec.js +4 -4
  204. package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
  205. package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
  206. package/lib/test/integration/profile-tokens.spec.js +10 -16
  207. package/lib/test/integration/recordings-router.spec.js +16 -16
  208. package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
  209. package/lib/test/integration/role-matrix.spec.js +4 -9
  210. package/lib/test/integration/sdk-leases-router.spec.js +9 -9
  211. package/lib/test/integration/sdk-version.spec.js +2 -2
  212. package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
  213. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  214. package/lib/test/integration/team-membership-migration.spec.js +73 -12
  215. package/lib/test/integration/team-visibility-apps.spec.js +15 -22
  216. package/lib/test/integration/team-visibility-control.spec.js +96 -69
  217. package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
  218. package/lib/test/integration/team-visibility-grid.spec.js +16 -15
  219. package/lib/test/integration/team-visibility-leases.spec.js +8 -11
  220. package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
  221. package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
  222. package/lib/test/integration/users-flow.spec.js +7 -12
  223. package/lib/test/integration/users-lockout.spec.js +5 -3
  224. package/lib/test/unit/EmailService.test.js +66 -0
  225. package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
  226. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  227. package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
  228. package/lib/test/unit/ai-engine-settings.spec.js +338 -0
  229. package/lib/test/unit/ai-provider-switch.spec.js +130 -0
  230. package/lib/test/unit/ai-rate-limit.spec.js +126 -0
  231. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  232. package/lib/test/unit/android-h264-viewers.spec.js +272 -0
  233. package/lib/test/unit/android-preview-hold.spec.js +3 -2
  234. package/lib/test/unit/apikeys-router.spec.js +84 -0
  235. package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
  236. package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
  237. package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
  238. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  239. package/lib/test/unit/dashboard-events-device-logs.spec.js +255 -0
  240. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  241. package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
  242. package/lib/test/unit/database-check.spec.js +1 -0
  243. package/lib/test/unit/device-log-book.spec.js +180 -0
  244. package/lib/test/unit/device-utils.spec.js +13 -4
  245. package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
  246. package/lib/test/unit/event-log-session-data.spec.js +251 -0
  247. package/lib/test/unit/failure-analysis.spec.js +196 -0
  248. package/lib/test/unit/failure-categories.spec.js +130 -0
  249. package/lib/test/unit/failure-categorization.spec.js +393 -0
  250. package/lib/test/unit/fingerprint-identity.spec.js +433 -0
  251. package/lib/test/unit/h264-multiplexer.spec.js +46 -0
  252. package/lib/test/unit/heal-report.spec.js +71 -0
  253. package/lib/test/unit/healing-instrumentation.spec.js +6 -1
  254. package/lib/test/unit/healing-tier-gate.spec.js +74 -29
  255. package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
  256. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +243 -1
  257. package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
  258. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  259. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  260. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  261. package/lib/test/unit/lease/lease-override.spec.js +23 -6
  262. package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
  263. package/lib/test/unit/logcat-ws.spec.js +78 -37
  264. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  265. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  266. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  267. package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
  268. package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
  269. package/lib/test/unit/ocr-offline.spec.js +213 -0
  270. package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
  271. package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
  272. package/lib/test/unit/omni-vision.spec.js +5 -7
  273. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  274. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  275. package/lib/test/unit/package-engines.spec.js +27 -0
  276. package/lib/test/unit/passwordResetMode.test.js +10 -2
  277. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  278. package/lib/test/unit/profile-router.test.js +122 -1
  279. package/lib/test/unit/proxy-option.spec.js +358 -0
  280. package/lib/test/unit/queue-requester.spec.js +1 -1
  281. package/lib/test/unit/reset-link-address.spec.js +266 -0
  282. package/lib/test/unit/reset-link.test.js +10 -4
  283. package/lib/test/unit/resilio-path-healing.spec.js +270 -0
  284. package/lib/test/unit/run-migrations-database.spec.js +807 -0
  285. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  286. package/lib/test/unit/run-migrations.test.js +864 -0
  287. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  288. package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
  289. package/lib/test/unit/selector-state-service.spec.js +72 -23
  290. package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
  291. package/lib/test/unit/selector-verification-job.spec.js +28 -19
  292. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  293. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  294. package/lib/test/unit/self-healing-boot.spec.js +10 -5
  295. package/lib/test/unit/self-healing-setting.spec.js +7 -6
  296. package/lib/test/unit/session-app-resolution.spec.js +5 -1
  297. package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
  298. package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
  299. package/lib/test/unit/session-credential-strip.spec.js +1 -1
  300. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  301. package/lib/test/unit/session-device-logs.spec.js +356 -0
  302. package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
  303. package/lib/test/unit/session-log-indexes.spec.js +64 -3
  304. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  305. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  306. package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
  307. package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
  308. package/lib/test/unit/token-mint-limits.spec.js +165 -0
  309. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  310. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  311. package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
  312. package/lib/test/unit/verify-credential.spec.js +33 -0
  313. package/lib/test/unit/vision-assertion.spec.js +3 -2
  314. package/lib/test/unit/web-config-service.spec.js +19 -0
  315. package/lib/tsconfig.tsbuildinfo +1 -1
  316. package/package.json +6 -5
  317. package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
  318. package/prisma/schema.prisma +3 -0
  319. package/schema.json +16 -12
  320. package/scripts/dev/readme-screenshots.js +276 -23
  321. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  322. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  323. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  324. package/scripts/export-openapi.js +32 -0
  325. package/scripts/lib/openapi-for-site.js +79 -0
  326. package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
  327. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
  328. package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
  329. package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
  330. package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
  331. package/lib/public/assets/index-BbKEXUFH.js +0 -219
  332. package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
  333. package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
  334. package/lib/src/scripts/prepare-prisma.js +0 -89
  335. /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
@@ -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,859 @@ 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 match no run of the migrations, 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
+ (0, chai_1.expect)(plan.resolve).to.equal(undefined);
80
+ });
81
+ it('deploys, as the history says, when the tables could not be compared', () => {
82
+ const history = { applied: LOCAL.slice(0, 1), failed: [] };
83
+ const plan = (0, run_migrations_1.planSchemaSync)(history, LOCAL, () => null);
84
+ (0, chai_1.expect)(plan.command).to.equal('migrate deploy');
85
+ (0, chai_1.expect)(plan.reason).to.match(/could not be compared/);
86
+ });
87
+ it('pushes a database whose history records a failed migration and whose tables match no run of the migrations', () => {
88
+ const history = { applied: LOCAL.slice(0, 2), failed: [LOCAL[2]] };
89
+ const plan = (0, run_migrations_1.planSchemaSync)(history, LOCAL, () => false);
90
+ (0, chai_1.expect)(plan.command).to.equal('db push');
91
+ (0, chai_1.expect)(plan.acceptDataLoss).to.equal(false);
92
+ (0, chai_1.expect)(plan.reason).to.include(LOCAL[2]);
93
+ (0, chai_1.expect)(plan.basis).to.equal('failed-migration');
94
+ (0, chai_1.expect)(plan.failed).to.deep.equal([LOCAL[2]]);
95
+ });
96
+ // Re-baselining. db push writes no history, so a history behind its tables,
97
+ // or one with a failed migration, stayed on db push for good: at the first
98
+ // release whose migration drops a column or adds a unique index, db push
99
+ // without --accept-data-loss refused it. When the tables are exactly what
100
+ // the first k migrations make, the history can say so.
101
+ describe('re-baselining', () => {
102
+ const FOUR = [...LOCAL, '20250104_d'];
103
+ it('records the migrations the tables already have, then deploys the rest', () => {
104
+ const history = { applied: FOUR.slice(0, 1), failed: [] };
105
+ const compared = [];
106
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => {
107
+ compared.push(migrations.length);
108
+ return migrations.length === 3;
109
+ });
110
+ (0, chai_1.expect)(plan.command).to.equal('migrate deploy');
111
+ (0, chai_1.expect)(plan.basis).to.equal('rebaselined');
112
+ (0, chai_1.expect)(plan.resolve).to.deep.equal({ rolledBack: [], applied: FOUR.slice(1, 3) });
113
+ (0, chai_1.expect)(plan.reason).to.match(/first 3 migrations/);
114
+ // The history first; then every migration (what db push leaves); then
115
+ // down; then one below the match, which must not match too.
116
+ (0, chai_1.expect)(compared).to.deep.equal([1, 4, 3, 2]);
117
+ });
118
+ // Prisma's comparison sees tables, never rows: a backfill makes the same
119
+ // tables as the migrations before it. Through #493 the largest k that
120
+ // matched won, so the backfill was recorded as applied and never ran.
121
+ it('runs a migration the comparison cannot see rather than record it', () => {
122
+ const history = { applied: FOUR.slice(0, 2), failed: [] };
123
+ const compared = [];
124
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => {
125
+ compared.push(migrations.length);
126
+ return migrations.length >= 3;
127
+ });
128
+ (0, chai_1.expect)(plan.basis).to.equal('rebaselined');
129
+ (0, chai_1.expect)(plan.resolve).to.deep.equal({ rolledBack: [], applied: [FOUR[2]] });
130
+ (0, chai_1.expect)(plan.reason).to.match(/first 3 migrations/);
131
+ (0, chai_1.expect)(compared).to.deep.equal([2, 4, 3]);
132
+ });
133
+ const dup = 'Database error code: 1\n\nDatabase error:\nduplicate column name: path';
134
+ const notNull = 'Database error code: 1299\n\nDatabase error:\nNOT NULL constraint failed: Team.name';
135
+ it('rolls a failed migration back first, and records it as applied when the tables have it', () => {
136
+ const history = {
137
+ applied: FOUR.slice(0, 2),
138
+ failed: [FOUR[2]],
139
+ failureLogs: { [FOUR[2]]: dup },
140
+ };
141
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length === 4);
142
+ (0, chai_1.expect)(plan.command).to.equal('migrate deploy');
143
+ (0, chai_1.expect)(plan.basis).to.equal('rebaselined');
144
+ (0, chai_1.expect)(plan.resolve).to.deep.equal({ rolledBack: [FOUR[2]], applied: FOUR.slice(2, 4) });
145
+ });
146
+ // The rows a migration failed on stay as they were until someone changes
147
+ // them, and a migration can fail after its first statements ran. Through
148
+ // #493 the start after such a failure recorded it as applied.
149
+ it('leaves a failed migration alone when the tables have what it makes but it failed on the rows', () => {
150
+ var _a;
151
+ const history = {
152
+ applied: FOUR.slice(0, 3),
153
+ failed: [FOUR[3]],
154
+ failureLogs: { [FOUR[3]]: notNull },
155
+ };
156
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length === 4);
157
+ (0, chai_1.expect)(plan.command).to.equal('none');
158
+ (0, chai_1.expect)(plan.basis).to.equal('failed-unresolved');
159
+ (0, chai_1.expect)(plan.resolve).to.equal(undefined);
160
+ (0, chai_1.expect)(plan.unresolved).to.deep.include({ migration: FOUR[3], why: 'part-way' });
161
+ (0, chai_1.expect)((_a = plan.unresolved) === null || _a === void 0 ? void 0 : _a.logs).to.include('NOT NULL');
162
+ });
163
+ it('leaves a failed migration alone when its record says nothing of why it failed', () => {
164
+ const history = { applied: FOUR.slice(0, 3), failed: [FOUR[3]] };
165
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length === 4);
166
+ (0, chai_1.expect)(plan.basis).to.equal('failed-unresolved');
167
+ });
168
+ const backfill = { statements: 1, unseen: ['UPDATE "Team" SET "name" = ...'] };
169
+ const twoBackfills = {
170
+ statements: 2,
171
+ unseen: ['UPDATE "Team" SET "a" = 1', 'UPDATE "Team" SET "b" = 2'],
172
+ };
173
+ const shapes = (special) => (name) => { var _a; return (_a = special[name]) !== null && _a !== void 0 ? _a : { statements: 1, unseen: [] }; };
174
+ it('rolls back a one-statement backfill that failed, so deploy runs it again', () => {
175
+ const history = {
176
+ applied: FOUR.slice(0, 3),
177
+ failed: [FOUR[3]],
178
+ failureLogs: { [FOUR[3]]: notNull },
179
+ };
180
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length >= 3, shapes({ [FOUR[3]]: backfill }));
181
+ (0, chai_1.expect)(plan.basis).to.equal('rebaselined');
182
+ (0, chai_1.expect)(plan.resolve).to.deep.equal({ rolledBack: [FOUR[3]], applied: [] });
183
+ });
184
+ it("leaves a failed backfill of several statements alone: the tables can't show how far it got", () => {
185
+ const history = {
186
+ applied: FOUR.slice(0, 3),
187
+ failed: [FOUR[3]],
188
+ failureLogs: { [FOUR[3]]: notNull },
189
+ };
190
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length >= 3, shapes({ [FOUR[3]]: twoBackfills }));
191
+ (0, chai_1.expect)(plan.command).to.equal('none');
192
+ (0, chai_1.expect)(plan.unresolved).to.deep.include({ migration: FOUR[3], why: 'part-way' });
193
+ });
194
+ it("never records a failed migration as applied when part of it is what the tables can't show", () => {
195
+ const history = {
196
+ applied: FOUR.slice(0, 3),
197
+ failed: [FOUR[3]],
198
+ failureLogs: { [FOUR[3]]: dup },
199
+ };
200
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length === 4, shapes({ [FOUR[3]]: { statements: 2, unseen: ['UPDATE "LocatorEtalon" ...'] } }));
201
+ (0, chai_1.expect)(plan.command).to.equal('none');
202
+ (0, chai_1.expect)(plan.unresolved).to.deep.include({ migration: FOUR[3], why: 'unseen-part' });
203
+ });
204
+ it("keeps a database on db push when a migration it would record changes what the tables can't show", () => {
205
+ const history = { applied: FOUR.slice(0, 1), failed: [] };
206
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length === 3, shapes({ [FOUR[1]]: backfill }));
207
+ (0, chai_1.expect)(plan.command).to.equal('db push');
208
+ (0, chai_1.expect)(plan.acceptDataLoss).to.equal(false);
209
+ (0, chai_1.expect)(plan.basis).to.equal('unrecordable');
210
+ (0, chai_1.expect)(plan.unseen).to.deep.equal([FOUR[1]]);
211
+ (0, chai_1.expect)(plan.resolve).to.equal(undefined);
212
+ });
213
+ it('leaves a failed migration of several statements alone when the tables match no run', () => {
214
+ const history = { applied: FOUR.slice(0, 3), failed: [FOUR[3]] };
215
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, () => false, shapes({ [FOUR[3]]: twoBackfills }));
216
+ (0, chai_1.expect)(plan.command).to.equal('none');
217
+ (0, chai_1.expect)(plan.basis).to.equal('failed-unresolved');
218
+ });
219
+ it('rolls back a failed migration that left the tables as they were, so deploy runs it again', () => {
220
+ const history = { applied: FOUR.slice(0, 2), failed: [FOUR[2]] };
221
+ const compared = [];
222
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => {
223
+ compared.push(migrations.length);
224
+ return migrations.length === 2;
225
+ });
226
+ (0, chai_1.expect)(plan.command).to.equal('migrate deploy');
227
+ (0, chai_1.expect)(plan.resolve).to.deep.equal({ rolledBack: [FOUR[2]], applied: [] });
228
+ // Every migration first, then the history's own, before the rest.
229
+ (0, chai_1.expect)(compared).to.deep.equal([4, 2]);
230
+ });
231
+ it('never records fewer migrations than the history has', () => {
232
+ const history = { applied: FOUR.slice(0, 3), failed: [] };
233
+ const compared = [];
234
+ (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => {
235
+ compared.push(migrations.length);
236
+ return false;
237
+ });
238
+ (0, chai_1.expect)(Math.min(...compared)).to.equal(3);
239
+ });
240
+ it('stops looking when the tables cannot be compared', () => {
241
+ const history = { applied: FOUR.slice(0, 1), failed: [] };
242
+ const compared = [];
243
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => {
244
+ compared.push(migrations.length);
245
+ return migrations.length === 1 ? false : null;
246
+ });
247
+ (0, chai_1.expect)(plan.command).to.equal('db push');
248
+ (0, chai_1.expect)(plan.basis).to.equal('tables-differ');
249
+ (0, chai_1.expect)(compared).to.deep.equal([1, 4]);
250
+ });
251
+ it('records a migration missing from the middle of the history when the tables have it', () => {
252
+ const history = { applied: [FOUR[0], FOUR[2]], failed: [] };
253
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => migrations.length === 3);
254
+ (0, chai_1.expect)(plan.command).to.equal('migrate deploy');
255
+ (0, chai_1.expect)(plan.resolve).to.deep.equal({ rolledBack: [], applied: [FOUR[1]] });
256
+ });
257
+ // A history that names a migration this release doesn't have (another
258
+ // branch's, or a later release's) is not this release's to rewrite.
259
+ it('leaves a history that names a migration this release lacks as it is', () => {
260
+ const history = { applied: [...LOCAL, '20990101_elsewhere'], failed: [] };
261
+ const compared = [];
262
+ const plan = (0, run_migrations_1.planSchemaSync)(history, FOUR, (migrations) => {
263
+ compared.push(migrations.length);
264
+ return false;
265
+ });
266
+ (0, chai_1.expect)(plan.command).to.equal('db push');
267
+ (0, chai_1.expect)(plan.basis).to.equal('other-release');
268
+ (0, chai_1.expect)(plan.unknown).to.deep.equal(['20990101_elsewhere']);
269
+ (0, chai_1.expect)(plan.resolve).to.equal(undefined);
270
+ (0, chai_1.expect)(compared, 'only the history itself').to.deep.equal([3]);
271
+ });
272
+ });
273
+ });
274
+ describe('migrationShape', () => {
275
+ it('counts what the tables show: tables, columns, indexes and the copy of a redefined table', () => {
276
+ const shape = (0, run_migrations_1.migrationShape)('-- RedefineTables\nPRAGMA defer_foreign_keys=ON;\nPRAGMA foreign_keys=OFF;\n' +
277
+ 'CREATE TABLE "new_Team" ("id" TEXT NOT NULL PRIMARY KEY, "name" TEXT NOT NULL);\n' +
278
+ 'INSERT INTO "new_Team" ("id", "name") SELECT "id", "name" FROM "Team";\n' +
279
+ 'DROP TABLE "Team";\nALTER TABLE "new_Team" RENAME TO "Team";\n' +
280
+ 'CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");\n' +
281
+ 'CREATE INDEX "x" ON "Team"("name") WHERE "name" IS NOT NULL;\n' +
282
+ 'DROP INDEX "y";\nPRAGMA foreign_keys=ON;\n');
283
+ (0, chai_1.expect)(shape).to.deep.equal({ statements: 7, unseen: [] });
284
+ });
285
+ it("finds what the tables can't show: rows, triggers, views, collations", () => {
286
+ const shape = (0, run_migrations_1.migrationShape)('UPDATE "Team" SET "name" = \'a; b\' || "name"; -- a comment; with a semicolon\n' +
287
+ 'INSERT INTO "TeamMember" ("teamId") SELECT "id" FROM "Team";\n' +
288
+ 'DELETE FROM "Log";\n' +
289
+ 'CREATE VIEW "v" AS SELECT 1;\n' +
290
+ 'CREATE TRIGGER "t" AFTER UPDATE ON "Team" BEGIN SELECT 1; END;\n' +
291
+ 'CREATE INDEX "c" ON "Team"("name" COLLATE NOCASE);\n' +
292
+ '/* a block; comment */ INSERT INTO "new_Team" ("id") SELECT "id" FROM "Other";\n');
293
+ (0, chai_1.expect)(shape.unseen).to.have.length(8);
294
+ (0, chai_1.expect)(shape.unseen[0]).to.match(/^UPDATE "Team" SET "name" = 'a; b'/);
295
+ (0, chai_1.expect)(shape.statements).to.equal(8);
296
+ });
297
+ });
298
+ describe('schemaMissingIn', () => {
299
+ it('finds nothing missing where the database only has more', () => {
300
+ (0, chai_1.expect)((0, run_migrations_1.schemaMissingIn)('No difference detected.\n')).to.equal(null);
301
+ (0, chai_1.expect)((0, run_migrations_1.schemaMissingIn)('\n[+] Added tables\n - LabNotes\n\n[*] Changed the `Team` table\n' +
302
+ ' [+] Added column `labCol`\n [+] Added index on columns (createdAt)\n')).to.equal(null);
303
+ });
304
+ it('lists what the database lacks, with its table', () => {
305
+ const missing = (0, run_migrations_1.schemaMissingIn)('\n[+] Added tables\n - LabNotes\n\n[*] Redefined table `LocatorEtalon`\n\n' +
306
+ '[*] Changed the `SessionLog` table\n [-] Removed index on columns (session_id, createdAt)\n' +
307
+ '\n[*] Changed the `Team` table\n [+] Added column `labCol`\n');
308
+ (0, chai_1.expect)(missing).to.equal('[*] Redefined table `LocatorEtalon`\n[*] Changed the `SessionLog` table\n' +
309
+ ' [-] Removed index on columns (session_id, createdAt)');
310
+ });
311
+ it("counts a line it doesn't know as missing", () => {
312
+ (0, chai_1.expect)((0, run_migrations_1.schemaMissingIn)('Something new from Prisma')).to.equal('Something new from Prisma');
313
+ (0, chai_1.expect)((0, run_migrations_1.schemaMissingIn)('[-] Removed tables\n - Session')).to.equal('[-] Removed tables\n - Session');
314
+ });
315
+ });
316
+ describe('runMigrations chooses by the database, not by databaseProvider', () => {
317
+ const scratchUrl = `file:${path_1.default.join(os_1.default.tmpdir(), `xenon-choose-${process.pid}-absent.db`)}`;
318
+ const everyMigration = fs_1.default
319
+ .readdirSync(path_1.default.resolve(__dirname, '../../prisma/migrations'))
320
+ .filter((name) => !name.endsWith('.toml'));
321
+ let saved;
322
+ beforeEach(() => {
323
+ saved = {
324
+ autoMigrate: config_1.config.autoMigrate,
325
+ databaseProvider: config_1.config.databaseProvider,
326
+ databaseUrl: config_1.config.databaseUrl,
327
+ };
328
+ config_1.config.autoMigrate = true;
329
+ config_1.config.databaseUrl = scratchUrl;
330
+ });
331
+ afterEach(() => {
332
+ Object.assign(config_1.config, saved);
333
+ });
334
+ /** The prisma subcommand runMigrations ran, given the history the database reports. */
335
+ function commandFor(provider, history) {
336
+ return __awaiter(this, void 0, void 0, function* () {
337
+ config_1.config.databaseProvider = provider;
338
+ const commands = [];
339
+ yield (0, run_migrations_1.runMigrations)((_cmd, args) => {
340
+ commands.push(args.filter((a) => !a.startsWith('-') && !a.includes(path_1.default.sep)).join(' '));
341
+ return Buffer.from('ok');
342
+ }, () => __awaiter(this, void 0, void 0, function* () { return history; }));
343
+ return commands[commands.length - 1];
344
+ });
345
+ }
346
+ for (const provider of ['sqlite', 'postgresql']) {
347
+ it(`pushes a database with no history when databaseProvider is ${provider}`, () => __awaiter(void 0, void 0, void 0, function* () {
348
+ (0, chai_1.expect)(yield commandFor(provider, null)).to.equal('db push');
349
+ }));
350
+ it(`deploys to a database with a complete history when databaseProvider is ${provider}`, () => __awaiter(void 0, void 0, void 0, function* () {
351
+ const history = { applied: everyMigration, failed: [] };
352
+ (0, chai_1.expect)(yield commandFor(provider, history)).to.equal('migrate deploy');
353
+ }));
354
+ }
355
+ });
356
+ // `db push --accept-data-loss` drops whatever the schema doesn't have. On a
357
+ // database with no history that is what the default always did. On one with a
358
+ // history it would drop what the history doesn't explain, such as a table added
359
+ // by hand or a half-finished table a failed migration left, where `migrate
360
+ // deploy` (the old `postgresql` path) kept them.
361
+ describe('runMigrations: what each plan runs', () => {
362
+ const scratchUrl = `file:${path_1.default.join(os_1.default.tmpdir(), `xenon-flags-${process.pid}-absent.db`)}`;
363
+ const everyMigration = fs_1.default
364
+ .readdirSync(path_1.default.resolve(__dirname, '../../prisma/migrations'))
365
+ .filter((name) => !name.endsWith('.toml'))
366
+ .sort();
367
+ let saved;
368
+ beforeEach(() => {
369
+ saved = { autoMigrate: config_1.config.autoMigrate, databaseUrl: config_1.config.databaseUrl };
370
+ config_1.config.autoMigrate = true;
371
+ config_1.config.databaseUrl = scratchUrl;
372
+ });
373
+ afterEach(() => {
374
+ Object.assign(config_1.config, saved);
375
+ sinon_1.default.restore();
376
+ });
377
+ /**
378
+ * Every prisma call's arguments and database URL, answered by the given
379
+ * steps: `diff` compares the tables with a run of migrations (`--exit-code`),
380
+ * `lack` lists what the tables lack of the recorded migrations before a
381
+ * copy, `check` what the copy lacks of the schema after the migrations,
382
+ * `resolve` writes the history, and `update` is the rest. A summary step
383
+ * left out finds nothing missing.
384
+ */
385
+ function run(history_1) {
386
+ return __awaiter(this, arguments, void 0, function* (history, steps = {}, options = {}) {
387
+ const calls = [];
388
+ const urls = [];
389
+ try {
390
+ yield (0, run_migrations_1.runMigrations)((_cmd, args, opts) => {
391
+ const url = String(opts.env.DATABASE_URL);
392
+ calls.push(args);
393
+ urls.push(url);
394
+ if (args.includes('diff') && !args.includes('--exit-code')) {
395
+ const summary = args.includes('--to-url') ? steps.check : steps.lack;
396
+ return summary ? summary(args, url) : Buffer.from('No difference detected.\n');
397
+ }
398
+ const step = args.includes('diff')
399
+ ? steps.diff
400
+ : args.includes('resolve')
401
+ ? steps.resolve
402
+ : steps.update;
403
+ return step ? step(args, url) : Buffer.from('ok');
404
+ }, () => __awaiter(this, void 0, void 0, function* () { return history; }), options);
405
+ return { calls, urls };
406
+ }
407
+ catch (error) {
408
+ return { calls, urls, error };
409
+ }
410
+ });
411
+ }
412
+ const fail = (status, stderr, stdout = '') => () => {
413
+ throw Object.assign(new Error('Command failed'), {
414
+ status,
415
+ stderr: Buffer.from(stderr),
416
+ stdout: Buffer.from(stdout),
417
+ });
418
+ };
419
+ const update = (calls) => calls[calls.length - 1];
420
+ /** The number of migrations a `migrate diff --from-migrations <dir>` call builds. */
421
+ const migrationsIn = (args) => fs_1.default.readdirSync(args[args.indexOf('--from-migrations') + 1]).filter((n) => !n.endsWith('.toml'))
422
+ .length;
423
+ /** A diff that finds the tables equal to the first `k` migrations, and only those. */
424
+ const tablesAreFirst = (k) => (args) => migrationsIn(args) === k ? Buffer.from('') : fail(2, '')();
425
+ /** The prisma subcommands called, as words: `migrate resolve --applied x`. */
426
+ const words = (calls) => calls.map((args) => args
427
+ .filter((a) => !a.includes(path_1.default.sep) && a !== '--schema' && a !== '--exit-code')
428
+ .join(' '));
429
+ // What `db push` without --accept-data-loss prints when it would drop a table
430
+ // that holds rows: the list on stdout, the refusal on stderr.
431
+ 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' +
432
+ ' • You are about to drop the `LabNotes` table, which is not empty (1 rows).');
433
+ const root = path_1.default.resolve(__dirname, '../..');
434
+ const prisma = path_1.default.join(root, 'node_modules', '.bin', 'prisma');
435
+ const schema = path_1.default.join(root, 'prisma', 'schema.prisma');
436
+ const scratchFile = scratchUrl.slice('file:'.length);
437
+ /** The commands a failure message gives, one to an indented line, in its order. */
438
+ const commandsIn = (message) => message
439
+ .split('\n')
440
+ .filter((line) => /^ {2}(DATABASE_URL=|echo |touch )/.test(line))
441
+ .map((line) => line.trim());
442
+ const letGo = `DATABASE_URL=file:${scratchFile} ${prisma} db push --skip-generate --accept-data-loss ` +
443
+ `--schema ${schema}`;
444
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
445
+ /** One command: a backup, and once it is made, `letGo`. */
446
+ const letGoAfterBackup = new RegExp(`^(touch \\S+ && chmod [0-7]{3} \\S+ && )?echo "VACUUM INTO '${escape(scratchFile)}\\.backup-` +
447
+ `\\d{8}T\\d{9}Z'" \\| \\S+ db execute --url \\S+ --stdin && ${escape(letGo)}$`);
448
+ it('accepts data loss only for a database with no history, as the default always did', () => __awaiter(void 0, void 0, void 0, function* () {
449
+ const { calls } = yield run(null);
450
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push', '--accept-data-loss']);
451
+ }));
452
+ it('pushes without accepting data loss when the tables match no run of the migrations', () => __awaiter(void 0, void 0, void 0, function* () {
453
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
454
+ const { calls } = yield run(history, { diff: fail(2, '') });
455
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
456
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
457
+ }));
458
+ it('pushes without accepting data loss when the history records a failed migration and the tables match no run', () => __awaiter(void 0, void 0, void 0, function* () {
459
+ const history = { applied: everyMigration.slice(0, -1), failed: everyMigration.slice(-1) };
460
+ const { calls } = yield run(history, { diff: fail(2, '') });
461
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
462
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
463
+ }));
464
+ it('deploys to a database whose history matches its tables', () => __awaiter(void 0, void 0, void 0, function* () {
465
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
466
+ const { calls } = yield run(history);
467
+ (0, chai_1.expect)(update(calls)).to.include.members(['migrate', 'deploy']);
468
+ }));
469
+ // The default setting's db push moved a migrate-deploy database's tables
470
+ // past its history. Recording what they have puts it back on migrate deploy.
471
+ it('records the migrations the tables already have, then deploys, without db push', () => __awaiter(void 0, void 0, void 0, function* () {
472
+ const newest = everyMigration[everyMigration.length - 1];
473
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
474
+ const { calls, error } = yield run(history, { diff: tablesAreFirst(everyMigration.length) });
475
+ (0, chai_1.expect)(error).to.equal(undefined);
476
+ (0, chai_1.expect)(words(calls).filter((w) => !w.startsWith('migrate diff'))).to.deep.equal([
477
+ `migrate resolve --applied ${newest}`,
478
+ 'migrate deploy',
479
+ ]);
480
+ }));
481
+ it('rolls a failed migration back before recording it as applied', () => __awaiter(void 0, void 0, void 0, function* () {
482
+ const newest = everyMigration[everyMigration.length - 1];
483
+ const history = {
484
+ applied: everyMigration.slice(0, -1),
485
+ failed: [newest],
486
+ failureLogs: { [newest]: 'Database error:\nduplicate column name: path' },
487
+ };
488
+ const { calls, error } = yield run(history, { diff: tablesAreFirst(everyMigration.length) });
489
+ (0, chai_1.expect)(error).to.equal(undefined);
490
+ (0, chai_1.expect)(words(calls).filter((w) => !w.startsWith('migrate diff'))).to.deep.equal([
491
+ `migrate resolve --rolled-back ${newest}`,
492
+ `migrate resolve --applied ${newest}`,
493
+ 'migrate deploy',
494
+ ]);
495
+ }));
496
+ // The start after a migration failed on the rows: the history stays as it
497
+ // is, nothing runs, and the message says how to finish or undo it.
498
+ it('changes nothing, and says how to finish or undo it, for a failed migration it cannot resolve', () => __awaiter(void 0, void 0, void 0, function* () {
499
+ var _a;
500
+ const newest = everyMigration[everyMigration.length - 1];
501
+ const history = {
502
+ applied: everyMigration.slice(0, -1),
503
+ failed: [newest],
504
+ failureLogs: {
505
+ [newest]: 'Database error:\nNOT NULL constraint failed: Team.name\n\n 0: sql_schema_connector::apply',
506
+ },
507
+ };
508
+ const { calls, error } = yield run(history, { diff: tablesAreFirst(everyMigration.length) });
509
+ (0, chai_1.expect)(error, 'the start stops').to.be.an('Error');
510
+ (0, chai_1.expect)(words(calls).filter((w) => !w.startsWith('migrate diff'))).to.deep.equal([]);
511
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
512
+ (0, chai_1.expect)(message).to.include(newest);
513
+ (0, chai_1.expect)(message).to.match(/part of it may be in the tables/);
514
+ (0, chai_1.expect)(message).to.include('NOT NULL constraint failed: Team.name');
515
+ (0, chai_1.expect)(message, 'without the engine stack').to.not.include('sql_schema_connector');
516
+ (0, chai_1.expect)(message).to.include(path_1.default.join(root, 'prisma', 'migrations', newest, 'migration.sql'));
517
+ const commands = commandsIn(message);
518
+ (0, chai_1.expect)(commands).to.include(`DATABASE_URL=file:${scratchFile} ${prisma} migrate resolve --applied ${newest} --schema ${schema}`);
519
+ (0, chai_1.expect)(commands).to.include(`DATABASE_URL=file:${scratchFile} ${prisma} migrate resolve --rolled-back ${newest} --schema ${schema}`);
520
+ }));
521
+ describe('the copy', () => {
522
+ const trialPrefix = `${path_1.default.basename(scratchFile)}.xenon-trial-`;
523
+ const trialFiles = () => fs_1.default.readdirSync(path_1.default.dirname(scratchFile)).filter((n) => n.startsWith(trialPrefix));
524
+ beforeEach(() => {
525
+ fs_1.default.writeFileSync(scratchFile, '');
526
+ });
527
+ afterEach(() => {
528
+ fs_1.default.rmSync(scratchFile, { force: true });
529
+ for (const name of trialFiles())
530
+ fs_1.default.rmSync(path_1.default.join(path_1.default.dirname(scratchFile), name));
531
+ });
532
+ /** A copier that makes the file it is asked for, and notes where. */
533
+ function copier(made) {
534
+ return (_from, to) => __awaiter(this, void 0, void 0, function* () {
535
+ made.push(to);
536
+ fs_1.default.writeFileSync(to, 'copy');
537
+ });
538
+ }
539
+ const roomy = () => 1e12;
540
+ it('runs migrate deploy on a copy next to the database first, then on the database', () => __awaiter(void 0, void 0, void 0, function* () {
541
+ const made = [];
542
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
543
+ const { calls, urls, error } = yield run(history, { diff: fail(2, '') }, { copyDatabase: copier(made), freeBytes: roomy });
544
+ (0, chai_1.expect)(error).to.equal(undefined);
545
+ (0, chai_1.expect)(made).to.have.length(1);
546
+ (0, chai_1.expect)(path_1.default.dirname(made[0])).to.equal(path_1.default.dirname(scratchFile));
547
+ (0, chai_1.expect)(path_1.default.basename(made[0])).to.equal(`${trialPrefix}${process.pid}`);
548
+ const deploys = urls.filter((_, i) => calls[i].includes('deploy'));
549
+ (0, chai_1.expect)(deploys).to.deep.equal([`file:${made[0]}`, scratchUrl]);
550
+ (0, chai_1.expect)(calls.some((args) => args.includes('push')), 'no db push').to.equal(false);
551
+ (0, chai_1.expect)(trialFiles(), 'the copy is deleted').to.deep.equal([]);
552
+ }));
553
+ it('rolls a failed migration back on the copy first, and on the database only when the copy took the rest', () => __awaiter(void 0, void 0, void 0, function* () {
554
+ const made = [];
555
+ const newest = everyMigration[everyMigration.length - 1];
556
+ const history = { applied: everyMigration.slice(0, -1), failed: [newest] };
557
+ const { calls, urls, error } = yield run(history, { diff: fail(2, '') }, { copyDatabase: copier(made), freeBytes: roomy });
558
+ (0, chai_1.expect)(error).to.equal(undefined);
559
+ const steps = calls
560
+ .map((args, i) => ({ word: words([args])[0], url: urls[i] }))
561
+ .filter((c) => !c.word.startsWith('migrate diff'));
562
+ (0, chai_1.expect)(steps).to.deep.equal([
563
+ { word: `migrate resolve --rolled-back ${newest}`, url: `file:${made[0]}` },
564
+ { word: 'migrate deploy', url: `file:${made[0]}` },
565
+ { word: `migrate resolve --rolled-back ${newest}`, url: scratchUrl },
566
+ { word: 'migrate deploy', url: scratchUrl },
567
+ ]);
568
+ }));
569
+ it('falls back to db push without the flag when the copy fails, and never deploys to the database', () => __awaiter(void 0, void 0, void 0, function* () {
570
+ var _a;
571
+ const made = [];
572
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
573
+ const { calls, urls, error } = yield run(history, {
574
+ diff: fail(2, ''),
575
+ update: (args, url) => {
576
+ if (args.includes('deploy')) {
577
+ return fail(1, 'Error: P3018\n\nA migration failed to apply.\n\nMigration name: ' +
578
+ `${everyMigration[everyMigration.length - 1]}\n\nDatabase error:\nduplicate column name: path`)();
579
+ }
580
+ (0, chai_1.expect)(url).to.equal(scratchUrl);
581
+ return refusesDataLoss();
582
+ },
583
+ }, { copyDatabase: copier(made), freeBytes: roomy });
584
+ const deploys = urls.filter((_, i) => calls[i].includes('deploy'));
585
+ (0, chai_1.expect)(deploys).to.deep.equal([`file:${made[0]}`]);
586
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
587
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
588
+ (0, chai_1.expect)(trialFiles()).to.deep.equal([]);
589
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
590
+ (0, chai_1.expect)(message).to.match(/stopped rather than delete/);
591
+ (0, chai_1.expect)(message).to.match(/failed on a copy/);
592
+ (0, chai_1.expect)(message, "the copy's own reason").to.include('duplicate column name: path');
593
+ (0, chai_1.expect)(message).to.include('LabNotes');
594
+ const commands = commandsIn(message);
595
+ (0, chai_1.expect)(commands[0]).to.include(`VACUUM INTO '${scratchFile}.backup-`);
596
+ (0, chai_1.expect)(commands[0], 'let go only once backed up').to.match(letGoAfterBackup);
597
+ (0, chai_1.expect)(commands.filter((c) => c.includes('migrate deploy'))).to.deep.equal([]);
598
+ }));
599
+ it("skips the copy when there isn't room for it next to the database", () => __awaiter(void 0, void 0, void 0, function* () {
600
+ var _a;
601
+ const made = [];
602
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
603
+ fs_1.default.writeFileSync(scratchFile, Buffer.alloc(1024 * 1024));
604
+ const { calls, error } = yield run(history, { diff: fail(2, ''), update: refusesDataLoss }, { copyDatabase: copier(made), freeBytes: () => 1024 * 1024 });
605
+ (0, chai_1.expect)(made, 'no copy').to.deep.equal([]);
606
+ (0, chai_1.expect)(calls.some((args) => args.includes('deploy'))).to.equal(false);
607
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
608
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/room/);
609
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/MB/);
610
+ (0, chai_1.expect)(commandsIn((_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '')[0]).to.match(letGoAfterBackup);
611
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.include(`free ${Math.ceil((2 * 1024 * 1024 + 64 * 1024 * 1024) / (1024 * 1024))} MB`);
612
+ }));
613
+ it('skips the copy when the database is locked', () => __awaiter(void 0, void 0, void 0, function* () {
614
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
615
+ const { calls, error } = yield run(history, { diff: fail(2, ''), update: refusesDataLoss }, {
616
+ copyDatabase: () => __awaiter(void 0, void 0, void 0, function* () {
617
+ throw new Error('Raw query failed. Code: `5`. Message: `database is locked`');
618
+ }),
619
+ freeBytes: roomy,
620
+ });
621
+ (0, chai_1.expect)(calls.some((args) => args.includes('deploy'))).to.equal(false);
622
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
623
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/locked/);
624
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/another server/);
625
+ (0, chai_1.expect)(trialFiles()).to.deep.equal([]);
626
+ }));
627
+ // migrate deploy builds on what the recorded migrations made. Through #493
628
+ // a copy it worked on stood for the file, even where the tables lacked
629
+ // part of that (an index dropped by hand, a column an older release's db
630
+ // push took away), and the file then got a full history over the gap.
631
+ it("doesn't try the copy when the tables lack part of what their recorded migrations make", () => __awaiter(void 0, void 0, void 0, function* () {
632
+ var _a;
633
+ const made = [];
634
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
635
+ const lacking = '[*] Changed the `SessionLog` table\n [-] Removed index on columns (session_id, createdAt)';
636
+ const { calls, error } = yield run(history, {
637
+ diff: fail(2, ''),
638
+ lack: () => Buffer.from(`\n[+] Added tables\n - LabNotes\n\n${lacking}\n`),
639
+ update: refusesDataLoss,
640
+ }, { copyDatabase: copier(made), freeBytes: roomy });
641
+ (0, chai_1.expect)(made, 'no copy').to.deep.equal([]);
642
+ (0, chai_1.expect)(calls.some((args) => args.includes('deploy'))).to.equal(false);
643
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
644
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
645
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
646
+ (0, chai_1.expect)(message).to.match(/differ from what its recorded migrations make: they lack part of what those make/);
647
+ (0, chai_1.expect)(message).to.include(lacking);
648
+ (0, chai_1.expect)(message).to.not.include('LabNotes\n - ');
649
+ }));
650
+ it('says both reasons when a database with a failed migration also lacks part of its migrations', () => __awaiter(void 0, void 0, void 0, function* () {
651
+ var _a;
652
+ const made = [];
653
+ const newest = everyMigration[everyMigration.length - 1];
654
+ const history = { applied: everyMigration.slice(0, -1), failed: [newest] };
655
+ const { error } = yield run(history, {
656
+ diff: fail(2, ''),
657
+ lack: () => Buffer.from('[*] Redefined table `LocatorEtalon`\n'),
658
+ update: refusesDataLoss,
659
+ }, { copyDatabase: copier(made), freeBytes: roomy });
660
+ (0, chai_1.expect)(made, 'no copy').to.deep.equal([]);
661
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
662
+ (0, chai_1.expect)(message).to.match(new RegExp(`records a failed migration \\(${newest}\\), which prisma migrate deploy won't run ` +
663
+ 'past, and its tables lack part of what its recorded migrations make'));
664
+ }));
665
+ it("doesn't run the migrations on the database when the copy still lacks part of the schema", () => __awaiter(void 0, void 0, void 0, function* () {
666
+ var _a;
667
+ const made = [];
668
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
669
+ const { calls, urls, error } = yield run(history, {
670
+ diff: fail(2, ''),
671
+ check: (args) => {
672
+ (0, chai_1.expect)(args).to.include('--from-schema-datamodel');
673
+ return Buffer.from('\n[*] Redefined table `LocatorEtalon`\n');
674
+ },
675
+ update: (args) => (args.includes('deploy') ? Buffer.from('ok') : refusesDataLoss()),
676
+ }, { copyDatabase: copier(made), freeBytes: roomy });
677
+ const deploys = urls.filter((_, i) => calls[i].includes('deploy'));
678
+ (0, chai_1.expect)(deploys, 'the copy only').to.deep.equal([`file:${made[0]}`]);
679
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
680
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
681
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
682
+ (0, chai_1.expect)(message).to.match(/a copy of it still lacked part of this version's schema/);
683
+ (0, chai_1.expect)(message).to.include('[*] Redefined table `LocatorEtalon`');
684
+ (0, chai_1.expect)(trialFiles()).to.deep.equal([]);
685
+ }));
686
+ it("tries the copy when it can't tell how much space is free", () => __awaiter(void 0, void 0, void 0, function* () {
687
+ const made = [];
688
+ const info = sinon_1.default.spy(logger_1.default, 'info');
689
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
690
+ const { error } = yield run(history, { diff: fail(2, '') }, { copyDatabase: copier(made), freeBytes: () => null });
691
+ (0, chai_1.expect)(error).to.equal(undefined);
692
+ (0, chai_1.expect)(made).to.have.length(1);
693
+ const lines = info.getCalls().map((c) => c.args.join(' '));
694
+ (0, chai_1.expect)(lines.some((l) => /could not tell how much space is free/.test(l))).to.equal(true);
695
+ }));
696
+ it('says what is copied before it copies', () => __awaiter(void 0, void 0, void 0, function* () {
697
+ const info = sinon_1.default.spy(logger_1.default, 'info');
698
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
699
+ fs_1.default.writeFileSync(scratchFile, Buffer.alloc(3 * 1024 * 1024));
700
+ let before = -1;
701
+ yield run(history, { diff: fail(2, '') }, {
702
+ copyDatabase: (_from, to) => __awaiter(void 0, void 0, void 0, function* () {
703
+ before = info.callCount;
704
+ fs_1.default.writeFileSync(to, 'copy');
705
+ }),
706
+ freeBytes: roomy,
707
+ });
708
+ const lines = info
709
+ .getCalls()
710
+ .slice(0, before)
711
+ .map((c) => c.args.join(' '));
712
+ (0, chai_1.expect)(lines.some((l) => /3 MB to copy, which can take a while/.test(l))).to.equal(true);
713
+ }));
714
+ it("gives the copy the database's own mode before any data is in it", () => __awaiter(void 0, void 0, void 0, function* () {
715
+ fs_1.default.chmodSync(scratchFile, 0o640);
716
+ let mode = -1;
717
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
718
+ yield run(history, { diff: fail(2, '') }, {
719
+ copyDatabase: (_from, to) => __awaiter(void 0, void 0, void 0, function* () {
720
+ mode = fs_1.default.statSync(to).mode & 0o777;
721
+ fs_1.default.writeFileSync(to, 'copy');
722
+ }),
723
+ freeBytes: roomy,
724
+ });
725
+ (0, chai_1.expect)(mode.toString(8)).to.equal('640');
726
+ }));
727
+ it('says there was no room when the copy runs out of space', () => __awaiter(void 0, void 0, void 0, function* () {
728
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
729
+ const { error } = yield run(history, { diff: fail(2, ''), update: refusesDataLoss }, {
730
+ copyDatabase: () => __awaiter(void 0, void 0, void 0, function* () {
731
+ throw new Error('Raw query failed. Code: `13`. Message: `database or disk is full`');
732
+ }),
733
+ freeBytes: () => null,
734
+ });
735
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/no room next to it for a copy/);
736
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/free \d+ MB next to the file/);
737
+ (0, chai_1.expect)(trialFiles()).to.deep.equal([]);
738
+ }));
739
+ // A history that names a migration this release lacks is another
740
+ // release's. Through #493 the copy ran this release's migrations on it,
741
+ // and then the file did.
742
+ it("doesn't try this release's migrations on a database from another release", () => __awaiter(void 0, void 0, void 0, function* () {
743
+ const made = [];
744
+ const history = {
745
+ applied: [...everyMigration.slice(0, -1), '29980101000000_elsewhere'],
746
+ failed: [],
747
+ };
748
+ const { calls, error } = yield run(history, { diff: fail(2, ''), update: refusesDataLoss }, { copyDatabase: copier(made), freeBytes: roomy });
749
+ (0, chai_1.expect)(made, 'no copy').to.deep.equal([]);
750
+ (0, chai_1.expect)(calls.some((args) => args.includes('deploy') || args.includes('resolve'))).to.equal(false);
751
+ (0, chai_1.expect)(update(calls)).to.include.members(['db', 'push']);
752
+ (0, chai_1.expect)(update(calls)).to.not.include('--accept-data-loss');
753
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/another release brought it up to date/);
754
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.include('29980101000000_elsewhere');
755
+ }));
756
+ // A start that died mid-trial leaves its copy. The next start removes it,
757
+ // unless the process that made it is still running.
758
+ it('removes a copy a previous start left, but not one a running start is using', () => __awaiter(void 0, void 0, void 0, function* () {
759
+ const dir = path_1.default.dirname(scratchFile);
760
+ const dead = path_1.default.join(dir, `${trialPrefix}999991`);
761
+ const alive = path_1.default.join(dir, `${trialPrefix}999992`);
762
+ const other = `${scratchFile}.backup-20261005T000000Z`;
763
+ for (const f of [dead, `${dead}-journal`, alive, other])
764
+ fs_1.default.writeFileSync(f, 'x');
765
+ // Signal 0 only asks whether a process is there; stubbed all the same.
766
+ sinon_1.default.stub(process, 'kill').callsFake(((pid) => {
767
+ if (pid === 999992)
768
+ return true;
769
+ throw Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' });
770
+ }));
771
+ try {
772
+ yield run({ applied: [...everyMigration], failed: [] });
773
+ (0, chai_1.expect)(trialFiles().sort()).to.deep.equal([path_1.default.basename(alive)]);
774
+ (0, chai_1.expect)(fs_1.default.existsSync(other)).to.equal(true);
775
+ }
776
+ finally {
777
+ fs_1.default.rmSync(other, { force: true });
778
+ }
779
+ }));
780
+ });
781
+ // With a failed migration recorded, `migrate deploy` stops at once (P3009),
782
+ // whatever the tables hold, so the message never suggests it.
783
+ it('names the failed migration and never suggests migrate deploy when the history records one', () => __awaiter(void 0, void 0, void 0, function* () {
784
+ var _a;
785
+ const failed = everyMigration[everyMigration.length - 1];
786
+ const history = { applied: everyMigration.slice(0, -1), failed: [failed] };
787
+ const { error } = yield run(history, { diff: fail(2, ''), update: refusesDataLoss });
788
+ (0, chai_1.expect)(error, 'the start stops').to.be.an('Error');
789
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
790
+ (0, chai_1.expect)(message).to.match(/stopped rather than delete/);
791
+ (0, chai_1.expect)(message).to.include(failed);
792
+ (0, chai_1.expect)(message).to.match(/records a failed migration/);
793
+ (0, chai_1.expect)(message).to.include('LabNotes');
794
+ const commands = commandsIn(message);
795
+ (0, chai_1.expect)(commands[0]).to.include(`VACUUM INTO '${scratchFile}.backup-`);
796
+ (0, chai_1.expect)(commands[0]).to.match(letGoAfterBackup);
797
+ (0, chai_1.expect)(commands.filter((c) => c.includes('migrate deploy'))).to.deep.equal([]);
798
+ (0, chai_1.expect)(message).to.not.match(/by hand with prisma migrate deploy/);
799
+ }));
800
+ // A migration can fail on what is already in the tables, which a later
801
+ // start deals with, or on the rows (a unique index over duplicate values),
802
+ // which no start gets past until someone changes them. The message says
803
+ // what the next start does for this migration, and nothing it doesn't.
804
+ describe('when migrate deploy fails on a migration (P3018)', () => {
805
+ const newest = everyMigration[everyMigration.length - 1];
806
+ const failure = (name, error) => fail(1, `Error: P3018\n\nA migration failed to apply.\n\nMigration name: ${name}\n\n` +
807
+ `Database error:\n${error}`);
808
+ const history = { applied: everyMigration.slice(0, -1), failed: [] };
809
+ it('promises the next start runs a one-statement migration that failed on the rows again', () => __awaiter(void 0, void 0, void 0, function* () {
810
+ var _a;
811
+ const { error } = yield run(history, {
812
+ diff: fail(1, 'simulated diff failure'),
813
+ update: failure(newest, 'UNIQUE constraint failed: LocatorEtalon.strategy'),
814
+ });
815
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
816
+ (0, chai_1.expect)(message).to.include(newest);
817
+ (0, chai_1.expect)(message).to.include('UNIQUE constraint failed');
818
+ (0, chai_1.expect)(message).to.match(/duplicate values/);
819
+ (0, chai_1.expect)(message).to.match(/change those rows, and start again: the next start runs it again/);
820
+ (0, chai_1.expect)(message).to.not.match(/next start brings the database up to date/);
821
+ }));
822
+ it('says the next start records it when what it makes was already there', () => __awaiter(void 0, void 0, void 0, function* () {
823
+ var _a;
824
+ const { error } = yield run(history, {
825
+ diff: fail(1, 'simulated diff failure'),
826
+ update: failure(newest, 'duplicate column name: path'),
827
+ });
828
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
829
+ (0, chai_1.expect)(message).to.match(/because what it makes was already there/);
830
+ (0, chai_1.expect)(message).to.match(/records it as applied if the tables are what/);
831
+ (0, chai_1.expect)(message).to.not.match(/change those rows/);
832
+ }));
833
+ it("promises nothing for a migration this release doesn't have", () => __awaiter(void 0, void 0, void 0, function* () {
834
+ var _a;
835
+ const { error } = yield run(history, {
836
+ diff: fail(1, 'simulated diff failure'),
837
+ update: failure('20261101000000_uniq', 'UNIQUE constraint failed'),
838
+ });
839
+ const message = (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : '';
840
+ (0, chai_1.expect)(message).to.include('20261101000000_uniq');
841
+ (0, chai_1.expect)(message).to.not.match(/next start/);
842
+ }));
843
+ });
844
+ it("says it couldn't read the history without blaming the file outright", () => __awaiter(void 0, void 0, void 0, function* () {
845
+ let error;
846
+ try {
847
+ yield (0, run_migrations_1.runMigrations)(() => Buffer.from('ok'), () => __awaiter(void 0, void 0, void 0, function* () {
848
+ throw new Error('database is locked');
849
+ }));
850
+ }
851
+ catch (e) {
852
+ error = e;
853
+ }
854
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/could not read the migration history/);
855
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/another server/);
856
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.include('database is locked');
857
+ }));
858
+ });
859
+ // `npm run db:generate` runs `prisma migrate dev` on the developer's database,
860
+ // which gives it a migration history. From then on `npm run db:migrate` updates
861
+ // it with `migrate deploy`, so a schema.prisma edit with no migration never
862
+ // reaches it (or any server the release reaches), while the log said "in sync".
863
+ describe('npm run db:migrate: schema changes without a migration', () => {
864
+ afterEach(() => sinon_1.default.restore());
865
+ const failing = (status) => () => {
866
+ throw Object.assign(new Error('Command failed'), {
867
+ status,
868
+ stderr: Buffer.from('boom'),
869
+ stdout: Buffer.from(''),
870
+ });
871
+ };
872
+ it('warns to run npm run db:generate when prisma/schema.prisma has changes no migration makes', () => {
873
+ const warn = sinon_1.default.stub(logger_1.default, 'warn');
874
+ const calls = [];
875
+ const found = (0, run_migrations_1.warnOfUnmigratedSchemaChanges)((_cmd, args) => {
876
+ calls.push(args);
877
+ return failing(2)();
878
+ });
879
+ (0, chai_1.expect)(found).to.equal(true);
880
+ (0, chai_1.expect)(calls).to.have.length(1);
881
+ (0, chai_1.expect)(calls[0]).to.include.members([
882
+ 'migrate',
883
+ 'diff',
884
+ '--from-migrations',
885
+ '--to-schema-datamodel',
886
+ '--exit-code',
887
+ ]);
888
+ const lines = warn.getCalls().map((c) => c.args.join(' '));
889
+ (0, chai_1.expect)(lines).to.have.length(1);
890
+ (0, chai_1.expect)(lines[0]).to.match(/npm run db:generate/);
891
+ (0, chai_1.expect)(lines[0]).to.match(/migrate deploy/);
892
+ });
893
+ it('says nothing when the migrations make the schema', () => {
894
+ const warn = sinon_1.default.stub(logger_1.default, 'warn');
895
+ (0, chai_1.expect)((0, run_migrations_1.warnOfUnmigratedSchemaChanges)(() => Buffer.from(''))).to.equal(false);
896
+ (0, chai_1.expect)(warn.called).to.equal(false);
897
+ });
898
+ it("doesn't stop when it can't tell", () => {
899
+ sinon_1.default.stub(logger_1.default, 'warn');
900
+ (0, chai_1.expect)((0, run_migrations_1.warnOfUnmigratedSchemaChanges)(failing(1))).to.equal(null);
901
+ });
902
+ });